Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
|
@ -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)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"reason":"idle timeout","timestamp":1774342828046}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
3597
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<h2>系统架构总览</h2>
|
|
||||||
<p class="subtitle">方案 A:Django 后端 + 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>
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<h2>系统架构总览</h2>
|
|
||||||
<p class="subtitle">方案 A:Django 后端 + 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>
|
|
||||||
|
|
@ -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 → Organization(admin 用)</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>
|
|
||||||
|
|
@ -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 → Organization(admin 用)</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>
|
|
||||||
|
|
@ -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;">流程 2:HR 处理投递(状态流转)</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>
|
|
||||||
|
|
@ -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 /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 (全局视图)</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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 个页面)
|
|
||||||
|
|
@ -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-simplejwt(JWT) |
|
|
||||||
| 数据库 | PostgreSQL |
|
|
||||||
| 缓存 | Redis |
|
|
||||||
| 文件存储 | 本地存储(初期),后期可切换至对象存储(OSS) |
|
|
||||||
| 部署 | Nginx + Gunicorn |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 架构方案
|
|
||||||
|
|
||||||
**方案:Django 后端 + 单个 Vue3 SPA(前后端分离)**
|
|
||||||
|
|
||||||
前端单页应用通过 Vue Router 划分三个区域,使用路由守卫控制访问权限。后端提供 RESTful API,Nginx 反向代理静态资源和 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. 投递成功提示,可在"我的投递"查看进度
|
|
||||||
|
|
||||||
### 流程 2:HR 处理投递(状态流转)
|
|
||||||
```
|
|
||||||
待查看(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
|
|
||||||
```
|
|
||||||
|
|
@ -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 无此字段)
|
|
||||||
|
|
@ -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
|
|
||||||
- 支持自定义每页行数选项
|
|
||||||
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
.env
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.pyd
|
|
||||||
.Python
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
media/
|
|
||||||
staticfiles/
|
|
||||||
db.sqlite3
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'apps.accounts'
|
|
||||||
verbose_name = '账户管理'
|
|
||||||
|
|
@ -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()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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 = '用户'
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()),
|
|
||||||
]
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class ApplicationsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'apps.applications'
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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 = '投递记录'
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
@ -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={},
|
|
||||||
)
|
|
||||||
|
|
@ -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)),
|
|
||||||
]
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class JobsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'apps.jobs'
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
@ -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'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()),
|
|
||||||
]
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class OrganizationsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'apps.organizations'
|
|
||||||
|
|
@ -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': '组织架构',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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))]
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class ResumesConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'apps.resumes'
|
|
||||||
|
|
@ -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': '简历',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
from django.urls import path
|
|
||||||
from .views import MyResumeView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('me/', MyResumeView.as_view()),
|
|
||||||
]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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='')
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import pytest
|
|
||||||
from django.test import Client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
return Client()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
[pytest]
|
|
||||||
DJANGO_SETTINGS_MODULE = config.settings.development
|
|
||||||
python_files = tests/test_*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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?
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"recommendations": ["Vue.volar"]
|
|
||||||
}
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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 })
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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/')
|
|
||||||
|
|
@ -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}/`)
|
|
||||||
|
|
@ -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' }
|
|
||||||
})
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue