feat: 管理后台央企红门户风格重构 + 职位编制字段及启动脚本
- 管理后台改用央企红(#C60C1E)门户风格:红顶白底、扁平务实、 金色点缀;侧栏导航加图标与分组,Dialog 红标题栏,表格/卡片/按钮统一 - 后端 jobs 新增 headcount(编制)字段及迁移、school_data 学校数据、 组织序列化调整 - 新增 start.bat / dev.bat 一键启动前后端,.gitignore 规范化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d551079fda
commit
1823b95819
|
|
@ -0,0 +1,35 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
offer_backend/.venv/
|
||||
.venv/
|
||||
|
||||
# Build output
|
||||
offer_frontend/dist/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# Env / local config
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.claude/settings.local.json
|
||||
.mcp.json
|
||||
.playwright-mcp/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
offer_frontend/server.log
|
||||
|
||||
# Root-level screenshots / temp artifacts
|
||||
/*.png
|
||||
/*.jpg
|
||||
/*.jpeg
|
||||
test-splash.js
|
||||
cl.bat
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
@echo off
|
||||
:: Start offer dev environment (activate venv first)
|
||||
:: Backend : http://127.0.0.1:8000
|
||||
:: Frontend: http://localhost:5173
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
set "BACKEND=%ROOT%offer_backend"
|
||||
set "FRONTEND=%ROOT%offer_frontend"
|
||||
set "VENV_ACTIVATE=%BACKEND%\.venv\Scripts\activate.bat"
|
||||
|
||||
echo [*] Starting backend (Django :8000) ...
|
||||
start "offer-backend" /D "%BACKEND%" cmd /k call "%VENV_ACTIVATE%" ^&^& python manage.py runserver 0.0.0.0:8000
|
||||
|
||||
echo [*] Starting frontend (Vite :5173) ...
|
||||
start "offer-frontend" /D "%FRONTEND%" cmd /k npm run dev
|
||||
|
||||
echo.
|
||||
echo === Started ===
|
||||
echo Backend : http://127.0.0.1:8000
|
||||
echo Frontend: http://localhost:5173
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.20 on 2026-03-31 00:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jobs', '0003_job_education'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='job',
|
||||
name='headcount',
|
||||
field=models.PositiveIntegerField(default=1, verbose_name='招聘人数'),
|
||||
),
|
||||
]
|
||||
|
|
@ -22,6 +22,7 @@ class Job(models.Model):
|
|||
location = models.CharField(max_length=100, verbose_name='工作地点')
|
||||
salary = models.CharField(max_length=50, verbose_name='薪资范围')
|
||||
education = models.CharField(max_length=20, choices=EDUCATION_CHOICES, default='本科及以下', verbose_name='学历要求')
|
||||
headcount = models.PositiveIntegerField(default=1, verbose_name='招聘人数')
|
||||
description = models.TextField(verbose_name='职位描述', blank=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
# 985/211 院校名单(用于 Dashboard 学校分布自动分类)
|
||||
|
||||
SCHOOLS_985 = {
|
||||
'北京大学', '清华大学', '中国人民大学', '北京航空航天大学', '北京理工大学',
|
||||
'北京师范大学', '中国农业大学', '中央民族大学',
|
||||
'南开大学', '天津大学',
|
||||
'大连理工大学', '东北大学',
|
||||
'吉林大学',
|
||||
'哈尔滨工业大学',
|
||||
'复旦大学', '上海交通大学', '同济大学', '华东师范大学',
|
||||
'南京大学', '东南大学',
|
||||
'浙江大学',
|
||||
'中国科学技术大学',
|
||||
'厦门大学',
|
||||
'山东大学', '中国海洋大学',
|
||||
'武汉大学', '华中科技大学',
|
||||
'湖南大学', '中南大学', '国防科技大学',
|
||||
'中山大学', '华南理工大学',
|
||||
'四川大学', '电子科技大学',
|
||||
'重庆大学',
|
||||
'西安交通大学', '西北工业大学', '西北农林科技大学',
|
||||
'兰州大学',
|
||||
}
|
||||
|
||||
# 211院校(不含985,即"纯211")
|
||||
SCHOOLS_211 = {
|
||||
'北京交通大学', '北京工业大学', '北京科技大学', '北京化工大学',
|
||||
'北京邮电大学', '北京林业大学', '北京中医药大学', '北京外国语大学',
|
||||
'中国传媒大学', '对外经济贸易大学', '中央财经大学', '中国政法大学',
|
||||
'华北电力大学', '中国矿业大学', '中国矿业大学(北京)', '中国石油大学',
|
||||
'中国石油大学(北京)', '中国地质大学', '中国地质大学(北京)',
|
||||
'北京体育大学',
|
||||
'河北工业大学',
|
||||
'太原理工大学',
|
||||
'内蒙古大学',
|
||||
'辽宁大学', '大连海事大学',
|
||||
'延边大学', '东北师范大学',
|
||||
'哈尔滨工程大学', '东北农业大学', '东北林业大学',
|
||||
'华东理工大学', '东华大学', '上海外国语大学', '上海财经大学', '上海大学',
|
||||
'第二军医大学',
|
||||
'苏州大学', '南京航空航天大学', '南京理工大学', '中国药科大学',
|
||||
'河海大学', '江南大学', '南京农业大学', '南京师范大学',
|
||||
'安徽大学', '合肥工业大学',
|
||||
'福州大学',
|
||||
'南昌大学',
|
||||
'郑州大学',
|
||||
'武汉理工大学', '华中农业大学', '华中师范大学', '中南财经政法大学',
|
||||
'湖南师范大学',
|
||||
'暨南大学', '华南师范大学',
|
||||
'广西大学',
|
||||
'海南大学',
|
||||
'西南交通大学', '西南大学', '四川农业大学', '西南财经大学',
|
||||
'贵州大学',
|
||||
'云南大学',
|
||||
'西藏大学',
|
||||
'西北大学', '西安电子科技大学', '长安大学', '陕西师范大学',
|
||||
'第四军医大学',
|
||||
'青海大学',
|
||||
'宁夏大学',
|
||||
'新疆大学', '石河子大学',
|
||||
}
|
||||
|
||||
|
||||
def classify_school(school_name):
|
||||
"""根据学校名称返回院校类型:985 / 211 / 其他"""
|
||||
if not school_name:
|
||||
return '其他'
|
||||
name = school_name.strip()
|
||||
if name in SCHOOLS_985:
|
||||
return '985'
|
||||
if name in SCHOOLS_211:
|
||||
return '211'
|
||||
# 模糊匹配:处理"xx大学xx学院"等情况
|
||||
for s in SCHOOLS_985:
|
||||
if s in name or name in s:
|
||||
return '985'
|
||||
for s in SCHOOLS_211:
|
||||
if s in name or name in s:
|
||||
return '211'
|
||||
return '其他'
|
||||
|
|
@ -9,7 +9,7 @@ class JobListSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['id', 'title', 'category', 'location', 'salary', 'education',
|
||||
fields = ['id', 'title', 'category', 'location', 'salary', 'education', 'headcount',
|
||||
'organization', 'organization_name', 'status', 'created_at']
|
||||
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ class JobDetailSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['id', 'title', 'category', 'location', 'salary', 'education',
|
||||
fields = ['id', 'title', 'category', 'location', 'salary', 'education', 'headcount',
|
||||
'description', 'organization', 'organization_id', 'status', 'created_at']
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView
|
||||
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView, DashboardView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('public', JobPublicViewSet, basename='job-public')
|
||||
|
|
@ -9,4 +9,5 @@ router.register('manage', JobManageViewSet, basename='job-manage')
|
|||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('favorites/', MyFavoritesView.as_view()),
|
||||
path('dashboard/', DashboardView.as_view()),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
from rest_framework import viewsets, permissions, generics
|
||||
import io
|
||||
from collections import Counter
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpResponse
|
||||
from rest_framework import viewsets, permissions, generics, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from .models import Job, JobFavorite
|
||||
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
||||
from .filters import JobFilter
|
||||
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
|
||||
from apps.organizations.models import Organization
|
||||
from apps.applications.models import Application
|
||||
from .school_data import classify_school
|
||||
|
||||
|
||||
class JobPublicPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 500
|
||||
|
||||
|
||||
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
|
@ -16,6 +34,7 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
search_fields = ['title', 'description', 'location']
|
||||
permission_classes = [permissions.AllowAny]
|
||||
pagination_class = JobPublicPagination
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
|
|
@ -68,3 +87,348 @@ class JobManageViewSet(viewsets.ModelViewSet):
|
|||
else:
|
||||
# 超管需要在请求体中提供 organization_id
|
||||
serializer.save()
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='template')
|
||||
def download_template(self, request):
|
||||
"""下载职位导入Excel模板"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = '职位导入模板'
|
||||
|
||||
# 表头定义: (列名, 是否必填, 列宽, 示例值)
|
||||
columns = [
|
||||
('职位名称', True, 20, 'Python开发工程师'),
|
||||
('职位类别', True, 15, '技术'),
|
||||
('工作地点', True, 15, '北京'),
|
||||
('薪资范围', True, 15, '15k-25k'),
|
||||
('学历要求', True, 15, '本科及以下'),
|
||||
('招聘人数', False, 12, '3'),
|
||||
('职位描述', False, 40, '负责后端系统开发与维护'),
|
||||
('状态', False, 12, '已发布'),
|
||||
]
|
||||
if request.user.is_superadmin:
|
||||
columns.insert(0, ('所属公司名称', True, 25, '华远科技有限公司'))
|
||||
|
||||
col_map_by_name = {name: idx for idx, (name, _, _, _) in enumerate(columns, 1)}
|
||||
|
||||
header_font = Font(bold=True, color='FFFFFF', size=11)
|
||||
required_fill = PatternFill(start_color='2196F3', end_color='2196F3', fill_type='solid')
|
||||
optional_fill = PatternFill(start_color='90CAF9', end_color='90CAF9', fill_type='solid')
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'), right=Side(style='thin'),
|
||||
top=Side(style='thin'), bottom=Side(style='thin'),
|
||||
)
|
||||
|
||||
for col_idx, (name, required, width, _) in enumerate(columns, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=f'{name}(必填)' if required else name)
|
||||
cell.font = header_font
|
||||
cell.fill = required_fill if required else optional_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
cell.border = thin_border
|
||||
ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = width
|
||||
|
||||
# 写入示例数据行
|
||||
for col_idx, (_, _, _, example) in enumerate(columns, 1):
|
||||
cell = ws.cell(row=2, column=col_idx, value=example)
|
||||
cell.border = thin_border
|
||||
|
||||
# 数据验证:学历要求下拉
|
||||
edu_col = openpyxl.utils.get_column_letter(col_map_by_name['学历要求'])
|
||||
edu_dv = DataValidation(type='list', formula1='"博士,硕士,本科及以下"', allow_blank=False)
|
||||
edu_dv.error = '请选择:博士、硕士、本科及以下'
|
||||
edu_dv.errorTitle = '学历要求错误'
|
||||
ws.add_data_validation(edu_dv)
|
||||
edu_dv.add(f'{edu_col}2:{edu_col}500')
|
||||
|
||||
# 数据验证:状态下拉
|
||||
status_col = openpyxl.utils.get_column_letter(col_map_by_name['状态'])
|
||||
status_dv = DataValidation(type='list', formula1='"草稿,已发布"', allow_blank=True)
|
||||
status_dv.error = '请选择:草稿、已发布'
|
||||
status_dv.errorTitle = '状态错误'
|
||||
ws.add_data_validation(status_dv)
|
||||
status_dv.add(f'{status_col}2:{status_col}500')
|
||||
|
||||
# 数据验证:所属公司下拉(超管)
|
||||
if request.user.is_superadmin:
|
||||
org_names = list(Organization.objects.values_list('name', flat=True).order_by('name'))
|
||||
if org_names:
|
||||
# 将公司列表写入隐藏sheet,用名称引用实现下拉
|
||||
org_ws = wb.create_sheet('_org_data')
|
||||
for i, name in enumerate(org_names, 1):
|
||||
org_ws.cell(row=i, column=1, value=name)
|
||||
org_ws.sheet_state = 'hidden'
|
||||
|
||||
org_col = openpyxl.utils.get_column_letter(col_map_by_name['所属公司名称'])
|
||||
org_dv = DataValidation(
|
||||
type='list',
|
||||
formula1=f"'_org_data'!$A$1:$A${len(org_names)}",
|
||||
allow_blank=False
|
||||
)
|
||||
org_dv.error = '请从下拉列表中选择公司,不可填写系统外的公司'
|
||||
org_dv.errorTitle = '公司名称错误'
|
||||
ws.add_data_validation(org_dv)
|
||||
org_dv.add(f'{org_col}2:{org_col}500')
|
||||
|
||||
# 添加说明 sheet
|
||||
info_ws = wb.create_sheet('填写说明')
|
||||
info_ws.column_dimensions['A'].width = 20
|
||||
info_ws.column_dimensions['B'].width = 60
|
||||
notes = [
|
||||
('字段', '说明'),
|
||||
('职位名称', '必填,最多100个字符'),
|
||||
('职位类别', '必填,如:技术、产品、设计、运营、市场等'),
|
||||
('工作地点', '必填,如:北京、上海、深圳'),
|
||||
('薪资范围', '必填,如:15k-25k、面议'),
|
||||
('学历要求', '必填,可选值:博士、硕士、本科及以下'),
|
||||
('招聘人数', '选填,默认为1,填写正整数'),
|
||||
('职位描述', '选填,职位的详细描述'),
|
||||
('状态', '选填,可选值:草稿、已发布,默认为草稿'),
|
||||
]
|
||||
if request.user.is_superadmin:
|
||||
notes.insert(1, ('所属公司名称', '超管必填,填写系统中已有的公司名称'))
|
||||
for row_idx, (field, desc) in enumerate(notes, 1):
|
||||
a = info_ws.cell(row=row_idx, column=1, value=field)
|
||||
b = info_ws.cell(row=row_idx, column=2, value=desc)
|
||||
if row_idx == 1:
|
||||
a.font = Font(bold=True)
|
||||
b.font = Font(bold=True)
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
response = HttpResponse(
|
||||
buf.getvalue(),
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
response['Content-Disposition'] = 'attachment; filename="job_import_template.xlsx"'
|
||||
return response
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='import-excel')
|
||||
def import_excel(self, request):
|
||||
"""通过Excel批量导入职位"""
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
return Response({'detail': '请上传Excel文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not file.name.endswith(('.xlsx', '.xls')):
|
||||
return Response({'detail': '仅支持 .xlsx 或 .xls 格式'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(file, read_only=True)
|
||||
except Exception:
|
||||
return Response({'detail': 'Excel文件解析失败,请检查文件格式'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(min_row=1, values_only=True))
|
||||
if len(rows) < 2:
|
||||
return Response({'detail': 'Excel中没有数据行'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 解析表头,建立列名到索引的映射
|
||||
headers = [str(h).replace('(必填)', '').strip() if h else '' for h in rows[0]]
|
||||
col_map = {name: idx for idx, name in enumerate(headers)}
|
||||
|
||||
user = request.user
|
||||
is_super = user.is_superadmin
|
||||
education_choices = {'博士', '硕士', '本科及以下'}
|
||||
status_choices = {'draft', 'published', '草稿', '已发布'}
|
||||
status_map = {'草稿': 'draft', '已发布': 'published', 'draft': 'draft', 'published': 'published'}
|
||||
|
||||
# 超管需要提前加载公司名映射
|
||||
org_map = {}
|
||||
if is_super:
|
||||
org_map = {o.name: o for o in Organization.objects.all()}
|
||||
|
||||
created = 0
|
||||
errors = []
|
||||
for row_idx, row in enumerate(rows[1:], start=2):
|
||||
def get_val(col_name):
|
||||
idx = col_map.get(col_name)
|
||||
if idx is not None and idx < len(row):
|
||||
v = row[idx]
|
||||
return str(v).strip() if v is not None else ''
|
||||
return ''
|
||||
|
||||
title = get_val('职位名称')
|
||||
category = get_val('职位类别')
|
||||
location = get_val('工作地点')
|
||||
salary = get_val('薪资范围')
|
||||
education = get_val('学历要求')
|
||||
headcount_str = get_val('招聘人数')
|
||||
description = get_val('职位描述')
|
||||
job_status = get_val('状态')
|
||||
|
||||
# 跳过全空行
|
||||
if not any([title, category, location, salary]):
|
||||
continue
|
||||
|
||||
# 校验必填字段
|
||||
missing = []
|
||||
if not title:
|
||||
missing.append('职位名称')
|
||||
if not category:
|
||||
missing.append('职位类别')
|
||||
if not location:
|
||||
missing.append('工作地点')
|
||||
if not salary:
|
||||
missing.append('薪资范围')
|
||||
if not education:
|
||||
education = '本科及以下'
|
||||
|
||||
if education not in education_choices:
|
||||
errors.append(f'第{row_idx}行:学历要求必须为 博士/硕士/本科及以下')
|
||||
continue
|
||||
|
||||
if missing:
|
||||
errors.append(f'第{row_idx}行:缺少必填字段 {", ".join(missing)}')
|
||||
continue
|
||||
|
||||
# 招聘人数
|
||||
headcount = 1
|
||||
if headcount_str:
|
||||
try:
|
||||
headcount = int(float(headcount_str))
|
||||
if headcount < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f'第{row_idx}行:招聘人数必须为正整数')
|
||||
continue
|
||||
|
||||
# 状态:支持中文和英文
|
||||
job_status = status_map.get(job_status, 'draft')
|
||||
|
||||
# 确定所属公司
|
||||
org = None
|
||||
if is_super:
|
||||
org_name = get_val('所属公司名称')
|
||||
if not org_name:
|
||||
errors.append(f'第{row_idx}行:超级管理员必须填写所属公司名称')
|
||||
continue
|
||||
org = org_map.get(org_name)
|
||||
if not org:
|
||||
errors.append(f'第{row_idx}行:公司"{org_name}"不存在')
|
||||
continue
|
||||
else:
|
||||
org = user.organization
|
||||
if not org:
|
||||
return Response({'detail': '当前账号未关联公司,无法导入'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
Job.objects.create(
|
||||
organization=org,
|
||||
title=title,
|
||||
category=category,
|
||||
location=location,
|
||||
salary=salary,
|
||||
education=education,
|
||||
headcount=headcount,
|
||||
description=description,
|
||||
status=job_status,
|
||||
)
|
||||
created += 1
|
||||
|
||||
result = {'created': created, 'errors': errors}
|
||||
if created == 0 and errors:
|
||||
return Response(result, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(result)
|
||||
|
||||
|
||||
class DashboardView(APIView):
|
||||
"""管理后台 Dashboard 统计数据"""
|
||||
permission_classes = [IsAdminOrSuperAdmin]
|
||||
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
is_super = user.is_superadmin
|
||||
|
||||
# 根据角色过滤数据范围
|
||||
if is_super:
|
||||
jobs_qs = Job.objects.all()
|
||||
apps_qs = Application.objects.all()
|
||||
else:
|
||||
jobs_qs = Job.objects.filter(organization=user.organization)
|
||||
apps_qs = Application.objects.filter(job__organization=user.organization)
|
||||
|
||||
# 职位统计
|
||||
job_stats = jobs_qs.aggregate(
|
||||
published=Count('id', filter=Q(status='published')),
|
||||
closed=Count('id', filter=Q(status='closed')),
|
||||
draft=Count('id', filter=Q(status='draft')),
|
||||
total=Count('id'),
|
||||
)
|
||||
|
||||
# 投递统计
|
||||
app_stats = apps_qs.aggregate(
|
||||
total=Count('id'),
|
||||
pending=Count('id', filter=Q(status='pending')),
|
||||
viewed=Count('id', filter=Q(status='viewed')),
|
||||
interviewing=Count('id', filter=Q(status='interviewing')),
|
||||
hired=Count('id', filter=Q(status='hired')),
|
||||
rejected=Count('id', filter=Q(status='rejected')),
|
||||
)
|
||||
|
||||
# 按投递人去重统计学历和院校分布(每人只算一次,取最高学历)
|
||||
DEGREE_RANK = {'博士': 4, 'MBA': 3, '硕士': 2, '本科': 1, '其他': 0}
|
||||
school_type_counter = Counter()
|
||||
degree_counter = Counter()
|
||||
|
||||
def pick_top_edu(edu_list):
|
||||
"""从教育经历中取最高学历的那条"""
|
||||
best = None
|
||||
best_rank = -1
|
||||
for edu in edu_list:
|
||||
d = edu.get('degree', '').strip()
|
||||
rank = DEGREE_RANK.get(d, 0)
|
||||
if rank > best_rank:
|
||||
best_rank = rank
|
||||
best = edu
|
||||
return best or (edu_list[0] if edu_list else None)
|
||||
|
||||
# 按 applicant_id 去重,每个人只取一条投递快照
|
||||
seen_applicants = set()
|
||||
for applicant_id, snapshot in apps_qs.values_list('applicant_id', 'resume_snapshot'):
|
||||
if applicant_id in seen_applicants:
|
||||
continue
|
||||
seen_applicants.add(applicant_id)
|
||||
edu_list = snapshot.get('education', []) if snapshot else []
|
||||
edu_list = [e for e in edu_list if e.get('school', '').strip() or e.get('degree', '').strip()]
|
||||
top = pick_top_edu(edu_list)
|
||||
if top:
|
||||
degree = top.get('degree', '').strip()
|
||||
if degree:
|
||||
degree_counter[degree] += 1
|
||||
school_name = top.get('school', '').strip()
|
||||
school_type_counter[classify_school(school_name)] += 1
|
||||
|
||||
# 如果没有投递数据,从所有简历统计
|
||||
if not seen_applicants:
|
||||
from apps.resumes.models import Resume
|
||||
for resume in Resume.objects.all():
|
||||
edu_list = [e for e in (resume.education or []) if e.get('school', '').strip() or e.get('degree', '').strip()]
|
||||
top = pick_top_edu(edu_list)
|
||||
if top:
|
||||
degree = top.get('degree', '').strip()
|
||||
if degree:
|
||||
degree_counter[degree] += 1
|
||||
school_name = top.get('school', '').strip()
|
||||
school_type_counter[classify_school(school_name)] += 1
|
||||
|
||||
# 保证 985/211/其他 顺序固定
|
||||
school_type_dist = [
|
||||
{'name': '985', 'value': school_type_counter.get('985', 0)},
|
||||
{'name': '211', 'value': school_type_counter.get('211', 0)},
|
||||
{'name': '其他', 'value': school_type_counter.get('其他', 0)},
|
||||
]
|
||||
degree_dist = [{'name': k, 'value': v} for k, v in degree_counter.items()]
|
||||
|
||||
# 职位类别分布(top 10)
|
||||
category_dist = list(
|
||||
jobs_qs.values('category')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')[:10]
|
||||
)
|
||||
|
||||
return Response({
|
||||
'job_stats': job_stats,
|
||||
'app_stats': app_stats,
|
||||
'school_type_dist': school_type_dist,
|
||||
'degree_dist': degree_dist,
|
||||
'category_dist': [{'name': c['category'], 'value': c['count']} for c in category_dist],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,23 +4,36 @@ from .models import Organization
|
|||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
job_count = serializers.SerializerMethodField()
|
||||
edu_summary = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active', 'job_count']
|
||||
fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active', 'job_count', 'edu_summary']
|
||||
|
||||
def get_job_count(self, obj):
|
||||
return obj.jobs.filter(status='published').count()
|
||||
|
||||
def get_edu_summary(self, obj):
|
||||
"""每个学历的招聘总人数"""
|
||||
from django.db.models import Sum
|
||||
jobs = obj.jobs.filter(status='published')
|
||||
result = []
|
||||
for edu in ['博士', '硕士', '本科及以下']:
|
||||
total = jobs.filter(education=edu).aggregate(s=Sum('headcount'))['s'] or 0
|
||||
if total > 0:
|
||||
result.append({'education': edu, 'count': total})
|
||||
return result
|
||||
|
||||
|
||||
class OrganizationTreeSerializer(serializers.ModelSerializer):
|
||||
"""带子公司列表,用于门户展示"""
|
||||
children = serializers.SerializerMethodField()
|
||||
job_count = serializers.SerializerMethodField()
|
||||
jobs_by_education = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ['id', 'name', 'logo', 'description', 'email', 'job_count', 'children']
|
||||
fields = ['id', 'name', 'logo', 'description', 'email', 'job_count', 'children', 'jobs_by_education']
|
||||
|
||||
def get_job_count(self, obj):
|
||||
return obj.jobs.filter(status='published').count()
|
||||
|
|
@ -29,3 +42,14 @@ class OrganizationTreeSerializer(serializers.ModelSerializer):
|
|||
return OrganizationSerializer(
|
||||
obj.children.filter(is_active=True), many=True, context=self.context
|
||||
).data
|
||||
|
||||
def get_jobs_by_education(self, obj):
|
||||
from apps.jobs.models import Job
|
||||
org_ids = [obj.id] + list(obj.children.filter(is_active=True).values_list('id', flat=True))
|
||||
jobs = Job.objects.filter(organization_id__in=org_ids, status='published').values('title', 'education', 'headcount')
|
||||
result = []
|
||||
for edu in ['博士', '硕士', '本科及以下']:
|
||||
edu_jobs = [{'title': j['title'], 'headcount': j['headcount']} for j in jobs if j['education'] == edu]
|
||||
if edu_jobs:
|
||||
result.append({'education': edu, 'jobs': edu_jobs})
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.6",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.6",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.12",
|
||||
|
|
@ -1286,6 +1287,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.13.6",
|
||||
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz",
|
||||
|
|
@ -2144,6 +2155,12 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz",
|
||||
|
|
@ -2407,6 +2424,15 @@
|
|||
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.6",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.6",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.12",
|
||||
|
|
|
|||
|
|
@ -3,8 +3,18 @@ import client from './client'
|
|||
export const getJobs = (params) => client.get('/jobs/public/', { params })
|
||||
export const getJob = (id) => client.get(`/jobs/public/${id}/`)
|
||||
export const manageJobs = (page = 1) => client.get('/jobs/manage/', { params: { page } })
|
||||
export const getManageJob = (id) => client.get(`/jobs/manage/${id}/`)
|
||||
export const createJob = (data) => client.post('/jobs/manage/', data)
|
||||
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
|
||||
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)
|
||||
export const toggleFavorite = (id) => client.post(`/jobs/public/${id}/favorite/`)
|
||||
export const getMyFavorites = () => client.get('/jobs/favorites/')
|
||||
export const getDashboard = () => client.get('/jobs/dashboard/')
|
||||
export const downloadJobTemplate = () => client.get('/jobs/manage/template/', { responseType: 'blob' })
|
||||
export const importJobsExcel = (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return client.post('/jobs/manage/import-excel/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
<template>
|
||||
<div class="admin-layout">
|
||||
<header class="admin-header">
|
||||
<div class="header-title">管理后台</div>
|
||||
<div class="header-brand">
|
||||
<div class="brand-mark">建材</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-title">Offer 招聘管理平台</span>
|
||||
<span class="brand-sub">ADMIN CONSOLE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta"></div>
|
||||
<el-dropdown>
|
||||
<div class="header-user">
|
||||
<span>{{ auth.user?.email }}</span>
|
||||
<div class="user-avatar">{{ initial }}</div>
|
||||
<span class="user-email">{{ auth.user?.email }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
|
|
@ -16,15 +24,36 @@
|
|||
</header>
|
||||
<div class="admin-body">
|
||||
<aside class="admin-aside">
|
||||
<el-menu router :default-active="$route.path" background-color="#B80006" text-color="#fff" active-text-color="#FFD0D2">
|
||||
<el-menu-item index="/admin/dashboard">数据概览</el-menu-item>
|
||||
<el-menu-item index="/admin/jobs">职位管理</el-menu-item>
|
||||
<el-menu-item index="/admin/applications">投递管理</el-menu-item>
|
||||
<el-menu
|
||||
router
|
||||
:default-active="$route.path"
|
||||
background-color="transparent"
|
||||
text-color="#4b5563"
|
||||
active-text-color="#C60C1E"
|
||||
>
|
||||
<div class="aside-label">业务管理</div>
|
||||
<el-menu-item index="/admin/dashboard">
|
||||
<el-icon><TrendCharts /></el-icon><span>数据概览</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/jobs">
|
||||
<el-icon><Briefcase /></el-icon><span>职位管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/applications">
|
||||
<el-icon><Document /></el-icon><span>投递管理</span>
|
||||
</el-menu-item>
|
||||
<template v-if="auth.isSuperAdmin">
|
||||
<el-menu-item index="/admin/organizations">组织架构</el-menu-item>
|
||||
<el-menu-item index="/admin/users">用户管理</el-menu-item>
|
||||
<div class="aside-label">系统设置</div>
|
||||
<el-menu-item index="/admin/organizations">
|
||||
<el-icon><OfficeBuilding /></el-icon><span>组织架构</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-icon><User /></el-icon><span>用户管理</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
<el-menu-item index="/home">返回首页</el-menu-item>
|
||||
<div class="aside-label">其他</div>
|
||||
<el-menu-item index="/home">
|
||||
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
<main class="admin-main">
|
||||
|
|
@ -34,14 +63,17 @@
|
|||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { ArrowDown, TrendCharts, Briefcase, Document, OfficeBuilding, User, HomeFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const initial = computed(() => (auth.user?.email || 'A').slice(0, 1).toUpperCase())
|
||||
|
||||
function logout() {
|
||||
localStorage.clear()
|
||||
ElMessage.success('已退出登入')
|
||||
|
|
@ -59,43 +91,147 @@ function logout() {
|
|||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— 顶栏:央企红通栏 —— */
|
||||
.admin-header {
|
||||
height: 56px;
|
||||
height: 58px;
|
||||
flex-shrink: 0;
|
||||
background: #B80006;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: linear-gradient(180deg, #C60C1E 0%, #a30815 100%);
|
||||
border-bottom: 2px solid var(--adm-gold);
|
||||
box-shadow: 0 2px 8px rgba(138, 7, 18, 0.25);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.header-title {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--adm-red);
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.brand-title { color: #fff; font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
||||
.brand-sub { color: rgba(255, 255, 255, 0.7); font-size: 10px; letter-spacing: 0.18em; }
|
||||
|
||||
.header-meta { flex: 1; }
|
||||
|
||||
.header-user {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px 4px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.header-user:hover { background: rgba(255, 255, 255, 0.12); }
|
||||
.user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: var(--adm-gold);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-email { font-size: 13px; }
|
||||
|
||||
.admin-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— 侧栏:白底 + 红色选中 —— */
|
||||
.admin-aside {
|
||||
width: 200px;
|
||||
width: 214px;
|
||||
flex-shrink: 0;
|
||||
background: #B80006;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--adm-line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 0 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.aside-label {
|
||||
padding: 16px 18px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
color: #9ca3af;
|
||||
font-weight: 600;
|
||||
}
|
||||
.aside-label:first-child { padding-top: 8px; }
|
||||
|
||||
.admin-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
}
|
||||
.admin-aside :deep(.el-menu-item) {
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
margin: 2px 10px;
|
||||
border-radius: 4px;
|
||||
padding: 0 14px !important;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
transition: background 0.16s ease, color 0.16s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.admin-aside :deep(.el-menu-item .el-icon) {
|
||||
font-size: 17px;
|
||||
margin-right: 10px;
|
||||
color: #9ca3af;
|
||||
transition: color 0.16s ease;
|
||||
}
|
||||
.admin-aside :deep(.el-menu-item:hover) {
|
||||
background: var(--adm-red-bg);
|
||||
color: var(--adm-red);
|
||||
}
|
||||
.admin-aside :deep(.el-menu-item:hover .el-icon) { color: var(--adm-red); }
|
||||
.admin-aside :deep(.el-menu-item.is-active) {
|
||||
background: var(--adm-red-bg);
|
||||
color: var(--adm-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-aside :deep(.el-menu-item.is-active .el-icon) { color: var(--adm-red); }
|
||||
.admin-aside :deep(.el-menu-item.is-active::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--adm-red);
|
||||
}
|
||||
|
||||
/* —— 内容区 —— */
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: #f5f6fa;
|
||||
padding: 18px 22px;
|
||||
background: var(--adm-page);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ const routes = [
|
|||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requireAuth: true, role: 'admin' },
|
||||
children: [
|
||||
{ path: '', redirect: '/admin/dashboard' },
|
||||
{ path: 'dashboard', name: 'AdminDashboard', component: () => import('@/views/admin/DashboardView.vue') },
|
||||
{ path: 'jobs', name: 'AdminJobs', component: () => import('@/views/admin/JobManageView.vue') },
|
||||
{ path: 'applications', name: 'AdminApplications', component: () => import('@/views/admin/ApplicationManageView.vue') },
|
||||
{ path: 'organizations', name: 'AdminOrganizations', component: () => import('@/views/admin/OrganizationManageView.vue'), meta: { role: 'superadmin' } },
|
||||
|
|
|
|||
|
|
@ -34,6 +34,177 @@
|
|||
--el-color-primary-dark-2: #B80006;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
管理后台主题 · 央企红门户风格
|
||||
红顶白底 · 扁平务实 · 信息密集 · 金色点缀
|
||||
仅作用于 .admin-layout,不影响门户/求职端
|
||||
============================================================ */
|
||||
.admin-layout {
|
||||
--adm-red: #C60C1E;
|
||||
--adm-red-dark: #a30815;
|
||||
--adm-red-deeper: #8a0712;
|
||||
--adm-red-bg: #fdecef;
|
||||
--adm-red-bg-2: #fbe7ea;
|
||||
--adm-gold: #C8A052;
|
||||
--adm-ink: #1a1a1a;
|
||||
--adm-muted: #6b7280;
|
||||
--adm-line: #e5e7eb;
|
||||
--adm-page: #f4f5f7;
|
||||
|
||||
/* Element Plus 主色 → 央企红 */
|
||||
--el-color-primary: #C60C1E;
|
||||
--el-color-primary-light-3: #d4404f;
|
||||
--el-color-primary-light-5: #df6671;
|
||||
--el-color-primary-light-7: #ec949c;
|
||||
--el-color-primary-light-8: #f4b4b9;
|
||||
--el-color-primary-light-9: #fdecef;
|
||||
--el-color-primary-dark-2: #a30815;
|
||||
|
||||
--el-text-color-primary: #1a1a1a;
|
||||
--el-text-color-regular: #4b5563;
|
||||
--el-text-color-secondary: #6b7280;
|
||||
--el-text-color-placeholder: #9ca3af;
|
||||
--el-border-color: #e5e7eb;
|
||||
--el-border-color-light: #eef0f3;
|
||||
--el-border-color-lighter: #f3f4f6;
|
||||
--el-fill-color: #f7f8fa;
|
||||
--el-fill-color-light: #fafbfc;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-bg-color: #ffffff;
|
||||
--el-bg-color-page: #f4f5f7;
|
||||
--el-box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--el-box-shadow-light: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--el-border-radius-base: 4px;
|
||||
--el-border-radius-small: 3px;
|
||||
--el-border-radius-round: 4px;
|
||||
|
||||
color-scheme: light;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
/* —— 内容区:浅灰纸面 —— */
|
||||
.admin-layout .admin-main {
|
||||
background: var(--adm-page);
|
||||
}
|
||||
|
||||
/* —— 页面标题 —— */
|
||||
.admin-layout h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--adm-ink);
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.admin-layout h2::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--adm-red);
|
||||
}
|
||||
|
||||
/* —— 卡片:白底 + 细边 + 极淡影,扁平 —— */
|
||||
.admin-layout .el-card {
|
||||
border: 1px solid var(--adm-line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.admin-layout .el-card:hover {
|
||||
border-color: #d8dbe0;
|
||||
box-shadow: 0 2px 8px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
.admin-layout .el-card .el-card__header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--adm-line);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.admin-layout .el-card .el-card__body { padding: 18px; }
|
||||
.admin-layout .stat-card { margin-bottom: 14px; }
|
||||
|
||||
/* —— 表格:清晰边框,表头浅灰底 —— */
|
||||
.admin-layout .el-table {
|
||||
--el-table-border-color: #e5e7eb;
|
||||
--el-table-header-bg-color: #f7f8fa;
|
||||
--el-table-header-text-color: #1a1a1a;
|
||||
--el-table-row-hover-bg-color: var(--adm-red-bg-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.admin-layout .el-table th.el-table__cell {
|
||||
font-weight: 700;
|
||||
color: var(--adm-ink);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.admin-layout .el-table th.el-table__cell,
|
||||
.admin-layout .el-table td.el-table__cell {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* —— 按钮:红底白字主按钮,扁平 —— */
|
||||
.admin-layout .el-button {
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.admin-layout .el-button--primary {
|
||||
background: var(--adm-red);
|
||||
border-color: var(--adm-red);
|
||||
}
|
||||
.admin-layout .el-button--primary:hover {
|
||||
background: var(--adm-red-dark);
|
||||
border-color: var(--adm-red-dark);
|
||||
}
|
||||
.admin-layout .el-button--primary:active {
|
||||
background: var(--adm-red-deeper);
|
||||
border-color: var(--adm-red-deeper);
|
||||
}
|
||||
.admin-layout .el-button--success {
|
||||
background: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
.admin-layout .el-button--success:hover { background: #0e5fd8; border-color: #0e5fd8; }
|
||||
|
||||
/* —— 输入/选择:聚焦红色细边 —— */
|
||||
.admin-layout .el-input__wrapper,
|
||||
.admin-layout .el-select__wrapper,
|
||||
.admin-layout .el-textarea__inner {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px var(--adm-line) inset;
|
||||
transition: box-shadow 0.18s ease;
|
||||
}
|
||||
.admin-layout .el-input__wrapper:hover,
|
||||
.admin-layout .el-select__wrapper:hover { box-shadow: 0 0 0 1px #c0c4cc inset; }
|
||||
.admin-layout .el-input__wrapper.is-focus,
|
||||
.admin-layout .el-select__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--adm-red) inset;
|
||||
}
|
||||
|
||||
/* —— 标签 / 分页 —— */
|
||||
.admin-layout .el-tag { border-radius: 3px; font-weight: 500; }
|
||||
.admin-layout .el-pagination .el-pager li.is-active {
|
||||
background: var(--adm-red);
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.admin-layout .el-pagination .el-pager li:hover { color: var(--adm-red); }
|
||||
|
||||
/* —— Dialog —— */
|
||||
.el-overlay .el-dialog { border-radius: 6px; overflow: hidden; }
|
||||
.el-overlay .el-dialog .el-dialog__header {
|
||||
background: var(--adm-red);
|
||||
margin: 0;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
.el-overlay .el-dialog .el-dialog__title { color: #fff; font-weight: 600; }
|
||||
.el-overlay .el-dialog .el-dialog__headerbtn .el-dialog__close { color: #fff; }
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: var(--brand-red);
|
||||
|
|
|
|||
|
|
@ -332,10 +332,6 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -343,32 +339,59 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a73e8;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #1a1a1a;
|
||||
position: relative;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.card-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--adm-red, #C60C1E);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
border-radius: 4px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #eef0f3;
|
||||
border-left: 3px solid var(--adm-line, #e5e7eb);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.stat-item:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
.stat-item .stat-value.primary { color: #C60C1E; }
|
||||
.stat-item:has(.stat-value.primary) { border-left-color: #C60C1E; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--adm-muted, #6b7280);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: #1a1a1a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-value.primary { color: #1a73e8; }
|
||||
.stat-value.success { color: #67c23a; }
|
||||
.stat-value.warning { color: #e6a23c; }
|
||||
.stat-value.danger { color: #f56c6c; }
|
||||
.stat-value.info { color: var(--brand-red); }
|
||||
.stat-value.primary { color: #C60C1E; }
|
||||
.stat-value.success { color: #16a34a; }
|
||||
.stat-value.warning { color: #d97706; }
|
||||
.stat-value.danger { color: #dc2626; }
|
||||
.stat-value.info { color: #1677ff; }
|
||||
|
||||
.chart-box {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,24 @@
|
|||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>职位管理</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button @click="handleDownloadTemplate">
|
||||
<el-icon style="margin-right:4px"><Download /></el-icon>下载模板
|
||||
</el-button>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="handleImportExcel"
|
||||
>
|
||||
<el-button type="success">
|
||||
<el-icon style="margin-right:4px"><Upload /></el-icon>导入Excel
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button type="primary" @click="openDialog()">发布职位</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="jobs" v-loading="loading" border>
|
||||
<el-table-column label="职位名称">
|
||||
<template #default="{ row }">
|
||||
|
|
@ -89,8 +105,9 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Download, Upload } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { manageJobs, createJob, updateJob, deleteJob } from '@/api/jobs'
|
||||
import { manageJobs, getManageJob, createJob, updateJob, deleteJob, downloadJobTemplate, importJobsExcel } from '@/api/jobs'
|
||||
import { getOrganizations } from '@/api/organizations'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
|
|
@ -128,20 +145,28 @@ const fetchOrgs = async () => {
|
|||
allOrgs.value = data.results
|
||||
}
|
||||
|
||||
function openDialog(job = null) {
|
||||
editingJob.value = job
|
||||
async function openDialog(job = null) {
|
||||
if (job) {
|
||||
// 从详情接口获取完整数据(含 description)
|
||||
try {
|
||||
const { data } = await getManageJob(job.id)
|
||||
editingJob.value = data
|
||||
Object.assign(form, {
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
education: job.education || '本科及以下',
|
||||
location: job.location,
|
||||
salary: job.salary,
|
||||
description: job.description,
|
||||
status: job.status,
|
||||
organization_id: job.organization?.id ?? job.organization ?? null,
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
education: data.education || '本科及以下',
|
||||
location: data.location,
|
||||
salary: data.salary,
|
||||
description: data.description || '',
|
||||
status: data.status,
|
||||
organization_id: data.organization?.id ?? data.organization ?? null,
|
||||
})
|
||||
} catch {
|
||||
ElMessage.error('获取职位详情失败')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
editingJob.value = null
|
||||
Object.assign(form, {
|
||||
title: '', category: '', education: '本科及以下', location: '', salary: '',
|
||||
description: '', status: 'draft', organization_id: null
|
||||
|
|
@ -155,7 +180,8 @@ function handlePageChange(newPage) {
|
|||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (auth.isSuperAdmin && !form.organization_id) {
|
||||
const isEdit = !!editingJob.value
|
||||
if (auth.isSuperAdmin && !isEdit && !form.organization_id) {
|
||||
return ElMessage.warning('请选择所属公司')
|
||||
}
|
||||
saving.value = true
|
||||
|
|
@ -164,13 +190,21 @@ async function handleSave() {
|
|||
if (!auth.isSuperAdmin) {
|
||||
payload.organization_id = auth.user?.organization ?? undefined
|
||||
}
|
||||
if (editingJob.value) await updateJob(editingJob.value.id, payload)
|
||||
// 编辑时如果没改公司,不传 organization_id 避免校验问题
|
||||
if (isEdit && !payload.organization_id) {
|
||||
delete payload.organization_id
|
||||
}
|
||||
if (isEdit) await updateJob(editingJob.value.id, payload)
|
||||
else await createJob(payload)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchJobs(1)
|
||||
fetchJobs(currentPage.value)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
const errData = e.response?.data
|
||||
const msg = typeof errData === 'object'
|
||||
? errData.detail || Object.values(errData).flat().join(';')
|
||||
: '保存失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
|
@ -183,6 +217,51 @@ async function handleDelete(id) {
|
|||
fetchJobs(1)
|
||||
}
|
||||
|
||||
const uploadRef = ref(null)
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
const { data } = await downloadJobTemplate()
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'job_import_template.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('模板下载成功')
|
||||
} catch {
|
||||
ElMessage.error('模板下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportExcel(uploadFile) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
try {
|
||||
const { data } = await importJobsExcel(file)
|
||||
const msg = `成功导入 ${data.created} 个职位`
|
||||
if (data.errors?.length) {
|
||||
ElMessageBox.alert(
|
||||
`${msg},但有 ${data.errors.length} 条错误:\n\n${data.errors.join('\n')}`,
|
||||
'导入结果',
|
||||
{ type: 'warning', confirmButtonText: '知道了' }
|
||||
)
|
||||
} else {
|
||||
ElMessage.success(msg)
|
||||
}
|
||||
fetchJobs(1)
|
||||
} catch (e) {
|
||||
const detail = e.response?.data
|
||||
if (detail?.errors?.length) {
|
||||
ElMessageBox.alert(detail.errors.join('\n'), '导入失败', { type: 'error', confirmButtonText: '知道了' })
|
||||
} else {
|
||||
ElMessage.error(detail?.detail || '导入失败,请检查文件格式')
|
||||
}
|
||||
}
|
||||
// 清除上传组件状态,允许重复上传同一文件
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchJobs()
|
||||
if (auth.isSuperAdmin) fetchOrgs()
|
||||
|
|
|
|||
|
|
@ -21,9 +21,24 @@
|
|||
</template>
|
||||
<div v-for="(edu, i) in form.education" :key="i" style="margin-bottom:8px">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><el-input v-model="edu.school" placeholder="学校名称" /></el-col>
|
||||
<el-col :span="6"><el-input v-model="edu.degree" placeholder="学历" /></el-col>
|
||||
<el-col :span="7"><el-input v-model="edu.major" placeholder="专业" /></el-col>
|
||||
<el-col :span="6"><el-input v-model="edu.school" placeholder="学校名称" /></el-col>
|
||||
<el-col :span="5">
|
||||
<el-select v-model="edu.school_type" placeholder="院校类型" style="width:100%">
|
||||
<el-option value="985" label="985" />
|
||||
<el-option value="211" label="211" />
|
||||
<el-option value="其他" label="其他" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-select v-model="edu.degree" placeholder="学历" style="width:100%">
|
||||
<el-option value="博士" label="博士" />
|
||||
<el-option value="硕士" label="硕士" />
|
||||
<el-option value="本科" label="本科" />
|
||||
<el-option value="MBA" label="MBA" />
|
||||
<el-option value="其他" label="其他" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="5"><el-input v-model="edu.major" placeholder="专业" /></el-col>
|
||||
<el-col :span="3"><el-button type="danger" link @click="form.education.splice(i,1)">删除</el-button></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
|
@ -81,7 +96,7 @@ const attachmentName = computed(() => {
|
|||
return decodeURIComponent(form.value.attachment.split('/').pop())
|
||||
})
|
||||
|
||||
const addEducation = () => form.value.education.push({ school: '', degree: '', major: '' })
|
||||
const addEducation = () => form.value.education.push({ school: '', school_type: '', degree: '', major: '' })
|
||||
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })
|
||||
|
||||
async function uploadAttachment({ file }) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
@echo off
|
||||
:: ============================================================
|
||||
:: Start offer dev environment
|
||||
:: Backend : http://127.0.0.1:8000 (Django, uses venv)
|
||||
:: Frontend: http://localhost:5173 (Vite)
|
||||
:: Close the window to stop the service.
|
||||
:: ============================================================
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
set "BACKEND=%ROOT%offer_backend"
|
||||
set "FRONTEND=%ROOT%offer_frontend"
|
||||
set "VENV_PY=%BACKEND%\.venv\Scripts\python.exe"
|
||||
|
||||
echo [*] Starting backend (Django :8000) ...
|
||||
start "offer-backend" /D "%BACKEND%" cmd /k "%VENV_PY%" manage.py runserver 0.0.0.0:8000
|
||||
|
||||
echo [*] Starting frontend (Vite :5173) ...
|
||||
start "offer-frontend" /D "%FRONTEND%" cmd /k npm run dev
|
||||
|
||||
echo.
|
||||
echo === Started ===
|
||||
echo Backend : http://127.0.0.1:8000
|
||||
echo Frontend: http://localhost:5173
|
||||
echo.
|
||||
pause
|
||||
Loading…
Reference in New Issue