feat: 管理后台央企红门户风格重构 + 职位编制字段及启动脚本

- 管理后台改用央企红(#C60C1E)门户风格:红顶白底、扁平务实、
  金色点缀;侧栏导航加图标与分组,Dialog 红标题栏,表格/卡片/按钮统一
- 后端 jobs 新增 headcount(编制)字段及迁移、school_data 学校数据、
  组织序列化调整
- 新增 start.bat / dev.bat 一键启动前后端,.gitignore 规范化

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude 2026-06-26 15:02:50 +08:00
parent d551079fda
commit 1823b95819
19 changed files with 1101 additions and 68 deletions

35
.gitignore vendored Normal file
View File

@ -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

22
dev.bat Normal file
View File

@ -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

View File

@ -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='招聘人数'),
),
]

View File

@ -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)

View File

@ -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 '其他'

View File

@ -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']

View File

@ -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()),
] ]

View File

@ -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],
})

View File

@ -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

View File

@ -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"
}
} }
} }
} }

View File

@ -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",

View File

@ -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' }
})
}

View File

@ -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>

View File

@ -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' } },

View File

@ -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);

View File

@ -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%;

View File

@ -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()

View File

@ -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 }) {

25
start.bat Normal file
View 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