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='工作地点')
|
location = models.CharField(max_length=100, verbose_name='工作地点')
|
||||||
salary = models.CharField(max_length=50, verbose_name='薪资范围')
|
salary = models.CharField(max_length=50, verbose_name='薪资范围')
|
||||||
education = models.CharField(max_length=20, choices=EDUCATION_CHOICES, default='本科及以下', 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)
|
description = models.TextField(verbose_name='职位描述', blank=True)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
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:
|
class Meta:
|
||||||
model = Job
|
model = Job
|
||||||
fields = ['id', 'title', 'category', 'location', 'salary', 'education',
|
fields = ['id', 'title', 'category', 'location', 'salary', 'education', 'headcount',
|
||||||
'organization', 'organization_name', 'status', 'created_at']
|
'organization', 'organization_name', 'status', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ class JobDetailSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Job
|
model = Job
|
||||||
fields = ['id', 'title', 'category', 'location', 'salary', 'education',
|
fields = ['id', 'title', 'category', 'location', 'salary', 'education', 'headcount',
|
||||||
'description', 'organization', 'organization_id', 'status', 'created_at']
|
'description', 'organization', 'organization_id', 'status', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView
|
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView, DashboardView
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register('public', JobPublicViewSet, basename='job-public')
|
router.register('public', JobPublicViewSet, basename='job-public')
|
||||||
|
|
@ -9,4 +9,5 @@ router.register('manage', JobManageViewSet, basename='job-manage')
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('favorites/', MyFavoritesView.as_view()),
|
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.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
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 .models import Job, JobFavorite
|
||||||
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
||||||
from .filters import JobFilter
|
from .filters import JobFilter
|
||||||
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
|
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):
|
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
|
@ -16,6 +34,7 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
search_fields = ['title', 'description', 'location']
|
search_fields = ['title', 'description', 'location']
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
pagination_class = JobPublicPagination
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == 'retrieve':
|
if self.action == 'retrieve':
|
||||||
|
|
@ -68,3 +87,348 @@ class JobManageViewSet(viewsets.ModelViewSet):
|
||||||
else:
|
else:
|
||||||
# 超管需要在请求体中提供 organization_id
|
# 超管需要在请求体中提供 organization_id
|
||||||
serializer.save()
|
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):
|
class OrganizationSerializer(serializers.ModelSerializer):
|
||||||
job_count = serializers.SerializerMethodField()
|
job_count = serializers.SerializerMethodField()
|
||||||
|
edu_summary = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Organization
|
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):
|
def get_job_count(self, obj):
|
||||||
return obj.jobs.filter(status='published').count()
|
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):
|
class OrganizationTreeSerializer(serializers.ModelSerializer):
|
||||||
"""带子公司列表,用于门户展示"""
|
"""带子公司列表,用于门户展示"""
|
||||||
children = serializers.SerializerMethodField()
|
children = serializers.SerializerMethodField()
|
||||||
job_count = serializers.SerializerMethodField()
|
job_count = serializers.SerializerMethodField()
|
||||||
|
jobs_by_education = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Organization
|
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):
|
def get_job_count(self, obj):
|
||||||
return obj.jobs.filter(status='published').count()
|
return obj.jobs.filter(status='published').count()
|
||||||
|
|
@ -29,3 +42,14 @@ class OrganizationTreeSerializer(serializers.ModelSerializer):
|
||||||
return OrganizationSerializer(
|
return OrganizationSerializer(
|
||||||
obj.children.filter(is_active=True), many=True, context=self.context
|
obj.children.filter(is_active=True), many=True, context=self.context
|
||||||
).data
|
).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": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.12",
|
"vue": "^3.5.12",
|
||||||
|
|
@ -1286,6 +1287,16 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.13.6",
|
"version": "2.13.6",
|
||||||
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz",
|
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz",
|
||||||
|
|
@ -2144,6 +2155,12 @@
|
||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/ufo": {
|
||||||
"version": "1.6.3",
|
"version": "1.6.3",
|
||||||
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz",
|
"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",
|
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||||
"dev": true
|
"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": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.12",
|
"vue": "^3.5.12",
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,18 @@ import client from './client'
|
||||||
export const getJobs = (params) => client.get('/jobs/public/', { params })
|
export const getJobs = (params) => client.get('/jobs/public/', { params })
|
||||||
export const getJob = (id) => client.get(`/jobs/public/${id}/`)
|
export const getJob = (id) => client.get(`/jobs/public/${id}/`)
|
||||||
export const manageJobs = (page = 1) => client.get('/jobs/manage/', { params: { page } })
|
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 createJob = (data) => client.post('/jobs/manage/', data)
|
||||||
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
|
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
|
||||||
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)
|
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)
|
||||||
export const toggleFavorite = (id) => client.post(`/jobs/public/${id}/favorite/`)
|
export const toggleFavorite = (id) => client.post(`/jobs/public/${id}/favorite/`)
|
||||||
export const getMyFavorites = () => client.get('/jobs/favorites/')
|
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>
|
<template>
|
||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
<header class="admin-header">
|
<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>
|
<el-dropdown>
|
||||||
<div class="header-user">
|
<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>
|
<el-icon><ArrowDown /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
|
|
@ -16,15 +24,36 @@
|
||||||
</header>
|
</header>
|
||||||
<div class="admin-body">
|
<div class="admin-body">
|
||||||
<aside class="admin-aside">
|
<aside class="admin-aside">
|
||||||
<el-menu router :default-active="$route.path" background-color="#B80006" text-color="#fff" active-text-color="#FFD0D2">
|
<el-menu
|
||||||
<el-menu-item index="/admin/dashboard">数据概览</el-menu-item>
|
router
|
||||||
<el-menu-item index="/admin/jobs">职位管理</el-menu-item>
|
:default-active="$route.path"
|
||||||
<el-menu-item index="/admin/applications">投递管理</el-menu-item>
|
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">
|
<template v-if="auth.isSuperAdmin">
|
||||||
<el-menu-item index="/admin/organizations">组织架构</el-menu-item>
|
<div class="aside-label">系统设置</div>
|
||||||
<el-menu-item index="/admin/users">用户管理</el-menu-item>
|
<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>
|
</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>
|
</el-menu>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
|
|
@ -34,14 +63,17 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
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'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const initial = computed(() => (auth.user?.email || 'A').slice(0, 1).toUpperCase())
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
ElMessage.success('已退出登入')
|
ElMessage.success('已退出登入')
|
||||||
|
|
@ -59,43 +91,147 @@ function logout() {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 顶栏:央企红通栏 —— */
|
||||||
.admin-header {
|
.admin-header {
|
||||||
height: 56px;
|
height: 58px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #B80006;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 24px;
|
||||||
padding: 0 20px;
|
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 {
|
.header-brand {
|
||||||
color: #fff;
|
display: flex;
|
||||||
font-weight: bold;
|
align-items: center;
|
||||||
font-size: 16px;
|
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 {
|
.header-user {
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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 {
|
.admin-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 侧栏:白底 + 红色选中 —— */
|
||||||
.admin-aside {
|
.admin-aside {
|
||||||
width: 200px;
|
width: 214px;
|
||||||
flex-shrink: 0;
|
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;
|
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 {
|
.admin-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 18px 22px;
|
||||||
background: #f5f6fa;
|
background: var(--adm-page);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ const routes = [
|
||||||
component: () => import('@/layouts/AdminLayout.vue'),
|
component: () => import('@/layouts/AdminLayout.vue'),
|
||||||
meta: { requireAuth: true, role: 'admin' },
|
meta: { requireAuth: true, role: 'admin' },
|
||||||
children: [
|
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: 'jobs', name: 'AdminJobs', component: () => import('@/views/admin/JobManageView.vue') },
|
||||||
{ path: 'applications', name: 'AdminApplications', component: () => import('@/views/admin/ApplicationManageView.vue') },
|
{ path: 'applications', name: 'AdminApplications', component: () => import('@/views/admin/ApplicationManageView.vue') },
|
||||||
{ path: 'organizations', name: 'AdminOrganizations', component: () => import('@/views/admin/OrganizationManageView.vue'), meta: { role: 'superadmin' } },
|
{ path: 'organizations', name: 'AdminOrganizations', component: () => import('@/views/admin/OrganizationManageView.vue'), meta: { role: 'superadmin' } },
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,177 @@
|
||||||
--el-color-primary-dark-2: #B80006;
|
--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 {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--brand-red);
|
color: var(--brand-red);
|
||||||
|
|
|
||||||
|
|
@ -332,10 +332,6 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stat-card {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -343,32 +339,59 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #1a73e8;
|
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 {
|
.stat-item {
|
||||||
text-align: left;
|
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 {
|
.stat-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--adm-muted, #6b7280);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 28px;
|
font-size: 30px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: #333;
|
letter-spacing: -0.01em;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.primary { color: #1a73e8; }
|
.stat-value.primary { color: #C60C1E; }
|
||||||
.stat-value.success { color: #67c23a; }
|
.stat-value.success { color: #16a34a; }
|
||||||
.stat-value.warning { color: #e6a23c; }
|
.stat-value.warning { color: #d97706; }
|
||||||
.stat-value.danger { color: #f56c6c; }
|
.stat-value.danger { color: #dc2626; }
|
||||||
.stat-value.info { color: var(--brand-red); }
|
.stat-value.info { color: #1677ff; }
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,24 @@
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
<h2>职位管理</h2>
|
<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>
|
<el-button type="primary" @click="openDialog()">发布职位</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<el-table :data="jobs" v-loading="loading" border>
|
<el-table :data="jobs" v-loading="loading" border>
|
||||||
<el-table-column label="职位名称">
|
<el-table-column label="职位名称">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
|
@ -89,8 +105,9 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { Download, Upload } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
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 { getOrganizations } from '@/api/organizations'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
|
@ -128,20 +145,28 @@ const fetchOrgs = async () => {
|
||||||
allOrgs.value = data.results
|
allOrgs.value = data.results
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDialog(job = null) {
|
async function openDialog(job = null) {
|
||||||
editingJob.value = job
|
|
||||||
if (job) {
|
if (job) {
|
||||||
|
// 从详情接口获取完整数据(含 description)
|
||||||
|
try {
|
||||||
|
const { data } = await getManageJob(job.id)
|
||||||
|
editingJob.value = data
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
title: job.title,
|
title: data.title,
|
||||||
category: job.category,
|
category: data.category,
|
||||||
education: job.education || '本科及以下',
|
education: data.education || '本科及以下',
|
||||||
location: job.location,
|
location: data.location,
|
||||||
salary: job.salary,
|
salary: data.salary,
|
||||||
description: job.description,
|
description: data.description || '',
|
||||||
status: job.status,
|
status: data.status,
|
||||||
organization_id: job.organization?.id ?? job.organization ?? null,
|
organization_id: data.organization?.id ?? data.organization ?? null,
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('获取职位详情失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
editingJob.value = null
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
title: '', category: '', education: '本科及以下', location: '', salary: '',
|
title: '', category: '', education: '本科及以下', location: '', salary: '',
|
||||||
description: '', status: 'draft', organization_id: null
|
description: '', status: 'draft', organization_id: null
|
||||||
|
|
@ -155,7 +180,8 @@ function handlePageChange(newPage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (auth.isSuperAdmin && !form.organization_id) {
|
const isEdit = !!editingJob.value
|
||||||
|
if (auth.isSuperAdmin && !isEdit && !form.organization_id) {
|
||||||
return ElMessage.warning('请选择所属公司')
|
return ElMessage.warning('请选择所属公司')
|
||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
|
@ -164,13 +190,21 @@ async function handleSave() {
|
||||||
if (!auth.isSuperAdmin) {
|
if (!auth.isSuperAdmin) {
|
||||||
payload.organization_id = auth.user?.organization ?? undefined
|
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)
|
else await createJob(payload)
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
fetchJobs(1)
|
fetchJobs(currentPage.value)
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +217,51 @@ async function handleDelete(id) {
|
||||||
fetchJobs(1)
|
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(() => {
|
onMounted(() => {
|
||||||
fetchJobs()
|
fetchJobs()
|
||||||
if (auth.isSuperAdmin) fetchOrgs()
|
if (auth.isSuperAdmin) fetchOrgs()
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,24 @@
|
||||||
</template>
|
</template>
|
||||||
<div v-for="(edu, i) in form.education" :key="i" style="margin-bottom:8px">
|
<div v-for="(edu, i) in form.education" :key="i" style="margin-bottom:8px">
|
||||||
<el-row :gutter="12">
|
<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.school" placeholder="学校名称" /></el-col>
|
||||||
<el-col :span="6"><el-input v-model="edu.degree" placeholder="学历" /></el-col>
|
<el-col :span="5">
|
||||||
<el-col :span="7"><el-input v-model="edu.major" placeholder="专业" /></el-col>
|
<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-col :span="3"><el-button type="danger" link @click="form.education.splice(i,1)">删除</el-button></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,7 +96,7 @@ const attachmentName = computed(() => {
|
||||||
return decodeURIComponent(form.value.attachment.split('/').pop())
|
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: '' })
|
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })
|
||||||
|
|
||||||
async function uploadAttachment({ file }) {
|
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