Compare commits

..

No commits in common. "master" and "main" have entirely different histories.
master ... main

129 changed files with 3 additions and 13922 deletions

View File

@ -1,60 +0,0 @@
{
"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

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

View File

@ -1,80 +0,0 @@
<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

@ -1,80 +0,0 @@
<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

@ -1,216 +0,0 @@
<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

@ -1,208 +0,0 @@
<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

@ -1,116 +0,0 @@
<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

@ -1,114 +0,0 @@
<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>

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# Recruitment_site
招聘网站

File diff suppressed because it is too large Load Diff

View File

@ -1,701 +0,0 @@
# 管理后台分页功能实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为管理后台的所有表格页面(职位管理、投递管理、组织架构、用户管理)添加统一的分页功能。
**Architecture:**
- 后端已支持分页DRF 的 `PageNumberPagination`,每页 20 条)
- 前端 API 层:修改各模块的 fetch 函数,接受 `page` 参数
- 前端组件层添加分页状态管理、分页事件处理、UI 控件,共 4 个页面
- 每个组件遵循相同的模式,便于维护和扩展
**Tech Stack:** Vue 3 Composition API、Element Plus (el-pagination)、DRF pagination
---
## 文件结构
### 前端 API 层4 个文件修改)
- `offer_frontend/src/api/organizations.js` - 修改 `manageOrganizations(page=1)`
- `offer_frontend/src/api/jobs.js` - 修改 `manageJobs(page=1)`
- `offer_frontend/src/api/applications.js` - 修改 `getManageApplications(page=1)`
- `offer_frontend/src/api/client.js` 或页面内 - 用户管理 API 调用
### 前端组件层4 个页面修改)
- `offer_frontend/src/views/admin/OrganizationManageView.vue`
- `offer_frontend/src/views/admin/JobManageView.vue`
- `offer_frontend/src/views/admin/ApplicationManageView.vue`
- `offer_frontend/src/views/admin/UserManageView.vue`
---
## 实现任务
### Task 1: 修改 organizations.js - 支持分页参数
**Files:**
- Modify: `offer_frontend/src/api/organizations.js`
- [ ] **Step 1: 修改 manageOrganizations 函数**
编辑 `offer_frontend/src/api/organizations.js`,将 `manageOrganizations` 函数改为(使用 params 对象方式保持与其他 API 的一致性):
```javascript
export const manageOrganizations = (page = 1) =>
client.get('/organizations/manage/', { params: { page } })
```
> 注:这个改动方式与 jobs.js 和 applications.js 中已有的 `{ params: { ... } }` 方式保持一致
- [ ] **Step 2: 验证其他导出函数不受影响**
检查文件中的其他函数(`getOrganizations`, `createOrganization` 等)保持不变。
- [ ] **Step 3: Commit**
```bash
git add offer_frontend/src/api/organizations.js
git commit -m "feat(api): 为 manageOrganizations 添加分页参数"
```
---
### Task 2: 修改 jobs.js - 支持分页参数
**Files:**
- Modify: `offer_frontend/src/api/jobs.js`
- [ ] **Step 1: 修改 manageJobs 函数**
编辑 `offer_frontend/src/api/jobs.js`,找到 `manageJobs` 函数(通常已有 params 参数),改为支持分页:
```javascript
export const manageJobs = (page = 1, params = {}) =>
client.get('/jobs/manage/', { params: { page, ...params } })
```
> 如果现有函数签名已经是 `(params = {})` 的形式,则改为:
> ```javascript
> export const manageJobs = (params = {}) =>
> client.get('/jobs/manage/', { params: { page: params.page || 1, ...params } })
> ```
- [ ] **Step 2: 验证其他导出函数**
检查 `getJobs`, `createJob`, `updateJob`, `deleteJob` 等函数保持不变。
- [ ] **Step 3: Commit**
```bash
git add offer_frontend/src/api/jobs.js
git commit -m "feat(api): 为 manageJobs 添加分页参数"
```
---
### Task 3: 修改 applications.js - 支持分页参数
**Files:**
- Modify: `offer_frontend/src/api/applications.js`
- [ ] **Step 1: 修改 getManageApplications 函数**
编辑 `offer_frontend/src/api/applications.js`,找到 `getManageApplications` 函数,改为(支持分页 + 保留其他参数):
```javascript
export const getManageApplications = (params = {}) =>
client.get('/applications/manage/', { params: { page: params.page || 1, ...params } })
```
> 调用时改为:`getManageApplications({ page: currentPage.value })`
- [ ] **Step 2: 验证其他函数**
确保 `applyJob`, `myApplications`, `updateApplicationStatus` 等函数不变。
- [ ] **Step 3: Commit**
```bash
git add offer_frontend/src/api/applications.js
git commit -m "feat(api): 为 getManageApplications 添加分页参数"
```
---
### Task 4: 修改 OrganizationManageView.vue - 添加分页
**Files:**
- Modify: `offer_frontend/src/views/admin/OrganizationManageView.vue`
**Step 4.1: 导入所需函数**
- [ ] 在 `<script setup>` 导入部分添加(如果还没有):
```javascript
import { ElMessageBox } from 'element-plus'
import { deleteOrganization } from '@/api/organizations'
```
**Step 4.2: 添加分页和数据获取状态**
- [ ] 在 `const form = reactive(...)` 之后添加以下状态变量:
```javascript
const allOrgs = ref([]) // 所有部门(用于下拉框)
const currentPage = ref(1) // 当前页码
const pageSize = ref(20) // 每页行数
const total = ref(0) // 总条数
```
**Step 4.3: 修改 fetchOrgs 函数**
- [ ] 将现有的 `fetchOrgs` 函数替换为:
```javascript
const fetchOrgs = async (page = 1) => {
loading.value = true
try {
const { data } = await manageOrganizations(page)
orgs.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载部门列表失败,请重试')
} finally {
loading.value = false
}
}
```
**Step 4.4: 新增 fetchAllOrgs 函数**
- [ ] 在 `fetchOrgs` 函数之后添加(用于获取所有部门给下拉框使用):
```javascript
const fetchAllOrgs = async () => {
try {
// 预加载前几页,保证下拉框有足够的数据
let allResults = []
for (let i = 1; i <= 5; i++) {
const { data } = await manageOrganizations(i)
allResults = allResults.concat(data.results)
if (i === Math.ceil(data.count / 20)) break // 已加载全部
}
allOrgs.value = allResults
} catch (error) {
ElMessage.error('加载部门列表失败')
}
}
```
**Step 4.5: 添加分页事件处理函数**
- [ ] 在 `fetchAllOrgs` 函数之后添加:
```javascript
function handlePageChange(newPage) {
fetchOrgs(newPage)
}
```
**Step 4.6: 新增删除函数**
- [ ] 添加 `handleDelete()` 函数:
```javascript
async function handleDelete(id) {
try {
await ElMessageBox.confirm('确认删除该部门?', '提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
await deleteOrganization(id)
ElMessage.success('已删除')
currentPage.value = 1
fetchOrgs(1)
fetchAllOrgs()
} catch (error) {
if (error.name !== 'ElMessageBoxCancel') {
ElMessage.error('删除失败')
}
}
}
```
**Step 4.7: 修改保存逻辑**
- [ ] 找到 `save()` 函数,修改最后的调用为:
```javascript
currentPage.value = 1
fetchOrgs(1)
fetchAllOrgs()
```
**Step 4.8: 修改初始化**
- [ ] 修改 `onMounted` 钩子:
```javascript
onMounted(() => {
fetchOrgs()
fetchAllOrgs()
})
```
**Step 4.9: 修改表格的下拉框**
- [ ] 找到表单中的"上级公司"下拉框,修改为使用 `allOrgs`
```vue
<el-form-item label="上级公司">
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
<el-option v-for="o in allOrgs" :key="o.id" :value="o.id" :label="o.name" />
</el-select>
</el-form-item>
```
**Step 4.10: 修改表格模板**
- [ ] 找到 `<el-table :data="orgs"` 这一行,改为:
```vue
<el-table :data="orgs" v-loading="loading" border>
```
**Step 4.11: 添加删除按钮到操作列**
- [ ] 找到操作列(`<el-table-column label="操作"`修改为
```vue
<el-table-column label="操作" width="120">
<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>
```
**Step 4.12: 添加分页控件**
- [ ] 在 `</el-table>` 之后、`<el-dialog` 之前添加
```vue
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
```
- [ ] **Commit**
```bash
git add offer_frontend/src/views/admin/OrganizationManageView.vue
git commit -m "feat(admin): 为部门管理页面添加分页、删除功能
- 添加分页状态管理currentPage, pageSize, total
- 修改 fetchOrgs 支持分页参数
- 新增 fetchAllOrgs 用于下拉框完整数据
- 新增 handleDelete 删除功能
- 修改下拉框使用 allOrgs 而非 orgs
- 添加分页控件和 loading 指示
- 删除操作后重置分页和刷新数据"
```
---
### Task 5: 修改 JobManageView.vue - 添加分页
**Files:**
- Modify: `offer_frontend/src/views/admin/JobManageView.vue`
**Step 5.1: 添加分页状态**
- [ ] 在 `<script setup>` 中添加分页相关状态(在 `const form = reactive(...)` 之后):
```javascript
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
```
**Step 5.2: 修改 fetchJobs 函数**
- [ ] 替换现有的 `fetchJobs` 函数为:
```javascript
const fetchJobs = async (page = 1) => {
loading.value = true
try {
const { data } = await manageJobs(page)
jobs.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载职位列表失败,请重试')
} finally {
loading.value = false
}
}
```
**Step 5.3: 添加分页事件处理**
- [ ] 在 `fetchJobs` 之后添加:
```javascript
function handlePageChange(newPage) {
fetchJobs(newPage)
}
```
**Step 5.4: 修改 handleSave 函数**
- [ ] 找到 `handleSave()` 函数中的 `fetchJobs()` 调用,改为:
```javascript
currentPage.value = 1
fetchJobs(1)
```
**Step 5.5: 修改表格 v-loading**
- [ ] 找到 `<el-table :data="jobs"` 这一行,确保有 `v-loading="loading"`(可能已有)
**Step 5.6: 添加分页控件**
- [ ] 在 `</el-table>` 之后添加:
```vue
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
```
- [ ] **Commit**
```bash
git add offer_frontend/src/views/admin/JobManageView.vue
git commit -m "feat(admin): 为职位管理页面添加分页功能"
```
---
### Task 6: 修改 ApplicationManageView.vue - 添加分页
**Files:**
- Modify: `offer_frontend/src/views/admin/ApplicationManageView.vue`
**Step 6.1: 添加分页状态**
- [ ] 在 `<script setup>` 中添加(在 `const currentResume = ref(null)` 之后):
```javascript
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
```
**Step 6.2: 新建 fetchApplications 函数**
- [ ] 在 `onMounted` 钩子之前添加新函数:
```javascript
const fetchApplications = async (page = 1) => {
loading.value = true
try {
const { data } = await getManageApplications({ page })
applications.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载投递列表失败,请重试')
} finally {
loading.value = false
}
}
```
**Step 6.3: 添加分页事件处理**
- [ ] 在 `fetchApplications` 之后添加函数:
```javascript
function handlePageChange(newPage) {
fetchApplications(newPage)
}
```
**Step 6.4: 修改 updateStatus 函数**
- [ ] 找到 `updateStatus()` 函数,修改最后为(停留在当前页,重新加载):
```javascript
fetchApplications(currentPage.value)
```
**Step 6.5: 修改 onMounted**
- [ ] 修改 `onMounted` 钩子中的初始化调用为:
```javascript
onMounted(() => {
fetchApplications()
})
```
**Step 6.6: 修改表格**
- [ ] 找到 `<el-table :data="applications"` 这一行,确保有 `v-loading="loading"`
```vue
<el-table :data="applications" v-loading="loading" border>
```
**Step 6.7: 添加分页控件**
- [ ] 在 `</el-table>` 之后、`<el-dialog` 之前添加
```vue
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
```
- [ ] **Commit**
```bash
git add offer_frontend/src/views/admin/ApplicationManageView.vue
git commit -m "feat(admin): 为投递管理页面添加分页功能
- 新增 fetchApplications 函数支持分页
- 修改 updateStatus 保留当前页并重新加载数据
- 添加分页状态和事件处理
- 添加分页控件和 loading 指示"
```
---
### Task 7: 修改 UserManageView.vue - 添加分页
**Files:**
- Modify: `offer_frontend/src/views/admin/UserManageView.vue`
**Step 7.1: 添加分页相关状态**
- [ ] 在 `<script setup>` 中添加(在 `const saving = ref(false)` 之后):
```javascript
const loading = ref(false) // 注意:该页面原本没有 loading ref需要新添加
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
```
**Step 7.2: 修改 fetchUsers 函数**
- [ ] 替换现有的 `fetchUsers` 函数为:
```javascript
const fetchUsers = async (page = 1) => {
loading.value = true
try {
const { data } = await client.get('/auth/users/', { params: { page } })
users.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载用户列表失败,请重试')
} finally {
loading.value = false
}
}
```
> **说明**: 假设后端已启用分页,响应格式为 `{ count, results, ... }`。如果返回格式不同,需要相应调整。
**Step 7.3: 添加分页事件处理**
- [ ] 添加函数:
```javascript
function handlePageChange(newPage) {
fetchUsers(newPage)
}
```
**Step 7.4: 修改 save 函数**
- [ ] 找到 `save()` 函数,修改其中的 `fetchUsers()` 调用为:
```javascript
currentPage.value = 1
fetchUsers(1)
```
**Step 7.5: 修改 onMounted**
- [ ] 确保 `onMounted` 中调用为:
```javascript
onMounted(() => {
fetchUsers()
fetchOrgs()
})
```
**Step 7.6: 修改表格模板**
- [ ] 找到 `<el-table :data="users"` 这一行,改为:
```vue
<el-table :data="users" v-loading="loading" border>
```
**Step 7.7: 添加分页控件**
- [ ] 在 `</el-table>` 之后、`<el-dialog` 之前添加
```vue
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
```
- [ ] **Commit**
```bash
git add offer_frontend/src/views/admin/UserManageView.vue
git commit -m "feat(admin): 为用户管理页面添加分页功能
- 新增 loading ref
- 修改 fetchUsers 支持分页参数
- 添加分页状态和事件处理
- 添加分页控件和 loading 指示
- 新增用户后重置分页到第1页"
```
---
### Task 8: 功能测试
**Files:**
- Test: 手动测试前端功能
- [ ] **Step 1: 启动前端开发服务器**
```bash
cd offer_frontend
npm run dev
```
- [ ] **Step 2: 测试部门管理页面分页**
- 访问 `/admin/organizations`
- 验证首次加载显示第 1 页,总数正确
- 点击下一页,数据更新
- 点击最后一页,显示剩余数据
- 新增部门后,页面返回第 1 页
- [ ] **Step 3: 测试职位管理页面分页**
- 访问 `/admin/jobs`
- 验证分页功能(翻页、跳转)
- 新增/编辑职位后返回第 1 页
- [ ] **Step 4: 测试投递管理页面分页**
- 访问 `/admin/applications`
- 验证分页功能(翻页、跳转)
- **关键**: 修改投递状态(如改为"已查看")后,页面停留在当前页并重新加载当前页数据(不重置分页)
- 验证修改生效
- [ ] **Step 5: 测试用户管理页面分页**
- 访问 `/admin/users`
- 验证分页功能
- 新增/编辑用户后返回第 1 页
- [ ] **Step 6: 错误处理测试**
- 模拟网络错误F12 打开控制台,修改请求),验证是否显示错误提示
- 点击分页按钮重试,是否能恢复
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "test: 手动验证所有管理表格分页功能"
```
---
## 验收标准
✅ 所有 4 个管理表格都支持分页:
- 表格显示当前页数据(最多 20 条)
- 底部分页控件正常工作(上一页、下一页、页码跳转)
- 总数显示正确
- 新增/编辑/删除后自动返回第 1 页
- 网络错误时显示错误提示,允许重试
- 加载中显示 loading 指示器
✅ 代码质量:
- 所有改动遵循现有代码风格
- 分页逻辑与组件逻辑清晰分离
- 无性能问题(不应有多余的 API 调用)
✅ Git 提交:
- 每个页面一个 commit
- API 层改动一个 commit
- Commit message 清晰规范
---
## 已知限制与后续优化
1. **下拉框数据**(仅部门管理):当前下拉框显示当前页的部门列表。如果部门数量超过 100部分部门无法在下拉框中选择。可在后续优化为搜索型下拉框或添加"加载更多"。
2. **搜索/过滤**:当前不支持与搜索结合的分页。如需要,可在分页基础上添加搜索字段和 API 参数。
3. **代码复用**4 个页面的分页逻辑基本相同。可在后续抽象为 Vue Composable`usePagination`),进一步 DRY。
---
## 检查清单
完成实现前,请验证:
- [ ] 所有 API 函数已更新,支持 `page` 参数
- [ ] 所有页面的分页状态变量已添加
- [ ] 所有页面的 fetch 函数已修改支持分页
- [ ] 所有页面的模板已添加 `<el-pagination>` 控件
- [ ] 所有 CRUD 操作后都重置到第 1 页
- [ ] 所有页面都有 loading 状态指示
- [ ] Git 提交消息遵循约定式提交规范
- [ ] 手动测试通过(所有 4 个页面)

View File

@ -1,217 +0,0 @@
# 集团招聘网站设计文档
**日期:** 2026-03-24
**状态:** 已确认
---
## 1. 项目概述
为中型集团5-20 家子公司)构建一个内部招聘网站,类似智联招聘但更简洁。支持多公司多角色管理,求职者可注册登录、搜索职位、在线填写或上传简历进行投递。
---
## 2. 技术栈
| 层级 | 技术选型 |
|------|----------|
| 前端 | Vue 3 + Vue Router + Pinia + Element Plus |
| 后端 | Django 4.2 + Django REST Framework |
| 认证 | djangorestframework-simplejwtJWT |
| 数据库 | PostgreSQL |
| 缓存 | Redis |
| 文件存储 | 本地存储初期后期可切换至对象存储OSS |
| 部署 | Nginx + Gunicorn |
---
## 3. 架构方案
**方案Django 后端 + 单个 Vue3 SPA前后端分离**
前端单页应用通过 Vue Router 划分三个区域,使用路由守卫控制访问权限。后端提供 RESTful APINginx 反向代理静态资源和 API 请求。
```
用户浏览器
Nginx
├── / → Vue3 静态文件
└── /api/ → Django (Gunicorn)
├── 认证模块
├── 职位模块
├── 投递模块
└── 组织架构模块
PostgreSQL + Redis
```
---
## 4. 用户角色
| 角色 | 标识 | 权限说明 |
|------|------|----------|
| 超级管理员 | superadmin | 管理组织架构、所有用户、查看全局职位 |
| 公司管理员 | admin | 管理本公司职位、处理本公司投递记录 |
| 求职者 | seeker | 注册登录、搜索职位、管理简历、投递职位 |
---
## 5. 数据模型
### Organization 组织架构
| 字段 | 类型 | 说明 |
|------|------|------|
| id | PK | 主键 |
| name | CharField | 公司名称 |
| parent | FK(self, null) | 父级公司(集团为 null |
| logo | ImageField | 公司 Logo |
| description | TextField | 公司简介 |
| email | EmailField | 公司联系邮箱 |
| is_active | BooleanField | 是否启用 |
### User 用户
| 字段 | 类型 | 说明 |
|------|------|------|
| id | PK | 主键 |
| username | CharField | 登录账号 |
| email | EmailField | 用户邮箱 |
| phone | CharField | 手机号 |
| role | CharField | superadmin / admin / seeker |
| organization | FK(Organization, null) | 所属公司admin 角色使用) |
| is_active | BooleanField | 账号状态 |
### Job 职位
| 字段 | 类型 | 说明 |
|------|------|------|
| id | PK | 主键 |
| organization | FK(Organization) | 所属公司 |
| title | CharField | 职位名称 |
| category | CharField | 职位类别 |
| location | CharField | 工作地点 |
| salary | CharField | 薪资范围 |
| description | TextField | 职位描述(富文本) |
| status | CharField | draft / published / closed |
| created_at | DateTimeField | 创建时间 |
### Resume 简历
| 字段 | 类型 | 说明 |
|------|------|------|
| id | PK | 主键 |
| user | FK(User) | 所属求职者 |
| name | CharField | 姓名 |
| gender | CharField | 性别 |
| birthday | DateField | 出生日期 |
| education | JSONField | 教育经历列表PostgreSQL JSONB |
| experience | JSONField | 工作经历列表PostgreSQL JSONB |
| attachment | FileField | 简历附件PDF/Word |
### Application 投递记录
| 字段 | 类型 | 说明 |
|------|------|------|
| id | PK | 主键 |
| job | FK(Job) | 投递的职位 |
| applicant | FK(User) | 求职者 |
| resume_snapshot | JSONField | 投递时将简历内容序列化复制存储,与 Resume 主表解耦 |
| status | CharField | pending / viewed / interviewing / hired / rejected |
| note | TextField | HR 备注 |
| applied_at | DateTimeField | 投递时间 |
---
## 6. 页面结构与路由
### 公开门户(无需登录)
| 路由 | 页面 | 说明 |
|------|------|------|
| `/` | 首页 | 职位推荐、公司展示、搜索入口 |
| `/jobs` | 职位列表 | 搜索+筛选(公司/地点/薪资/类别) |
| `/jobs/:id` | 职位详情 | 职位描述、要求、投递按钮 |
| `/companies` | 公司列表 | 所有子公司展示 |
| `/companies/:id` | 公司详情 | 公司介绍 + 在招职位 |
| `/login` | 求职者登录 | 管理员也使用此页面登录,通过 role 跳转不同后台 |
| `/register` | 求职者注册 | - |
### 求职者中心seeker 登录后可访问)
| 路由 | 页面 | 说明 |
|------|------|------|
| `/seeker/resume` | 我的简历 | 填写/编辑在线简历 + 上传附件 |
| `/seeker/applications` | 我的投递 | 投递记录列表 + 状态追踪 |
| `/seeker/profile` | 账号设置 | 修改密码/邮箱/手机号 |
### 管理后台admin/superadmin 登录后可访问)
| 路由 | 页面 | 权限 |
|------|------|------|
| `/admin/jobs` | 职位管理 | admin本公司/ superadmin全局只读 |
| `/admin/applications` | 投递管理 | admin本公司 |
| `/admin/organizations` | 组织架构管理 | superadmin 专属 |
| `/admin/users` | 用户管理 | superadmin 专属 |
### 路由守卫规则
- 未登录访问 `/seeker/*` → 跳转 `/login`,登录后返回原页
- 未登录访问 `/admin/*` → 跳转管理员登录页
- admin 角色访问 superadmin 专属页 → 403
- seeker 角色访问 `/admin/*` → 403
---
## 7. 关键业务流程
### 流程 1求职者投递
1. 求职者浏览职位详情,点击"投递"
2. 检查是否已登录,未登录跳转注册/登录页
3. 检查简历是否填写完整,未完善提示引导至简历页
4. 弹出投递确认框,确认后创建 Application 记录status: pending
5. 投递成功提示,可在"我的投递"查看进度
### 流程 2HR 处理投递(状态流转)
```
待查看(pending) → 已查看(viewed) → 面试中(interviewing) → 已录用(hired)
→ 已拒绝(rejected)
```
- 每次状态变更通过 Django 内置邮件后端SMTP同步发送通知邮件给求职者
- HR 可在任意阶段填写备注
### 流程 3公司管理员发布职位
1. 填写职位信息(标题、描述、薪资、地点、类别)
2. 保存草稿status: draft或直接发布status: published
3. 需要停止招聘时手动关闭status: closed
4. 权限隔离:管理员只能操作本公司职位
---
## 8. Django App 划分
```
offer_backend/
├── apps/
│ ├── accounts/ # 用户认证、角色管理
│ ├── organizations/ # 组织架构(集团/子公司)
│ ├── jobs/ # 职位管理
│ ├── resumes/ # 简历管理
│ └── applications/ # 投递记录
```
---
## 9. 项目目录结构
```
offer/
├── offer_backend/ # Django 后端
│ ├── apps/
│ ├── config/ # 设置、URL、wsgi
│ ├── manage.py
│ └── requirements.txt
└── offer_frontend/ # Vue3 前端
├── src/
│ ├── views/
│ │ ├── portal/ # 公开门户页面
│ │ ├── seeker/ # 求职者中心页面
│ │ └── admin/ # 管理后台页面
│ ├── stores/ # Pinia 状态管理
│ ├── router/ # Vue Router
│ └── api/ # API 请求封装
└── package.json
```

View File

@ -1,152 +0,0 @@
# 公司列表三栏布局设计文档
**日期:** 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

@ -1,389 +0,0 @@
# 管理后台分页功能设计文档
**日期**: 2026-03-25
**功能**: 为管理后台所有表格页面(职位管理、投递管理、组织架构、用户管理)添加分页功能
**优先级**: 标准
**涉及页面**:
- 职位管理 (JobManageView.vue)
- 投递管理 (ApplicationManageView.vue)
- 组织架构 (OrganizationManageView.vue)
- 用户管理 (UserManageView.vue)
## 概述
当前所有管理后台表格一次性加载所有数据到表格中。为了改善用户体验和减少初始加载时间,为所有管理表格页面实现统一的标准分页功能。后端已配置 `PageNumberPagination`(每页 20 条),前端只需统一接入分页参数和 UI 组件。
## 背景
- **现状**: 所有管理表格页面(职位、投递、部门、用户)都直接加载所有数据到表格,无分页
- **问题**: 数据量增多时,页面加载缓慢,表格拥挤,用户体验差
- **后端支持**: Django REST Framework 已全局配置分页(`DEFAULT_PAGE_SIZE: 20`),自动处理 `?page=1` 参数
- **目标**: 为所有管理表格统一添加分页功能,提升用户体验
## 设计方案
### 1. 后端 API 层
**无需改动**。`OrganizationManageViewSet` 继承 DRF 的 `ModelViewSet`,已自动支持 `PageNumberPagination`
**API 响应格式**(现有):
```json
{
"count": 50,
"next": "http://.../?page=2",
"previous": null,
"results": [
{ "id": 1, "name": "集团", "email": "...", "is_active": true, "parent": null },
...
]
}
```
### 2. 前端 API 层修改
**文件**: `offer_frontend/src/api/organizations.js`
修改 `manageOrganizations` 函数,支持可选的页码参数:
```javascript
export const manageOrganizations = (page = 1) =>
client.get(`/organizations/manage/?page=${page}`)
```
**调用示例**:
```javascript
// 获取第 1 页
const { data } = await manageOrganizations(1)
// 获取第 2 页
const { data } = await manageOrganizations(2)
```
### 3. 前端组件修改
**文件**: `offer_frontend/src/views/admin/OrganizationManageView.vue`
#### 3.1 状态管理
新增和保留响应式状态:
```javascript
const orgs = ref([]) // 当前页部门列表
const allOrgs = ref([]) // 所有部门列表(用于下拉框)
const loading = ref(false) // 当前页加载状态
const currentPage = ref(1) // 当前页码
const pageSize = ref(20) // 每页行数(与后端 PAGE_SIZE 一致)
const total = ref(0) // 总记录数
```
#### 3.2 修改 fetchOrgs 函数
用于加载分页数据:
```javascript
const fetchOrgs = async (page = 1) => {
loading.value = true
try {
const { data } = await manageOrganizations(page)
orgs.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载部门列表失败,请重试')
} finally {
loading.value = false
}
}
```
#### 3.3 新增 fetchAllOrgs 函数(用于下拉框)
为了支持下拉框选择任意部门,需要单独加载**所有部门**(不分页):
```javascript
const fetchAllOrgs = async () => {
try {
// 调用同一个 API但不传 page 参数时默认返回第 1 页
// 实际需要:修改后端 API 或调用一个无分页的端点
// 临时方案:调用 getOrganizations() 获取公开的组织(如果有)
// 或者:预加载足够多的页(假设部门不超过 500调用 3-4 次)
const { data } = await manageOrganizations(1)
// 如果只有少量部门,可以一次性加载最多 5 页
let allResults = data.results
for (let i = 2; i <= Math.ceil(data.count / 20) && i <= 5; i++) {
const { data: nextData } = await manageOrganizations(i)
allResults = allResults.concat(nextData.results)
}
allOrgs.value = allResults
} catch (error) {
ElMessage.error('加载部门列表失败')
}
}
```
> **注意**: 这是一个临时方案。如果部门数量可能超过 100建议后端新增一个 `list_all` 参数或单独的无分页 API 端点。
#### 3.4 添加分页事件处理
```javascript
function handlePageChange(newPage) {
fetchOrgs(newPage)
}
```
#### 3.5 修改保存后的逻辑
无论新增还是编辑,保存成功后都重置分页状态并返回第 1 页(保持用户体验一致):
```javascript
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
currentPage.value = 1 // 重置到第 1 页
fetchOrgs(1) // 重新加载第 1 页(明确传入页码)
fetchAllOrgs() // 刷新下拉框数据
} catch (error) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
```
#### 3.6 新增删除功能
添加 `handleDelete()` 函数:
```javascript
async function handleDelete(id) {
try {
await ElMessageBox.confirm('确认删除该部门?', '提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
await deleteOrganization(id)
ElMessage.success('已删除')
currentPage.value = 1 // 删除后回到第 1 页
fetchOrgs(1) // 重新加载第 1 页(明确传入页码)
fetchAllOrgs() // 刷新下拉框数据
} catch (error) {
// ElMessageBox.confirm 取消时会抛出 ElMessageBoxCancel 异常,需要判断
if (error.name !== 'ElMessageBoxCancel') {
ElMessage.error('删除失败')
}
}
}
```
#### 3.7 初始化时加载所有数据
修改 `onMounted` 钩子:
```javascript
onMounted(() => {
fetchOrgs() // 加载分页数据(第 1 页)
fetchAllOrgs() // 加载所有部门用于下拉框
})
```
### 4. UI 层修改
#### 4.1 表格加载状态
修改表格标签,添加 `v-loading` 指令:
```vue
<el-table :data="orgs" v-loading="loading" border>
```
#### 4.2 操作列添加删除按钮
修改操作列,添加删除按钮:
```vue
<el-table-column label="操作" width="150">
<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>
```
#### 4.3 下拉框使用所有部门列表
修改编辑对话框中的"上级公司"选择,使用 `allOrgs` 而非 `orgs`
```vue
<el-form-item label="上级公司">
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
<el-option v-for="o in allOrgs" :key="o.id" :value="o.id" :label="o.name" />
</el-select>
</el-form-item>
```
#### 4.4 表格下方添加分页控件
```vue
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
```
**样式说明**:
- `layout="total, prev, pager, next, jumper"` - 显示总数、前一页、页码、后一页、跳转输入框
- 右对齐,与表格宽度对称
- `margin-top: 16px` 与表格距离保持一致
### 5. 数据流
```
用户交互
点击分页按钮 → handlePageChange(newPage)
fetchOrgs(newPage) → manageOrganizations(newPage)
API 返回 { count, results }
更新 orgs、total、currentPage
表格重新渲染
```
### 6. 错误处理
- **加载失败**: 显示 ElMessage.error(),保留当前页状态
- **删除部门**: 删除后调用 `currentPage.value = 1; fetchOrgs()` 回到第 1 页(避免最后一条删除后页码超出范围)
- **网络异常**: 用户可点击分页按钮重试
### 7. 边界情况
| 场景 | 处理方式 |
|------|---------|
| 初始化(无数据) | `currentPage=1, total=0, orgs=[]`,分页组件自动隐藏 |
| 新增部门后 | 重置到第 1 页,重新加载分页数据和下拉框数据 |
| 编辑部门后 | 重置到第 1 页,重新加载分页数据和下拉框数据(保持一致体验) |
| 删除部门后 | 重置到第 1 页,重新加载分页数据和下拉框数据 |
| 当前页无数据 | 自动回到第 1 页(由分页组件自动处理) |
| 用户取消删除确认 | 不执行任何操作,保留当前状态 |
### 8. 下拉框数据一致性
**问题**: 分页后,表格显示当前页的 20 条部门,但"父公司"下拉框需要显示所有部门(因为用户可能需要将某个部门的父级设置为其他页的部门)
**解决方案**:
- 维护单独的 `allOrgs` 数组,通过 `fetchAllOrgs()` 函数加载
- 表格显示分页数据 `orgs`(当前页)
- 下拉框显示所有数据 `allOrgs`(所有页)
- 新增/编辑/删除后同时刷新两个列表
**临时方案说明**:
当前方案通过预加载前几页(最多 5 页、共 100 条)来获取所有部门。若部门数量可能超过 100建议后续优化为
1. 后端添加 `?list_all=true` 参数,返回无分页的完整列表
2. 或单独新增一个无分页的 API 端点(如 `/organizations/manage/all/`
**后续优化**: 若需要搜索功能,改为搜索型下拉框(`el-select` + `filterable`
## 实现清单
**后端** (无需改动):
- [x] Django REST Framework 已支持 PageNumberPagination
**前端 - API 层** (各修改一次):
- [ ] `organizations.js` - `manageOrganizations()` 添加 `page` 参数
- [ ] `jobs.js` - `manageJobs()` 添加 `page` 参数
- [ ] `applications.js` - `getManageApplications()` 添加 `page` 参数
- [ ] `/auth/users/` 调用位置 - 传入 `page` 参数
**前端 - 组件层** (共 4 个页面):
- [ ] **OrganizationManageView.vue** (部门管理)
- [ ] 添加分页状态和函数
- [ ] 修改表格和操作列
- [ ] 添加分页控件
- [ ] **JobManageView.vue** (职位管理)
- [ ] 添加分页状态和函数
- [ ] 修改表格和操作列
- [ ] 添加分页控件
- [ ] **ApplicationManageView.vue** (投递管理)
- [ ] 添加分页状态和函数
- [ ] 修改表格
- [ ] 添加分页控件
- [ ] **UserManageView.vue** (用户管理)
- [ ] 添加分页状态和函数
- [ ] 修改表格和操作列
- [ ] 添加分页控件
**测试** (每个页面):
- [ ] 验证分页功能(第 1 页、中间页、最后一页)
- [ ] 验证新增/编辑/删除后的分页重置
- [ ] 验证网络错误恢复
## 性能考量
- **API 调用**: 每次页码变更触发一次 API 调用(频率不高,通常用户不会频繁翻页)
- **数据量**: 每次最多 20 条记录,前端内存占用低
- **首屏加载**: 从一次性加载 N 条改为加载 20 条,性能改善明显
## 向后兼容性
无破坏性改动:
- API 默认参数 `page=1`,未传参时返回第 1 页
- 现有其他功能不受影响
## 测试场景
1. **初次加载**
- 验证第 1 页正确显示(最多 20 条)
- 验证总数、页码正确
- 验证下拉框加载所有部门
2. **翻页**
- 点击下一页、上一页、特定页码,数据正确更新
- 验证 loading 状态在加载期间显示
3. **跳转输入**
- 直接输入页码(如第 5 页),正确跳转
- 输入超出范围的页码(如第 100 页),无报错
4. **新增部门**
- 新增后,回到第 1 页并显示新部门
- 验证下拉框立即更新,新部门可被选为父级
5. **编辑部门**
- 编辑后,回到第 1 页,验证修改内容生效
- 下拉框同时更新
6. **删除部门**
- 点击删除,出现确认对话框
- 确认删除后,回到第 1 页,验证被删除部门消失
- 下拉框同时移除该部门
- 取消删除,页面无变化
7. **边界情况**
- 当前为最后一页,删除该页最后一条,自动回到第 1 页
- 删除所有部门,表格显示空,分页组件隐藏
8. **错误恢复**
- 网络超时,显示错误提示,保留当前页状态
- 点击分页按钮重试,正确加载
9. **下拉框完整性**
- 在下拉框中能选到所有部门(不仅仅是当前页)
- 搜索或滚动下拉框,能找到目标部门
## 后续扩展
- 为其他管理页面(职位、用户)添加相同的分页逻辑
- 考虑将分页逻辑抽象为可复用的 Composable
- 支持自定义每页行数选项

View File

@ -1,12 +0,0 @@
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
media/
staticfiles/
db.sqlite3

View File

@ -1,7 +0,0 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.accounts'
verbose_name = '账户管理'

View File

@ -1,48 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-24 09:09
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('organizations', '__first__'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('role', models.CharField(choices=[('superadmin', '超级管理员'), ('admin', '公司管理员'), ('seeker', '求职者')], default='seeker', max_length=20)),
('phone', models.CharField(blank=True, max_length=20)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admins', to='organizations.organization')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-25 07:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='VerificationCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('code', models.CharField(max_length=6)),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_verified', models.BooleanField(default=False)),
('attempts', models.IntegerField(default=0)),
('locked_until', models.DateTimeField(blank=True, null=True)),
],
options={
'verbose_name': '验证码',
'verbose_name_plural': '验证码',
'unique_together': {('email', 'code')},
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-25 07:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_verificationcode'),
]
operations = [
migrations.AlterField(
model_name='user',
name='phone',
field=models.CharField(max_length=20),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-25 08:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_alter_user_phone'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True),
),
]

View File

@ -1,81 +0,0 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
import random
import string
class VerificationCode(models.Model):
"""邮箱验证码模型"""
email = models.EmailField()
code = models.CharField(max_length=6)
created_at = models.DateTimeField(auto_now_add=True)
is_verified = models.BooleanField(default=False)
attempts = models.IntegerField(default=0)
locked_until = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = '验证码'
verbose_name_plural = '验证码'
unique_together = [['email', 'code']]
def is_valid(self):
"""检查验证码是否有效"""
if self.is_verified:
return False
if self.locked_until and timezone.now() < self.locked_until:
return False
# 检查10分钟内有效
if (timezone.now() - self.created_at).total_seconds() > 600:
return False
return True
def increment_attempts(self):
"""增加失败尝试次数5次后锁定"""
self.attempts += 1
if self.attempts >= 5:
self.locked_until = timezone.now() + timezone.timedelta(minutes=10)
self.save()
def mark_as_verified(self):
"""标记为已使用"""
self.is_verified = True
self.save()
@staticmethod
def generate_code():
"""生成6位数字验证码"""
return ''.join(random.choices(string.digits, k=6))
class User(AbstractUser):
ROLE_CHOICES = [
('superadmin', '超级管理员'),
('admin', '公司管理员'),
('seeker', '求职者'),
]
email = models.EmailField(unique=True) # 设置邮箱为唯一且必填
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='seeker')
phone = models.CharField(max_length=20)
organization = models.ForeignKey(
'organizations.Organization',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='admins'
)
@property
def is_superadmin(self):
return self.role == 'superadmin'
@property
def is_admin(self):
return self.role == 'admin'
@property
def is_seeker(self):
return self.role == 'seeker'
class Meta:
verbose_name = '用户'
verbose_name_plural = '用户'

View File

@ -1,23 +0,0 @@
from rest_framework.permissions import BasePermission
class IsSuperAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and request.user.is_superadmin
class IsCompanyAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and request.user.is_admin
class IsAdminOrSuperAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and (
request.user.is_admin or request.user.is_superadmin
)
class IsSeeker(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and request.user.is_seeker

View File

@ -1,199 +0,0 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import VerificationCode
User = get_user_model()
class RegisterSerializer(serializers.Serializer):
"""密码注册 serializer"""
username = serializers.CharField(max_length=150)
email = serializers.EmailField()
phone = serializers.CharField(max_length=20)
password = serializers.CharField(write_only=True, min_length=6)
def validate_username(self, value):
"""验证用户名是否已存在"""
if User.objects.filter(username=value).exists():
raise serializers.ValidationError('用户名已存在')
return value
def validate_email(self, value):
"""验证邮箱是否已存在"""
if User.objects.filter(email=value).exists():
raise serializers.ValidationError('邮箱已被注册')
return value
def create(self, validated_data):
"""创建用户"""
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data['email'],
phone=validated_data['phone'],
password=validated_data['password'],
role='seeker'
)
return user
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'phone', 'role', 'organization']
read_only_fields = ['role']
class AdminUserSerializer(serializers.ModelSerializer):
"""超管用于创建/管理公司管理员账号"""
password = serializers.CharField(write_only=True, min_length=6)
class Meta:
model = User
fields = ['id', 'username', 'email', 'phone', 'role', 'organization', 'password', 'is_active']
def create(self, validated_data):
password = validated_data.pop('password')
user = User(**validated_data)
user.set_password(password)
user.save()
return user
class SendCodeSerializer(serializers.Serializer):
"""发送验证码 serializer"""
email = serializers.EmailField()
def validate_email(self, value):
"""验证邮箱是否存在于系统"""
if not User.objects.filter(email=value).exists():
raise serializers.ValidationError('该邮箱未在系统中注册')
return value
class CodeLoginSerializer(serializers.Serializer):
"""邮箱验证码登入 serializer"""
email = serializers.EmailField()
code = serializers.CharField(max_length=6, min_length=6)
def validate(self, attrs):
"""验证邮箱和验证码"""
email = attrs.get('email')
code = attrs.get('code')
# 检查用户是否存在
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
raise serializers.ValidationError('用户不存在')
# 检查验证码
try:
vc = VerificationCode.objects.filter(email=email).latest('created_at')
except VerificationCode.DoesNotExist:
raise serializers.ValidationError('请先获取验证码')
# 检查是否被锁定
if vc.locked_until:
from django.utils import timezone
if timezone.now() < vc.locked_until:
raise serializers.ValidationError('验证码错误次数过多请10分钟后重试')
# 检查验证码是否有效
if not vc.is_valid():
raise serializers.ValidationError('验证码已过期或已使用')
# 验证码是否正确
if vc.code != code:
vc.increment_attempts()
raise serializers.ValidationError('验证码错误')
attrs['user'] = user
attrs['vc'] = vc
return attrs
class PasswordLoginSerializer(serializers.Serializer):
"""邮箱/用户名 + 密码登入 serializer"""
username = serializers.CharField(required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True)
password = serializers.CharField()
def validate(self, attrs):
"""验证用户名/邮箱和密码"""
username = attrs.get('username')
email = attrs.get('email')
password = attrs.get('password')
if not username and not email:
raise serializers.ValidationError('请输入用户名或邮箱')
# 查找用户
user = None
if username:
user = User.objects.filter(username=username).first()
elif email:
user = User.objects.filter(email=email).first()
if not user:
raise serializers.ValidationError('用户不存在')
# 验证密码
if not user.check_password(password):
raise serializers.ValidationError('密码错误')
attrs['user'] = user
return attrs
class ResetPasswordSerializer(serializers.Serializer):
"""请求密码重置 serializer"""
email = serializers.EmailField()
def validate_email(self, value):
"""验证邮箱是否存在"""
if not User.objects.filter(email=value).exists():
raise serializers.ValidationError('该邮箱未在系统中注册')
return value
class ConfirmResetPasswordSerializer(serializers.Serializer):
"""确认密码重置 serializer"""
email = serializers.EmailField()
code = serializers.CharField(max_length=6, min_length=6)
new_password = serializers.CharField(write_only=True, min_length=6)
def validate(self, attrs):
"""验证邮箱、验证码和新密码"""
email = attrs.get('email')
code = attrs.get('code')
# 检查用户是否存在
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
raise serializers.ValidationError('用户不存在')
# 检查验证码
try:
vc = VerificationCode.objects.filter(email=email).latest('created_at')
except VerificationCode.DoesNotExist:
raise serializers.ValidationError({'code': '请先获取验证码'})
# 检查是否被锁定
if vc.locked_until:
from django.utils import timezone
if timezone.now() < vc.locked_until:
raise serializers.ValidationError('验证码错误次数过多请10分钟后重试')
# 检查验证码是否有效
if not vc.is_valid():
raise serializers.ValidationError({'code': '验证码已过期或已使用'})
# 验证码是否正确
if vc.code != code:
vc.increment_attempts()
raise serializers.ValidationError({'code': '验证码错误'})
attrs['user'] = user
attrs['vc'] = vc
return attrs

View File

@ -1,27 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
class TestUserModel:
def test_create_seeker(self):
user = User.objects.create_user(
username='seeker1', password='pass123', role='seeker'
)
assert user.role == 'seeker'
assert user.is_seeker is True
assert user.is_admin is False
def test_create_admin(self):
user = User.objects.create_user(
username='admin1', password='pass123', role='admin'
)
assert user.is_admin is True
def test_create_superadmin(self):
user = User.objects.create_user(
username='super1', password='pass123', role='superadmin'
)
assert user.is_superadmin is True

View File

@ -1,19 +0,0 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from .views import (
RegisterView, MeView, UserManageViewSet, UserDetailView,
SendCodeView, CustomTokenObtainPairView,
RequestResetPasswordView, ConfirmResetPasswordView
)
urlpatterns = [
path('register/', RegisterView.as_view()),
path('send-code/', SendCodeView.as_view()),
path('login/', CustomTokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.as_view()),
path('reset-password/', RequestResetPasswordView.as_view()),
path('confirm-reset-password/', ConfirmResetPasswordView.as_view()),
path('me/', MeView.as_view()),
path('users/', UserManageViewSet.as_view()),
path('users/<int:pk>/', UserDetailView.as_view()),
]

View File

@ -1,213 +0,0 @@
from rest_framework import generics, status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.conf import settings
from .models import VerificationCode
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, CodeLoginSerializer, PasswordLoginSerializer, ResetPasswordSerializer, ConfirmResetPasswordSerializer
from .permissions import IsSuperAdmin
User = get_user_model()
class SendCodeView(APIView):
"""发送邮箱验证码(用于登入或密码重置)"""
permission_classes = [AllowAny]
def post(self, request):
serializer = SendCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data['email']
# 清除旧验证码
VerificationCode.objects.filter(email=email).delete()
# 生成新验证码
code = VerificationCode.generate_code()
vc = VerificationCode.objects.create(email=email, code=code)
# 发送邮件
try:
send_mail(
subject='【集团招聘平台】验证码',
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟请勿泄露给他人。',
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
recipient_list=[email],
fail_silently=False,
)
except Exception as e:
vc.delete()
return Response(
{'error': '邮件发送失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'message': '验证码已发送到您的邮箱'})
class RequestResetPasswordView(APIView):
"""请求密码重置(发送验证码)"""
permission_classes = [AllowAny]
def post(self, request):
serializer = ResetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data['email']
# 使用 SendCodeView 的逻辑发送验证码
VerificationCode.objects.filter(email=email).delete()
code = VerificationCode.generate_code()
vc = VerificationCode.objects.create(email=email, code=code)
try:
send_mail(
subject='【集团招聘平台】密码重置验证码',
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟请勿泄露给他人。',
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
recipient_list=[email],
fail_silently=False,
)
except Exception as e:
vc.delete()
return Response(
{'error': '邮件发送失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'message': '验证码已发送到您的邮箱'})
class ConfirmResetPasswordView(APIView):
"""确认密码重置"""
permission_classes = [AllowAny]
def post(self, request):
serializer = ConfirmResetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
vc = serializer.validated_data['vc']
new_password = serializer.validated_data['new_password']
# 设置新密码
user.set_password(new_password)
user.save()
# 标记验证码为已使用
vc.mark_as_verified()
return Response({'message': '密码重置成功,请使用新密码登入'})
class CustomTokenObtainPairView(TokenObtainPairView):
"""自定义登入视图,支持三种方式:邮箱验证码、邮箱密码、用户名密码"""
def post(self, request, *args, **kwargs):
# 判断登入方式
has_code = 'code' in request.data
has_email = 'email' in request.data
has_username = 'username' in request.data
has_password = 'password' in request.data
if has_email and has_code:
# 邮箱验证码登入
return self._login_with_code(request)
elif (has_email or has_username) and has_password:
# 邮箱或用户名 + 密码登入
return self._login_with_password(request)
else:
return Response(
{'error': '请提供正确的登入方式'},
status=status.HTTP_400_BAD_REQUEST
)
def _login_with_code(self, request):
"""邮箱验证码登入"""
serializer = CodeLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
vc = serializer.validated_data['vc']
# 标记验证码为已使用
vc.mark_as_verified()
# 生成 JWT token
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
}, status=status.HTTP_200_OK)
def _login_with_password(self, request):
"""邮箱或用户名 + 密码登入"""
serializer = PasswordLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
# 生成 JWT token
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
}, status=status.HTTP_200_OK)
class RegisterView(APIView):
"""密码注册"""
permission_classes = [AllowAny]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 创建用户
user = serializer.save()
# 生成 JWT token
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
return Response({
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'role': user.role,
},
'refresh': str(refresh),
'access': str(refresh.access_token),
}, status=status.HTTP_201_CREATED)
class MeView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(UserSerializer(request.user).data)
def patch(self, request):
serializer = UserSerializer(request.user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class UserManageViewSet(generics.ListCreateAPIView):
"""超管:管理所有用户"""
serializer_class = AdminUserSerializer
permission_classes = [IsSuperAdmin]
queryset = User.objects.all()
class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = AdminUserSerializer
permission_classes = [IsSuperAdmin]
queryset = User.objects.all()

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class ApplicationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.applications'

View File

@ -1,22 +0,0 @@
from django.core.mail import send_mail
from django.conf import settings
STATUS_LABELS = {
'viewed': '已查看',
'interviewing': '面试邀请',
'hired': '恭喜录用',
'rejected': '很遗憾未通过',
}
def notify_status_change(application):
label = STATUS_LABELS.get(application.status)
if not label or not application.applicant.email:
return
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com')
send_mail(
subject=f'【招聘通知】您投递的"{application.job.title}"状态更新:{label}',
message=f'您好 {application.applicant.username}\n\n您投递的职位"{application.job.title}"状态已更新为:{label}\n\n请登录平台查看详情。',
from_email=from_email,
recipient_list=[application.applicant.email],
fail_silently=True,
)

View File

@ -1,36 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-24 09:45
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('jobs', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('resume_snapshot', models.JSONField(verbose_name='简历快照')),
('status', models.CharField(choices=[('pending', '待查看'), ('viewed', '已查看'), ('interviewing', '面试中'), ('hired', '已录用'), ('rejected', '已拒绝')], default='pending', max_length=20)),
('note', models.TextField(blank=True, verbose_name='HR备注')),
('applied_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('applicant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='jobs.job')),
],
options={
'verbose_name': '投递记录',
'ordering': ['-applied_at'],
'unique_together': {('job', 'applicant')},
},
),
]

View File

@ -1,25 +0,0 @@
from django.db import models
from django.conf import settings
class Application(models.Model):
STATUS_CHOICES = [
('pending', '待查看'),
('viewed', '已查看'),
('interviewing', '面试中'),
('hired', '已录用'),
('rejected', '已拒绝'),
]
job = models.ForeignKey('jobs.Job', on_delete=models.CASCADE, related_name='applications')
applicant = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='applications'
)
resume_snapshot = models.JSONField(verbose_name='简历快照')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
note = models.TextField(blank=True, verbose_name='HR备注')
applied_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [['job', 'applicant']]
ordering = ['-applied_at']
verbose_name = '投递记录'

View File

@ -1,47 +0,0 @@
from rest_framework import serializers
from .models import Application
class ApplicationCreateSerializer(serializers.ModelSerializer):
class Meta:
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:
resume = request.user.resume
except Exception:
raise serializers.ValidationError(
{'detail': '请先完善简历后再投递'}
)
if not resume.name:
raise serializers.ValidationError(
{'detail': '请先完善简历后再投递'}
)
return Application.objects.create(
job=validated_data['job'],
applicant=request.user,
resume_snapshot=resume.to_snapshot(),
)
class ApplicationSerializer(serializers.ModelSerializer):
job_title = serializers.CharField(source='job.title', read_only=True)
company_name = serializers.CharField(source='job.organization.name', read_only=True)
class Meta:
model = Application
fields = ['id', 'job', 'job_title', 'company_name',
'resume_snapshot', 'status', 'note', 'applied_at']
read_only_fields = ['resume_snapshot', 'applied_at']
class ApplicationStatusSerializer(serializers.ModelSerializer):
"""HR 更新状态"""
class Meta:
model = Application
fields = ['status', 'note']

View File

@ -1,43 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
from apps.organizations.models import Organization
from apps.jobs.models import Job
from apps.resumes.models import Resume
from apps.applications.models import Application
User = get_user_model()
@pytest.fixture
def setup(db):
org = Organization.objects.create(name='公司', email='co@test.com')
seeker = User.objects.create_user(username='seeker1', password='pass', role='seeker')
job = Job.objects.create(
organization=org, title='测试职位', category='技术',
location='北京', salary='15k', status='published'
)
resume = Resume.objects.create(user=seeker, name='张三')
return {'org': org, 'seeker': seeker, 'job': job, 'resume': resume}
@pytest.mark.django_db
class TestApplicationModel:
def test_create_application(self, setup):
app = Application.objects.create(
job=setup['job'],
applicant=setup['seeker'],
resume_snapshot=setup['resume'].to_snapshot(),
)
assert app.status == 'pending'
assert app.resume_snapshot['name'] == '张三'
def test_cannot_apply_twice(self, setup):
Application.objects.create(
job=setup['job'],
applicant=setup['seeker'],
resume_snapshot=setup['resume'].to_snapshot(),
)
with pytest.raises(Exception):
Application.objects.create(
job=setup['job'],
applicant=setup['seeker'],
resume_snapshot={},
)

View File

@ -1,13 +0,0 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ApplyView, MyApplicationsView, ApplicationManageViewSet, ApplicationStatusUpdateView
router = DefaultRouter()
router.register('manage', ApplicationManageViewSet, basename='application-manage')
urlpatterns = [
path('apply/', ApplyView.as_view()),
path('mine/', MyApplicationsView.as_view()),
path('manage/<int:pk>/status/', ApplicationStatusUpdateView.as_view()),
path('', include(router.urls)),
]

View File

@ -1,44 +0,0 @@
from rest_framework import generics, viewsets
from .models import Application
from .serializers import ApplicationCreateSerializer, ApplicationSerializer, ApplicationStatusSerializer
from .emails import notify_status_change
from apps.accounts.permissions import IsSeeker, IsAdminOrSuperAdmin
class ApplyView(generics.CreateAPIView):
serializer_class = ApplicationCreateSerializer
permission_classes = [IsSeeker]
class MyApplicationsView(generics.ListAPIView):
serializer_class = ApplicationSerializer
permission_classes = [IsSeeker]
def get_queryset(self):
return Application.objects.filter(applicant=self.request.user).select_related('job__organization')
class ApplicationManageViewSet(viewsets.ReadOnlyModelViewSet):
"""HR 查看本公司投递"""
serializer_class = ApplicationSerializer
permission_classes = [IsAdminOrSuperAdmin]
def get_queryset(self):
user = self.request.user
if user.is_superadmin:
return Application.objects.all().select_related('job__organization', 'applicant')
return Application.objects.filter(
job__organization=user.organization
).select_related('job__organization', 'applicant')
class ApplicationStatusUpdateView(generics.UpdateAPIView):
serializer_class = ApplicationStatusSerializer
permission_classes = [IsAdminOrSuperAdmin]
def get_queryset(self):
user = self.request.user
if user.is_superadmin:
return Application.objects.all()
return Application.objects.filter(job__organization=user.organization)
def perform_update(self, serializer):
instance = serializer.save()
notify_status_change(instance)

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.jobs'

View File

@ -1,13 +0,0 @@
import django_filters
from .models import Job
class JobFilter(django_filters.FilterSet):
title = django_filters.CharFilter(lookup_expr='icontains')
category = django_filters.CharFilter(lookup_expr='exact')
location = django_filters.CharFilter(lookup_expr='icontains')
organization = django_filters.NumberFilter(field_name='organization__id')
class Meta:
model = Job
fields = ['title', 'category', 'location', 'organization']

View File

@ -1,35 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-24 09:39
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Job',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='职位名称')),
('category', models.CharField(max_length=50, verbose_name='职位类别')),
('location', models.CharField(max_length=100, verbose_name='工作地点')),
('salary', models.CharField(max_length=50, verbose_name='薪资范围')),
('description', models.TextField(blank=True, verbose_name='职位描述')),
('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('closed', '已关闭')], default='draft', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='organizations.organization')),
],
options={
'verbose_name': '职位',
'ordering': ['-created_at'],
},
),
]

View File

@ -1,29 +0,0 @@
# 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

@ -1,41 +0,0 @@
from django.db import models
class Job(models.Model):
STATUS_CHOICES = [
('draft', '草稿'),
('published', '已发布'),
('closed', '已关闭'),
]
organization = models.ForeignKey(
'organizations.Organization',
on_delete=models.CASCADE,
related_name='jobs'
)
title = models.CharField(max_length=100, verbose_name='职位名称')
category = models.CharField(max_length=50, verbose_name='职位类别')
location = models.CharField(max_length=100, verbose_name='工作地点')
salary = models.CharField(max_length=50, verbose_name='薪资范围')
description = models.TextField(verbose_name='职位描述', blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = '职位'
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,35 +0,0 @@
from rest_framework import serializers
from .models import Job, JobFavorite
from apps.organizations.serializers import OrganizationSerializer
from apps.organizations.models import Organization
class JobListSerializer(serializers.ModelSerializer):
organization_name = serializers.CharField(source='organization.name', read_only=True)
class Meta:
model = Job
fields = ['id', 'title', 'category', 'location', 'salary',
'organization', 'organization_name', 'status', 'created_at']
class JobDetailSerializer(serializers.ModelSerializer):
organization = OrganizationSerializer(read_only=True)
organization_id = serializers.PrimaryKeyRelatedField(
source='organization',
queryset=Organization.objects.all(),
write_only=True
)
class Meta:
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,45 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
from apps.organizations.models import Organization
from apps.jobs.models import Job
User = get_user_model()
@pytest.fixture
def org():
return Organization.objects.create(name='测试公司', email='test@test.com')
@pytest.fixture
def admin_user(org):
return User.objects.create_user(
username='admin1', password='pass123',
role='admin', organization=org
)
@pytest.mark.django_db
class TestJobModel:
def test_create_job(self, org):
job = Job.objects.create(
organization=org, title='Python工程师',
category='技术', location='北京', salary='20k-30k',
description='职位描述', status='published'
)
assert job.status == 'published'
assert str(job) == 'Python工程师'
@pytest.mark.django_db
class TestJobAPI:
def test_public_can_list_published_jobs(self, client, org):
Job.objects.create(
organization=org, title='已发布职位',
status='published', category='技术',
location='北京', salary='10k'
)
Job.objects.create(
organization=org, title='草稿职位',
status='draft', category='技术',
location='北京', salary='10k'
)
response = client.get('/api/jobs/public/')
assert response.status_code == 200
assert response.data['count'] == 1

View File

@ -1,12 +0,0 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
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)),
path('favorites/', MyFavoritesView.as_view()),
]

View File

@ -1,70 +0,0 @@
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, JobFavorite
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
from .filters import JobFilter
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
"""公开只读,仅返回已发布职位"""
queryset = Job.objects.filter(status='published').select_related('organization')
filterset_class = JobFilter
filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ['title', 'description', 'location']
permission_classes = [permissions.AllowAny]
def get_serializer_class(self):
if self.action == 'retrieve':
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):
"""管理端:公司管理员管理本公司职位"""
permission_classes = [IsAdminOrSuperAdmin]
def get_serializer_class(self):
if self.action in ['retrieve', 'create', 'update', 'partial_update']:
return JobDetailSerializer
return JobListSerializer
def get_queryset(self):
user = self.request.user
if user.is_superadmin:
return Job.objects.all().select_related('organization')
# 防御 organization 为空的情况
if not user.organization_id:
return Job.objects.none()
return Job.objects.filter(organization=user.organization).select_related('organization')
def perform_create(self, serializer):
if self.request.user.is_admin:
# Admin 强制使用自己公司,忽略请求体中的 organization_id
serializer.save(organization=self.request.user.organization)
else:
# 超管需要在请求体中提供 organization_id
serializer.save()

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class OrganizationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.organizations'

View File

@ -1,32 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-24 09:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='公司名称')),
('logo', models.ImageField(blank=True, null=True, upload_to='org_logos/')),
('description', models.TextField(blank=True, verbose_name='公司简介')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='联系邮箱')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='organizations.organization', verbose_name='上级公司')),
],
options={
'verbose_name': '组织架构',
'verbose_name_plural': '组织架构',
},
),
]

View File

@ -1,24 +0,0 @@
from django.db import models
class Organization(models.Model):
name = models.CharField(max_length=100, verbose_name='公司名称')
parent = models.ForeignKey(
'self', null=True, blank=True,
on_delete=models.SET_NULL,
related_name='children',
verbose_name='上级公司'
)
logo = models.ImageField(upload_to='org_logos/', null=True, blank=True)
description = models.TextField(blank=True, verbose_name='公司简介')
email = models.EmailField(blank=True, verbose_name='联系邮箱')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'organizations'
verbose_name = '组织架构'
verbose_name_plural = '组织架构'
def __str__(self):
return self.name

View File

@ -1,31 +0,0 @@
from rest_framework import serializers
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', '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', 'job_count', 'children']
def get_job_count(self, obj):
return obj.jobs.filter(status='published').count()
def get_children(self, obj):
return OrganizationSerializer(
obj.children.filter(is_active=True), many=True, context=self.context
).data

View File

@ -1,81 +0,0 @@
import pytest
from apps.organizations.models import Organization
@pytest.mark.django_db
class TestOrganizationModel:
def test_create_group(self):
org = Organization.objects.create(name='示例集团', email='group@example.com')
assert org.parent is None
assert org.is_active is True
def test_create_subsidiary(self):
parent = Organization.objects.create(name='示例集团', email='group@example.com')
child = Organization.objects.create(
name='子公司A', email='a@example.com', parent=parent
)
assert child.parent == parent
def test_list_subsidiaries(self):
parent = Organization.objects.create(name='集团', email='g@example.com')
Organization.objects.create(name='子A', email='a@example.com', parent=parent)
Organization.objects.create(name='子B', email='b@example.com', parent=parent)
assert parent.children.count() == 2
@pytest.mark.django_db
class TestOrganizationPublicAPI:
def test_public_list_returns_top_level_orgs(self, client):
parent = Organization.objects.create(name='集团', email='g@example.com')
Organization.objects.create(name='子A', email='a@example.com', parent=parent)
response = client.get('/api/organizations/public/')
assert response.status_code == 200
# 只返回顶级组织(集团),不直接返回子公司
assert response.data['count'] == 1
assert response.data['results'][0]['name'] == '集团'
def test_inactive_org_not_in_public_list(self, client):
Organization.objects.create(name='停用集团', email='inactive@example.com', is_active=False)
Organization.objects.create(name='启用集团', email='active@example.com', is_active=True)
response = client.get('/api/organizations/public/')
assert response.status_code == 200
assert response.data['count'] == 1
def test_manage_requires_auth(self, client):
response = client.get('/api/organizations/manage/')
assert response.status_code == 401
@pytest.mark.django_db
class TestOrganizationManageAPI:
def test_superadmin_can_create_org(self, client):
from django.contrib.auth import get_user_model
from rest_framework_simplejwt.tokens import RefreshToken
User = get_user_model()
superadmin = User.objects.create_user(
username='super1', password='pass123', role='superadmin'
)
refresh = RefreshToken.for_user(superadmin)
token = str(refresh.access_token)
response = client.post(
'/api/organizations/manage/',
{'name': '新集团', 'email': 'new@example.com'},
content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {token}'
)
assert response.status_code == 201
def test_seeker_cannot_access_manage(self, client):
from django.contrib.auth import get_user_model
from rest_framework_simplejwt.tokens import RefreshToken
User = get_user_model()
seeker = User.objects.create_user(
username='seeker1', password='pass123', role='seeker'
)
refresh = RefreshToken.for_user(seeker)
token = str(refresh.access_token)
response = client.get(
'/api/organizations/manage/',
HTTP_AUTHORIZATION=f'Bearer {token}'
)
assert response.status_code == 403

View File

@ -1,9 +0,0 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import OrganizationPublicViewSet, OrganizationManageViewSet
router = DefaultRouter()
router.register('public', OrganizationPublicViewSet, basename='org-public')
router.register('manage', OrganizationManageViewSet, basename='org-manage')
urlpatterns = [path('', include(router.urls))]

View File

@ -1,25 +0,0 @@
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from .models import Organization
from .serializers import OrganizationSerializer, OrganizationTreeSerializer
from apps.accounts.permissions import IsSuperAdmin
class OrganizationPublicViewSet(viewsets.ReadOnlyModelViewSet):
"""公开只读:门户展示用,返回顶级组织(集团)及其子公司树"""
queryset = Organization.objects.filter(is_active=True)
serializer_class = OrganizationTreeSerializer
def get_queryset(self):
# 列表只返回顶级,详情可查任意
if self.action == 'list':
return Organization.objects.filter(is_active=True, parent__isnull=True)
return Organization.objects.filter(is_active=True)
permission_classes = [AllowAny]
class OrganizationManageViewSet(viewsets.ModelViewSet):
"""超管:完整增删改查"""
queryset = Organization.objects.all()
serializer_class = OrganizationSerializer
permission_classes = [IsSuperAdmin]

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class ResumesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.resumes'

View File

@ -1,34 +0,0 @@
# Generated by Django 4.2.20 on 2026-03-24 09:43
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Resume',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name='姓名')),
('gender', models.CharField(blank=True, choices=[('male', ''), ('female', ''), ('other', '其他')], max_length=10)),
('birthday', models.DateField(blank=True, null=True)),
('education', models.JSONField(default=list, verbose_name='教育经历')),
('experience', models.JSONField(default=list, verbose_name='工作经历')),
('attachment', models.FileField(blank=True, null=True, upload_to='resumes/')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='resume', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '简历',
},
),
]

View File

@ -1,39 +0,0 @@
from django.db import models
from django.conf import settings
class Resume(models.Model):
GENDER_CHOICES = [('male', ''), ('female', ''), ('other', '其他')]
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='resume'
)
name = models.CharField(max_length=50, verbose_name='姓名')
gender = models.CharField(max_length=10, choices=GENDER_CHOICES, blank=True)
birthday = models.DateField(null=True, blank=True)
education = models.JSONField(default=list, verbose_name='教育经历')
experience = models.JSONField(default=list, verbose_name='工作经历')
attachment = models.FileField(upload_to='resumes/', null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '简历'
def to_snapshot(self):
"""序列化为投递快照,与主表解耦"""
attachment_url = None
if self.attachment:
# 返回相对 URL前端会处理
attachment_url = self.attachment.url
return {
'name': self.name,
'gender': self.gender,
'birthday': str(self.birthday) if self.birthday else None,
'education': self.education,
'experience': self.experience,
'email': self.user.email,
'phone': self.user.phone,
'attachment_url': attachment_url,
}

View File

@ -1,10 +0,0 @@
from rest_framework import serializers
from .models import Resume
class ResumeSerializer(serializers.ModelSerializer):
class Meta:
model = Resume
fields = ['id', 'name', 'gender', 'birthday', 'education',
'experience', 'attachment', 'updated_at']
read_only_fields = ['updated_at']

View File

@ -1,29 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
from apps.resumes.models import Resume
User = get_user_model()
@pytest.fixture
def seeker():
return User.objects.create_user(username='seeker1', password='pass', role='seeker')
@pytest.mark.django_db
class TestResumeModel:
def test_create_resume(self, seeker):
resume = Resume.objects.create(
user=seeker,
name='张三',
gender='male',
education=[{'school': '北京大学', 'degree': '本科', 'major': '计算机'}],
experience=[{'company': 'ABC公司', 'position': '工程师', 'duration': '2年'}],
)
assert resume.name == '张三'
assert len(resume.education) == 1
assert len(resume.experience) == 1
def test_seeker_has_one_resume(self, seeker):
Resume.objects.create(user=seeker, name='张三')
assert Resume.objects.filter(user=seeker).count() == 1

View File

@ -1,6 +0,0 @@
from django.urls import path
from .views import MyResumeView
urlpatterns = [
path('me/', MyResumeView.as_view()),
]

View File

@ -1,17 +0,0 @@
from rest_framework import generics
from .models import Resume
from .serializers import ResumeSerializer
from apps.accounts.permissions import IsSeeker
class MyResumeView(generics.RetrieveUpdateAPIView):
"""求职者获取/更新自己的简历(不存在则自动创建)"""
serializer_class = ResumeSerializer
permission_classes = [IsSeeker]
def get_object(self):
resume, _ = Resume.objects.get_or_create(
user=self.request.user,
defaults={'name': self.request.user.username}
)
return resume

View File

@ -1,16 +0,0 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
application = get_asgi_application()

View File

@ -1,100 +0,0 @@
from datetime import timedelta
from pathlib import Path
from decouple import config
# offer_backend/ (3 levels up from config/settings/base.py)
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = config('SECRET_KEY')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third party
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'django_filters',
# Local
'apps.accounts',
'apps.organizations',
'apps.jobs',
'apps.resumes',
'apps.applications',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
AUTH_USER_MODEL = 'accounts.User'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
# Email Configuration
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@offer.com')
EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com')
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')

View File

@ -1,34 +0,0 @@
from .base import *
DEBUG = True
# Development only — never use '*' in production
ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='offer_db'),
'USER': config('DB_USER', default='postgres'),
'PASSWORD': config('DB_PASSWORD', default='postgres'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
}
}
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': config('REDIS_URL', default='redis://127.0.0.1:6379/1'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SOCKET_CONNECT_TIMEOUT': 5,
'SOCKET_TIMEOUT': 5,
},
}
}
# Development only — allow all cross-origin requests
CORS_ALLOW_ALL_ORIGINS = True
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

View File

@ -1,13 +0,0 @@
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('api/auth/', include('apps.accounts.urls')),
path('api/organizations/', include('apps.organizations.urls')),
path('api/jobs/', include('apps.jobs.urls')),
path('api/resumes/', include('apps.resumes.urls')),
path('api/applications/', include('apps.applications.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -1,16 +0,0 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
application = get_wsgi_application()

View File

@ -1,7 +0,0 @@
import pytest
from django.test import Client
@pytest.fixture
def client():
return Client()

View File

@ -1,22 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@ -1,5 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.development
python_files = tests/test_*.py
python_classes = Test*
python_functions = test_*

View File

@ -1,12 +0,0 @@
Django==4.2.20
djangorestframework==3.16.0
djangorestframework-simplejwt==5.3.1
django-cors-headers==4.3.1
django-filter==23.5
psycopg2-binary==2.9.9
redis==5.0.1
django-redis==5.4.0
Pillow==10.3.0
python-decouple==3.8
pytest==8.1.1
pytest-django==4.8.0

View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -1,5 +0,0 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +0,0 @@
{
"name": "offer_frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.6",
"pinia": "^3.0.4",
"vue": "^3.5.12",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.10"
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,15 +0,0 @@
<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

@ -1,6 +0,0 @@
import client from './client'
export const applyJob = (jobId) => client.post('/applications/apply/', { job: jobId })
export const getMyApplications = () => client.get('/applications/mine/')
export const getManageApplications = (page = 1) => client.get('/applications/manage/', { params: { page } })
export const updateApplicationStatus = (id, data) => client.patch(`/applications/manage/${id}/status/`, data)

View File

@ -1,11 +0,0 @@
import client from './client'
import axios from 'axios'
export const sendCode = (email) => axios.post('/api/auth/send-code/', { email })
export const loginApi = (data) => axios.post('/api/auth/login/', data)
export const register = (data) => axios.post('/api/auth/register/', data)
export const getMe = () => client.get('/auth/me/')
export const updateMe = (data) => client.patch('/auth/me/', data)
export const resetPassword = (email) => axios.post('/api/auth/reset-password/', { email })
export const confirmResetPassword = (email, code, newPassword) =>
axios.post('/api/auth/confirm-reset-password/', { email, code, new_password: newPassword })

View File

@ -1,33 +0,0 @@
// offer_frontend/src/api/client.js
import axios from 'axios'
const client = axios.create({ baseURL: '/api' })
client.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
client.interceptors.response.use(
res => res,
async err => {
const original = err.config
if (err.response?.status === 401 && !original._retry) {
original._retry = true
try {
const refresh = localStorage.getItem('refresh_token')
const { data } = await axios.post('/api/auth/token/refresh/', { refresh })
localStorage.setItem('access_token', data.access)
original.headers.Authorization = `Bearer ${data.access}`
return client(original)
} catch {
localStorage.clear()
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
export default client

View File

@ -1,10 +0,0 @@
import client from './client'
export const getJobs = (params) => client.get('/jobs/public/', { params })
export const getJob = (id) => client.get(`/jobs/public/${id}/`)
export const manageJobs = (page = 1) => client.get('/jobs/manage/', { params: { page } })
export const 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

@ -1,8 +0,0 @@
import client from './client'
export const getOrganizations = () => client.get('/organizations/public/')
export const getOrganization = (id) => client.get(`/organizations/public/${id}/`)
export const manageOrganizations = (page = 1) => client.get('/organizations/manage/', { params: { page } })
export const createOrganization = (data) => client.post('/organizations/manage/', data)
export const updateOrganization = (id, data) => client.patch(`/organizations/manage/${id}/`, data)
export const deleteOrganization = (id) => client.delete(`/organizations/manage/${id}/`)

View File

@ -1,7 +0,0 @@
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 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

Some files were not shown because too many files have changed in this diff Show More