From 1823b95819a506979a2b886f4e4ac9125e2af257 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 15:02:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=A4=AE=E4=BC=81=E7=BA=A2=E9=97=A8=E6=88=B7=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20+=20=E8=81=8C=E4=BD=8D=E7=BC=96=E5=88=B6?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=8F=8A=E5=90=AF=E5=8A=A8=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理后台改用央企红(#C60C1E)门户风格:红顶白底、扁平务实、 金色点缀;侧栏导航加图标与分组,Dialog 红标题栏,表格/卡片/按钮统一 - 后端 jobs 新增 headcount(编制)字段及迁移、school_data 学校数据、 组织序列化调整 - 新增 start.bat / dev.bat 一键启动前后端,.gitignore 规范化 Co-Authored-By: Claude --- .gitignore | 35 ++ dev.bat | 22 ++ .../jobs/migrations/0004_job_headcount.py | 18 + offer_backend/apps/jobs/models.py | 1 + offer_backend/apps/jobs/school_data.py | 80 ++++ offer_backend/apps/jobs/serializers.py | 4 +- offer_backend/apps/jobs/urls.py | 3 +- offer_backend/apps/jobs/views.py | 366 +++++++++++++++++- .../apps/organizations/serializers.py | 28 +- offer_frontend/package-lock.json | 26 ++ offer_frontend/package.json | 1 + offer_frontend/src/api/jobs.js | 10 + offer_frontend/src/layouts/AdminLayout.vue | 184 +++++++-- offer_frontend/src/router/index.js | 2 + offer_frontend/src/style.css | 171 ++++++++ .../src/views/admin/DashboardView.vue | 55 ++- .../src/views/admin/JobManageView.vue | 115 +++++- .../src/views/seeker/ResumeView.vue | 23 +- start.bat | 25 ++ 19 files changed, 1101 insertions(+), 68 deletions(-) create mode 100644 .gitignore create mode 100644 dev.bat create mode 100644 offer_backend/apps/jobs/migrations/0004_job_headcount.py create mode 100644 offer_backend/apps/jobs/school_data.py create mode 100644 start.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5124ee2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/dev.bat b/dev.bat new file mode 100644 index 0000000..19d4723 --- /dev/null +++ b/dev.bat @@ -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 diff --git a/offer_backend/apps/jobs/migrations/0004_job_headcount.py b/offer_backend/apps/jobs/migrations/0004_job_headcount.py new file mode 100644 index 0000000..66f81bc --- /dev/null +++ b/offer_backend/apps/jobs/migrations/0004_job_headcount.py @@ -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='招聘人数'), + ), + ] diff --git a/offer_backend/apps/jobs/models.py b/offer_backend/apps/jobs/models.py index 93387ad..a5d7cdb 100644 --- a/offer_backend/apps/jobs/models.py +++ b/offer_backend/apps/jobs/models.py @@ -22,6 +22,7 @@ class Job(models.Model): location = models.CharField(max_length=100, verbose_name='工作地点') salary = models.CharField(max_length=50, verbose_name='薪资范围') education = models.CharField(max_length=20, choices=EDUCATION_CHOICES, default='本科及以下', verbose_name='学历要求') + headcount = models.PositiveIntegerField(default=1, verbose_name='招聘人数') description = models.TextField(verbose_name='职位描述', blank=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') created_at = models.DateTimeField(auto_now_add=True) diff --git a/offer_backend/apps/jobs/school_data.py b/offer_backend/apps/jobs/school_data.py new file mode 100644 index 0000000..628cd4d --- /dev/null +++ b/offer_backend/apps/jobs/school_data.py @@ -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 '其他' diff --git a/offer_backend/apps/jobs/serializers.py b/offer_backend/apps/jobs/serializers.py index cba1775..8033910 100644 --- a/offer_backend/apps/jobs/serializers.py +++ b/offer_backend/apps/jobs/serializers.py @@ -9,7 +9,7 @@ class JobListSerializer(serializers.ModelSerializer): class Meta: model = Job - fields = ['id', 'title', 'category', 'location', 'salary', 'education', + fields = ['id', 'title', 'category', 'location', 'salary', 'education', 'headcount', 'organization', 'organization_name', 'status', 'created_at'] @@ -24,7 +24,7 @@ class JobDetailSerializer(serializers.ModelSerializer): class Meta: model = Job - fields = ['id', 'title', 'category', 'location', 'salary', 'education', + fields = ['id', 'title', 'category', 'location', 'salary', 'education', 'headcount', 'description', 'organization', 'organization_id', 'status', 'created_at'] diff --git a/offer_backend/apps/jobs/urls.py b/offer_backend/apps/jobs/urls.py index fe4822f..c61f885 100644 --- a/offer_backend/apps/jobs/urls.py +++ b/offer_backend/apps/jobs/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView +from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView, DashboardView router = DefaultRouter() router.register('public', JobPublicViewSet, basename='job-public') @@ -9,4 +9,5 @@ router.register('manage', JobManageViewSet, basename='job-manage') urlpatterns = [ path('', include(router.urls)), path('favorites/', MyFavoritesView.as_view()), + path('dashboard/', DashboardView.as_view()), ] diff --git a/offer_backend/apps/jobs/views.py b/offer_backend/apps/jobs/views.py index 0be3ecc..1bf94eb 100644 --- a/offer_backend/apps/jobs/views.py +++ b/offer_backend/apps/jobs/views.py @@ -1,12 +1,30 @@ -from rest_framework import viewsets, permissions, generics +import io +from collections import Counter +from django.db.models import Count, Q +from django.http import HttpResponse +from rest_framework import viewsets, permissions, generics, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.filters import SearchFilter +from rest_framework.pagination import PageNumberPagination from django_filters.rest_framework import DjangoFilterBackend +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.worksheet.datavalidation import DataValidation from .models import Job, JobFavorite from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer from .filters import JobFilter from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker +from apps.organizations.models import Organization +from apps.applications.models import Application +from .school_data import classify_school + + +class JobPublicPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = 'page_size' + max_page_size = 500 class JobPublicViewSet(viewsets.ReadOnlyModelViewSet): @@ -16,6 +34,7 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter] search_fields = ['title', 'description', 'location'] permission_classes = [permissions.AllowAny] + pagination_class = JobPublicPagination def get_serializer_class(self): if self.action == 'retrieve': @@ -68,3 +87,348 @@ class JobManageViewSet(viewsets.ModelViewSet): else: # 超管需要在请求体中提供 organization_id serializer.save() + + @action(detail=False, methods=['get'], url_path='template') + def download_template(self, request): + """下载职位导入Excel模板""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = '职位导入模板' + + # 表头定义: (列名, 是否必填, 列宽, 示例值) + columns = [ + ('职位名称', True, 20, 'Python开发工程师'), + ('职位类别', True, 15, '技术'), + ('工作地点', True, 15, '北京'), + ('薪资范围', True, 15, '15k-25k'), + ('学历要求', True, 15, '本科及以下'), + ('招聘人数', False, 12, '3'), + ('职位描述', False, 40, '负责后端系统开发与维护'), + ('状态', False, 12, '已发布'), + ] + if request.user.is_superadmin: + columns.insert(0, ('所属公司名称', True, 25, '华远科技有限公司')) + + col_map_by_name = {name: idx for idx, (name, _, _, _) in enumerate(columns, 1)} + + header_font = Font(bold=True, color='FFFFFF', size=11) + required_fill = PatternFill(start_color='2196F3', end_color='2196F3', fill_type='solid') + optional_fill = PatternFill(start_color='90CAF9', end_color='90CAF9', fill_type='solid') + thin_border = Border( + left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin'), + ) + + for col_idx, (name, required, width, _) in enumerate(columns, 1): + cell = ws.cell(row=1, column=col_idx, value=f'{name}(必填)' if required else name) + cell.font = header_font + cell.fill = required_fill if required else optional_fill + cell.alignment = Alignment(horizontal='center') + cell.border = thin_border + ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = width + + # 写入示例数据行 + for col_idx, (_, _, _, example) in enumerate(columns, 1): + cell = ws.cell(row=2, column=col_idx, value=example) + cell.border = thin_border + + # 数据验证:学历要求下拉 + edu_col = openpyxl.utils.get_column_letter(col_map_by_name['学历要求']) + edu_dv = DataValidation(type='list', formula1='"博士,硕士,本科及以下"', allow_blank=False) + edu_dv.error = '请选择:博士、硕士、本科及以下' + edu_dv.errorTitle = '学历要求错误' + ws.add_data_validation(edu_dv) + edu_dv.add(f'{edu_col}2:{edu_col}500') + + # 数据验证:状态下拉 + status_col = openpyxl.utils.get_column_letter(col_map_by_name['状态']) + status_dv = DataValidation(type='list', formula1='"草稿,已发布"', allow_blank=True) + status_dv.error = '请选择:草稿、已发布' + status_dv.errorTitle = '状态错误' + ws.add_data_validation(status_dv) + status_dv.add(f'{status_col}2:{status_col}500') + + # 数据验证:所属公司下拉(超管) + if request.user.is_superadmin: + org_names = list(Organization.objects.values_list('name', flat=True).order_by('name')) + if org_names: + # 将公司列表写入隐藏sheet,用名称引用实现下拉 + org_ws = wb.create_sheet('_org_data') + for i, name in enumerate(org_names, 1): + org_ws.cell(row=i, column=1, value=name) + org_ws.sheet_state = 'hidden' + + org_col = openpyxl.utils.get_column_letter(col_map_by_name['所属公司名称']) + org_dv = DataValidation( + type='list', + formula1=f"'_org_data'!$A$1:$A${len(org_names)}", + allow_blank=False + ) + org_dv.error = '请从下拉列表中选择公司,不可填写系统外的公司' + org_dv.errorTitle = '公司名称错误' + ws.add_data_validation(org_dv) + org_dv.add(f'{org_col}2:{org_col}500') + + # 添加说明 sheet + info_ws = wb.create_sheet('填写说明') + info_ws.column_dimensions['A'].width = 20 + info_ws.column_dimensions['B'].width = 60 + notes = [ + ('字段', '说明'), + ('职位名称', '必填,最多100个字符'), + ('职位类别', '必填,如:技术、产品、设计、运营、市场等'), + ('工作地点', '必填,如:北京、上海、深圳'), + ('薪资范围', '必填,如:15k-25k、面议'), + ('学历要求', '必填,可选值:博士、硕士、本科及以下'), + ('招聘人数', '选填,默认为1,填写正整数'), + ('职位描述', '选填,职位的详细描述'), + ('状态', '选填,可选值:草稿、已发布,默认为草稿'), + ] + if request.user.is_superadmin: + notes.insert(1, ('所属公司名称', '超管必填,填写系统中已有的公司名称')) + for row_idx, (field, desc) in enumerate(notes, 1): + a = info_ws.cell(row=row_idx, column=1, value=field) + b = info_ws.cell(row=row_idx, column=2, value=desc) + if row_idx == 1: + a.font = Font(bold=True) + b.font = Font(bold=True) + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + response = HttpResponse( + buf.getvalue(), + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="job_import_template.xlsx"' + return response + + @action(detail=False, methods=['post'], url_path='import-excel') + def import_excel(self, request): + """通过Excel批量导入职位""" + file = request.FILES.get('file') + if not file: + return Response({'detail': '请上传Excel文件'}, status=status.HTTP_400_BAD_REQUEST) + if not file.name.endswith(('.xlsx', '.xls')): + return Response({'detail': '仅支持 .xlsx 或 .xls 格式'}, status=status.HTTP_400_BAD_REQUEST) + + try: + wb = openpyxl.load_workbook(file, read_only=True) + except Exception: + return Response({'detail': 'Excel文件解析失败,请检查文件格式'}, status=status.HTTP_400_BAD_REQUEST) + + ws = wb.active + rows = list(ws.iter_rows(min_row=1, values_only=True)) + if len(rows) < 2: + return Response({'detail': 'Excel中没有数据行'}, status=status.HTTP_400_BAD_REQUEST) + + # 解析表头,建立列名到索引的映射 + headers = [str(h).replace('(必填)', '').strip() if h else '' for h in rows[0]] + col_map = {name: idx for idx, name in enumerate(headers)} + + user = request.user + is_super = user.is_superadmin + education_choices = {'博士', '硕士', '本科及以下'} + status_choices = {'draft', 'published', '草稿', '已发布'} + status_map = {'草稿': 'draft', '已发布': 'published', 'draft': 'draft', 'published': 'published'} + + # 超管需要提前加载公司名映射 + org_map = {} + if is_super: + org_map = {o.name: o for o in Organization.objects.all()} + + created = 0 + errors = [] + for row_idx, row in enumerate(rows[1:], start=2): + def get_val(col_name): + idx = col_map.get(col_name) + if idx is not None and idx < len(row): + v = row[idx] + return str(v).strip() if v is not None else '' + return '' + + title = get_val('职位名称') + category = get_val('职位类别') + location = get_val('工作地点') + salary = get_val('薪资范围') + education = get_val('学历要求') + headcount_str = get_val('招聘人数') + description = get_val('职位描述') + job_status = get_val('状态') + + # 跳过全空行 + if not any([title, category, location, salary]): + continue + + # 校验必填字段 + missing = [] + if not title: + missing.append('职位名称') + if not category: + missing.append('职位类别') + if not location: + missing.append('工作地点') + if not salary: + missing.append('薪资范围') + if not education: + education = '本科及以下' + + if education not in education_choices: + errors.append(f'第{row_idx}行:学历要求必须为 博士/硕士/本科及以下') + continue + + if missing: + errors.append(f'第{row_idx}行:缺少必填字段 {", ".join(missing)}') + continue + + # 招聘人数 + headcount = 1 + if headcount_str: + try: + headcount = int(float(headcount_str)) + if headcount < 1: + raise ValueError + except (ValueError, TypeError): + errors.append(f'第{row_idx}行:招聘人数必须为正整数') + continue + + # 状态:支持中文和英文 + job_status = status_map.get(job_status, 'draft') + + # 确定所属公司 + org = None + if is_super: + org_name = get_val('所属公司名称') + if not org_name: + errors.append(f'第{row_idx}行:超级管理员必须填写所属公司名称') + continue + org = org_map.get(org_name) + if not org: + errors.append(f'第{row_idx}行:公司"{org_name}"不存在') + continue + else: + org = user.organization + if not org: + return Response({'detail': '当前账号未关联公司,无法导入'}, status=status.HTTP_400_BAD_REQUEST) + + Job.objects.create( + organization=org, + title=title, + category=category, + location=location, + salary=salary, + education=education, + headcount=headcount, + description=description, + status=job_status, + ) + created += 1 + + result = {'created': created, 'errors': errors} + if created == 0 and errors: + return Response(result, status=status.HTTP_400_BAD_REQUEST) + return Response(result) + + +class DashboardView(APIView): + """管理后台 Dashboard 统计数据""" + permission_classes = [IsAdminOrSuperAdmin] + + def get(self, request): + user = request.user + is_super = user.is_superadmin + + # 根据角色过滤数据范围 + if is_super: + jobs_qs = Job.objects.all() + apps_qs = Application.objects.all() + else: + jobs_qs = Job.objects.filter(organization=user.organization) + apps_qs = Application.objects.filter(job__organization=user.organization) + + # 职位统计 + job_stats = jobs_qs.aggregate( + published=Count('id', filter=Q(status='published')), + closed=Count('id', filter=Q(status='closed')), + draft=Count('id', filter=Q(status='draft')), + total=Count('id'), + ) + + # 投递统计 + app_stats = apps_qs.aggregate( + total=Count('id'), + pending=Count('id', filter=Q(status='pending')), + viewed=Count('id', filter=Q(status='viewed')), + interviewing=Count('id', filter=Q(status='interviewing')), + hired=Count('id', filter=Q(status='hired')), + rejected=Count('id', filter=Q(status='rejected')), + ) + + # 按投递人去重统计学历和院校分布(每人只算一次,取最高学历) + DEGREE_RANK = {'博士': 4, 'MBA': 3, '硕士': 2, '本科': 1, '其他': 0} + school_type_counter = Counter() + degree_counter = Counter() + + def pick_top_edu(edu_list): + """从教育经历中取最高学历的那条""" + best = None + best_rank = -1 + for edu in edu_list: + d = edu.get('degree', '').strip() + rank = DEGREE_RANK.get(d, 0) + if rank > best_rank: + best_rank = rank + best = edu + return best or (edu_list[0] if edu_list else None) + + # 按 applicant_id 去重,每个人只取一条投递快照 + seen_applicants = set() + for applicant_id, snapshot in apps_qs.values_list('applicant_id', 'resume_snapshot'): + if applicant_id in seen_applicants: + continue + seen_applicants.add(applicant_id) + edu_list = snapshot.get('education', []) if snapshot else [] + edu_list = [e for e in edu_list if e.get('school', '').strip() or e.get('degree', '').strip()] + top = pick_top_edu(edu_list) + if top: + degree = top.get('degree', '').strip() + if degree: + degree_counter[degree] += 1 + school_name = top.get('school', '').strip() + school_type_counter[classify_school(school_name)] += 1 + + # 如果没有投递数据,从所有简历统计 + if not seen_applicants: + from apps.resumes.models import Resume + for resume in Resume.objects.all(): + edu_list = [e for e in (resume.education or []) if e.get('school', '').strip() or e.get('degree', '').strip()] + top = pick_top_edu(edu_list) + if top: + degree = top.get('degree', '').strip() + if degree: + degree_counter[degree] += 1 + school_name = top.get('school', '').strip() + school_type_counter[classify_school(school_name)] += 1 + + # 保证 985/211/其他 顺序固定 + school_type_dist = [ + {'name': '985', 'value': school_type_counter.get('985', 0)}, + {'name': '211', 'value': school_type_counter.get('211', 0)}, + {'name': '其他', 'value': school_type_counter.get('其他', 0)}, + ] + degree_dist = [{'name': k, 'value': v} for k, v in degree_counter.items()] + + # 职位类别分布(top 10) + category_dist = list( + jobs_qs.values('category') + .annotate(count=Count('id')) + .order_by('-count')[:10] + ) + + return Response({ + 'job_stats': job_stats, + 'app_stats': app_stats, + 'school_type_dist': school_type_dist, + 'degree_dist': degree_dist, + 'category_dist': [{'name': c['category'], 'value': c['count']} for c in category_dist], + }) diff --git a/offer_backend/apps/organizations/serializers.py b/offer_backend/apps/organizations/serializers.py index 697c35d..00407ef 100644 --- a/offer_backend/apps/organizations/serializers.py +++ b/offer_backend/apps/organizations/serializers.py @@ -4,23 +4,36 @@ from .models import Organization class OrganizationSerializer(serializers.ModelSerializer): job_count = serializers.SerializerMethodField() + edu_summary = serializers.SerializerMethodField() class Meta: model = Organization - fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active', 'job_count'] + fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active', 'job_count', 'edu_summary'] def get_job_count(self, obj): return obj.jobs.filter(status='published').count() + def get_edu_summary(self, obj): + """每个学历的招聘总人数""" + from django.db.models import Sum + jobs = obj.jobs.filter(status='published') + result = [] + for edu in ['博士', '硕士', '本科及以下']: + total = jobs.filter(education=edu).aggregate(s=Sum('headcount'))['s'] or 0 + if total > 0: + result.append({'education': edu, 'count': total}) + return result + class OrganizationTreeSerializer(serializers.ModelSerializer): """带子公司列表,用于门户展示""" children = serializers.SerializerMethodField() job_count = serializers.SerializerMethodField() + jobs_by_education = serializers.SerializerMethodField() class Meta: model = Organization - fields = ['id', 'name', 'logo', 'description', 'email', 'job_count', 'children'] + fields = ['id', 'name', 'logo', 'description', 'email', 'job_count', 'children', 'jobs_by_education'] def get_job_count(self, obj): return obj.jobs.filter(status='published').count() @@ -29,3 +42,14 @@ class OrganizationTreeSerializer(serializers.ModelSerializer): return OrganizationSerializer( obj.children.filter(is_active=True), many=True, context=self.context ).data + + def get_jobs_by_education(self, obj): + from apps.jobs.models import Job + org_ids = [obj.id] + list(obj.children.filter(is_active=True).values_list('id', flat=True)) + jobs = Job.objects.filter(organization_id__in=org_ids, status='published').values('title', 'education', 'headcount') + result = [] + for edu in ['博士', '硕士', '本科及以下']: + edu_jobs = [{'title': j['title'], 'headcount': j['headcount']} for j in jobs if j['education'] == edu] + if edu_jobs: + result.append({'education': edu, 'jobs': edu_jobs}) + return result diff --git a/offer_frontend/package-lock.json b/offer_frontend/package-lock.json index 8e5784f..5933103 100644 --- a/offer_frontend/package-lock.json +++ b/offer_frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "axios": "^1.13.6", + "echarts": "^6.0.0", "element-plus": "^2.13.6", "pinia": "^3.0.4", "vue": "^3.5.12", @@ -1286,6 +1287,16 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, "node_modules/element-plus": { "version": "2.13.6", "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz", @@ -2144,6 +2155,12 @@ "node": ">=8.0" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", @@ -2407,6 +2424,15 @@ "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "dev": true + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/offer_frontend/package.json b/offer_frontend/package.json index 9ff1d21..cb37126 100644 --- a/offer_frontend/package.json +++ b/offer_frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "axios": "^1.13.6", + "echarts": "^6.0.0", "element-plus": "^2.13.6", "pinia": "^3.0.4", "vue": "^3.5.12", diff --git a/offer_frontend/src/api/jobs.js b/offer_frontend/src/api/jobs.js index 3cc7c17..44255bb 100644 --- a/offer_frontend/src/api/jobs.js +++ b/offer_frontend/src/api/jobs.js @@ -3,8 +3,18 @@ import client from './client' export const getJobs = (params) => client.get('/jobs/public/', { params }) export const getJob = (id) => client.get(`/jobs/public/${id}/`) export const manageJobs = (page = 1) => client.get('/jobs/manage/', { params: { page } }) +export const getManageJob = (id) => client.get(`/jobs/manage/${id}/`) export const createJob = (data) => client.post('/jobs/manage/', data) export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data) export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`) export const toggleFavorite = (id) => client.post(`/jobs/public/${id}/favorite/`) export const getMyFavorites = () => client.get('/jobs/favorites/') +export const getDashboard = () => client.get('/jobs/dashboard/') +export const downloadJobTemplate = () => client.get('/jobs/manage/template/', { responseType: 'blob' }) +export const importJobsExcel = (file) => { + const formData = new FormData() + formData.append('file', file) + return client.post('/jobs/manage/import-excel/', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} diff --git a/offer_frontend/src/layouts/AdminLayout.vue b/offer_frontend/src/layouts/AdminLayout.vue index c90a933..b998435 100644 --- a/offer_frontend/src/layouts/AdminLayout.vue +++ b/offer_frontend/src/layouts/AdminLayout.vue @@ -1,10 +1,18 @@