Compare commits

...

10 Commits

Author SHA1 Message Date
TianyangZhang ed3b29aafe chore: 代码格式化和依赖更新
- 设计规范文档格式化
- 求职者布局样式微调

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 13:13:17 +08:00
TianyangZhang 536be6c1a1 refactor: 调整岗位、应用等模块,适配三栏设计
后端变更:
- 岗位序列化器调整,支持组织树形结构
- 应用序列化器更新
- 岗位视图逻辑兼容新的过滤需求
- 新增 JobFavorite 数据库迁移(岗位收藏功能)
- 岗位URL路由配置更新

前端变更:
- 岗位详情页面兼容新设计
- 求职者应用、简历页面样式调整
- 路由配置更新,支持三栏布局
- App.vue 组件调整
- Vite 配置微调

这些调整为首页三栏布局的完整实现提供支撑。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 13:13:09 +08:00
TianyangZhang e3bdb0b496 feat(admin): 岗位发布支持超级管理员选择企业
- 超级管理员发布职位时,表单新增「所属公司」下拉选择器
- 支持集团 + 子公司层级选择(子公司用 '└' 缩进显示)
- 职位列表新增「所属公司」列,方便区分各企业岗位
- 保存前验证:超级管理员未选公司时提示「请选择所属公司」
- 普通管理员(admin)看不到选择器,后端自动绑定其公司
- 编辑职位时自动回填公司选择
- 优化错误提示:保存失败时显示后端返回的具体错误信息

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 13:13:03 +08:00
TianyangZhang 7e86ec5ea0 feat(frontend): 首页三栏布局完全重设计,新增企业选择器和岗位联动
- 重构首页为三栏联动布局(企业 + 岗位 + 详情)
- 左栏:深蓝背景企业列表,支持集团和子公司分层显示
  - 选中企业时显示金色左边框 + 背景色变化
  - 每个企业/子公司显示实时在招岗位数
  - 子公司采用缩进 + 树形连线视觉
- 中栏:选中企业的岗位列表
  - 岗位卡片显示位置/薪资/类别标签
  - 朱红左边框高亮选中岗位
- 右栏:岗位详情内容(完整复制 JobDetailView)
  - Banner:深蓝红色渐变背景,显示岗位名/薪资/企业
  - 操作:收藏 + 投递按钮,权限校验
  - 详情:信息网格 + 职位介绍 + 工作地点 + 企业卡片
  - 完整的投递流程和状态反馈
- 配色系统:
  - 深蓝 #0E1E3D(左栏背景)
  - 朱红 #B5272C(选中、强调)
  - 金色 #C8973A(accent)
  - 米色 #FAF7F3(右侧背景)
- 完整的加载/错误/空状态处理
- 骨架屏动画和交互反馈

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 13:12:57 +08:00
TianyangZhang 88c0bb223c feat(frontend): 门户导航重设计,采用央企视觉体系
- 导航栏采用深蓝底色 + 金色点缀,体现央企庄重感
- 新增顶部公告条,快速链接登录/注册
- Logo 采用金色圆圈徽章 + 公司名设计
- 导航链接动画效果(下划线滑出)
- 新增用户信息展示区(头像 + 用户名)
- 页脚带红色分割线,强化视觉层级
- 配色:深蓝 #0E1E3D + 朱红 #B5272C + 金色 #C8973A

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 13:12:49 +08:00
TianyangZhang 11d1cec192 feat(backend): 组织序列化器添加在招岗位数统计
- JobListSerializer 和 JobDetailSerializer 新增 job_count 字段
- 统计 status='published' 的岗位数量
- 前端展示每个公司/子公司的在招岗位数

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 13:12:43 +08:00
TianyangZhang f56e562a78 docs: add company jobs three-panel layout design spec 2026-03-25 11:04:03 +08:00
TianyangZhang 46e35074ed feat: complete recruitment website MVP
All backend tests pass (17/17), frontend builds successfully, and superadmin account created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:43:50 +08:00
TianyangZhang ef4c9bf307 feat: add admin management views (jobs, applications, organizations, users)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:42:51 +08:00
TianyangZhang ca629a403c feat: add seeker center (resume editor, applications, profile)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:42:46 +08:00
35 changed files with 3111 additions and 100 deletions

View File

@ -0,0 +1,60 @@
{
"permissions": {
"allow": [
"Bash(bash \"C:/Users/11825/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/start-server.sh\" --project-dir \"C:/code/offer\")",
"Bash(cat \"C:/code/offer/.superpowers/brainstorm\"/.*/server-info)",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''Vue:'''', d.get\\(''''dependencies'''',{}\\).get\\(''''vue'''',''''N/A''''\\)\\); print\\(''''Vite/webpack:'''', list\\(d.get\\(''''devDependencies'''',{}\\).keys\\(\\)\\)[:5]\\)\")",
"Bash(git -C /c/code/offer log --oneline)",
"Bash(pip install:*)",
"Bash(py --version)",
"Bash(python3 -m pip install -r /c/code/offer/offer_backend/requirements.txt)",
"Bash(python3 -c \"import sys; print\\(sys.executable\\)\")",
"Bash(cmd.exe /c \"python --version\")",
"Bash(cmd.exe /c \"py --version\")",
"Bash(cmd.exe /c \"python --version && python -m pip --version\" 2>&1)",
"Read(//c/Users/11825/AppData/Local/Programs/**)",
"Bash(ls /c/Python*)",
"Read(//c/Users/11825/AppData/Local/Microsoft/WindowsApps//**)",
"Read(//c//**)",
"Bash(/c/Users/11825/AppData/Local/Microsoft/WindowsApps/python3.exe --version)",
"Bash(/c/Users/11825/AppData/Local/Microsoft/WindowsApps/python3.exe -m pip --version)",
"Read(//c/miniconda3/bin/**)",
"Read(//c/anaconda3/bin/**)",
"Bash(/c/software/python3_10/python --version)",
"Bash(/c/software/python3_10/python -m pip --version)",
"Bash(/c/software/python3_10/python -m ensurepip --upgrade)",
"Bash(/c/software/python3_10/python -m pip install -r /c/code/offer/offer_backend/requirements.txt 2>&1)",
"Bash(/c/software/python3_10/Scripts/django-admin startproject:*)",
"Bash(rm:*)",
"Bash(rm /c/code/offer/offer_backend/manage.py)",
"Bash(rm -rf /c/code/offer/offer_backend/config)",
"Bash(mkdir -p /c/code/offer/offer_backend/config/settings)",
"Bash(mkdir -p /c/code/offer/offer_backend/apps)",
"Bash(DJANGO_SETTINGS_MODULE=config.settings.development /c/software/python3_10/python manage.py check)",
"Bash(git -C /c/code/offer status)",
"Bash(git -C /c/code/offer add offer_backend/)",
"Bash(git:*)",
"Bash(python manage.py startapp accounts apps/accounts)",
"Bash(python manage.py makemigrations accounts)",
"Bash(python manage.py migrate)",
"Bash(python -m pytest apps/accounts/tests/test_auth.py -v)",
"Bash(/c/software/python3_10/python -m pytest apps/accounts/tests/test_auth.py -v)",
"Bash(/c/software/python3_10/Scripts/pytest --version)",
"Bash(ls /c/software/python3_10/Scripts/pytest*)",
"Bash(pip show:*)",
"Bash(/c/software/python3_10/python -c \"import sys; print\\(sys.path\\)\")",
"Bash(/c/software/python3_10/pip3 list:*)",
"Bash(/c/software/python3_10/pip3 install:*)",
"Bash(ls /c/software/python3_10/Scripts/pip*)",
"Read(//c/software/python3_10/**)",
"Bash(/c/software/python3_10/Scripts/pip3.exe install:*)",
"Bash(/c/software/python3_10/Scripts/pytest.exe apps/accounts/tests/test_auth.py -v)",
"Bash(pg_isready)",
"Bash(psql -U postgres -c \"SELECT 1\")",
"Bash(\"/c/Program Files/PostgreSQL/16/bin/psql.exe\" -U postgres -c \"SELECT version\\(\\);\" 2>&1)",
"Bash(PGPASSWORD=zcDsj@2024 \"/c/Program Files/PostgreSQL/16/bin/psql.exe\" -U postgres -c \"SELECT version\\(\\);\" 2>&1)",
"Bash(PGPASSWORD=zcDsj@2024 \"/c/Program Files/PostgreSQL/16/bin/psql.exe\" -U postgres -c \"CREATE DATABASE offer_db;\" 2>&1)",
"Bash(/c/software/python3_10/python manage.py migrate)"
]
}
}

View File

@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1774342828046}

View File

@ -0,0 +1 @@
3597

View File

@ -0,0 +1,80 @@
<h2>系统架构总览</h2>
<p class="subtitle">方案 ADjango 后端 + Vue3 单页应用</p>
<div style="display: flex; gap: 24px; flex-wrap: wrap; margin-top: 16px;">
<!-- 前端 -->
<div style="flex: 1; min-width: 280px; background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155;">
<div class="label" style="color: #38bdf8; margin-bottom: 12px;">前端 — Vue3 SPA</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #38bdf8;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">公开门户</div>
<div style="font-size: 12px; color: #94a3b8;">职位列表 / 搜索 / 职位详情<br>公司介绍 / 注册登录</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #818cf8;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">求职者中心</div>
<div style="font-size: 12px; color: #94a3b8;">我的简历 / 投递记录<br>个人信息管理</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #f472b6;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">管理后台</div>
<div style="font-size: 12px; color: #94a3b8;">超管:组织架构 / 账号管理<br>公司管理员:职位 / 投递管理</div>
</div>
</div>
<div style="text-align: center; margin-top: 12px; font-size: 11px; color: #475569;">Vue Router + Pinia + Element Plus</div>
</div>
<!-- 箭头 -->
<div style="display: flex; align-items: center; justify-content: center; font-size: 28px; color: #475569; min-width: 40px;"></div>
<!-- 后端 -->
<div style="flex: 1; min-width: 280px; background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155;">
<div class="label" style="color: #34d399; margin-bottom: 12px;">后端 — Django + DRF</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">认证模块</div>
<div style="font-size: 12px; color: #94a3b8;">JWT 登录 / 注册 / 权限控制<br>角色:超管 / 公司管理员 / 求职者</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">职位模块</div>
<div style="font-size: 12px; color: #94a3b8;">职位 CRUD / 搜索过滤<br>公司隔离权限</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">投递模块</div>
<div style="font-size: 12px; color: #94a3b8;">投递记录 / 状态流转<br>简历存储(表单 + 文件)</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">组织架构模块</div>
<div style="font-size: 12px; color: #94a3b8;">集团 / 子公司 / 管理员绑定</div>
</div>
</div>
<div style="text-align: center; margin-top: 12px; font-size: 11px; color: #475569;">Django 4.2 + DRF + JWT</div>
</div>
<!-- 箭头 -->
<div style="display: flex; align-items: center; justify-content: center; font-size: 28px; color: #475569; min-width: 40px;"></div>
<!-- 存储 -->
<div style="flex: 0 0 160px; background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155;">
<div class="label" style="color: #fb923c; margin-bottom: 12px;">存储</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: #0f172a; border-radius: 8px; padding: 10px; border-left: 3px solid #fb923c;">
<div style="font-size: 12px; color: #e2e8f0; font-weight: 600;">PostgreSQL</div>
<div style="font-size: 11px; color: #94a3b8;">业务数据</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px; border-left: 3px solid #fb923c;">
<div style="font-size: 12px; color: #e2e8f0; font-weight: 600;">Redis</div>
<div style="font-size: 11px; color: #94a3b8;">缓存 / 会话</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px; border-left: 3px solid #fb923c;">
<div style="font-size: 12px; color: #e2e8f0; font-weight: 600;">文件存储</div>
<div style="font-size: 11px; color: #94a3b8;">简历附件<br>(本地/OSS</div>
</div>
</div>
</div>
</div>
<div style="margin-top: 20px; background: #0f172a; border-radius: 8px; padding: 14px; border: 1px solid #1e3a5f;">
<span class="label" style="color: #38bdf8;">部署方式</span>
<span style="color: #94a3b8; margin-left: 8px; font-size: 13px;">Nginx 反向代理 → Vue3 静态文件 + Django API (Gunicorn)</span>
</div>

View File

@ -0,0 +1,80 @@
<h2>系统架构总览</h2>
<p class="subtitle">方案 ADjango 后端 + Vue3 单页应用</p>
<div style="display: flex; gap: 24px; flex-wrap: wrap; margin-top: 16px;">
<!-- 前端 -->
<div style="flex: 1; min-width: 280px; background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155;">
<div class="label" style="color: #38bdf8; margin-bottom: 12px;">前端 — Vue3 SPA</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #38bdf8;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">公开门户</div>
<div style="font-size: 12px; color: #94a3b8;">职位列表 / 搜索 / 职位详情<br>公司介绍 / 注册登录</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #818cf8;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">求职者中心</div>
<div style="font-size: 12px; color: #94a3b8;">我的简历 / 投递记录<br>个人信息管理</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #f472b6;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">管理后台</div>
<div style="font-size: 12px; color: #94a3b8;">超管:组织架构 / 账号管理<br>公司管理员:职位 / 投递管理</div>
</div>
</div>
<div style="text-align: center; margin-top: 12px; font-size: 11px; color: #475569;">Vue Router + Pinia + Element Plus</div>
</div>
<!-- 箭头 -->
<div style="display: flex; align-items: center; justify-content: center; font-size: 28px; color: #475569; min-width: 40px;"></div>
<!-- 后端 -->
<div style="flex: 1; min-width: 280px; background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155;">
<div class="label" style="color: #34d399; margin-bottom: 12px;">后端 — Django + DRF</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">认证模块</div>
<div style="font-size: 12px; color: #94a3b8;">JWT 登录 / 注册 / 权限控制<br>角色:超管 / 公司管理员 / 求职者</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">职位模块</div>
<div style="font-size: 12px; color: #94a3b8;">职位 CRUD / 搜索过滤<br>公司隔离权限</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">投递模块</div>
<div style="font-size: 12px; color: #94a3b8;">投递记录 / 状态流转<br>简历存储(表单 + 文件)</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 12px; border-left: 3px solid #34d399;">
<div style="font-weight: 600; color: #e2e8f0; margin-bottom: 4px;">组织架构模块</div>
<div style="font-size: 12px; color: #94a3b8;">集团 / 子公司 / 管理员绑定</div>
</div>
</div>
<div style="text-align: center; margin-top: 12px; font-size: 11px; color: #475569;">Django 4.2 + DRF + JWT</div>
</div>
<!-- 箭头 -->
<div style="display: flex; align-items: center; justify-content: center; font-size: 28px; color: #475569; min-width: 40px;"></div>
<!-- 存储 -->
<div style="flex: 0 0 160px; background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155;">
<div class="label" style="color: #fb923c; margin-bottom: 12px;">存储</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: #0f172a; border-radius: 8px; padding: 10px; border-left: 3px solid #fb923c;">
<div style="font-size: 12px; color: #e2e8f0; font-weight: 600;">MySQL</div>
<div style="font-size: 11px; color: #94a3b8;">业务数据</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px; border-left: 3px solid #fb923c;">
<div style="font-size: 12px; color: #e2e8f0; font-weight: 600;">Redis</div>
<div style="font-size: 11px; color: #94a3b8;">缓存 / 会话</div>
</div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px; border-left: 3px solid #fb923c;">
<div style="font-size: 12px; color: #e2e8f0; font-weight: 600;">文件存储</div>
<div style="font-size: 11px; color: #94a3b8;">简历附件<br>(本地/OSS</div>
</div>
</div>
</div>
</div>
<div style="margin-top: 20px; background: #0f172a; border-radius: 8px; padding: 14px; border: 1px solid #1e3a5f;">
<span class="label" style="color: #38bdf8;">部署方式</span>
<span style="color: #94a3b8; margin-left: 8px; font-size: 13px;">Nginx 反向代理 → Vue3 静态文件 + Django API (Gunicorn)</span>
</div>

View File

@ -0,0 +1,216 @@
<h2>数据模型设计</h2>
<p class="subtitle">核心表结构与关联关系</p>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 16px;">
<!-- Organization -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #0c4a6e; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">🏢</span>
<span style="font-weight: 700; color: #e2e8f0;">Organization 组织架构</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">name</td>
<td style="padding: 5px 0; color: #94a3b8;">公司名称</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">parent</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → self集团/子公司)</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">logo</td>
<td style="padding: 5px 0; color: #94a3b8;">公司 Logo</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">description</td>
<td style="padding: 5px 0; color: #94a3b8;">公司简介</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">email</td>
<td style="padding: 5px 0; color: #94a3b8;">公司联系邮箱 ✨</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">is_active</td>
<td style="padding: 5px 0; color: #94a3b8;">是否启用</td>
</tr>
</table>
</div>
</div>
<!-- User -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #312e81; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">👤</span>
<span style="font-weight: 700; color: #e2e8f0;">User 用户</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">username</td>
<td style="padding: 5px 0; color: #94a3b8;">登录账号</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">email</td>
<td style="padding: 5px 0; color: #94a3b8;">用户邮箱 ✨</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">phone</td>
<td style="padding: 5px 0; color: #94a3b8;">手机号</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">role</td>
<td style="padding: 5px 0; color: #94a3b8;">superadmin / admin / seeker</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">organization</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Organizationadmin 用)</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">is_active</td>
<td style="padding: 5px 0; color: #94a3b8;">账号状态</td>
</tr>
</table>
</div>
</div>
<!-- Job -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #14532d; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">💼</span>
<span style="font-weight: 700; color: #e2e8f0;">Job 职位</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">organization</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Organization</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">title</td>
<td style="padding: 5px 0; color: #94a3b8;">职位名称</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">category</td>
<td style="padding: 5px 0; color: #94a3b8;">职位类别</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">location / salary</td>
<td style="padding: 5px 0; color: #94a3b8;">地点 / 薪资</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">description</td>
<td style="padding: 5px 0; color: #94a3b8;">职位描述(富文本)</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">status</td>
<td style="padding: 5px 0; color: #94a3b8;">draft / published / closed</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">created_at</td>
<td style="padding: 5px 0; color: #94a3b8;">发布时间</td>
</tr>
</table>
</div>
</div>
<!-- Resume -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #78350f; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">📄</span>
<span style="font-weight: 700; color: #e2e8f0;">Resume 简历</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">user</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → User求职者</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">name / gender / birthday</td>
<td style="padding: 5px 0; color: #94a3b8;">基本信息</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">education (JSONB)</td>
<td style="padding: 5px 0; color: #94a3b8;">教育经历列表</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">experience (JSONB)</td>
<td style="padding: 5px 0; color: #94a3b8;">工作经历列表</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">attachment</td>
<td style="padding: 5px 0; color: #94a3b8;">简历附件PDF/Word</td>
</tr>
</table>
</div>
</div>
<!-- Application -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #7f1d1d; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">📨</span>
<span style="font-weight: 700; color: #e2e8f0;">Application 投递记录</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">job</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Job</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">applicant</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → User</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">resume</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Resume投递时快照</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">status</td>
<td style="padding: 5px 0; color: #94a3b8;">待查看 → 已查看 → 面试 → 录用/拒绝</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">note</td>
<td style="padding: 5px 0; color: #94a3b8;">HR 备注</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">applied_at</td>
<td style="padding: 5px 0; color: #94a3b8;">投递时间</td>
</tr>
</table>
</div>
</div>
</div>
<div style="margin-top: 16px; background: #0f172a; border-radius: 8px; padding: 14px; border: 1px solid #334155; font-size: 12px; color: #94a3b8;">
<strong style="color: #e2e8f0;">关键设计说明:</strong>
<ul style="margin: 8px 0 0 16px; line-height: 2;">
<li>Organization 自关联支持集团→子公司层级</li>
<li>User.role 控制权限superadmin 管全局admin 只能操作本公司数据</li>
<li>Resume.education / experience 用 PostgreSQL JSONB 存储,灵活应对结构差异</li>
<li>Application 投递时关联当前简历,保证历史记录不受简历修改影响</li>
</ul>
</div>

View File

@ -0,0 +1,208 @@
<h2>数据模型设计</h2>
<p class="subtitle">核心表结构与关联关系</p>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 16px;">
<!-- Organization -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #0c4a6e; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">🏢</span>
<span style="font-weight: 700; color: #e2e8f0;">Organization 组织架构</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">name</td>
<td style="padding: 5px 0; color: #94a3b8;">公司名称</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">parent</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → self集团/子公司)</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">logo</td>
<td style="padding: 5px 0; color: #94a3b8;">公司 Logo</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">description</td>
<td style="padding: 5px 0; color: #94a3b8;">公司简介</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">is_active</td>
<td style="padding: 5px 0; color: #94a3b8;">是否启用</td>
</tr>
</table>
</div>
</div>
<!-- User -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #312e81; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">👤</span>
<span style="font-weight: 700; color: #e2e8f0;">User 用户</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">username / email</td>
<td style="padding: 5px 0; color: #94a3b8;">登录账号</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">phone</td>
<td style="padding: 5px 0; color: #94a3b8;">手机号</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">role</td>
<td style="padding: 5px 0; color: #94a3b8;">superadmin / admin / seeker</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">organization</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Organizationadmin 用)</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">is_active</td>
<td style="padding: 5px 0; color: #94a3b8;">账号状态</td>
</tr>
</table>
</div>
</div>
<!-- Job -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #14532d; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">💼</span>
<span style="font-weight: 700; color: #e2e8f0;">Job 职位</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">organization</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Organization</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">title</td>
<td style="padding: 5px 0; color: #94a3b8;">职位名称</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">category</td>
<td style="padding: 5px 0; color: #94a3b8;">职位类别</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">location / salary</td>
<td style="padding: 5px 0; color: #94a3b8;">地点 / 薪资</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">description</td>
<td style="padding: 5px 0; color: #94a3b8;">职位描述(富文本)</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">status</td>
<td style="padding: 5px 0; color: #94a3b8;">draft / published / closed</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">created_at</td>
<td style="padding: 5px 0; color: #94a3b8;">发布时间</td>
</tr>
</table>
</div>
</div>
<!-- Resume -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #78350f; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">📄</span>
<span style="font-weight: 700; color: #e2e8f0;">Resume 简历</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">user</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → User求职者</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">name / gender / birthday</td>
<td style="padding: 5px 0; color: #94a3b8;">基本信息</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">education (JSON)</td>
<td style="padding: 5px 0; color: #94a3b8;">教育经历列表</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">experience (JSON)</td>
<td style="padding: 5px 0; color: #94a3b8;">工作经历列表</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">attachment</td>
<td style="padding: 5px 0; color: #94a3b8;">简历附件PDF/Word</td>
</tr>
</table>
</div>
</div>
<!-- Application -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155; grid-column: span 1;">
<div style="background: #7f1d1d; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">📨</span>
<span style="font-weight: 700; color: #e2e8f0;">Application 投递记录</span>
</div>
<div style="padding: 12px 14px;">
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">job</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Job</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">applicant</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → User</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">resume</td>
<td style="padding: 5px 0; color: #94a3b8;">FK → Resume投递时快照</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">status</td>
<td style="padding: 5px 0; color: #94a3b8;">待查看 → 已查看 → 面试 → 录用/拒绝</td>
</tr>
<tr style="border-bottom: 1px solid #1e3a5f;">
<td style="padding: 5px 0; color: #e2e8f0;">note</td>
<td style="padding: 5px 0; color: #94a3b8;">HR 备注</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #e2e8f0;">applied_at</td>
<td style="padding: 5px 0; color: #94a3b8;">投递时间</td>
</tr>
</table>
</div>
</div>
</div>
<div style="margin-top: 16px; background: #0f172a; border-radius: 8px; padding: 14px; border: 1px solid #334155; font-size: 12px; color: #94a3b8;">
<strong style="color: #e2e8f0;">关键设计说明:</strong>
<ul style="margin: 8px 0 0 16px; line-height: 2;">
<li>Organization 自关联支持集团→子公司层级</li>
<li>User.role 控制权限superadmin 管全局admin 只能操作本公司数据</li>
<li>Resume.education / experience 用 PostgreSQL JSONB 存储,灵活应对结构差异</li>
<li>Application 投递时关联当前简历,保证历史记录不受简历修改影响</li>
</ul>
</div>

View File

@ -0,0 +1,116 @@
<h2>关键业务流程</h2>
<p class="subtitle">三条核心流程的状态流转</p>
<div style="display: flex; flex-direction: column; gap: 20px; margin-top: 16px;">
<!-- 流程1求职者投递 -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #0c4a6e; padding: 10px 16px;">
<span style="font-weight: 700; color: #38bdf8;">流程 1求职者投递简历</span>
</div>
<div style="padding: 16px; overflow-x: auto;">
<div style="display: flex; align-items: center; gap: 0; min-width: 600px;">
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 100px; border: 1px solid #1e3a5f;">
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">求职者</div>
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">浏览职位</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 100px; border: 1px solid #1e3a5f;">
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">检查</div>
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">是否已登录</div>
<div style="font-size: 11px; color: #fbbf24; margin-top: 4px;">未登录→跳注册</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 110px; border: 1px solid #1e3a5f;">
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">检查</div>
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">简历是否完善</div>
<div style="font-size: 11px; color: #fbbf24; margin-top: 4px;">未完善→提示填写</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 110px; border: 1px solid #1e3a5f;">
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">确认</div>
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">投递弹窗</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 4px;">选简历版本</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #14532d; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 100px; border: 1px solid #166534;">
<div style="font-size: 11px; color: #86efac; margin-bottom: 4px;">完成</div>
<div style="color: #4ade80; font-size: 13px; font-weight: 600;">投递成功</div>
<div style="font-size: 11px; color: #86efac; margin-top: 4px;">状态:待查看</div>
</div>
</div>
</div>
</div>
<!-- 流程2投递状态流转 -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #7f1d1d; padding: 10px 16px;">
<span style="font-weight: 700; color: #fca5a5;">流程 2HR 处理投递(状态流转)</span>
</div>
<div style="padding: 16px; overflow-x: auto;">
<div style="display: flex; align-items: center; gap: 0; min-width: 640px;">
<div style="background: #374151; border-radius: 8px; padding: 10px 16px; text-align: center; min-width: 90px; border: 1px solid #4b5563;">
<div style="color: #9ca3af; font-size: 13px; font-weight: 600;">待查看</div>
</div>
<div style="color: #475569; padding: 0 8px; font-size: 18px;"></div>
<div style="background: #1e3a5f; border-radius: 8px; padding: 10px 16px; text-align: center; min-width: 90px; border: 1px solid #1d4ed8;">
<div style="color: #60a5fa; font-size: 13px; font-weight: 600;">已查看</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 3px;">HR 打开简历</div>
</div>
<div style="color: #475569; padding: 0 8px; font-size: 18px;"></div>
<div style="background: #1e293b; border-radius: 8px; padding: 10px 16px; text-align: center; min-width: 90px; border: 1px solid #7c3aed;">
<div style="color: #a78bfa; font-size: 13px; font-weight: 600;">面试中</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 3px;">HR 手动更新</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; padding: 0 8px;">
<div style="color: #475569; font-size: 18px;"></div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="background: #14532d; border-radius: 8px; padding: 8px 16px; text-align: center; min-width: 90px; border: 1px solid #166534;">
<div style="color: #4ade80; font-size: 13px; font-weight: 600;">已录用</div>
</div>
<div style="background: #450a0a; border-radius: 8px; padding: 8px 16px; text-align: center; min-width: 90px; border: 1px solid #7f1d1d;">
<div style="color: #f87171; font-size: 13px; font-weight: 600;">已拒绝</div>
</div>
</div>
</div>
<div style="margin-top: 12px; font-size: 12px; color: #64748b;">
* 每次状态变更自动发送邮件通知求职者
</div>
</div>
</div>
<!-- 流程3公司管理员发布职位 -->
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #14532d; padding: 10px 16px;">
<span style="font-weight: 700; color: #86efac;">流程 3公司管理员发布职位</span>
</div>
<div style="padding: 16px; overflow-x: auto;">
<div style="display: flex; align-items: center; gap: 0; min-width: 560px;">
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 90px; border: 1px solid #1e3a5f;">
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">填写职位</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 3px;">标题/描述/薪资等</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #374151; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 90px; border: 1px solid #4b5563;">
<div style="color: #9ca3af; font-size: 13px; font-weight: 600;">保存草稿</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 3px;">status: draft</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #14532d; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 90px; border: 1px solid #166534;">
<div style="color: #4ade80; font-size: 13px; font-weight: 600;">发布上线</div>
<div style="font-size: 11px; color: #86efac; margin-top: 3px;">status: published</div>
</div>
<div style="color: #475569; padding: 0 6px; font-size: 18px;"></div>
<div style="background: #450a0a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 90px; border: 1px solid #7f1d1d;">
<div style="color: #f87171; font-size: 13px; font-weight: 600;">关闭招聘</div>
<div style="font-size: 11px; color: #fca5a5; margin-top: 3px;">status: closed</div>
</div>
</div>
<div style="margin-top: 12px; font-size: 12px; color: #64748b;">
* 管理员只能操作本公司职位;超管可查看所有公司职位但不能代发
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,114 @@
<h2>页面结构与路由设计</h2>
<p class="subtitle">三个区域的页面划分</p>
<div style="display: flex; gap: 16px; flex-wrap: wrap; margin-top: 16px;">
<!-- 公开门户 -->
<div style="flex: 1; min-width: 240px; background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #0c4a6e; padding: 10px 14px;">
<span style="font-weight: 700; color: #38bdf8;">公开门户</span>
<span style="font-size: 11px; color: #7dd3fc; margin-left: 8px;">无需登录</span>
</div>
<div style="padding: 12px 14px; font-size: 12px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #38bdf8; font-family: monospace;">/</div>
<div style="color: #94a3b8; margin-top: 2px;">首页(职位推荐 + 公司展示 + 搜索入口)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #38bdf8; font-family: monospace;">/jobs</div>
<div style="color: #94a3b8; margin-top: 2px;">职位列表(搜索 + 筛选:公司/地点/薪资/类别)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #38bdf8; font-family: monospace;">/jobs/:id</div>
<div style="color: #94a3b8; margin-top: 2px;">职位详情(描述 + 要求 + 投递按钮)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #38bdf8; font-family: monospace;">/companies</div>
<div style="color: #94a3b8; margin-top: 2px;">公司列表</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #38bdf8; font-family: monospace;">/companies/:id</div>
<div style="color: #94a3b8; margin-top: 2px;">公司详情 + 该公司在招职位</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #38bdf8; font-family: monospace;">/login &nbsp; /register</div>
<div style="color: #94a3b8; margin-top: 2px;">求职者登录 / 注册</div>
</div>
</div>
</div>
</div>
<!-- 求职者中心 -->
<div style="flex: 1; min-width: 240px; background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #312e81; padding: 10px 14px;">
<span style="font-weight: 700; color: #a5b4fc;">求职者中心</span>
<span style="font-size: 11px; color: #c7d2fe; margin-left: 8px;">需登录seeker</span>
</div>
<div style="padding: 12px 14px; font-size: 12px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #a5b4fc; font-family: monospace;">/seeker/resume</div>
<div style="color: #94a3b8; margin-top: 2px;">我的简历(填写/编辑在线简历 + 上传附件)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #a5b4fc; font-family: monospace;">/seeker/applications</div>
<div style="color: #94a3b8; margin-top: 2px;">我的投递(投递记录 + 状态跟踪)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #a5b4fc; font-family: monospace;">/seeker/profile</div>
<div style="color: #94a3b8; margin-top: 2px;">账号设置(修改密码/邮箱/手机)</div>
</div>
</div>
</div>
</div>
<!-- 管理后台 -->
<div style="flex: 1; min-width: 240px; background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
<div style="background: #4a1d96; padding: 10px 14px;">
<span style="font-weight: 700; color: #ddd6fe;">管理后台</span>
<span style="font-size: 11px; color: #ede9fe; margin-left: 8px;">需登录admin / superadmin</span>
</div>
<div style="padding: 12px 14px; font-size: 12px;">
<div style="color: #f472b6; font-size: 11px; font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em;">公司管理员</div>
<div style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;">
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #f9a8d4; font-family: monospace;">/admin/jobs</div>
<div style="color: #94a3b8; margin-top: 2px;">职位管理(列表 / 新建 / 编辑 / 上下架)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #f9a8d4; font-family: monospace;">/admin/applications</div>
<div style="color: #94a3b8; margin-top: 2px;">投递管理(查看简历 / 更新状态 / 备注)</div>
</div>
</div>
<div style="color: #fb923c; font-size: 11px; font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em;">超管专属</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #fdba74; font-family: monospace;">/admin/organizations</div>
<div style="color: #94a3b8; margin-top: 2px;">组织架构管理(增删改查子公司)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #fdba74; font-family: monospace;">/admin/users</div>
<div style="color: #94a3b8; margin-top: 2px;">用户管理(创建公司管理员 / 禁用账号)</div>
</div>
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
<div style="color: #fdba74; font-family: monospace;">/admin/jobs &nbsp;(全局视图)</div>
<div style="color: #94a3b8; margin-top: 2px;">查看所有公司的职位</div>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top: 16px; background: #0f172a; border-radius: 8px; padding: 14px; border: 1px solid #334155; font-size: 12px; color: #94a3b8;">
<strong style="color: #e2e8f0;">路由守卫规则:</strong>
<ul style="margin: 8px 0 0 16px; line-height: 2;">
<li>未登录访问 /seeker/* → 跳转登录页,登录后返回原页面</li>
<li>未登录访问 /admin/* → 跳转管理员登录页</li>
<li>admin 角色访问超管页面 → 403 提示</li>
<li>seeker 角色访问 /admin/* → 403 提示</li>
</ul>
</div>

View File

@ -382,7 +382,7 @@ git commit -m "chore: add pytest configuration for Django tests"
---
## Task 4: accounts app — 自定义用户模型
## Task 3: accounts app — 自定义用户模型
**Files:**
- Create: `offer_backend/apps/accounts/models.py`

View File

@ -20,7 +20,7 @@
| 认证 | djangorestframework-simplejwtJWT |
| 数据库 | PostgreSQL |
| 缓存 | Redis |
| 文件存储 | 本地存储 / 对象存储OSS |
| 文件存储 | 本地存储(初期),后期可切换至对象存储OSS |
| 部署 | Nginx + Gunicorn |
---
@ -113,7 +113,7 @@ Nginx
| id | PK | 主键 |
| job | FK(Job) | 投递的职位 |
| applicant | FK(User) | 求职者 |
| resume | FK(Resume) | 投递时关联的简历(快照) |
| resume_snapshot | JSONField | 投递时将简历内容序列化复制存储,与 Resume 主表解耦 |
| status | CharField | pending / viewed / interviewing / hired / rejected |
| note | TextField | HR 备注 |
| applied_at | DateTimeField | 投递时间 |
@ -130,7 +130,7 @@ Nginx
| `/jobs/:id` | 职位详情 | 职位描述、要求、投递按钮 |
| `/companies` | 公司列表 | 所有子公司展示 |
| `/companies/:id` | 公司详情 | 公司介绍 + 在招职位 |
| `/login` | 求职者登录 | - |
| `/login` | 求职者登录 | 管理员也使用此页面登录,通过 role 跳转不同后台 |
| `/register` | 求职者注册 | - |
### 求职者中心seeker 登录后可访问)
@ -170,7 +170,7 @@ Nginx
待查看(pending) → 已查看(viewed) → 面试中(interviewing) → 已录用(hired)
→ 已拒绝(rejected)
```
- 每次状态变更自动发送邮件通知求职者
- 每次状态变更通过 Django 内置邮件后端SMTP同步发送通知邮件给求职者
- HR 可在任意阶段填写备注
### 流程 3公司管理员发布职位

View File

@ -0,0 +1,152 @@
# 公司列表三栏布局设计文档
**日期:** 2026-03-25
**状态:** 已批准
**影响页面:** `/companies``CompanyListView.vue`
---
## 目标
将现有 `/companies` 页面从网格卡片布局改造为三栏联动布局,用户可在同一页面浏览公司、查看岗位列表、阅读岗位详情,无需跳转。
---
## 布局结构
```
┌─────────────────────────────────────────────────────┐
│ 左栏 220px │ 中栏 260px │ 右栏 flex:1 │
│ 公司列表 │ 岗位列表 │ 岗位详情 │
└─────────────────────────────────────────────────────┘
```
**高度设置:** PortalLayout 包含 `el-header`60px+ `el-main`(默认 padding 20px+ `el-footer`60px。三栏容器根元素设置 `height: calc(100vh - 200px)`60 header + 60 footer + 40 el-main padding + 40 buffer`overflow: hidden`,各栏内部 `overflow-y: auto` 独立滚动。此高度值在主流桌面分辨率下提供良好体验,无需精确到像素。
---
## 各栏设计
### 左栏 — 公司列表
- 顶部标题「全部企业」+ 公司总数 badge
- 每个公司卡片包含:
- Logo如有 `org.logo`,显示 `<img>`;否则显示公司名首字 + 蓝色背景 div
- 公司名称(`org.name`
- 简介摘要(`org.description` 截取前 20 字,为空则不显示)
- **不显示在招岗位数**Organization API 无此字段)
- 选中状态:左边框 `3px solid #409EFF`,背景白色
- 未选中:左边框透明,背景 `#f5f7fa`
- **加载状态**`orgsLoading` 为 true 时显示 `el-skeleton`3 行)
- **错误状态**`orgsError` 为 true 时显示「加载失败,请刷新重试」居中提示
- **空状态**`orgs` 为空数组时显示「暂无公司数据」居中提示
- 数据来源:`getOrganizations()` → `GET /organizations/public/`,取 `data.results`
### 中栏 — 岗位列表
- 标题:「{org.name} · 职位列表」
- 每条岗位显示:
- 岗位名称(`job.title`
- 标签行:`job.location`(城市)、`job.salary`(薪资)、`job.category`(类别)
- 选中状态:左边框 `3px solid #409EFF`,背景 `#ecf5ff`
- **空状态**:该公司无已发布岗位时,显示「暂无在招职位」居中提示
- **加载状态**`jobsLoading` 为 true 时显示 `el-skeleton`3 行)
- **错误状态**API 失败时显示「加载失败,请刷新重试」
- 数据来源:`getJobs({ organization: org.id })` → 取 `data.results`
### 右栏 — 岗位详情
**右栏渲染优先级(严格按顺序):**
1. `detailLoading === true` → 显示 `el-skeleton`
2. `detailError === true` → 显示「加载失败,请刷新重试」
3. `selectedJob !== null` → 显示岗位详情
4. 以上均不满足(`selectedJob === null` 且无加载/错误)→ 显示空状态
**各状态内容:**
- **空状态**:居中空状态图标 + 「← 请选择左侧职位查看详情」
- **岗位详情**
- 顶部:公司首字 Logo + 岗位名称(`job.title`+ 公司/城市副标题 + 「立即申请」按钮
- 标签行:`job.location`、`job.salary`、`job.category`(无经验字段,不显示)
- 正文:`job.description`(用 `white-space: pre-wrap` 保留换行)
- **加载状态**`el-skeleton`5 行,含头部和正文)
- **错误状态**:「加载失败,请刷新重试」居中提示
- **「立即申请」按钮行为**
- 已登录seeker`router.push({ name: 'JobDetail', params: { id: job.id } })`(跳转现有详情页完成申请)
- 未登录:`router.push({ name: 'Login', query: { redirect: '/jobs/' + job.id } })`
---
## 数据结构
`getOrganizations()` 返回的 Organization 对象字段:`id, name, logo (string|null), description, email, is_active`
`getJobs()` 使用 `JobListSerializer`,返回字段:`id, title, category, location, salary, organization (id only), organization_name, status, created_at`。**不含 `description`。**
`getJob(id)` 使用 `JobDetailSerializer`,返回字段:`id, title, category, location, salary, description, organization: {id, name, logo, ...}, status, created_at`。**含完整 `description`,因此点击岗位时必须调用此接口获取详情。**
`/jobs/public/` 后端已在 queryset 层过滤 `status='published'`,无需前端额外传 status 参数。
**Logo URL 处理**`org.logo` 为相对路径(如 `/media/org_logos/foo.png`),需拼接后端地址。通过 Vite proxy开发环境直接使用原始路径即可proxy 会转发到 `http://127.0.0.1:8000`)。渲染时用 `org.logo ? org.logo : null` 判断是否显示图片。
---
## 状态管理
组件内使用以下 ref
| 变量 | 类型 | 说明 |
|------|------|------|
| `orgs` | `Ref<Organization[]>` | 所有公司列表 |
| `orgsLoading` | `Ref<boolean>` | 左栏加载状态 |
| `orgsError` | `Ref<boolean>` | 左栏错误状态 |
| `selectedOrg` | `Ref<Organization \| null>` | 当前选中公司 |
| `jobs` | `Ref<Job[]>` | 当前公司的岗位列表 |
| `selectedJob` | `Ref<Job \| null>` | 当前选中岗位getJob 返回的完整 Job 对象) |
| `jobsLoading` | `Ref<boolean>` | 中栏加载状态 |
| `detailLoading` | `Ref<boolean>` | 右栏加载状态 |
| `jobsError` | `Ref<boolean>` | 中栏错误状态 |
| `detailError` | `Ref<boolean>` | 右栏错误状态 |
---
## 交互流程
1. `onMounted` → 设置 `orgsLoading = true`,调用 `getOrganizations()`
- 成功且列表非空:`orgsLoading = false`,渲染左栏,自动调用 `selectOrg(orgs.value[0])`
- 成功但列表为空:`orgsLoading = false`,左栏显示「暂无公司数据」
- 失败:`orgsLoading = false``orgsError = true`,左栏显示「加载失败,请刷新重试」
2. `selectOrg(org)`
- 重置 `jobsError = false`,清空 `selectedJob``jobs = []`
- 设置 `selectedOrg = org``jobsLoading = true`
- 调用 `getJobs({ organization: org.id })`,取 `data.results` 更新 `jobs`
- 完成后 `jobsLoading = false`;失败则 `jobsError = true`
3. `selectJob(job)`
- 重置 `detailError = false`
- 设置 `detailLoading = true``selectedJob = null`
- 调用 `getJob(job.id)` 更新 `selectedJob`
- 完成后 `detailLoading = false`;失败则 `detailError = true`
4. 「立即申请」按钮点击 →
- 若 `authStore.isSeeker``router.push({ name: 'JobDetail', params: { id: selectedJob.id } })`
- 否则(未登录或非 seeker 角色):`router.push({ name: 'Login', query: { redirect: '/jobs/' + selectedJob.id } })`
---
## 文件变更范围
| 文件 | 变更类型 |
|------|---------|
| `offer_frontend/src/views/portal/CompanyListView.vue` | 完全重写 |
无需改动后端、无需新增路由、无需新增组件。
---
## 不在范围内
- 搜索/筛选公司或岗位
- 分页(公司数量有限,一次性加载)
- CompanyDetailView`/companies/:id`)不变
- 在招岗位数统计Organization API 无此字段)

View File

@ -6,6 +6,12 @@ class ApplicationCreateSerializer(serializers.ModelSerializer):
model = Application
fields = ['job']
def validate(self, data):
request = self.context['request']
if Application.objects.filter(job=data['job'], applicant=request.user).exists():
raise serializers.ValidationError({'detail': '您已投递过该职位'})
return data
def create(self, validated_data):
request = self.context['request']
try:

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.20 on 2026-03-25 02:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('jobs', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='JobFavorite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='jobs.job')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_favorites', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'unique_together': {('user', 'job')},
},
),
]

View File

@ -27,3 +27,15 @@ class Job(models.Model):
def __str__(self):
return self.title
class JobFavorite(models.Model):
user = models.ForeignKey(
'accounts.User', on_delete=models.CASCADE, related_name='job_favorites'
)
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='favorited_by')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'job')
ordering = ['-created_at']

View File

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import Job
from .models import Job, JobFavorite
from apps.organizations.serializers import OrganizationSerializer
from apps.organizations.models import Organization
@ -25,3 +25,11 @@ class JobDetailSerializer(serializers.ModelSerializer):
model = Job
fields = ['id', 'title', 'category', 'location', 'salary',
'description', 'organization', 'organization_id', 'status', 'created_at']
class JobFavoriteSerializer(serializers.ModelSerializer):
job = JobListSerializer(read_only=True)
class Meta:
model = JobFavorite
fields = ['id', 'job', 'created_at']

View File

@ -1,9 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import JobPublicViewSet, JobManageViewSet
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView
router = DefaultRouter()
router.register('public', JobPublicViewSet, basename='job-public')
router.register('manage', JobManageViewSet, basename='job-manage')
urlpatterns = [path('', include(router.urls))]
urlpatterns = [
path('', include(router.urls)),
path('favorites/', MyFavoritesView.as_view()),
]

View File

@ -1,10 +1,12 @@
from rest_framework import viewsets, permissions
from rest_framework import viewsets, permissions, generics
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from .models import Job
from .serializers import JobListSerializer, JobDetailSerializer
from .models import Job, JobFavorite
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
from .filters import JobFilter
from apps.accounts.permissions import IsAdminOrSuperAdmin
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
@ -20,6 +22,26 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
return JobDetailSerializer
return JobListSerializer
@action(detail=True, methods=['post'], permission_classes=[IsSeeker])
def favorite(self, request, pk=None):
job = self.get_object()
fav, created = JobFavorite.objects.get_or_create(user=request.user, job=job)
if not created:
fav.delete()
return Response({'collected': False})
return Response({'collected': True})
class MyFavoritesView(generics.ListAPIView):
"""求职者的收藏列表"""
serializer_class = JobFavoriteSerializer
permission_classes = [IsSeeker]
def get_queryset(self):
return JobFavorite.objects.filter(user=self.request.user).select_related(
'job', 'job__organization'
)
class JobManageViewSet(viewsets.ModelViewSet):
"""管理端:公司管理员管理本公司职位"""

View File

@ -3,18 +3,27 @@ from .models import Organization
class OrganizationSerializer(serializers.ModelSerializer):
job_count = serializers.SerializerMethodField()
class Meta:
model = Organization
fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active']
fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active', 'job_count']
def get_job_count(self, obj):
return obj.jobs.filter(status='published').count()
class OrganizationTreeSerializer(serializers.ModelSerializer):
"""带子公司列表,用于门户展示"""
children = serializers.SerializerMethodField()
job_count = serializers.SerializerMethodField()
class Meta:
model = Organization
fields = ['id', 'name', 'logo', 'description', 'email', 'children']
fields = ['id', 'name', 'logo', 'description', 'email', 'job_count', 'children']
def get_job_count(self, obj):
return obj.jobs.filter(status='published').count()
def get_children(self, obj):
return OrganizationSerializer(

View File

@ -1,3 +1,15 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
onMounted(async () => {
if (localStorage.getItem('access_token') && !auth.user) {
try { await auth.fetchMe() } catch { /* token expired, ignore */ }
}
})
</script>

View File

@ -6,3 +6,5 @@ export const manageJobs = (params) => client.get('/jobs/manage/', { params })
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/')

View File

@ -2,3 +2,6 @@ import client from './client'
export const getMyResume = () => client.get('/resumes/me/')
export const updateMyResume = (data) => client.patch('/resumes/me/', data)
export const uploadResumeAttachment = (formData) => client.patch('/resumes/me/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})

View File

@ -1,28 +1,91 @@
<template>
<el-container class="portal-layout">
<el-header>
<div class="header-inner">
<router-link to="/" class="logo">集团招聘</router-link>
<nav>
<router-link to="/jobs">职位列表</router-link>
<router-link to="/companies">公司介绍</router-link>
</nav>
<div class="header-actions">
<div class="portal-wrap">
<!-- 顶部公告条 -->
<div class="top-bar">
<div class="top-bar-inner">
<span class="top-bar-left">欢迎访问集团人才招募平台</span>
<div class="top-bar-right">
<template v-if="auth.isLoggedIn">
<router-link v-if="auth.isSeeker" to="/seeker/applications">我的投递</router-link>
<router-link v-else to="/admin/jobs">管理后台</router-link>
<el-button text @click="logout">退出</el-button>
<router-link v-if="auth.isSeeker" to="/seeker/applications" class="top-link">我的投递</router-link>
<router-link v-else to="/admin/jobs" class="top-link">管理后台</router-link>
<span class="top-divider">|</span>
<span class="top-link clickable" @click="logout">退出登录</span>
</template>
<template v-else>
<router-link to="/login"><el-button>登录</el-button></router-link>
<router-link to="/register"><el-button type="primary">注册</el-button></router-link>
<router-link to="/login" class="top-link">登录</router-link>
<span class="top-divider">|</span>
<router-link to="/register" class="top-link">注册</router-link>
</template>
</div>
</div>
</el-header>
<el-main><router-view /></el-main>
<el-footer>© 集团招聘平台</el-footer>
</el-container>
</div>
<!-- 主导航 -->
<header class="main-header">
<div class="header-inner">
<!-- Logo 区域 -->
<router-link to="/home" class="logo-area">
<div class="logo-emblem">
<div class="emblem-ring">
<span class="emblem-char"></span>
</div>
</div>
<div class="logo-text-block">
<span class="logo-title">集团招聘平台</span>
<span class="logo-sub">GROUP TALENT RECRUITMENT</span>
</div>
</router-link>
<!-- 导航 -->
<nav class="main-nav">
<router-link to="/home" class="nav-link" active-class="active">首页</router-link>
<router-link to="/jobs" class="nav-link" active-class="active">职位列表</router-link>
<router-link to="/companies" class="nav-link" active-class="active">公司介绍</router-link>
</nav>
<!-- 操作区 -->
<div class="header-actions">
<template v-if="!auth.isLoggedIn">
<router-link to="/login">
<button class="btn-ghost">登录</button>
</router-link>
<router-link to="/register">
<button class="btn-primary">立即注册</button>
</router-link>
</template>
<template v-else>
<div class="user-badge">
<span class="user-avatar">{{ auth.user?.username?.[0]?.toUpperCase() }}</span>
<span class="user-name">{{ auth.user?.username }}</span>
</div>
</template>
</div>
</div>
<!-- 红色底线 -->
<div class="header-underline">
<div class="underline-fill"></div>
</div>
</header>
<!-- 内容区 -->
<main class="portal-main">
<router-view />
</main>
<!-- 页脚 -->
<footer class="portal-footer">
<div class="footer-inner">
<div class="footer-logo">
<div class="f-emblem"></div>
<span>集团招聘平台</span>
</div>
<div class="footer-copy">
Copyright © 集团招聘平台 · All Rights Reserved · 为国家建设输送优秀人才
</div>
</div>
</footer>
</div>
</template>
<script setup>
@ -34,11 +97,224 @@ const logout = () => { auth.logout(); router.push('/') }
</script>
<style scoped>
.portal-layout { min-height: 100vh; }
.el-header { display: flex; align-items: center; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.header-inner { display: flex; align-items: center; width: 100%; max-width: 1200px; margin: 0 auto; }
.logo { font-size: 20px; font-weight: bold; color: #409eff; text-decoration: none; margin-right: 32px; }
nav { display: flex; gap: 24px; flex: 1; }
nav a { text-decoration: none; color: #333; }
.header-actions { display: flex; gap: 12px; align-items: center; }
:root {
--c-red: #B5272C;
--c-dark: #0E1E3D;
--c-gold: #C8973A;
--c-cream: #F8F4EF;
--c-border: #E2D9D0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
a { text-decoration: none; }
.portal-wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #F0EDE8;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
}
/* 顶部公告条 */
.top-bar {
background: #0E1E3D;
color: rgba(255,255,255,0.65);
font-size: 12px;
letter-spacing: 0.05em;
}
.top-bar-inner {
max-width: 1280px;
margin: 0 auto;
padding: 6px 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.top-bar-left::before {
content: '★';
color: #C8973A;
margin-right: 6px;
font-size: 10px;
}
.top-bar-right { display: flex; align-items: center; gap: 6px; }
.top-link { color: rgba(255,255,255,0.65); transition: color 0.2s; }
.top-link:hover, .clickable:hover { color: #C8973A; cursor: pointer; }
.top-divider { color: rgba(255,255,255,0.25); }
/* 主导航 */
.main-header {
background: linear-gradient(180deg, #12264F 0%, #0E1E3D 100%);
box-shadow: 0 4px 20px rgba(14,30,61,0.4);
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
max-width: 1280px;
margin: 0 auto;
padding: 0 32px;
height: 72px;
display: flex;
align-items: center;
gap: 48px;
}
/* Logo */
.logo-area {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.logo-emblem { position: relative; }
.emblem-ring {
width: 48px; height: 48px;
border-radius: 50%;
border: 2px solid #C8973A;
background: linear-gradient(135deg, rgba(200,151,58,0.15), rgba(200,151,58,0.05));
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 16px rgba(200,151,58,0.3), inset 0 0 8px rgba(200,151,58,0.1);
}
.emblem-char {
font-size: 20px;
font-weight: 900;
color: #C8973A;
letter-spacing: -1px;
}
.logo-text-block { display: flex; flex-direction: column; }
.logo-title {
font-size: 20px;
font-weight: 800;
color: #FFFFFF;
letter-spacing: 0.15em;
line-height: 1.2;
}
.logo-sub {
font-size: 9px;
color: rgba(200,151,58,0.7);
letter-spacing: 0.2em;
margin-top: 2px;
}
/* 导航链接 */
.main-nav {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.nav-link {
position: relative;
padding: 8px 20px;
color: rgba(255,255,255,0.75);
font-size: 14px;
letter-spacing: 0.08em;
transition: color 0.2s;
border-radius: 4px;
}
.nav-link::after {
content: '';
position: absolute;
bottom: 4px; left: 50%; right: 50%;
height: 2px;
background: #C8973A;
border-radius: 1px;
transition: left 0.25s ease, right 0.25s ease;
}
.nav-link:hover { color: #fff; }
.nav-link:hover::after, .nav-link.active::after { left: 20%; right: 20%; }
.nav-link.active { color: #C8973A; font-weight: 600; }
/* 操作区 */
.header-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.btn-ghost {
padding: 7px 20px;
background: transparent;
border: 1px solid rgba(255,255,255,0.35);
color: rgba(255,255,255,0.85);
border-radius: 3px;
font-size: 13px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 0.2s;
font-family: inherit;
}
.btn-ghost:hover { border-color: #C8973A; color: #C8973A; }
.btn-primary {
padding: 7px 20px;
background: #B5272C;
border: 1px solid #B5272C;
color: #fff;
border-radius: 3px;
font-size: 13px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 0.2s;
font-weight: 600;
font-family: inherit;
}
.btn-primary:hover { background: #9A2024; border-color: #9A2024; }
.user-badge {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 24px;
}
.user-avatar {
width: 26px; height: 26px;
background: #B5272C;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: #fff;
}
.user-name { color: rgba(255,255,255,0.85); font-size: 13px; }
/* 红色底线装饰 */
.header-underline { height: 3px; background: #0E1E3D; }
.underline-fill {
height: 100%;
background: linear-gradient(90deg, #B5272C 0%, #E63329 40%, #C8973A 100%);
}
/* 内容区 */
.portal-main {
flex: 1;
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 24px 32px;
}
/* 页脚 */
.portal-footer {
background: #0E1E3D;
border-top: 3px solid #B5272C;
padding: 20px 0;
}
.footer-inner {
max-width: 1280px;
margin: 0 auto;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-logo {
display: flex; align-items: center; gap: 10px;
color: rgba(255,255,255,0.7);
font-size: 14px; letter-spacing: 0.1em;
}
.f-emblem {
width: 28px; height: 28px; border-radius: 50%;
border: 1px solid #C8973A;
color: #C8973A; font-weight: 900; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.footer-copy {
font-size: 12px;
color: rgba(255,255,255,0.35);
letter-spacing: 0.05em;
}
</style>

View File

@ -5,7 +5,9 @@
<el-menu router :default-active="$route.path">
<el-menu-item index="/seeker/resume">我的简历</el-menu-item>
<el-menu-item index="/seeker/applications">我的投递</el-menu-item>
<el-menu-item index="/seeker/favorites">关注职位</el-menu-item>
<el-menu-item index="/seeker/profile">账号设置</el-menu-item>
<el-menu-item index="/home">返回主页</el-menu-item>
</el-menu>
</el-aside>
<el-main><router-view /></el-main>

View File

@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
// 独立入口页
{ path: '/', name: 'Splash', component: () => import('@/views/SplashView.vue') },
// 公开门户
{
path: '/',
component: () => import('@/layouts/PortalLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('@/views/portal/HomeView.vue') },
{ path: 'home', name: 'Home', component: () => import('@/views/portal/HomeView.vue') },
{ path: 'jobs', name: 'JobList', component: () => import('@/views/portal/JobListView.vue') },
{ path: 'jobs/:id', name: 'JobDetail', component: () => import('@/views/portal/JobDetailView.vue') },
{ path: 'companies', name: 'CompanyList', component: () => import('@/views/portal/CompanyListView.vue') },
@ -24,6 +26,7 @@ const routes = [
children: [
{ path: 'resume', name: 'SeekerResume', component: () => import('@/views/seeker/ResumeView.vue') },
{ path: 'applications', name: 'SeekerApplications', component: () => import('@/views/seeker/ApplicationsView.vue') },
{ path: 'favorites', name: 'SeekerFavorites', component: () => import('@/views/seeker/FavoritesView.vue') },
{ path: 'profile', name: 'SeekerProfile', component: () => import('@/views/seeker/ProfileView.vue') },
]
},

View File

@ -1,3 +1,71 @@
<template>
<div>投递管理 - 开发中</div>
<div>
<h2>投递管理</h2>
<el-table :data="applications" v-loading="loading" border>
<el-table-column prop="job_title" label="职位" />
<el-table-column label="求职者">
<template #default="{ row }">{{ row.resume_snapshot?.name }}</template>
</el-table-column>
<el-table-column prop="applied_at" label="投递时间" :formatter="(r,c,v) => v?.slice(0,10)" />
<el-table-column label="状态" width="150">
<template #default="{ row }">
<el-select v-model="row.status" size="small" @change="updateStatus(row)">
<el-option value="pending" label="待查看" />
<el-option value="viewed" label="已查看" />
<el-option value="interviewing" label="面试中" />
<el-option value="hired" label="已录用" />
<el-option value="rejected" label="已拒绝" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="viewResume(row)">查看简历</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="resumeVisible" title="简历详情" width="600px">
<div v-if="currentResume">
<p><strong>姓名</strong>{{ currentResume.name }}</p>
<p><strong>性别</strong>{{ currentResume.gender }}</p>
<el-divider>教育经历</el-divider>
<div v-for="(e, i) in currentResume.education" :key="i">{{ e.school }} · {{ e.degree }} · {{ e.major }}</div>
<el-divider>工作经历</el-divider>
<div v-for="(e, i) in currentResume.experience" :key="i">{{ e.company }} · {{ e.position }} · {{ e.duration }}</div>
<div v-if="currentResume.attachment_url" style="margin-top:16px">
<a :href="currentResume.attachment_url" target="_blank">下载简历附件</a>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getManageApplications, updateApplicationStatus } from '@/api/applications'
import { ElMessage } from 'element-plus'
const applications = ref([])
const loading = ref(false)
const resumeVisible = ref(false)
const currentResume = ref(null)
onMounted(async () => {
loading.value = true
const { data } = await getManageApplications()
applications.value = data.results
loading.value = false
})
async function updateStatus(row) {
try {
await updateApplicationStatus(row.id, { status: row.status })
ElMessage.success('状态已更新,求职者将收到邮件通知')
} catch { ElMessage.error('更新失败') }
}
function viewResume(row) {
currentResume.value = row.resume_snapshot
resumeVisible.value = true
}
</script>

View File

@ -1,3 +1,146 @@
<template>
<div>职位管理 - 开发中</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>职位管理</h2>
<el-button type="primary" @click="openDialog()">发布职位</el-button>
</div>
<el-table :data="jobs" v-loading="loading" border>
<el-table-column prop="title" label="职位名称" />
<el-table-column prop="organization_name" label="所属公司" />
<el-table-column prop="location" label="地点" />
<el-table-column prop="salary" label="薪资" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'published' ? 'success' : row.status === 'draft' ? 'info' : 'danger'">
{{ { draft:'草稿', published:'已发布', closed:'已关闭' }[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button size="small" @click="openDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editingJob ? '编辑职位' : '发布职位'" width="600px">
<el-form :model="form" label-width="90px">
<!-- 超管才需要选择公司 -->
<el-form-item v-if="auth.isSuperAdmin" label="所属公司">
<el-select v-model="form.organization_id" placeholder="请选择公司" style="width:100%" filterable>
<template v-for="org in allOrgs" :key="org.id">
<el-option :label="org.name" :value="org.id" />
<el-option
v-for="child in org.children"
:key="child.id"
:label="'└ ' + child.name"
:value="child.id"
/>
</template>
</el-select>
</el-form-item>
<el-form-item label="职位名称"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="职位类别"><el-input v-model="form.category" /></el-form-item>
<el-form-item label="工作地点"><el-input v-model="form.location" /></el-form-item>
<el-form-item label="薪资范围"><el-input v-model="form.salary" /></el-form-item>
<el-form-item label="职位描述"><el-input v-model="form.description" type="textarea" :rows="5" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option value="draft" label="草稿" />
<el-option value="published" label="立即发布" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { manageJobs, createJob, updateJob, deleteJob } from '@/api/jobs'
import { getOrganizations } from '@/api/organizations'
import { ElMessage, ElMessageBox } from 'element-plus'
const auth = useAuthStore()
const jobs = ref([])
const allOrgs = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingJob = ref(null)
const form = reactive({
title: '', category: '', location: '', salary: '',
description: '', status: 'draft', organization_id: null
})
const fetchJobs = async () => {
loading.value = true
const { data } = await manageJobs()
jobs.value = data.results
loading.value = false
}
const fetchOrgs = async () => {
const { data } = await getOrganizations()
allOrgs.value = data.results
}
function openDialog(job = null) {
editingJob.value = job
if (job) {
Object.assign(form, {
title: job.title,
category: job.category,
location: job.location,
salary: job.salary,
description: job.description,
status: job.status,
organization_id: job.organization?.id ?? job.organization ?? null,
})
} else {
Object.assign(form, {
title: '', category: '', location: '', salary: '',
description: '', status: 'draft', organization_id: null
})
}
dialogVisible.value = true
}
async function handleSave() {
if (auth.isSuperAdmin && !form.organization_id) {
return ElMessage.warning('请选择所属公司')
}
saving.value = true
try {
const payload = { ...form }
if (!auth.isSuperAdmin) delete payload.organization_id
if (editingJob.value) await updateJob(editingJob.value.id, payload)
else await createJob(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchJobs()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(id) {
await ElMessageBox.confirm('确认删除该职位?', '提示', { type: 'warning' })
await deleteJob(id)
ElMessage.success('已删除')
fetchJobs()
}
onMounted(() => {
fetchJobs()
if (auth.isSuperAdmin) fetchOrgs()
})
</script>

View File

@ -1,3 +1,74 @@
<template>
<div>组织架构管理 - 开发中</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
<h2>组织架构管理</h2>
<el-button type="primary" @click="openDialog()">新增公司</el-button>
</div>
<el-table :data="orgs" border>
<el-table-column prop="name" label="公司名称" />
<el-table-column label="上级公司">
<template #default="{ row }">{{ row.parent ? orgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
</el-table-column>
<el-table-column prop="email" label="联系邮箱" />
<el-table-column label="状态">
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'启用':'停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑公司' : '新增公司'" width="480px">
<el-form :model="form" label-width="90px">
<el-form-item label="公司名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="上级公司">
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
<el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
</el-select>
</el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="简介"><el-input v-model="form.description" type="textarea" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.is_active" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible=false">取消</el-button>
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { manageOrganizations, createOrganization, updateOrganization } from '@/api/organizations'
import { ElMessage } from 'element-plus'
const orgs = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const saving = ref(false)
const form = reactive({ name: '', parent: null, email: '', description: '', is_active: true })
const fetchOrgs = async () => {
const { data } = await manageOrganizations()
orgs.value = data.results
}
function openDialog(org = null) {
editing.value = org
if (org) Object.assign(form, org)
else Object.assign(form, { name: '', parent: null, email: '', description: '', is_active: true })
dialogVisible.value = true
}
async function save() {
saving.value = true
try {
if (editing.value) await updateOrganization(editing.value.id, form)
else await createOrganization(form)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchOrgs()
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
}
onMounted(fetchOrgs)
</script>

View File

@ -1,3 +1,88 @@
<template>
<div>用户管理 - 开发中</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
<h2>用户管理</h2>
<el-button type="primary" @click="openDialog()">新增管理员</el-button>
</div>
<el-table :data="users" border>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="角色">
<template #default="{ row }">
<el-tag :type="row.role==='superadmin'?'danger':row.role==='admin'?'warning':'info'">
{{ { superadmin:'超管', admin:'公司管理员', seeker:'求职者' }[row.role] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="所属公司">
<template #default="{ row }">{{ row.organization ? orgs.find(o=>o.id===row.organization)?.name : '-' }}</template>
</el-table-column>
<el-table-column label="状态">
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'正常':'停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新增管理员'" width="480px">
<el-form :model="form" label-width="90px">
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role">
<el-option value="admin" label="公司管理员" />
<el-option value="superadmin" label="超级管理员" />
</el-select>
</el-form-item>
<el-form-item label="所属公司" v-if="form.role==='admin'">
<el-select v-model="form.organization" clearable>
<el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
</el-select>
</el-form-item>
<el-form-item label="密码" v-if="!editing"><el-input v-model="form.password" type="password" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.is_active" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible=false">取消</el-button>
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { manageOrganizations } from '@/api/organizations'
import client from '@/api/client'
import { ElMessage } from 'element-plus'
const users = ref([])
const orgs = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const saving = ref(false)
const form = reactive({ username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
const fetchUsers = async () => { const { data } = await client.get('/auth/users/'); users.value = data.results || data }
const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }
function openDialog(user = null) {
editing.value = user
if (user) Object.assign(form, user)
else Object.assign(form, { username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
dialogVisible.value = true
}
async function save() {
saving.value = true
try {
if (editing.value) await client.patch(`/auth/users/${editing.value.id}/`, form)
else await client.post('/auth/users/', form)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchUsers()
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
}
onMounted(() => { fetchUsers(); fetchOrgs() })
</script>

View File

@ -1,37 +1,700 @@
<template>
<div class="home">
<div class="hero">
<h1>发现你的理想职位</h1>
<el-input v-model="keyword" placeholder="搜索职位..." size="large" @keyup.enter="search">
<template #append><el-button @click="search">搜索</el-button></template>
</el-input>
</div>
<div class="latest-jobs">
<h3>最新职位</h3>
<JobCard v-for="job in latestJobs" :key="job.id" :job="job" />
</div>
<div class="three-panel">
<!-- 左栏公司列表 -->
<aside class="panel-left">
<div class="left-header">
<span class="left-title">全部企业</span>
<span class="left-count">{{ totalOrgs }}</span>
</div>
<div class="left-body">
<template v-if="orgsLoading">
<div v-for="i in 4" :key="i" class="skeleton-row" />
</template>
<template v-else-if="orgsError">
<div class="state-tip">加载失败请刷新重试</div>
</template>
<template v-else-if="orgs.length === 0">
<div class="state-tip">暂无公司数据</div>
</template>
<template v-else>
<template v-for="org in orgs" :key="org.id">
<!-- 集团 -->
<div
class="org-row"
:class="{ active: selectedOrg?.id === org.id }"
@click="selectOrg(org)"
>
<div class="org-avatar parent-avatar">
<img v-if="org.logo" :src="org.logo" :alt="org.name" />
<span v-else>{{ org.name[0] }}</span>
</div>
<div class="org-meta">
<span class="org-name">{{ org.name }}</span>
<span class="org-stat">在招 <em>{{ org.job_count }}</em> 个岗位</span>
</div>
</div>
<!-- 子公司 -->
<div
v-for="child in org.children"
:key="child.id"
class="org-row org-child"
:class="{ active: selectedOrg?.id === child.id }"
@click="selectOrg(child)"
>
<div class="child-indent">
<span class="child-line"></span>
</div>
<div class="org-avatar child-avatar">
<img v-if="child.logo" :src="child.logo" :alt="child.name" />
<span v-else>{{ child.name[0] }}</span>
</div>
<div class="org-meta">
<span class="org-name child-name">{{ child.name }}</span>
<span class="org-stat">在招 <em>{{ child.job_count }}</em> 个岗位</span>
</div>
</div>
</template>
</template>
</div>
</aside>
<!-- 中栏岗位列表 -->
<section class="panel-mid">
<div class="mid-header">
<span v-if="selectedOrg" class="mid-title">
<span class="mid-title-org">{{ selectedOrg.name }}</span>
<span class="mid-title-sep"> · </span>职位列表
</span>
<span v-else class="mid-title muted"> 请选择企业</span>
<span v-if="jobs.length" class="mid-count">{{ jobs.length }} </span>
</div>
<div class="mid-body">
<template v-if="jobsLoading">
<div v-for="i in 4" :key="i" class="skeleton-row" />
</template>
<template v-else-if="jobsError">
<div class="state-tip">加载失败请刷新重试</div>
</template>
<template v-else-if="jobs.length === 0 && selectedOrg">
<div class="state-tip">暂无在招职位</div>
</template>
<template v-else>
<div
v-for="job in jobs"
:key="job.id"
class="job-row"
:class="{ active: selectedJob?.id === job.id }"
@click="selectJob(job)"
>
<div class="job-row-title">{{ job.title }}</div>
<div class="job-row-tags">
<span class="tag tag-loc">{{ job.location }}</span>
<span class="tag tag-sal">{{ job.salary }}</span>
<span class="tag tag-cat">{{ job.category }}</span>
</div>
</div>
</template>
</div>
</section>
<!-- 右栏岗位详情 -->
<section class="panel-right">
<!-- 加载 -->
<template v-if="detailLoading">
<div class="detail-loading">
<div v-for="i in 6" :key="i" class="skeleton-row" style="margin-bottom:12px" />
</div>
</template>
<!-- 错误 -->
<template v-else-if="detailError">
<div class="state-tip full-tip">加载失败请刷新重试</div>
</template>
<!-- 详情 -->
<template v-else-if="selectedJob">
<!-- Banner -->
<div class="detail-banner">
<div class="banner-deco"></div>
<div class="banner-content">
<div class="banner-left">
<div class="banner-category">{{ selectedJob.category }}</div>
<h2 class="banner-title">{{ selectedJob.title }}</h2>
<div class="banner-salary">{{ selectedJob.salary }}</div>
<div class="banner-meta">
<span class="bmeta-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ selectedJob.location }}
</span>
<span class="bmeta-sep">|</span>
<span class="bmeta-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
{{ selectedJob.organization?.name }}
</span>
</div>
</div>
<div class="banner-right">
<div class="banner-date">发布于 {{ formatDate(selectedJob.created_at) }}</div>
<div class="banner-btns">
<button class="btn-collect" @click="handleCollect">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
{{ collected ? '已收藏' : '收藏' }}
</button>
<button class="btn-apply" :class="{ loading: applying }" @click="handleApply">
{{ applying ? '提交中…' : '立即投递' }}
</button>
</div>
<p class="banner-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
<p class="banner-hint success" v-if="applied"> 投递成功可在我的投递查看</p>
</div>
</div>
</div>
<!-- 主体 -->
<div class="detail-main">
<div class="detail-left">
<!-- 信息格 -->
<div class="info-grid">
<div class="info-cell">
<span class="info-label">职位类别</span>
<span class="info-value">{{ selectedJob.category || '未填写' }}</span>
</div>
<div class="info-cell">
<span class="info-label">工作地点</span>
<span class="info-value">{{ selectedJob.location }}</span>
</div>
<div class="info-cell">
<span class="info-label">薪资范围</span>
<span class="info-value red-val">{{ selectedJob.salary }}</span>
</div>
<div class="info-cell">
<span class="info-label">发布时间</span>
<span class="info-value">{{ formatDate(selectedJob.created_at) }}</span>
</div>
</div>
<div class="section-divider"></div>
<!-- 职位介绍 -->
<div class="content-section">
<div class="section-heading"><span class="heading-bar"></span>职位介绍</div>
<div class="desc-text" v-html="selectedJob.description"></div>
</div>
<div class="section-divider"></div>
<!-- 工作地点 -->
<div class="content-section">
<div class="section-heading"><span class="heading-bar"></span>工作地点</div>
<div class="location-row">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B5272C" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ selectedJob.location }}
</div>
</div>
</div>
<!-- 公司卡片 -->
<div class="detail-right">
<div class="company-card">
<div class="company-card-label">招聘单位</div>
<div class="company-card-top">
<div class="cc-logo">
<img v-if="selectedJob.organization?.logo" :src="selectedJob.organization.logo" alt="" />
<span v-else>{{ selectedJob.organization?.name?.[0] }}</span>
</div>
<div class="cc-info">
<div class="cc-name">{{ selectedJob.organization?.name }}</div>
<div class="cc-desc" v-if="selectedJob.organization?.description">
{{ selectedJob.organization.description.slice(0, 50) }}{{ selectedJob.organization.description.length > 50 ? '…' : '' }}
</div>
</div>
</div>
<div class="cc-divider"></div>
<div class="cc-contact" v-if="selectedJob.organization?.email">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#B5272C" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
{{ selectedJob.organization.email }}
</div>
<button class="btn-apply-card" :class="{ loading: applying }" @click="handleApply">
{{ applying ? '提交中…' : '立即投递' }}
</button>
<p class="apply-success" v-if="applied"> 投递成功</p>
</div>
</div>
</div>
</template>
<!-- 空状态 -->
<template v-else>
<div class="state-tip full-tip">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#C8973A" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<p>请从左侧选择企业及职位</p>
</div>
</template>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import JobCard from '@/components/JobCard.vue'
import { getJobs } from '@/api/jobs'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { getOrganizations } from '@/api/organizations'
import { getJobs, getJob, toggleFavorite } from '@/api/jobs'
import { applyJob } from '@/api/applications'
const router = useRouter()
const keyword = ref('')
const latestJobs = ref([])
const auth = useAuthStore()
onMounted(async () => {
const { data } = await getJobs({ page: 1 })
latestJobs.value = data.results.slice(0, 6)
const orgs = ref([])
const orgsLoading = ref(false)
const orgsError = ref(false)
const selectedOrg = ref(null)
const jobs = ref([])
const jobsLoading = ref(false)
const jobsError = ref(false)
const selectedJob = ref(null)
const detailLoading = ref(false)
const detailError = ref(false)
const applying = ref(false)
const applied = ref(false)
const collected = ref(false)
const totalOrgs = computed(() => {
return orgs.value.reduce((n, o) => n + 1 + (o.children?.length || 0), 0)
})
const search = () => router.push({ name: 'JobList', query: { search: keyword.value } })
function formatDate(dt) {
if (!dt) return ''
return new Date(dt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
async function selectOrg(org) {
selectedOrg.value = org
selectedJob.value = null
jobs.value = []
jobsError.value = false
jobsLoading.value = true
try {
const { data } = await getJobs({ organization: org.id })
jobs.value = data.results
} catch { jobsError.value = true }
finally { jobsLoading.value = false }
}
async function selectJob(job) {
detailError.value = false
detailLoading.value = true
selectedJob.value = null
applied.value = false
collected.value = false
try {
const { data } = await getJob(job.id)
selectedJob.value = data
} catch { detailError.value = true }
finally { detailLoading.value = false }
}
async function handleCollect() {
if (!auth.isLoggedIn) return router.push({ name: 'Login' })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法收藏职位')
const { data } = await toggleFavorite(selectedJob.value.id)
collected.value = data.collected
ElMessage.success(collected.value ? '已收藏,可在「关注职位」中查看' : '已取消收藏')
}
async function handleApply() {
if (!auth.isLoggedIn) return router.push({ name: 'Login' })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
applying.value = true
try {
await applyJob(selectedJob.value.id)
applied.value = true
ElMessage.success('投递成功!')
} catch (e) {
if (e.response?.status === 400) ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
else ElMessage.error('投递失败,请先完善简历')
} finally { applying.value = false }
}
onMounted(async () => {
orgsLoading.value = true
try {
const { data } = await getOrganizations()
orgs.value = data.results
if (orgs.value.length > 0) selectOrg(orgs.value[0])
} catch { orgsError.value = true }
finally { orgsLoading.value = false }
})
</script>
<style scoped>
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(135deg, #409eff22, #fff); }
.hero h1 { font-size: 36px; margin-bottom: 24px; }
.el-input { max-width: 500px; }
.latest-jobs { max-width: 900px; margin: 40px auto; }
/* ── 变量 ── */
.three-panel {
--red: #B5272C;
--dark: #0E1E3D;
--gold: #C8973A;
--gold-lt: #F0D080;
--cream: #FAF7F3;
--border: #E5DDD5;
--text: #1A1A2E;
--muted: #7A8094;
display: flex;
height: calc(100vh - 220px);
min-height: 520px;
overflow: hidden;
border-radius: 6px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(14,30,61,0.12);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
/* ── 左栏 ── */
.panel-left {
width: 228px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--dark);
border-right: 1px solid rgba(255,255,255,0.07);
}
.left-header {
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.left-title {
font-size: 13px;
font-weight: 700;
color: rgba(255,255,255,0.9);
letter-spacing: 0.1em;
}
.left-count {
background: var(--red);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
min-width: 24px;
text-align: center;
}
.left-body { flex: 1; overflow-y: auto; }
.left-body::-webkit-scrollbar { width: 4px; }
.left-body::-webkit-scrollbar-track { background: transparent; }
.left-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
/* 公司行 */
.org-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-left: 3px solid transparent;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: background 0.15s;
}
.org-row:hover { background: rgba(255,255,255,0.06); }
.org-row.active {
border-left-color: var(--gold);
background: rgba(200,151,58,0.1);
}
.org-avatar {
width: 34px; height: 34px;
border-radius: 6px;
background: linear-gradient(135deg, #C8973A, #9A6E28);
display: flex; align-items: center; justify-content: center;
font-size: 15px; font-weight: 900; color: #fff;
flex-shrink: 0; overflow: hidden;
}
.org-avatar img { width: 100%; height: 100%; object-fit: cover; }
.child-avatar {
width: 28px; height: 28px; font-size: 12px;
background: linear-gradient(135deg, #3D5A8A, #1E3460);
}
.org-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.org-name {
font-size: 13px; font-weight: 600;
color: rgba(255,255,255,0.88);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.child-name { font-size: 12px; color: rgba(255,255,255,0.7); }
.org-row.active .org-name { color: var(--gold-lt); }
.org-stat { font-size: 11px; color: rgba(255,255,255,0.38); }
.org-stat em { font-style: normal; color: var(--gold); font-weight: 700; }
/* 子公司缩进 */
.org-child { padding-left: 12px; background: rgba(0,0,0,0.12); }
.child-indent {
display: flex; align-items: center; flex-shrink: 0;
padding-left: 6px;
}
.child-line {
display: block; width: 10px; height: 1px;
background: rgba(200,151,58,0.35);
}
/* ── 中栏 ── */
.panel-mid {
width: 268px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--cream);
border-right: 1px solid var(--border);
}
.mid-header {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--border);
flex-shrink: 0;
background: #fff;
}
.mid-title { font-size: 13px; font-weight: 600; color: var(--text); }
.mid-title-org { color: var(--red); }
.mid-title.muted { color: var(--muted); font-weight: 400; }
.mid-count {
font-size: 11px; color: var(--muted);
background: #EDE8E1; border-radius: 10px; padding: 1px 8px;
}
.mid-body { flex: 1; overflow-y: auto; }
/* 岗位行 */
.job-row {
padding: 13px 16px;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
background: #fff;
}
.job-row:hover { background: #F5EFE8; }
.job-row.active {
border-left-color: var(--red);
background: #FFF5F5;
}
.job-row-title {
font-size: 13px; font-weight: 600;
color: var(--text); margin-bottom: 7px;
}
.job-row.active .job-row-title { color: var(--red); }
.job-row-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.tag {
font-size: 11px; padding: 2px 7px;
border-radius: 3px; font-weight: 500;
}
.tag-loc { background: #E8EEF8; color: #2C5282; }
.tag-sal { background: #FFF3E0; color: #B7610A; }
.tag-cat { background: #E8F5E9; color: #2E7D32; }
/* ── 右栏 ── */
.panel-right {
flex: 1;
overflow-y: auto;
background: #F8F4EF;
}
/* Banner */
.detail-banner {
position: relative;
background: linear-gradient(135deg, #0E1E3D 0%, #1A2E56 50%, #1C1C3A 100%);
overflow: hidden;
}
.banner-deco {
position: absolute; inset: 0;
background:
repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(200,151,58,0.03) 20px, rgba(200,151,58,0.03) 21px),
repeating-linear-gradient(-45deg, transparent, transparent 20px, rgba(200,151,58,0.03) 20px, rgba(200,151,58,0.03) 21px);
}
.banner-content {
position: relative;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding: 28px 32px;
}
.banner-left { flex: 1; }
.banner-category {
display: inline-block;
padding: 3px 12px;
background: rgba(200,151,58,0.15);
border: 1px solid rgba(200,151,58,0.35);
color: var(--gold-lt);
font-size: 11px; letter-spacing: 0.1em;
border-radius: 2px; margin-bottom: 10px;
}
.banner-title {
font-size: 22px; font-weight: 800;
color: #fff; letter-spacing: 0.05em;
margin: 0 0 8px;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.banner-salary {
font-size: 18px; font-weight: 700;
color: #FF6B6B; margin-bottom: 12px;
}
.banner-meta { display: flex; align-items: center; gap: 8px; }
.bmeta-item {
display: flex; align-items: center; gap: 4px;
font-size: 13px; color: rgba(255,255,255,0.6);
}
.bmeta-sep { color: rgba(255,255,255,0.2); }
.banner-right { text-align: right; flex-shrink: 0; }
.banner-date { font-size: 12px; color: rgba(255,255,255,0.38); margin-bottom: 14px; }
.banner-btns { display: flex; gap: 10px; justify-content: flex-end; }
.btn-collect {
display: flex; align-items: center; gap: 6px;
padding: 9px 18px;
background: transparent;
border: 1px solid rgba(200,151,58,0.5);
color: var(--gold-lt);
border-radius: 3px; font-size: 13px;
cursor: pointer; transition: all 0.2s;
font-family: inherit;
}
.btn-collect:hover { background: rgba(200,151,58,0.1); border-color: var(--gold); }
.btn-apply {
padding: 9px 24px;
background: var(--red);
border: none; color: #fff;
border-radius: 3px; font-size: 13px; font-weight: 700;
cursor: pointer; transition: all 0.2s;
letter-spacing: 0.05em;
font-family: inherit;
box-shadow: 0 4px 16px rgba(181,39,44,0.4);
}
.btn-apply:hover { background: #9A1F23; box-shadow: 0 6px 20px rgba(181,39,44,0.5); }
.btn-apply.loading { opacity: 0.7; cursor: not-allowed; }
.banner-hint { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; }
.banner-hint.success { color: #6FCF97; }
/* 主体 */
.detail-main {
display: flex;
gap: 20px;
padding: 24px 28px;
align-items: flex-start;
}
.detail-left {
flex: 1;
background: #fff;
border-radius: 6px;
padding: 24px 28px;
box-shadow: 0 2px 12px rgba(14,30,61,0.07);
border: 1px solid var(--border);
}
.detail-right { width: 224px; flex-shrink: 0; }
/* 信息格 */
.info-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
margin-bottom: 4px;
}
.info-cell { display: flex; flex-direction: column; gap: 4px; }
.info-label {
font-size: 11px; color: var(--muted); letter-spacing: 0.05em;
display: flex; align-items: center; gap: 5px;
}
.info-label::before {
content: ''; width: 5px; height: 5px;
background: var(--red); border-radius: 50%;
}
.info-value { font-size: 14px; font-weight: 600; color: var(--text); }
.red-val { color: var(--red); }
.section-divider {
height: 1px; background: var(--border);
margin: 20px 0;
}
/* 内容区块 */
.content-section { }
.section-heading {
display: flex; align-items: center; gap: 10px;
font-size: 15px; font-weight: 700; color: var(--text);
margin-bottom: 14px; letter-spacing: 0.05em;
}
.heading-bar {
display: block; width: 4px; height: 16px;
background: var(--red); border-radius: 2px;
}
.desc-text {
font-size: 13px; line-height: 2;
color: #3A3A4A; white-space: pre-wrap;
}
.location-row {
display: flex; align-items: center; gap: 6px;
font-size: 13px; color: #3A3A4A;
}
/* 公司卡片 */
.company-card {
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 2px 12px rgba(14,30,61,0.07);
border: 1px solid var(--border);
}
.company-card-label {
font-size: 11px; font-weight: 700;
color: var(--muted); letter-spacing: 0.15em;
margin-bottom: 14px; text-transform: uppercase;
}
.company-card-top { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
.cc-logo {
width: 48px; height: 48px;
border-radius: 8px; overflow: hidden; flex-shrink: 0;
background: linear-gradient(135deg, #C8973A, #9A6E28);
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 900; color: #fff;
border: 1px solid var(--border);
}
.cc-logo img { width: 100%; height: 100%; object-fit: cover; }
.cc-name { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
.cc-desc { font-size: 11px; color: var(--muted); line-height: 1.6; }
.cc-divider { height: 1px; background: var(--border); margin-bottom: 12px; }
.cc-contact {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--muted); word-break: break-all;
margin-bottom: 14px;
}
.btn-apply-card {
width: 100%; padding: 10px;
background: var(--red); border: none;
color: #fff; font-size: 13px; font-weight: 700;
border-radius: 3px; cursor: pointer;
transition: background 0.2s;
font-family: inherit; letter-spacing: 0.05em;
}
.btn-apply-card:hover { background: #9A1F23; }
.btn-apply-card.loading { opacity: 0.7; cursor: not-allowed; }
.apply-success { text-align: center; font-size: 12px; color: #27AE60; margin-top: 8px; }
/* 空状态 / 骨架 */
.state-tip {
display: flex; align-items: center; justify-content: center;
height: 100%; min-height: 100px;
color: var(--muted); font-size: 13px;
}
.full-tip { flex-direction: column; gap: 12px; }
.empty-icon { opacity: 0.5; }
.detail-loading { padding: 24px 28px; }
.skeleton-row {
height: 14px; background: linear-gradient(90deg, #E8E0D8 25%, #F0E8E0 50%, #E8E0D8 75%);
background-size: 200% 100%;
border-radius: 3px; margin-bottom: 10px;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
</style>

View File

@ -1,41 +1,140 @@
<template>
<el-row :gutter="24" v-loading="loading" style="max-width:1000px;margin:0 auto">
<el-col :span="16">
<el-card v-if="job">
<template #header>
<h2>{{ job.title }}</h2>
<div class="meta">
{{ job.organization?.name }} · {{ job.location }} · {{ job.salary }}
<div class="job-detail-page" v-loading="loading">
<!-- 顶部 Banner -->
<div class="job-banner" v-if="job">
<div class="banner-inner">
<div class="banner-left">
<h1 class="job-title">{{ job.title }}</h1>
<div class="job-salary">{{ job.salary }}</div>
<div class="job-meta-row">
<span class="meta-item">
<el-icon><Location /></el-icon>
{{ job.location }}
</span>
<span class="meta-divider">|</span>
<span class="meta-item">
<el-icon><OfficeBuilding /></el-icon>
{{ job.organization?.name }}
</span>
</div>
</template>
<div v-html="job.description" class="description"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<el-button type="primary" size="large" style="width:100%" @click="handleApply">
立即投递
</el-button>
<p class="hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
<p class="hint success" v-if="applied">已投递可在"我的投递"查看进度</p>
</el-card>
</el-col>
</el-row>
</div>
<div class="banner-right">
<div class="banner-date">发布于 {{ formatDate(job.created_at) }}</div>
<div class="banner-actions">
<el-button class="btn-collect" plain @click="toggleCollect">
<el-icon><Star /></el-icon>
{{ collected ? '已收藏' : '收藏' }}
</el-button>
<el-button class="btn-apply" @click="handleApply" :loading="applying">
立即投递
</el-button>
</div>
<p class="apply-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
<p class="apply-hint success" v-if="applied">已投递可在我的投递查看进度</p>
</div>
</div>
</div>
<!-- 主体内容 -->
<div class="job-body" v-if="job">
<div class="body-inner">
<!-- 左栏 -->
<div class="body-left">
<!-- 职位标签 -->
<div class="info-tags">
<div class="tag-item">
<span class="tag-label">职位类别</span>
<span class="tag-value">{{ job.category || '未填写' }}</span>
</div>
<div class="tag-item">
<span class="tag-label">工作地点</span>
<span class="tag-value">{{ job.location }}</span>
</div>
<div class="tag-item">
<span class="tag-label">薪资范围</span>
<span class="tag-value salary-val">{{ job.salary }}</span>
</div>
<div class="tag-item">
<span class="tag-label">发布时间</span>
<span class="tag-value">{{ formatDate(job.created_at) }}</span>
</div>
</div>
<el-divider />
<!-- 职位介绍 -->
<div class="section">
<div class="section-title">
<span class="title-bar"></span>职位介绍
</div>
<div class="category-tag" v-if="job.category">{{ job.category }}</div>
<div class="description-content" v-html="job.description"></div>
</div>
<el-divider />
<!-- 工作地点 -->
<div class="section">
<div class="section-title">
<span class="title-bar"></span>工作地点
</div>
<div class="location-text">
<el-icon><Location /></el-icon>
{{ job.location }}
</div>
</div>
</div>
<!-- 右栏 -->
<div class="body-right">
<!-- 公司信息 -->
<div class="company-card">
<div class="company-card-header">单位信息</div>
<div class="company-logo-row">
<div class="company-logo">
<img v-if="job.organization?.logo" :src="job.organization.logo" alt="logo" />
<div v-else class="logo-placeholder">{{ job.organization?.name?.charAt(0) }}</div>
</div>
<div class="company-info">
<div class="company-name">{{ job.organization?.name }}</div>
<div class="company-desc" v-if="job.organization?.description">
{{ job.organization.description.slice(0, 40) }}{{ job.organization.description.length > 40 ? '...' : '' }}
</div>
</div>
</div>
<el-divider style="margin: 12px 0" />
<div class="company-contact" v-if="job.organization?.email">
<el-icon><Message /></el-icon>
<span>{{ job.organization.email }}</span>
</div>
<el-button class="btn-apply-sm" @click="handleApply" :loading="applying" style="margin-top:12px;width:100%">
立即投递
</el-button>
<p class="apply-hint-sm" v-if="applied">已投递成功</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJob } from '@/api/jobs'
import { getJob, toggleFavorite } from '@/api/jobs'
import { applyJob } from '@/api/applications'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { Location, OfficeBuilding, Star, Message } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const job = ref(null)
const loading = ref(false)
const applying = ref(false)
const applied = ref(false)
const collected = ref(false)
onMounted(async () => {
loading.value = true
@ -44,22 +143,316 @@ onMounted(async () => {
loading.value = false
})
function formatDate(dt) {
if (!dt) return ''
return new Date(dt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
async function toggleCollect() {
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法收藏职位')
const { data } = await toggleFavorite(job.value.id)
collected.value = data.collected
ElMessage.success(collected.value ? '已收藏,可在「关注职位」中查看' : '已取消收藏')
}
async function handleApply() {
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
applying.value = true
try {
await applyJob(job.value.id)
applied.value = true
ElMessage.success('投递成功!')
} catch (e) {
if (e.response?.status === 400) ElMessage.warning('您已投递过该职位')
else ElMessage.error('投递失败,请先完善简历')
if (e.response?.status === 400) {
ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
} else {
ElMessage.error('投递失败,请先完善简历')
}
} finally {
applying.value = false
}
}
</script>
<style scoped>
.meta { color: #666; margin-top: 8px; }
.description { line-height: 1.8; }
.hint { color: #999; font-size: 13px; margin-top: 12px; }
.success { color: #67c23a; }
.job-detail-page {
background: #f5f6fa;
min-height: calc(100vh - 120px);
}
/* Banner */
.job-banner {
background: linear-gradient(135deg, #e8f4fd 0%, #d0e9f8 60%, #b8d9f0 100%);
padding: 36px 0 32px;
border-bottom: 1px solid #dce8f3;
}
.banner-inner {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.job-title {
font-size: 26px;
font-weight: 700;
color: #1a1a2e;
margin: 0 0 10px;
letter-spacing: 0.5px;
}
.job-salary {
font-size: 22px;
font-weight: 700;
color: #e63329;
margin-bottom: 12px;
}
.job-meta-row {
display: flex;
align-items: center;
gap: 8px;
color: #555;
font-size: 14px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-divider {
color: #ccc;
}
.banner-right {
text-align: right;
flex-shrink: 0;
}
.banner-date {
font-size: 13px;
color: #888;
margin-bottom: 14px;
}
.banner-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn-collect {
border-color: #e63329 !important;
color: #e63329 !important;
background: transparent !important;
}
.btn-collect:hover {
background: #fff0f0 !important;
}
.btn-apply {
background: #e63329 !important;
border-color: #e63329 !important;
color: #fff !important;
font-weight: 600;
padding: 10px 28px;
font-size: 15px;
}
.btn-apply:hover {
background: #c42820 !important;
border-color: #c42820 !important;
}
.apply-hint {
font-size: 12px;
color: #999;
margin-top: 8px;
}
.apply-hint.success {
color: #52c41a;
}
/* Body */
.job-body {
padding: 28px 0 40px;
}
.body-inner {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
display: flex;
gap: 20px;
align-items: flex-start;
}
.body-left {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 24px 28px;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.body-right {
width: 280px;
flex-shrink: 0;
}
/* Info tags */
.info-tags {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 24px;
padding-bottom: 4px;
}
.tag-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.tag-label {
color: #888;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.tag-label::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #e63329;
flex-shrink: 0;
}
.tag-value {
color: #333;
font-weight: 500;
}
.tag-value.salary-val {
color: #e63329;
}
/* Section */
.section {
padding: 4px 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.title-bar {
display: inline-block;
width: 4px;
height: 16px;
background: #e63329;
border-radius: 2px;
}
.category-tag {
display: inline-block;
background: #fff0f0;
color: #e63329;
border: 1px solid #fad2d0;
border-radius: 4px;
padding: 3px 10px;
font-size: 13px;
margin-bottom: 16px;
}
.description-content {
font-size: 14px;
line-height: 1.9;
color: #444;
white-space: pre-wrap;
}
.location-text {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #555;
}
/* Company card */
.company-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.company-card-header {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 16px;
}
.company-logo-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.company-logo {
width: 52px;
height: 52px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
border: 1px solid #eee;
}
.company-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.logo-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
font-size: 14px;
font-weight: 600;
color: #222;
margin-bottom: 4px;
line-height: 1.4;
}
.company-desc {
font-size: 12px;
color: #888;
line-height: 1.5;
}
.company-contact {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
word-break: break-all;
}
.btn-apply-sm {
background: #e63329 !important;
border-color: #e63329 !important;
color: #fff !important;
font-weight: 600;
}
.btn-apply-sm:hover {
background: #c42820 !important;
border-color: #c42820 !important;
}
.apply-hint-sm {
text-align: center;
font-size: 12px;
color: #52c41a;
margin-top: 8px;
}
</style>

View File

@ -1,3 +1,35 @@
<template>
<div>我的投递 - 开发中</div>
<div>
<h2>我的投递</h2>
<el-table :data="applications" v-loading="loading" border>
<el-table-column prop="job_title" label="职位" />
<el-table-column prop="company_name" label="公司" />
<el-table-column prop="applied_at" label="投递时间" :formatter="(r,c,v) => v?.slice(0,10)" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="applications.length === 0 && !loading" description="暂无投递记录" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getMyApplications } from '@/api/applications'
const applications = ref([])
const loading = ref(false)
const STATUS_MAP = { pending:'待查看', viewed:'已查看', interviewing:'面试中', hired:'已录用', rejected:'已拒绝' }
const STATUS_TYPE = { pending:'info', viewed:'primary', interviewing:'warning', hired:'success', rejected:'danger' }
const statusLabel = s => STATUS_MAP[s] || s
const statusType = s => STATUS_TYPE[s] || 'info'
onMounted(async () => {
loading.value = true
const { data } = await getMyApplications()
applications.value = data.results
loading.value = false
})
</script>

View File

@ -1,3 +1,36 @@
<template>
<div>账号设置 - 开发中</div>
<div>
<h2>账号设置</h2>
<el-card style="max-width:480px">
<el-form :model="form" label-width="80px">
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { updateMe } from '@/api/auth'
import { ElMessage } from 'element-plus'
const auth = useAuthStore()
const form = ref({ email: '', phone: '' })
const saving = ref(false)
onMounted(() => {
form.value.email = auth.user?.email || ''
form.value.phone = auth.user?.phone || ''
})
async function save() {
saving.value = true
try {
await updateMe({ email: form.value.email, phone: form.value.phone })
ElMessage.success('保存成功')
} catch { ElMessage.error('保存失败') }
finally { saving.value = false }
}
</script>

View File

@ -1,3 +1,111 @@
<template>
<div>我的简历 - 开发中</div>
<div v-loading="loading">
<h2>我的简历</h2>
<el-form :model="form" label-width="80px" v-if="form">
<el-card style="margin-bottom:16px">
<template #header>基本信息</template>
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.gender">
<el-radio value="male"></el-radio>
<el-radio value="female"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="生日"><el-date-picker v-model="form.birthday" type="date" value-format="YYYY-MM-DD" /></el-form-item>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>
教育经历
<el-button size="small" @click="addEducation" style="float:right">添加</el-button>
</template>
<div v-for="(edu, i) in form.education" :key="i" style="margin-bottom:8px">
<el-row :gutter="12">
<el-col :span="8"><el-input v-model="edu.school" placeholder="学校名称" /></el-col>
<el-col :span="6"><el-input v-model="edu.degree" placeholder="学历" /></el-col>
<el-col :span="7"><el-input v-model="edu.major" placeholder="专业" /></el-col>
<el-col :span="3"><el-button type="danger" link @click="form.education.splice(i,1)">删除</el-button></el-col>
</el-row>
</div>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>
工作经历
<el-button size="small" @click="addExperience" style="float:right">添加</el-button>
</template>
<div v-for="(exp, i) in form.experience" :key="i" style="margin-bottom:8px">
<el-row :gutter="12">
<el-col :span="8"><el-input v-model="exp.company" placeholder="公司名称" /></el-col>
<el-col :span="7"><el-input v-model="exp.position" placeholder="职位" /></el-col>
<el-col :span="6"><el-input v-model="exp.duration" placeholder="时长2年" /></el-col>
<el-col :span="3"><el-button type="danger" link @click="form.experience.splice(i,1)">删除</el-button></el-col>
</el-row>
</div>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>简历附件</template>
<div v-if="form.attachment" style="margin-bottom:12px">
<span style="margin-right:12px">当前附件{{ attachmentName }}</span>
<el-button type="primary" link :href="attachmentUrl" target="_blank" tag="a">查看附件</el-button>
</div>
<el-upload :http-request="uploadAttachment" name="attachment" accept=".pdf,.doc,.docx" :show-file-list="false">
<el-button>{{ form.attachment ? '重新上传' : '上传简历PDF/Word' }}</el-button>
</el-upload>
</el-card>
<el-button type="primary" @click="save" :loading="saving">保存简历</el-button>
</el-form>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getMyResume, updateMyResume, uploadResumeAttachment } from '@/api/resumes'
import { ElMessage } from 'element-plus'
const form = ref(null)
const loading = ref(false)
const saving = ref(false)
onMounted(async () => {
loading.value = true
const { data } = await getMyResume()
form.value = { ...data, education: data.education || [], experience: data.experience || [] }
loading.value = false
})
const attachmentUrl = computed(() => form.value?.attachment || '')
const attachmentName = computed(() => {
if (!form.value?.attachment) return ''
return decodeURIComponent(form.value.attachment.split('/').pop())
})
const addEducation = () => form.value.education.push({ school: '', degree: '', major: '' })
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })
async function uploadAttachment({ file }) {
const fd = new FormData()
fd.append('attachment', file)
try {
const { data } = await uploadResumeAttachment(fd)
form.value.attachment = data.attachment
ElMessage.success('附件上传成功')
} catch {
ElMessage.error('附件上传失败')
}
}
async function save() {
saving.value = true
try {
const { attachment, ...rest } = form.value
await updateMyResume(rest)
ElMessage.success('简历已保存')
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
</script>

View File

@ -16,7 +16,7 @@ export default defineConfig({
},
server: {
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true }
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }
}
}
})