Compare commits
10 Commits
0a44e4a18d
...
ed3b29aafe
| Author | SHA1 | Date |
|---|---|---|
|
|
ed3b29aafe | |
|
|
536be6c1a1 | |
|
|
e3bdb0b496 | |
|
|
7e86ec5ea0 | |
|
|
88c0bb223c | |
|
|
11d1cec192 | |
|
|
f56e562a78 | |
|
|
46e35074ed | |
|
|
ef4c9bf307 | |
|
|
ca629a403c |
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bash \"C:/Users/11825/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/start-server.sh\" --project-dir \"C:/code/offer\")",
|
||||
"Bash(cat \"C:/code/offer/.superpowers/brainstorm\"/.*/server-info)",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''Vue:'''', d.get\\(''''dependencies'''',{}\\).get\\(''''vue'''',''''N/A''''\\)\\); print\\(''''Vite/webpack:'''', list\\(d.get\\(''''devDependencies'''',{}\\).keys\\(\\)\\)[:5]\\)\")",
|
||||
"Bash(git -C /c/code/offer log --oneline)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(py --version)",
|
||||
"Bash(python3 -m pip install -r /c/code/offer/offer_backend/requirements.txt)",
|
||||
"Bash(python3 -c \"import sys; print\\(sys.executable\\)\")",
|
||||
"Bash(cmd.exe /c \"python --version\")",
|
||||
"Bash(cmd.exe /c \"py --version\")",
|
||||
"Bash(cmd.exe /c \"python --version && python -m pip --version\" 2>&1)",
|
||||
"Read(//c/Users/11825/AppData/Local/Programs/**)",
|
||||
"Bash(ls /c/Python*)",
|
||||
"Read(//c/Users/11825/AppData/Local/Microsoft/WindowsApps//**)",
|
||||
"Read(//c//**)",
|
||||
"Bash(/c/Users/11825/AppData/Local/Microsoft/WindowsApps/python3.exe --version)",
|
||||
"Bash(/c/Users/11825/AppData/Local/Microsoft/WindowsApps/python3.exe -m pip --version)",
|
||||
"Read(//c/miniconda3/bin/**)",
|
||||
"Read(//c/anaconda3/bin/**)",
|
||||
"Bash(/c/software/python3_10/python --version)",
|
||||
"Bash(/c/software/python3_10/python -m pip --version)",
|
||||
"Bash(/c/software/python3_10/python -m ensurepip --upgrade)",
|
||||
"Bash(/c/software/python3_10/python -m pip install -r /c/code/offer/offer_backend/requirements.txt 2>&1)",
|
||||
"Bash(/c/software/python3_10/Scripts/django-admin startproject:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(rm /c/code/offer/offer_backend/manage.py)",
|
||||
"Bash(rm -rf /c/code/offer/offer_backend/config)",
|
||||
"Bash(mkdir -p /c/code/offer/offer_backend/config/settings)",
|
||||
"Bash(mkdir -p /c/code/offer/offer_backend/apps)",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=config.settings.development /c/software/python3_10/python manage.py check)",
|
||||
"Bash(git -C /c/code/offer status)",
|
||||
"Bash(git -C /c/code/offer add offer_backend/)",
|
||||
"Bash(git:*)",
|
||||
"Bash(python manage.py startapp accounts apps/accounts)",
|
||||
"Bash(python manage.py makemigrations accounts)",
|
||||
"Bash(python manage.py migrate)",
|
||||
"Bash(python -m pytest apps/accounts/tests/test_auth.py -v)",
|
||||
"Bash(/c/software/python3_10/python -m pytest apps/accounts/tests/test_auth.py -v)",
|
||||
"Bash(/c/software/python3_10/Scripts/pytest --version)",
|
||||
"Bash(ls /c/software/python3_10/Scripts/pytest*)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(/c/software/python3_10/python -c \"import sys; print\\(sys.path\\)\")",
|
||||
"Bash(/c/software/python3_10/pip3 list:*)",
|
||||
"Bash(/c/software/python3_10/pip3 install:*)",
|
||||
"Bash(ls /c/software/python3_10/Scripts/pip*)",
|
||||
"Read(//c/software/python3_10/**)",
|
||||
"Bash(/c/software/python3_10/Scripts/pip3.exe install:*)",
|
||||
"Bash(/c/software/python3_10/Scripts/pytest.exe apps/accounts/tests/test_auth.py -v)",
|
||||
"Bash(pg_isready)",
|
||||
"Bash(psql -U postgres -c \"SELECT 1\")",
|
||||
"Bash(\"/c/Program Files/PostgreSQL/16/bin/psql.exe\" -U postgres -c \"SELECT version\\(\\);\" 2>&1)",
|
||||
"Bash(PGPASSWORD=zcDsj@2024 \"/c/Program Files/PostgreSQL/16/bin/psql.exe\" -U postgres -c \"SELECT version\\(\\);\" 2>&1)",
|
||||
"Bash(PGPASSWORD=zcDsj@2024 \"/c/Program Files/PostgreSQL/16/bin/psql.exe\" -U postgres -c \"CREATE DATABASE offer_db;\" 2>&1)",
|
||||
"Bash(/c/software/python3_10/python manage.py migrate)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"reason":"idle timeout","timestamp":1774342828046}
|
||||
|
|
@ -0,0 +1 @@
|
|||
3597
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
<h2>数据模型设计</h2>
|
||||
<p class="subtitle">核心表结构与关联关系</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 16px;">
|
||||
|
||||
<!-- Organization -->
|
||||
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #0c4a6e; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 16px;">🏢</span>
|
||||
<span style="font-weight: 700; color: #e2e8f0;">Organization 组织架构</span>
|
||||
</div>
|
||||
<div style="padding: 12px 14px;">
|
||||
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">name</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司名称</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">parent</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">FK → self(集团/子公司)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">logo</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司 Logo</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">description</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司简介</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">email</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司联系邮箱 ✨</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">is_active</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">是否启用</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User -->
|
||||
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #312e81; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 16px;">👤</span>
|
||||
<span style="font-weight: 700; color: #e2e8f0;">User 用户</span>
|
||||
</div>
|
||||
<div style="padding: 12px 14px;">
|
||||
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">username</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">登录账号</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">email</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">用户邮箱 ✨</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">phone</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">手机号</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">role</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">superadmin / admin / seeker</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">organization</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">FK → 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>
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
<h2>数据模型设计</h2>
|
||||
<p class="subtitle">核心表结构与关联关系</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 16px;">
|
||||
|
||||
<!-- Organization -->
|
||||
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #0c4a6e; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 16px;">🏢</span>
|
||||
<span style="font-weight: 700; color: #e2e8f0;">Organization 组织架构</span>
|
||||
</div>
|
||||
<div style="padding: 12px 14px;">
|
||||
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">name</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司名称</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">parent</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">FK → self(集团/子公司)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">logo</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司 Logo</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">description</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">公司简介</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">is_active</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">是否启用</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User -->
|
||||
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #312e81; padding: 10px 14px; display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 16px;">👤</span>
|
||||
<span style="font-weight: 700; color: #e2e8f0;">User 用户</span>
|
||||
</div>
|
||||
<div style="padding: 12px 14px;">
|
||||
<table style="width: 100%; font-size: 12px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #fbbf24; font-weight: 600;">id</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">PK</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">username / email</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">登录账号</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">phone</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">手机号</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #34d399; font-weight: 600;">role</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">superadmin / admin / seeker</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #1e3a5f;">
|
||||
<td style="padding: 5px 0; color: #e2e8f0;">organization</td>
|
||||
<td style="padding: 5px 0; color: #94a3b8;">FK → 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>
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<h2>关键业务流程</h2>
|
||||
<p class="subtitle">三条核心流程的状态流转</p>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px; margin-top: 16px;">
|
||||
|
||||
<!-- 流程1:求职者投递 -->
|
||||
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #0c4a6e; padding: 10px 16px;">
|
||||
<span style="font-weight: 700; color: #38bdf8;">流程 1:求职者投递简历</span>
|
||||
</div>
|
||||
<div style="padding: 16px; overflow-x: auto;">
|
||||
<div style="display: flex; align-items: center; gap: 0; min-width: 600px;">
|
||||
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 100px; border: 1px solid #1e3a5f;">
|
||||
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">求职者</div>
|
||||
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">浏览职位</div>
|
||||
</div>
|
||||
<div style="color: #475569; padding: 0 6px; font-size: 18px;">→</div>
|
||||
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 100px; border: 1px solid #1e3a5f;">
|
||||
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">检查</div>
|
||||
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">是否已登录</div>
|
||||
<div style="font-size: 11px; color: #fbbf24; margin-top: 4px;">未登录→跳注册</div>
|
||||
</div>
|
||||
<div style="color: #475569; padding: 0 6px; font-size: 18px;">→</div>
|
||||
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 110px; border: 1px solid #1e3a5f;">
|
||||
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">检查</div>
|
||||
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">简历是否完善</div>
|
||||
<div style="font-size: 11px; color: #fbbf24; margin-top: 4px;">未完善→提示填写</div>
|
||||
</div>
|
||||
<div style="color: #475569; padding: 0 6px; font-size: 18px;">→</div>
|
||||
<div style="background: #0f172a; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 110px; border: 1px solid #1e3a5f;">
|
||||
<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">确认</div>
|
||||
<div style="color: #e2e8f0; font-size: 13px; font-weight: 600;">投递弹窗</div>
|
||||
<div style="font-size: 11px; color: #94a3b8; margin-top: 4px;">选简历版本</div>
|
||||
</div>
|
||||
<div style="color: #475569; padding: 0 6px; font-size: 18px;">→</div>
|
||||
<div style="background: #14532d; border-radius: 8px; padding: 10px 14px; text-align: center; min-width: 100px; border: 1px solid #166534;">
|
||||
<div style="font-size: 11px; color: #86efac; margin-bottom: 4px;">完成</div>
|
||||
<div style="color: #4ade80; font-size: 13px; font-weight: 600;">投递成功</div>
|
||||
<div style="font-size: 11px; color: #86efac; margin-top: 4px;">状态:待查看</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流程2:投递状态流转 -->
|
||||
<div style="background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #7f1d1d; padding: 10px 16px;">
|
||||
<span style="font-weight: 700; color: #fca5a5;">流程 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>
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<h2>页面结构与路由设计</h2>
|
||||
<p class="subtitle">三个区域的页面划分</p>
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; margin-top: 16px;">
|
||||
|
||||
<!-- 公开门户 -->
|
||||
<div style="flex: 1; min-width: 240px; background: #1e293b; border-radius: 10px; overflow: hidden; border: 1px solid #334155;">
|
||||
<div style="background: #0c4a6e; padding: 10px 14px;">
|
||||
<span style="font-weight: 700; color: #38bdf8;">公开门户</span>
|
||||
<span style="font-size: 11px; color: #7dd3fc; margin-left: 8px;">无需登录</span>
|
||||
</div>
|
||||
<div style="padding: 12px 14px; font-size: 12px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="color: #38bdf8; font-family: monospace;">/</div>
|
||||
<div style="color: #94a3b8; margin-top: 2px;">首页(职位推荐 + 公司展示 + 搜索入口)</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="color: #38bdf8; font-family: monospace;">/jobs</div>
|
||||
<div style="color: #94a3b8; margin-top: 2px;">职位列表(搜索 + 筛选:公司/地点/薪资/类别)</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="color: #38bdf8; font-family: monospace;">/jobs/:id</div>
|
||||
<div style="color: #94a3b8; margin-top: 2px;">职位详情(描述 + 要求 + 投递按钮)</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="color: #38bdf8; font-family: monospace;">/companies</div>
|
||||
<div style="color: #94a3b8; margin-top: 2px;">公司列表</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="color: #38bdf8; font-family: monospace;">/companies/:id</div>
|
||||
<div style="color: #94a3b8; margin-top: 2px;">公司详情 + 该公司在招职位</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; border-radius: 6px; padding: 8px 10px;">
|
||||
<div style="color: #38bdf8; font-family: monospace;">/login /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>
|
||||
|
|
@ -382,7 +382,7 @@ git commit -m "chore: add pytest configuration for Django tests"
|
|||
|
||||
---
|
||||
|
||||
## Task 4: accounts app — 自定义用户模型
|
||||
## Task 3: accounts app — 自定义用户模型
|
||||
|
||||
**Files:**
|
||||
- Create: `offer_backend/apps/accounts/models.py`
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
| 认证 | djangorestframework-simplejwt(JWT) |
|
||||
| 数据库 | PostgreSQL |
|
||||
| 缓存 | Redis |
|
||||
| 文件存储 | 本地存储 / 对象存储(OSS) |
|
||||
| 文件存储 | 本地存储(初期),后期可切换至对象存储(OSS) |
|
||||
| 部署 | Nginx + Gunicorn |
|
||||
|
||||
---
|
||||
|
|
@ -113,7 +113,7 @@ Nginx
|
|||
| id | PK | 主键 |
|
||||
| job | FK(Job) | 投递的职位 |
|
||||
| applicant | FK(User) | 求职者 |
|
||||
| resume | FK(Resume) | 投递时关联的简历(快照) |
|
||||
| resume_snapshot | JSONField | 投递时将简历内容序列化复制存储,与 Resume 主表解耦 |
|
||||
| status | CharField | pending / viewed / interviewing / hired / rejected |
|
||||
| note | TextField | HR 备注 |
|
||||
| applied_at | DateTimeField | 投递时间 |
|
||||
|
|
@ -130,7 +130,7 @@ Nginx
|
|||
| `/jobs/:id` | 职位详情 | 职位描述、要求、投递按钮 |
|
||||
| `/companies` | 公司列表 | 所有子公司展示 |
|
||||
| `/companies/:id` | 公司详情 | 公司介绍 + 在招职位 |
|
||||
| `/login` | 求职者登录 | - |
|
||||
| `/login` | 求职者登录 | 管理员也使用此页面登录,通过 role 跳转不同后台 |
|
||||
| `/register` | 求职者注册 | - |
|
||||
|
||||
### 求职者中心(seeker 登录后可访问)
|
||||
|
|
@ -170,7 +170,7 @@ Nginx
|
|||
待查看(pending) → 已查看(viewed) → 面试中(interviewing) → 已录用(hired)
|
||||
→ 已拒绝(rejected)
|
||||
```
|
||||
- 每次状态变更自动发送邮件通知求职者
|
||||
- 每次状态变更通过 Django 内置邮件后端(SMTP)同步发送通知邮件给求职者
|
||||
- HR 可在任意阶段填写备注
|
||||
|
||||
### 流程 3:公司管理员发布职位
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
# 公司列表三栏布局设计文档
|
||||
|
||||
**日期:** 2026-03-25
|
||||
**状态:** 已批准
|
||||
**影响页面:** `/companies` → `CompanyListView.vue`
|
||||
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
将现有 `/companies` 页面从网格卡片布局改造为三栏联动布局,用户可在同一页面浏览公司、查看岗位列表、阅读岗位详情,无需跳转。
|
||||
|
||||
---
|
||||
|
||||
## 布局结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 左栏 220px │ 中栏 260px │ 右栏 flex:1 │
|
||||
│ 公司列表 │ 岗位列表 │ 岗位详情 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**高度设置:** PortalLayout 包含 `el-header`(60px)+ `el-main`(默认 padding 20px)+ `el-footer`(60px)。三栏容器根元素设置 `height: calc(100vh - 200px)`(60 header + 60 footer + 40 el-main padding + 40 buffer),`overflow: hidden`,各栏内部 `overflow-y: auto` 独立滚动。此高度值在主流桌面分辨率下提供良好体验,无需精确到像素。
|
||||
|
||||
---
|
||||
|
||||
## 各栏设计
|
||||
|
||||
### 左栏 — 公司列表
|
||||
|
||||
- 顶部标题「全部企业」+ 公司总数 badge
|
||||
- 每个公司卡片包含:
|
||||
- Logo:如有 `org.logo`,显示 `<img>`;否则显示公司名首字 + 蓝色背景 div
|
||||
- 公司名称(`org.name`)
|
||||
- 简介摘要(`org.description` 截取前 20 字,为空则不显示)
|
||||
- **不显示在招岗位数**(Organization API 无此字段)
|
||||
- 选中状态:左边框 `3px solid #409EFF`,背景白色
|
||||
- 未选中:左边框透明,背景 `#f5f7fa`
|
||||
- **加载状态**:`orgsLoading` 为 true 时显示 `el-skeleton`(3 行)
|
||||
- **错误状态**:`orgsError` 为 true 时显示「加载失败,请刷新重试」居中提示
|
||||
- **空状态**:`orgs` 为空数组时显示「暂无公司数据」居中提示
|
||||
- 数据来源:`getOrganizations()` → `GET /organizations/public/`,取 `data.results`
|
||||
|
||||
### 中栏 — 岗位列表
|
||||
|
||||
- 标题:「{org.name} · 职位列表」
|
||||
- 每条岗位显示:
|
||||
- 岗位名称(`job.title`)
|
||||
- 标签行:`job.location`(城市)、`job.salary`(薪资)、`job.category`(类别)
|
||||
- 选中状态:左边框 `3px solid #409EFF`,背景 `#ecf5ff`
|
||||
- **空状态**:该公司无已发布岗位时,显示「暂无在招职位」居中提示
|
||||
- **加载状态**:`jobsLoading` 为 true 时显示 `el-skeleton`(3 行)
|
||||
- **错误状态**:API 失败时显示「加载失败,请刷新重试」
|
||||
- 数据来源:`getJobs({ organization: org.id })` → 取 `data.results`
|
||||
|
||||
### 右栏 — 岗位详情
|
||||
|
||||
**右栏渲染优先级(严格按顺序):**
|
||||
1. `detailLoading === true` → 显示 `el-skeleton`
|
||||
2. `detailError === true` → 显示「加载失败,请刷新重试」
|
||||
3. `selectedJob !== null` → 显示岗位详情
|
||||
4. 以上均不满足(`selectedJob === null` 且无加载/错误)→ 显示空状态
|
||||
|
||||
**各状态内容:**
|
||||
- **空状态**:居中空状态图标 + 「← 请选择左侧职位查看详情」
|
||||
- **岗位详情**:
|
||||
- 顶部:公司首字 Logo + 岗位名称(`job.title`)+ 公司/城市副标题 + 「立即申请」按钮
|
||||
- 标签行:`job.location`、`job.salary`、`job.category`(无经验字段,不显示)
|
||||
- 正文:`job.description`(用 `white-space: pre-wrap` 保留换行)
|
||||
- **加载状态**:`el-skeleton`(5 行,含头部和正文)
|
||||
- **错误状态**:「加载失败,请刷新重试」居中提示
|
||||
- **「立即申请」按钮行为**:
|
||||
- 已登录(seeker):`router.push({ name: 'JobDetail', params: { id: job.id } })`(跳转现有详情页完成申请)
|
||||
- 未登录:`router.push({ name: 'Login', query: { redirect: '/jobs/' + job.id } })`
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
|
||||
`getOrganizations()` 返回的 Organization 对象字段:`id, name, logo (string|null), description, email, is_active`
|
||||
|
||||
`getJobs()` 使用 `JobListSerializer`,返回字段:`id, title, category, location, salary, organization (id only), organization_name, status, created_at`。**不含 `description`。**
|
||||
|
||||
`getJob(id)` 使用 `JobDetailSerializer`,返回字段:`id, title, category, location, salary, description, organization: {id, name, logo, ...}, status, created_at`。**含完整 `description`,因此点击岗位时必须调用此接口获取详情。**
|
||||
|
||||
`/jobs/public/` 后端已在 queryset 层过滤 `status='published'`,无需前端额外传 status 参数。
|
||||
|
||||
**Logo URL 处理**:`org.logo` 为相对路径(如 `/media/org_logos/foo.png`),需拼接后端地址。通过 Vite proxy,开发环境直接使用原始路径即可(proxy 会转发到 `http://127.0.0.1:8000`)。渲染时用 `org.logo ? org.logo : null` 判断是否显示图片。
|
||||
|
||||
---
|
||||
|
||||
## 状态管理
|
||||
|
||||
组件内使用以下 ref:
|
||||
|
||||
| 变量 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `orgs` | `Ref<Organization[]>` | 所有公司列表 |
|
||||
| `orgsLoading` | `Ref<boolean>` | 左栏加载状态 |
|
||||
| `orgsError` | `Ref<boolean>` | 左栏错误状态 |
|
||||
| `selectedOrg` | `Ref<Organization \| null>` | 当前选中公司 |
|
||||
| `jobs` | `Ref<Job[]>` | 当前公司的岗位列表 |
|
||||
| `selectedJob` | `Ref<Job \| null>` | 当前选中岗位(getJob 返回的完整 Job 对象) |
|
||||
| `jobsLoading` | `Ref<boolean>` | 中栏加载状态 |
|
||||
| `detailLoading` | `Ref<boolean>` | 右栏加载状态 |
|
||||
| `jobsError` | `Ref<boolean>` | 中栏错误状态 |
|
||||
| `detailError` | `Ref<boolean>` | 右栏错误状态 |
|
||||
|
||||
---
|
||||
|
||||
## 交互流程
|
||||
|
||||
1. `onMounted` → 设置 `orgsLoading = true`,调用 `getOrganizations()`
|
||||
- 成功且列表非空:`orgsLoading = false`,渲染左栏,自动调用 `selectOrg(orgs.value[0])`
|
||||
- 成功但列表为空:`orgsLoading = false`,左栏显示「暂无公司数据」
|
||||
- 失败:`orgsLoading = false`,`orgsError = true`,左栏显示「加载失败,请刷新重试」
|
||||
|
||||
2. `selectOrg(org)` →
|
||||
- 重置 `jobsError = false`,清空 `selectedJob`,`jobs = []`
|
||||
- 设置 `selectedOrg = org`,`jobsLoading = true`
|
||||
- 调用 `getJobs({ organization: org.id })`,取 `data.results` 更新 `jobs`
|
||||
- 完成后 `jobsLoading = false`;失败则 `jobsError = true`
|
||||
|
||||
3. `selectJob(job)` →
|
||||
- 重置 `detailError = false`
|
||||
- 设置 `detailLoading = true`,`selectedJob = null`
|
||||
- 调用 `getJob(job.id)` 更新 `selectedJob`
|
||||
- 完成后 `detailLoading = false`;失败则 `detailError = true`
|
||||
|
||||
4. 「立即申请」按钮点击 →
|
||||
- 若 `authStore.isSeeker`:`router.push({ name: 'JobDetail', params: { id: selectedJob.id } })`
|
||||
- 否则(未登录或非 seeker 角色):`router.push({ name: 'Login', query: { redirect: '/jobs/' + selectedJob.id } })`
|
||||
|
||||
---
|
||||
|
||||
## 文件变更范围
|
||||
|
||||
| 文件 | 变更类型 |
|
||||
|------|---------|
|
||||
| `offer_frontend/src/views/portal/CompanyListView.vue` | 完全重写 |
|
||||
|
||||
无需改动后端、无需新增路由、无需新增组件。
|
||||
|
||||
---
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 搜索/筛选公司或岗位
|
||||
- 分页(公司数量有限,一次性加载)
|
||||
- CompanyDetailView(`/companies/:id`)不变
|
||||
- 在招岗位数统计(Organization API 无此字段)
|
||||
|
|
@ -6,6 +6,12 @@ class ApplicationCreateSerializer(serializers.ModelSerializer):
|
|||
model = Application
|
||||
fields = ['job']
|
||||
|
||||
def validate(self, data):
|
||||
request = self.context['request']
|
||||
if Application.objects.filter(job=data['job'], applicant=request.user).exists():
|
||||
raise serializers.ValidationError({'detail': '您已投递过该职位'})
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
request = self.context['request']
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 4.2.20 on 2026-03-25 02:21
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('jobs', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JobFavorite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='jobs.job')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_favorites', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'unique_together': {('user', 'job')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -27,3 +27,15 @@ class Job(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class JobFavorite(models.Model):
|
||||
user = models.ForeignKey(
|
||||
'accounts.User', on_delete=models.CASCADE, related_name='job_favorites'
|
||||
)
|
||||
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='favorited_by')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'job')
|
||||
ordering = ['-created_at']
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Job
|
||||
from .models import Job, JobFavorite
|
||||
from apps.organizations.serializers import OrganizationSerializer
|
||||
from apps.organizations.models import Organization
|
||||
|
||||
|
|
@ -25,3 +25,11 @@ class JobDetailSerializer(serializers.ModelSerializer):
|
|||
model = Job
|
||||
fields = ['id', 'title', 'category', 'location', 'salary',
|
||||
'description', 'organization', 'organization_id', 'status', 'created_at']
|
||||
|
||||
|
||||
class JobFavoriteSerializer(serializers.ModelSerializer):
|
||||
job = JobListSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = JobFavorite
|
||||
fields = ['id', 'job', 'created_at']
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import JobPublicViewSet, JobManageViewSet
|
||||
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('public', JobPublicViewSet, basename='job-public')
|
||||
router.register('manage', JobManageViewSet, basename='job-manage')
|
||||
|
||||
urlpatterns = [path('', include(router.urls))]
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('favorites/', MyFavoritesView.as_view()),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from rest_framework import viewsets, permissions
|
||||
from rest_framework import viewsets, permissions, generics
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.filters import SearchFilter
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import Job
|
||||
from .serializers import JobListSerializer, JobDetailSerializer
|
||||
from .models import Job, JobFavorite
|
||||
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
||||
from .filters import JobFilter
|
||||
from apps.accounts.permissions import IsAdminOrSuperAdmin
|
||||
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
|
||||
|
||||
|
||||
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
|
@ -20,6 +22,26 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return JobDetailSerializer
|
||||
return JobListSerializer
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsSeeker])
|
||||
def favorite(self, request, pk=None):
|
||||
job = self.get_object()
|
||||
fav, created = JobFavorite.objects.get_or_create(user=request.user, job=job)
|
||||
if not created:
|
||||
fav.delete()
|
||||
return Response({'collected': False})
|
||||
return Response({'collected': True})
|
||||
|
||||
|
||||
class MyFavoritesView(generics.ListAPIView):
|
||||
"""求职者的收藏列表"""
|
||||
serializer_class = JobFavoriteSerializer
|
||||
permission_classes = [IsSeeker]
|
||||
|
||||
def get_queryset(self):
|
||||
return JobFavorite.objects.filter(user=self.request.user).select_related(
|
||||
'job', 'job__organization'
|
||||
)
|
||||
|
||||
|
||||
class JobManageViewSet(viewsets.ModelViewSet):
|
||||
"""管理端:公司管理员管理本公司职位"""
|
||||
|
|
|
|||
|
|
@ -3,18 +3,27 @@ from .models import Organization
|
|||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
job_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active']
|
||||
fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active', 'job_count']
|
||||
|
||||
def get_job_count(self, obj):
|
||||
return obj.jobs.filter(status='published').count()
|
||||
|
||||
|
||||
class OrganizationTreeSerializer(serializers.ModelSerializer):
|
||||
"""带子公司列表,用于门户展示"""
|
||||
children = serializers.SerializerMethodField()
|
||||
job_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ['id', 'name', 'logo', 'description', 'email', 'children']
|
||||
fields = ['id', 'name', 'logo', 'description', 'email', 'job_count', 'children']
|
||||
|
||||
def get_job_count(self, obj):
|
||||
return obj.jobs.filter(status='published').count()
|
||||
|
||||
def get_children(self, obj):
|
||||
return OrganizationSerializer(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (localStorage.getItem('access_token') && !auth.user) {
|
||||
try { await auth.fetchMe() } catch { /* token expired, ignore */ }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@ export const manageJobs = (params) => client.get('/jobs/manage/', { params })
|
|||
export const createJob = (data) => client.post('/jobs/manage/', data)
|
||||
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
|
||||
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)
|
||||
export const toggleFavorite = (id) => client.post(`/jobs/public/${id}/favorite/`)
|
||||
export const getMyFavorites = () => client.get('/jobs/favorites/')
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@ import client from './client'
|
|||
|
||||
export const getMyResume = () => client.get('/resumes/me/')
|
||||
export const updateMyResume = (data) => client.patch('/resumes/me/', data)
|
||||
export const uploadResumeAttachment = (formData) => client.patch('/resumes/me/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,28 +1,91 @@
|
|||
<template>
|
||||
<el-container class="portal-layout">
|
||||
<el-header>
|
||||
<div class="header-inner">
|
||||
<router-link to="/" class="logo">集团招聘</router-link>
|
||||
<nav>
|
||||
<router-link to="/jobs">职位列表</router-link>
|
||||
<router-link to="/companies">公司介绍</router-link>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<div class="portal-wrap">
|
||||
<!-- 顶部公告条 -->
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-inner">
|
||||
<span class="top-bar-left">欢迎访问集团人才招募平台</span>
|
||||
<div class="top-bar-right">
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<router-link v-if="auth.isSeeker" to="/seeker/applications">我的投递</router-link>
|
||||
<router-link v-else to="/admin/jobs">管理后台</router-link>
|
||||
<el-button text @click="logout">退出</el-button>
|
||||
<router-link v-if="auth.isSeeker" to="/seeker/applications" class="top-link">我的投递</router-link>
|
||||
<router-link v-else to="/admin/jobs" class="top-link">管理后台</router-link>
|
||||
<span class="top-divider">|</span>
|
||||
<span class="top-link clickable" @click="logout">退出登录</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login"><el-button>登录</el-button></router-link>
|
||||
<router-link to="/register"><el-button type="primary">注册</el-button></router-link>
|
||||
<router-link to="/login" class="top-link">登录</router-link>
|
||||
<span class="top-divider">|</span>
|
||||
<router-link to="/register" class="top-link">注册</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main><router-view /></el-main>
|
||||
<el-footer>© 集团招聘平台</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
|
||||
<!-- 主导航 -->
|
||||
<header class="main-header">
|
||||
<div class="header-inner">
|
||||
<!-- Logo 区域 -->
|
||||
<router-link to="/home" class="logo-area">
|
||||
<div class="logo-emblem">
|
||||
<div class="emblem-ring">
|
||||
<span class="emblem-char">招</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logo-text-block">
|
||||
<span class="logo-title">集团招聘平台</span>
|
||||
<span class="logo-sub">GROUP TALENT RECRUITMENT</span>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<!-- 导航 -->
|
||||
<nav class="main-nav">
|
||||
<router-link to="/home" class="nav-link" active-class="active">首页</router-link>
|
||||
<router-link to="/jobs" class="nav-link" active-class="active">职位列表</router-link>
|
||||
<router-link to="/companies" class="nav-link" active-class="active">公司介绍</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="header-actions">
|
||||
<template v-if="!auth.isLoggedIn">
|
||||
<router-link to="/login">
|
||||
<button class="btn-ghost">登录</button>
|
||||
</router-link>
|
||||
<router-link to="/register">
|
||||
<button class="btn-primary">立即注册</button>
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="user-badge">
|
||||
<span class="user-avatar">{{ auth.user?.username?.[0]?.toUpperCase() }}</span>
|
||||
<span class="user-name">{{ auth.user?.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 红色底线 -->
|
||||
<div class="header-underline">
|
||||
<div class="underline-fill"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="portal-main">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="portal-footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-logo">
|
||||
<div class="f-emblem">招</div>
|
||||
<span>集团招聘平台</span>
|
||||
</div>
|
||||
<div class="footer-copy">
|
||||
Copyright © 集团招聘平台 · All Rights Reserved · 为国家建设输送优秀人才
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -34,11 +97,224 @@ const logout = () => { auth.logout(); router.push('/') }
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portal-layout { min-height: 100vh; }
|
||||
.el-header { display: flex; align-items: center; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.header-inner { display: flex; align-items: center; width: 100%; max-width: 1200px; margin: 0 auto; }
|
||||
.logo { font-size: 20px; font-weight: bold; color: #409eff; text-decoration: none; margin-right: 32px; }
|
||||
nav { display: flex; gap: 24px; flex: 1; }
|
||||
nav a { text-decoration: none; color: #333; }
|
||||
.header-actions { display: flex; gap: 12px; align-items: center; }
|
||||
:root {
|
||||
--c-red: #B5272C;
|
||||
--c-dark: #0E1E3D;
|
||||
--c-gold: #C8973A;
|
||||
--c-cream: #F8F4EF;
|
||||
--c-border: #E2D9D0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
a { text-decoration: none; }
|
||||
|
||||
.portal-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F0EDE8;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
|
||||
/* 顶部公告条 */
|
||||
.top-bar {
|
||||
background: #0E1E3D;
|
||||
color: rgba(255,255,255,0.65);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.top-bar-inner {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 6px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.top-bar-left::before {
|
||||
content: '★';
|
||||
color: #C8973A;
|
||||
margin-right: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.top-bar-right { display: flex; align-items: center; gap: 6px; }
|
||||
.top-link { color: rgba(255,255,255,0.65); transition: color 0.2s; }
|
||||
.top-link:hover, .clickable:hover { color: #C8973A; cursor: pointer; }
|
||||
.top-divider { color: rgba(255,255,255,0.25); }
|
||||
|
||||
/* 主导航 */
|
||||
.main-header {
|
||||
background: linear-gradient(180deg, #12264F 0%, #0E1E3D 100%);
|
||||
box-shadow: 0 4px 20px rgba(14,30,61,0.4);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-emblem { position: relative; }
|
||||
.emblem-ring {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #C8973A;
|
||||
background: linear-gradient(135deg, rgba(200,151,58,0.15), rgba(200,151,58,0.05));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 16px rgba(200,151,58,0.3), inset 0 0 8px rgba(200,151,58,0.1);
|
||||
}
|
||||
.emblem-char {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: #C8973A;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.logo-text-block { display: flex; flex-direction: column; }
|
||||
.logo-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
letter-spacing: 0.15em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.logo-sub {
|
||||
font-size: 9px;
|
||||
color: rgba(200,151,58,0.7);
|
||||
letter-spacing: 0.2em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 导航链接 */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-link {
|
||||
position: relative;
|
||||
padding: 8px 20px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.08em;
|
||||
transition: color 0.2s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px; left: 50%; right: 50%;
|
||||
height: 2px;
|
||||
background: #C8973A;
|
||||
border-radius: 1px;
|
||||
transition: left 0.25s ease, right 0.25s ease;
|
||||
}
|
||||
.nav-link:hover { color: #fff; }
|
||||
.nav-link:hover::after, .nav-link.active::after { left: 20%; right: 20%; }
|
||||
.nav-link.active { color: #C8973A; font-weight: 600; }
|
||||
|
||||
/* 操作区 */
|
||||
.header-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.35);
|
||||
color: rgba(255,255,255,0.85);
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-ghost:hover { border-color: #C8973A; color: #C8973A; }
|
||||
.btn-primary {
|
||||
padding: 7px 20px;
|
||||
background: #B5272C;
|
||||
border: 1px solid #B5272C;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.2s;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary:hover { background: #9A2024; border-color: #9A2024; }
|
||||
.user-badge {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 24px;
|
||||
}
|
||||
.user-avatar {
|
||||
width: 26px; height: 26px;
|
||||
background: #B5272C;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.user-name { color: rgba(255,255,255,0.85); font-size: 13px; }
|
||||
|
||||
/* 红色底线装饰 */
|
||||
.header-underline { height: 3px; background: #0E1E3D; }
|
||||
.underline-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #B5272C 0%, #E63329 40%, #C8973A 100%);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.portal-main {
|
||||
flex: 1;
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.portal-footer {
|
||||
background: #0E1E3D;
|
||||
border-top: 3px solid #B5272C;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.footer-logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 14px; letter-spacing: 0.1em;
|
||||
}
|
||||
.f-emblem {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 1px solid #C8973A;
|
||||
color: #C8973A; font-weight: 900; font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.footer-copy {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
<el-menu router :default-active="$route.path">
|
||||
<el-menu-item index="/seeker/resume">我的简历</el-menu-item>
|
||||
<el-menu-item index="/seeker/applications">我的投递</el-menu-item>
|
||||
<el-menu-item index="/seeker/favorites">关注职位</el-menu-item>
|
||||
<el-menu-item index="/seeker/profile">账号设置</el-menu-item>
|
||||
<el-menu-item index="/home">返回主页</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main><router-view /></el-main>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
// 独立入口页
|
||||
{ path: '/', name: 'Splash', component: () => import('@/views/SplashView.vue') },
|
||||
// 公开门户
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/PortalLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'Home', component: () => import('@/views/portal/HomeView.vue') },
|
||||
{ path: 'home', name: 'Home', component: () => import('@/views/portal/HomeView.vue') },
|
||||
{ path: 'jobs', name: 'JobList', component: () => import('@/views/portal/JobListView.vue') },
|
||||
{ path: 'jobs/:id', name: 'JobDetail', component: () => import('@/views/portal/JobDetailView.vue') },
|
||||
{ path: 'companies', name: 'CompanyList', component: () => import('@/views/portal/CompanyListView.vue') },
|
||||
|
|
@ -24,6 +26,7 @@ const routes = [
|
|||
children: [
|
||||
{ path: 'resume', name: 'SeekerResume', component: () => import('@/views/seeker/ResumeView.vue') },
|
||||
{ path: 'applications', name: 'SeekerApplications', component: () => import('@/views/seeker/ApplicationsView.vue') },
|
||||
{ path: 'favorites', name: 'SeekerFavorites', component: () => import('@/views/seeker/FavoritesView.vue') },
|
||||
{ path: 'profile', name: 'SeekerProfile', component: () => import('@/views/seeker/ProfileView.vue') },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,71 @@
|
|||
<template>
|
||||
<div>投递管理 - 开发中</div>
|
||||
<div>
|
||||
<h2>投递管理</h2>
|
||||
<el-table :data="applications" v-loading="loading" border>
|
||||
<el-table-column prop="job_title" label="职位" />
|
||||
<el-table-column label="求职者">
|
||||
<template #default="{ row }">{{ row.resume_snapshot?.name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="applied_at" label="投递时间" :formatter="(r,c,v) => v?.slice(0,10)" />
|
||||
<el-table-column label="状态" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.status" size="small" @change="updateStatus(row)">
|
||||
<el-option value="pending" label="待查看" />
|
||||
<el-option value="viewed" label="已查看" />
|
||||
<el-option value="interviewing" label="面试中" />
|
||||
<el-option value="hired" label="已录用" />
|
||||
<el-option value="rejected" label="已拒绝" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewResume(row)">查看简历</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="resumeVisible" title="简历详情" width="600px">
|
||||
<div v-if="currentResume">
|
||||
<p><strong>姓名:</strong>{{ currentResume.name }}</p>
|
||||
<p><strong>性别:</strong>{{ currentResume.gender }}</p>
|
||||
<el-divider>教育经历</el-divider>
|
||||
<div v-for="(e, i) in currentResume.education" :key="i">{{ e.school }} · {{ e.degree }} · {{ e.major }}</div>
|
||||
<el-divider>工作经历</el-divider>
|
||||
<div v-for="(e, i) in currentResume.experience" :key="i">{{ e.company }} · {{ e.position }} · {{ e.duration }}</div>
|
||||
<div v-if="currentResume.attachment_url" style="margin-top:16px">
|
||||
<a :href="currentResume.attachment_url" target="_blank">下载简历附件</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getManageApplications, updateApplicationStatus } from '@/api/applications'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(false)
|
||||
const resumeVisible = ref(false)
|
||||
const currentResume = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const { data } = await getManageApplications()
|
||||
applications.value = data.results
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function updateStatus(row) {
|
||||
try {
|
||||
await updateApplicationStatus(row.id, { status: row.status })
|
||||
ElMessage.success('状态已更新,求职者将收到邮件通知')
|
||||
} catch { ElMessage.error('更新失败') }
|
||||
}
|
||||
|
||||
function viewResume(row) {
|
||||
currentResume.value = row.resume_snapshot
|
||||
resumeVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,146 @@
|
|||
<template>
|
||||
<div>职位管理 - 开发中</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>职位管理</h2>
|
||||
<el-button type="primary" @click="openDialog()">发布职位</el-button>
|
||||
</div>
|
||||
<el-table :data="jobs" v-loading="loading" border>
|
||||
<el-table-column prop="title" label="职位名称" />
|
||||
<el-table-column prop="organization_name" label="所属公司" />
|
||||
<el-table-column prop="location" label="地点" />
|
||||
<el-table-column prop="salary" label="薪资" />
|
||||
<el-table-column prop="status" label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'published' ? 'success' : row.status === 'draft' ? 'info' : 'danger'">
|
||||
{{ { draft:'草稿', published:'已发布', closed:'已关闭' }[row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingJob ? '编辑职位' : '发布职位'" width="600px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<!-- 超管才需要选择公司 -->
|
||||
<el-form-item v-if="auth.isSuperAdmin" label="所属公司">
|
||||
<el-select v-model="form.organization_id" placeholder="请选择公司" style="width:100%" filterable>
|
||||
<template v-for="org in allOrgs" :key="org.id">
|
||||
<el-option :label="org.name" :value="org.id" />
|
||||
<el-option
|
||||
v-for="child in org.children"
|
||||
:key="child.id"
|
||||
:label="'└ ' + child.name"
|
||||
:value="child.id"
|
||||
/>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="职位名称"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="职位类别"><el-input v-model="form.category" /></el-form-item>
|
||||
<el-form-item label="工作地点"><el-input v-model="form.location" /></el-form-item>
|
||||
<el-form-item label="薪资范围"><el-input v-model="form.salary" /></el-form-item>
|
||||
<el-form-item label="职位描述"><el-input v-model="form.description" type="textarea" :rows="5" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option value="draft" label="草稿" />
|
||||
<el-option value="published" label="立即发布" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { manageJobs, createJob, updateJob, deleteJob } from '@/api/jobs'
|
||||
import { getOrganizations } from '@/api/organizations'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const jobs = ref([])
|
||||
const allOrgs = ref([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingJob = ref(null)
|
||||
const form = reactive({
|
||||
title: '', category: '', location: '', salary: '',
|
||||
description: '', status: 'draft', organization_id: null
|
||||
})
|
||||
|
||||
const fetchJobs = async () => {
|
||||
loading.value = true
|
||||
const { data } = await manageJobs()
|
||||
jobs.value = data.results
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await getOrganizations()
|
||||
allOrgs.value = data.results
|
||||
}
|
||||
|
||||
function openDialog(job = null) {
|
||||
editingJob.value = job
|
||||
if (job) {
|
||||
Object.assign(form, {
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
location: job.location,
|
||||
salary: job.salary,
|
||||
description: job.description,
|
||||
status: job.status,
|
||||
organization_id: job.organization?.id ?? job.organization ?? null,
|
||||
})
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
title: '', category: '', location: '', salary: '',
|
||||
description: '', status: 'draft', organization_id: null
|
||||
})
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (auth.isSuperAdmin && !form.organization_id) {
|
||||
return ElMessage.warning('请选择所属公司')
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (!auth.isSuperAdmin) delete payload.organization_id
|
||||
if (editingJob.value) await updateJob(editingJob.value.id, payload)
|
||||
else await createJob(payload)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchJobs()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await ElMessageBox.confirm('确认删除该职位?', '提示', { type: 'warning' })
|
||||
await deleteJob(id)
|
||||
ElMessage.success('已删除')
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchJobs()
|
||||
if (auth.isSuperAdmin) fetchOrgs()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,74 @@
|
|||
<template>
|
||||
<div>组织架构管理 - 开发中</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
|
||||
<h2>组织架构管理</h2>
|
||||
<el-button type="primary" @click="openDialog()">新增公司</el-button>
|
||||
</div>
|
||||
<el-table :data="orgs" border>
|
||||
<el-table-column prop="name" label="公司名称" />
|
||||
<el-table-column label="上级公司">
|
||||
<template #default="{ row }">{{ row.parent ? orgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="联系邮箱" />
|
||||
<el-table-column label="状态">
|
||||
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'启用':'停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑公司' : '新增公司'" width="480px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="公司名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="上级公司">
|
||||
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
|
||||
<el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="简介"><el-input v-model="form.description" type="textarea" /></el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.is_active" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { manageOrganizations, createOrganization, updateOrganization } from '@/api/organizations'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const orgs = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ name: '', parent: null, email: '', description: '', is_active: true })
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await manageOrganizations()
|
||||
orgs.value = data.results
|
||||
}
|
||||
|
||||
function openDialog(org = null) {
|
||||
editing.value = org
|
||||
if (org) Object.assign(form, org)
|
||||
else Object.assign(form, { name: '', parent: null, email: '', description: '', is_active: true })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) await updateOrganization(editing.value.id, form)
|
||||
else await createOrganization(form)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchOrgs()
|
||||
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(fetchOrgs)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,88 @@
|
|||
<template>
|
||||
<div>用户管理 - 开发中</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="openDialog()">新增管理员</el-button>
|
||||
</div>
|
||||
<el-table :data="users" border>
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="角色">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role==='superadmin'?'danger':row.role==='admin'?'warning':'info'">
|
||||
{{ { superadmin:'超管', admin:'公司管理员', seeker:'求职者' }[row.role] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属公司">
|
||||
<template #default="{ row }">{{ row.organization ? orgs.find(o=>o.id===row.organization)?.name : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态">
|
||||
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'正常':'停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新增管理员'" width="480px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role">
|
||||
<el-option value="admin" label="公司管理员" />
|
||||
<el-option value="superadmin" label="超级管理员" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属公司" v-if="form.role==='admin'">
|
||||
<el-select v-model="form.organization" clearable>
|
||||
<el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" v-if="!editing"><el-input v-model="form.password" type="password" /></el-form-item>
|
||||
<el-form-item label="状态"><el-switch v-model="form.is_active" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { manageOrganizations } from '@/api/organizations'
|
||||
import client from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const users = ref([])
|
||||
const orgs = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
|
||||
|
||||
const fetchUsers = async () => { const { data } = await client.get('/auth/users/'); users.value = data.results || data }
|
||||
const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }
|
||||
|
||||
function openDialog(user = null) {
|
||||
editing.value = user
|
||||
if (user) Object.assign(form, user)
|
||||
else Object.assign(form, { username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) await client.patch(`/auth/users/${editing.value.id}/`, form)
|
||||
else await client.post('/auth/users/', form)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchUsers()
|
||||
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => { fetchUsers(); fetchOrgs() })
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,700 @@
|
|||
<template>
|
||||
<div class="home">
|
||||
<div class="hero">
|
||||
<h1>发现你的理想职位</h1>
|
||||
<el-input v-model="keyword" placeholder="搜索职位..." size="large" @keyup.enter="search">
|
||||
<template #append><el-button @click="search">搜索</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="latest-jobs">
|
||||
<h3>最新职位</h3>
|
||||
<JobCard v-for="job in latestJobs" :key="job.id" :job="job" />
|
||||
</div>
|
||||
<div class="three-panel">
|
||||
|
||||
<!-- ── 左栏:公司列表 ── -->
|
||||
<aside class="panel-left">
|
||||
<div class="left-header">
|
||||
<span class="left-title">全部企业</span>
|
||||
<span class="left-count">{{ totalOrgs }}</span>
|
||||
</div>
|
||||
<div class="left-body">
|
||||
<template v-if="orgsLoading">
|
||||
<div v-for="i in 4" :key="i" class="skeleton-row" />
|
||||
</template>
|
||||
<template v-else-if="orgsError">
|
||||
<div class="state-tip">加载失败,请刷新重试</div>
|
||||
</template>
|
||||
<template v-else-if="orgs.length === 0">
|
||||
<div class="state-tip">暂无公司数据</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="org in orgs" :key="org.id">
|
||||
<!-- 集团 -->
|
||||
<div
|
||||
class="org-row"
|
||||
:class="{ active: selectedOrg?.id === org.id }"
|
||||
@click="selectOrg(org)"
|
||||
>
|
||||
<div class="org-avatar parent-avatar">
|
||||
<img v-if="org.logo" :src="org.logo" :alt="org.name" />
|
||||
<span v-else>{{ org.name[0] }}</span>
|
||||
</div>
|
||||
<div class="org-meta">
|
||||
<span class="org-name">{{ org.name }}</span>
|
||||
<span class="org-stat">在招 <em>{{ org.job_count }}</em> 个岗位</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 子公司 -->
|
||||
<div
|
||||
v-for="child in org.children"
|
||||
:key="child.id"
|
||||
class="org-row org-child"
|
||||
:class="{ active: selectedOrg?.id === child.id }"
|
||||
@click="selectOrg(child)"
|
||||
>
|
||||
<div class="child-indent">
|
||||
<span class="child-line"></span>
|
||||
</div>
|
||||
<div class="org-avatar child-avatar">
|
||||
<img v-if="child.logo" :src="child.logo" :alt="child.name" />
|
||||
<span v-else>{{ child.name[0] }}</span>
|
||||
</div>
|
||||
<div class="org-meta">
|
||||
<span class="org-name child-name">{{ child.name }}</span>
|
||||
<span class="org-stat">在招 <em>{{ child.job_count }}</em> 个岗位</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── 中栏:岗位列表 ── -->
|
||||
<section class="panel-mid">
|
||||
<div class="mid-header">
|
||||
<span v-if="selectedOrg" class="mid-title">
|
||||
<span class="mid-title-org">{{ selectedOrg.name }}</span>
|
||||
<span class="mid-title-sep"> · </span>职位列表
|
||||
</span>
|
||||
<span v-else class="mid-title muted">← 请选择企业</span>
|
||||
<span v-if="jobs.length" class="mid-count">{{ jobs.length }} 个</span>
|
||||
</div>
|
||||
<div class="mid-body">
|
||||
<template v-if="jobsLoading">
|
||||
<div v-for="i in 4" :key="i" class="skeleton-row" />
|
||||
</template>
|
||||
<template v-else-if="jobsError">
|
||||
<div class="state-tip">加载失败,请刷新重试</div>
|
||||
</template>
|
||||
<template v-else-if="jobs.length === 0 && selectedOrg">
|
||||
<div class="state-tip">暂无在招职位</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="job in jobs"
|
||||
:key="job.id"
|
||||
class="job-row"
|
||||
:class="{ active: selectedJob?.id === job.id }"
|
||||
@click="selectJob(job)"
|
||||
>
|
||||
<div class="job-row-title">{{ job.title }}</div>
|
||||
<div class="job-row-tags">
|
||||
<span class="tag tag-loc">{{ job.location }}</span>
|
||||
<span class="tag tag-sal">{{ job.salary }}</span>
|
||||
<span class="tag tag-cat">{{ job.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 右栏:岗位详情 ── -->
|
||||
<section class="panel-right">
|
||||
<!-- 加载 -->
|
||||
<template v-if="detailLoading">
|
||||
<div class="detail-loading">
|
||||
<div v-for="i in 6" :key="i" class="skeleton-row" style="margin-bottom:12px" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 错误 -->
|
||||
<template v-else-if="detailError">
|
||||
<div class="state-tip full-tip">加载失败,请刷新重试</div>
|
||||
</template>
|
||||
<!-- 详情 -->
|
||||
<template v-else-if="selectedJob">
|
||||
<!-- Banner -->
|
||||
<div class="detail-banner">
|
||||
<div class="banner-deco"></div>
|
||||
<div class="banner-content">
|
||||
<div class="banner-left">
|
||||
<div class="banner-category">{{ selectedJob.category }}</div>
|
||||
<h2 class="banner-title">{{ selectedJob.title }}</h2>
|
||||
<div class="banner-salary">{{ selectedJob.salary }}</div>
|
||||
<div class="banner-meta">
|
||||
<span class="bmeta-item">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ selectedJob.location }}
|
||||
</span>
|
||||
<span class="bmeta-sep">|</span>
|
||||
<span class="bmeta-item">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
{{ selectedJob.organization?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner-right">
|
||||
<div class="banner-date">发布于 {{ formatDate(selectedJob.created_at) }}</div>
|
||||
<div class="banner-btns">
|
||||
<button class="btn-collect" @click="handleCollect">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
{{ collected ? '已收藏' : '收藏' }}
|
||||
</button>
|
||||
<button class="btn-apply" :class="{ loading: applying }" @click="handleApply">
|
||||
{{ applying ? '提交中…' : '立即投递' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="banner-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||
<p class="banner-hint success" v-if="applied">✓ 投递成功,可在「我的投递」查看</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="detail-main">
|
||||
<div class="detail-left">
|
||||
<!-- 信息格 -->
|
||||
<div class="info-grid">
|
||||
<div class="info-cell">
|
||||
<span class="info-label">职位类别</span>
|
||||
<span class="info-value">{{ selectedJob.category || '未填写' }}</span>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<span class="info-label">工作地点</span>
|
||||
<span class="info-value">{{ selectedJob.location }}</span>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<span class="info-label">薪资范围</span>
|
||||
<span class="info-value red-val">{{ selectedJob.salary }}</span>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<span class="info-label">发布时间</span>
|
||||
<span class="info-value">{{ formatDate(selectedJob.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- 职位介绍 -->
|
||||
<div class="content-section">
|
||||
<div class="section-heading"><span class="heading-bar"></span>职位介绍</div>
|
||||
<div class="desc-text" v-html="selectedJob.description"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- 工作地点 -->
|
||||
<div class="content-section">
|
||||
<div class="section-heading"><span class="heading-bar"></span>工作地点</div>
|
||||
<div class="location-row">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#B5272C" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ selectedJob.location }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 公司卡片 -->
|
||||
<div class="detail-right">
|
||||
<div class="company-card">
|
||||
<div class="company-card-label">招聘单位</div>
|
||||
<div class="company-card-top">
|
||||
<div class="cc-logo">
|
||||
<img v-if="selectedJob.organization?.logo" :src="selectedJob.organization.logo" alt="" />
|
||||
<span v-else>{{ selectedJob.organization?.name?.[0] }}</span>
|
||||
</div>
|
||||
<div class="cc-info">
|
||||
<div class="cc-name">{{ selectedJob.organization?.name }}</div>
|
||||
<div class="cc-desc" v-if="selectedJob.organization?.description">
|
||||
{{ selectedJob.organization.description.slice(0, 50) }}{{ selectedJob.organization.description.length > 50 ? '…' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-divider"></div>
|
||||
<div class="cc-contact" v-if="selectedJob.organization?.email">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#B5272C" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
{{ selectedJob.organization.email }}
|
||||
</div>
|
||||
<button class="btn-apply-card" :class="{ loading: applying }" @click="handleApply">
|
||||
{{ applying ? '提交中…' : '立即投递' }}
|
||||
</button>
|
||||
<p class="apply-success" v-if="applied">✓ 投递成功</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 空状态 -->
|
||||
<template v-else>
|
||||
<div class="state-tip full-tip">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#C8973A" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<p>请从左侧选择企业及职位</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import JobCard from '@/components/JobCard.vue'
|
||||
import { getJobs } from '@/api/jobs'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOrganizations } from '@/api/organizations'
|
||||
import { getJobs, getJob, toggleFavorite } from '@/api/jobs'
|
||||
import { applyJob } from '@/api/applications'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const latestJobs = ref([])
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await getJobs({ page: 1 })
|
||||
latestJobs.value = data.results.slice(0, 6)
|
||||
const orgs = ref([])
|
||||
const orgsLoading = ref(false)
|
||||
const orgsError = ref(false)
|
||||
const selectedOrg = ref(null)
|
||||
|
||||
const jobs = ref([])
|
||||
const jobsLoading = ref(false)
|
||||
const jobsError = ref(false)
|
||||
|
||||
const selectedJob = ref(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref(false)
|
||||
const applying = ref(false)
|
||||
const applied = ref(false)
|
||||
const collected = ref(false)
|
||||
|
||||
const totalOrgs = computed(() => {
|
||||
return orgs.value.reduce((n, o) => n + 1 + (o.children?.length || 0), 0)
|
||||
})
|
||||
|
||||
const search = () => router.push({ name: 'JobList', query: { search: keyword.value } })
|
||||
function formatDate(dt) {
|
||||
if (!dt) return ''
|
||||
return new Date(dt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
async function selectOrg(org) {
|
||||
selectedOrg.value = org
|
||||
selectedJob.value = null
|
||||
jobs.value = []
|
||||
jobsError.value = false
|
||||
jobsLoading.value = true
|
||||
try {
|
||||
const { data } = await getJobs({ organization: org.id })
|
||||
jobs.value = data.results
|
||||
} catch { jobsError.value = true }
|
||||
finally { jobsLoading.value = false }
|
||||
}
|
||||
|
||||
async function selectJob(job) {
|
||||
detailError.value = false
|
||||
detailLoading.value = true
|
||||
selectedJob.value = null
|
||||
applied.value = false
|
||||
collected.value = false
|
||||
try {
|
||||
const { data } = await getJob(job.id)
|
||||
selectedJob.value = data
|
||||
} catch { detailError.value = true }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCollect() {
|
||||
if (!auth.isLoggedIn) return router.push({ name: 'Login' })
|
||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法收藏职位')
|
||||
const { data } = await toggleFavorite(selectedJob.value.id)
|
||||
collected.value = data.collected
|
||||
ElMessage.success(collected.value ? '已收藏,可在「关注职位」中查看' : '已取消收藏')
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!auth.isLoggedIn) return router.push({ name: 'Login' })
|
||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
|
||||
applying.value = true
|
||||
try {
|
||||
await applyJob(selectedJob.value.id)
|
||||
applied.value = true
|
||||
ElMessage.success('投递成功!')
|
||||
} catch (e) {
|
||||
if (e.response?.status === 400) ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
|
||||
else ElMessage.error('投递失败,请先完善简历')
|
||||
} finally { applying.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
orgsLoading.value = true
|
||||
try {
|
||||
const { data } = await getOrganizations()
|
||||
orgs.value = data.results
|
||||
if (orgs.value.length > 0) selectOrg(orgs.value[0])
|
||||
} catch { orgsError.value = true }
|
||||
finally { orgsLoading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(135deg, #409eff22, #fff); }
|
||||
.hero h1 { font-size: 36px; margin-bottom: 24px; }
|
||||
.el-input { max-width: 500px; }
|
||||
.latest-jobs { max-width: 900px; margin: 40px auto; }
|
||||
/* ── 变量 ── */
|
||||
.three-panel {
|
||||
--red: #B5272C;
|
||||
--dark: #0E1E3D;
|
||||
--gold: #C8973A;
|
||||
--gold-lt: #F0D080;
|
||||
--cream: #FAF7F3;
|
||||
--border: #E5DDD5;
|
||||
--text: #1A1A2E;
|
||||
--muted: #7A8094;
|
||||
|
||||
display: flex;
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(14,30,61,0.12);
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
/* ── 左栏 ── */
|
||||
.panel-left {
|
||||
width: 228px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dark);
|
||||
border-right: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
.left-header {
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.left-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255,255,255,0.9);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.left-count {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.left-body { flex: 1; overflow-y: auto; }
|
||||
.left-body::-webkit-scrollbar { width: 4px; }
|
||||
.left-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.left-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
|
||||
/* 公司行 */
|
||||
.org-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.org-row:hover { background: rgba(255,255,255,0.06); }
|
||||
.org-row.active {
|
||||
border-left-color: var(--gold);
|
||||
background: rgba(200,151,58,0.1);
|
||||
}
|
||||
.org-avatar {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #C8973A, #9A6E28);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 15px; font-weight: 900; color: #fff;
|
||||
flex-shrink: 0; overflow: hidden;
|
||||
}
|
||||
.org-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.child-avatar {
|
||||
width: 28px; height: 28px; font-size: 12px;
|
||||
background: linear-gradient(135deg, #3D5A8A, #1E3460);
|
||||
}
|
||||
.org-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.org-name {
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: rgba(255,255,255,0.88);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.child-name { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
.org-row.active .org-name { color: var(--gold-lt); }
|
||||
.org-stat { font-size: 11px; color: rgba(255,255,255,0.38); }
|
||||
.org-stat em { font-style: normal; color: var(--gold); font-weight: 700; }
|
||||
|
||||
/* 子公司缩进 */
|
||||
.org-child { padding-left: 12px; background: rgba(0,0,0,0.12); }
|
||||
.child-indent {
|
||||
display: flex; align-items: center; flex-shrink: 0;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.child-line {
|
||||
display: block; width: 10px; height: 1px;
|
||||
background: rgba(200,151,58,0.35);
|
||||
}
|
||||
|
||||
/* ── 中栏 ── */
|
||||
.panel-mid {
|
||||
width: 268px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--cream);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.mid-header {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.mid-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.mid-title-org { color: var(--red); }
|
||||
.mid-title.muted { color: var(--muted); font-weight: 400; }
|
||||
.mid-count {
|
||||
font-size: 11px; color: var(--muted);
|
||||
background: #EDE8E1; border-radius: 10px; padding: 1px 8px;
|
||||
}
|
||||
.mid-body { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* 岗位行 */
|
||||
.job-row {
|
||||
padding: 13px 16px;
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
background: #fff;
|
||||
}
|
||||
.job-row:hover { background: #F5EFE8; }
|
||||
.job-row.active {
|
||||
border-left-color: var(--red);
|
||||
background: #FFF5F5;
|
||||
}
|
||||
.job-row-title {
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: var(--text); margin-bottom: 7px;
|
||||
}
|
||||
.job-row.active .job-row-title { color: var(--red); }
|
||||
.job-row-tags { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.tag {
|
||||
font-size: 11px; padding: 2px 7px;
|
||||
border-radius: 3px; font-weight: 500;
|
||||
}
|
||||
.tag-loc { background: #E8EEF8; color: #2C5282; }
|
||||
.tag-sal { background: #FFF3E0; color: #B7610A; }
|
||||
.tag-cat { background: #E8F5E9; color: #2E7D32; }
|
||||
|
||||
/* ── 右栏 ── */
|
||||
.panel-right {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #F8F4EF;
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #0E1E3D 0%, #1A2E56 50%, #1C1C3A 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.banner-deco {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(200,151,58,0.03) 20px, rgba(200,151,58,0.03) 21px),
|
||||
repeating-linear-gradient(-45deg, transparent, transparent 20px, rgba(200,151,58,0.03) 20px, rgba(200,151,58,0.03) 21px);
|
||||
}
|
||||
.banner-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.banner-left { flex: 1; }
|
||||
.banner-category {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
background: rgba(200,151,58,0.15);
|
||||
border: 1px solid rgba(200,151,58,0.35);
|
||||
color: var(--gold-lt);
|
||||
font-size: 11px; letter-spacing: 0.1em;
|
||||
border-radius: 2px; margin-bottom: 10px;
|
||||
}
|
||||
.banner-title {
|
||||
font-size: 22px; font-weight: 800;
|
||||
color: #fff; letter-spacing: 0.05em;
|
||||
margin: 0 0 8px;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.banner-salary {
|
||||
font-size: 18px; font-weight: 700;
|
||||
color: #FF6B6B; margin-bottom: 12px;
|
||||
}
|
||||
.banner-meta { display: flex; align-items: center; gap: 8px; }
|
||||
.bmeta-item {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 13px; color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.bmeta-sep { color: rgba(255,255,255,0.2); }
|
||||
|
||||
.banner-right { text-align: right; flex-shrink: 0; }
|
||||
.banner-date { font-size: 12px; color: rgba(255,255,255,0.38); margin-bottom: 14px; }
|
||||
.banner-btns { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.btn-collect {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 9px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(200,151,58,0.5);
|
||||
color: var(--gold-lt);
|
||||
border-radius: 3px; font-size: 13px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-collect:hover { background: rgba(200,151,58,0.1); border-color: var(--gold); }
|
||||
.btn-apply {
|
||||
padding: 9px 24px;
|
||||
background: var(--red);
|
||||
border: none; color: #fff;
|
||||
border-radius: 3px; font-size: 13px; font-weight: 700;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: inherit;
|
||||
box-shadow: 0 4px 16px rgba(181,39,44,0.4);
|
||||
}
|
||||
.btn-apply:hover { background: #9A1F23; box-shadow: 0 6px 20px rgba(181,39,44,0.5); }
|
||||
.btn-apply.loading { opacity: 0.7; cursor: not-allowed; }
|
||||
.banner-hint { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; }
|
||||
.banner-hint.success { color: #6FCF97; }
|
||||
|
||||
/* 主体 */
|
||||
.detail-main {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 24px 28px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.detail-left {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 24px 28px;
|
||||
box-shadow: 0 2px 12px rgba(14,30,61,0.07);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.detail-right { width: 224px; flex-shrink: 0; }
|
||||
|
||||
/* 信息格 */
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.info-label {
|
||||
font-size: 11px; color: var(--muted); letter-spacing: 0.05em;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.info-label::before {
|
||||
content: ''; width: 5px; height: 5px;
|
||||
background: var(--red); border-radius: 50%;
|
||||
}
|
||||
.info-value { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.red-val { color: var(--red); }
|
||||
|
||||
.section-divider {
|
||||
height: 1px; background: var(--border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 内容区块 */
|
||||
.content-section { }
|
||||
.section-heading {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 15px; font-weight: 700; color: var(--text);
|
||||
margin-bottom: 14px; letter-spacing: 0.05em;
|
||||
}
|
||||
.heading-bar {
|
||||
display: block; width: 4px; height: 16px;
|
||||
background: var(--red); border-radius: 2px;
|
||||
}
|
||||
.desc-text {
|
||||
font-size: 13px; line-height: 2;
|
||||
color: #3A3A4A; white-space: pre-wrap;
|
||||
}
|
||||
.location-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: #3A3A4A;
|
||||
}
|
||||
|
||||
/* 公司卡片 */
|
||||
.company-card {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 2px 12px rgba(14,30,61,0.07);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.company-card-label {
|
||||
font-size: 11px; font-weight: 700;
|
||||
color: var(--muted); letter-spacing: 0.15em;
|
||||
margin-bottom: 14px; text-transform: uppercase;
|
||||
}
|
||||
.company-card-top { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
|
||||
.cc-logo {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 8px; overflow: hidden; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #C8973A, #9A6E28);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 900; color: #fff;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cc-logo img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cc-name { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
||||
.cc-desc { font-size: 11px; color: var(--muted); line-height: 1.6; }
|
||||
.cc-divider { height: 1px; background: var(--border); margin-bottom: 12px; }
|
||||
.cc-contact {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--muted); word-break: break-all;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.btn-apply-card {
|
||||
width: 100%; padding: 10px;
|
||||
background: var(--red); border: none;
|
||||
color: #fff; font-size: 13px; font-weight: 700;
|
||||
border-radius: 3px; cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit; letter-spacing: 0.05em;
|
||||
}
|
||||
.btn-apply-card:hover { background: #9A1F23; }
|
||||
.btn-apply-card.loading { opacity: 0.7; cursor: not-allowed; }
|
||||
.apply-success { text-align: center; font-size: 12px; color: #27AE60; margin-top: 8px; }
|
||||
|
||||
/* 空状态 / 骨架 */
|
||||
.state-tip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; min-height: 100px;
|
||||
color: var(--muted); font-size: 13px;
|
||||
}
|
||||
.full-tip { flex-direction: column; gap: 12px; }
|
||||
.empty-icon { opacity: 0.5; }
|
||||
.detail-loading { padding: 24px 28px; }
|
||||
.skeleton-row {
|
||||
height: 14px; background: linear-gradient(90deg, #E8E0D8 25%, #F0E8E0 50%, #E8E0D8 75%);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 3px; margin-bottom: 10px;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,140 @@
|
|||
<template>
|
||||
<el-row :gutter="24" v-loading="loading" style="max-width:1000px;margin:0 auto">
|
||||
<el-col :span="16">
|
||||
<el-card v-if="job">
|
||||
<template #header>
|
||||
<h2>{{ job.title }}</h2>
|
||||
<div class="meta">
|
||||
{{ job.organization?.name }} · {{ job.location }} · {{ job.salary }}
|
||||
<div class="job-detail-page" v-loading="loading">
|
||||
<!-- 顶部 Banner -->
|
||||
<div class="job-banner" v-if="job">
|
||||
<div class="banner-inner">
|
||||
<div class="banner-left">
|
||||
<h1 class="job-title">{{ job.title }}</h1>
|
||||
<div class="job-salary">{{ job.salary }}</div>
|
||||
<div class="job-meta-row">
|
||||
<span class="meta-item">
|
||||
<el-icon><Location /></el-icon>
|
||||
{{ job.location }}
|
||||
</span>
|
||||
<span class="meta-divider">|</span>
|
||||
<span class="meta-item">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
{{ job.organization?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-html="job.description" class="description"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<el-button type="primary" size="large" style="width:100%" @click="handleApply">
|
||||
立即投递
|
||||
</el-button>
|
||||
<p class="hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||
<p class="hint success" v-if="applied">已投递,可在"我的投递"查看进度</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="banner-right">
|
||||
<div class="banner-date">发布于 {{ formatDate(job.created_at) }}</div>
|
||||
<div class="banner-actions">
|
||||
<el-button class="btn-collect" plain @click="toggleCollect">
|
||||
<el-icon><Star /></el-icon>
|
||||
{{ collected ? '已收藏' : '收藏' }}
|
||||
</el-button>
|
||||
<el-button class="btn-apply" @click="handleApply" :loading="applying">
|
||||
立即投递
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="apply-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||
<p class="apply-hint success" v-if="applied">已投递,可在「我的投递」查看进度</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="job-body" v-if="job">
|
||||
<div class="body-inner">
|
||||
<!-- 左栏 -->
|
||||
<div class="body-left">
|
||||
<!-- 职位标签 -->
|
||||
<div class="info-tags">
|
||||
<div class="tag-item">
|
||||
<span class="tag-label">职位类别</span>
|
||||
<span class="tag-value">{{ job.category || '未填写' }}</span>
|
||||
</div>
|
||||
<div class="tag-item">
|
||||
<span class="tag-label">工作地点</span>
|
||||
<span class="tag-value">{{ job.location }}</span>
|
||||
</div>
|
||||
<div class="tag-item">
|
||||
<span class="tag-label">薪资范围</span>
|
||||
<span class="tag-value salary-val">{{ job.salary }}</span>
|
||||
</div>
|
||||
<div class="tag-item">
|
||||
<span class="tag-label">发布时间</span>
|
||||
<span class="tag-value">{{ formatDate(job.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 职位介绍 -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>职位介绍
|
||||
</div>
|
||||
<div class="category-tag" v-if="job.category">{{ job.category }}</div>
|
||||
<div class="description-content" v-html="job.description"></div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 工作地点 -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>工作地点
|
||||
</div>
|
||||
<div class="location-text">
|
||||
<el-icon><Location /></el-icon>
|
||||
{{ job.location }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏 -->
|
||||
<div class="body-right">
|
||||
<!-- 公司信息 -->
|
||||
<div class="company-card">
|
||||
<div class="company-card-header">单位信息</div>
|
||||
<div class="company-logo-row">
|
||||
<div class="company-logo">
|
||||
<img v-if="job.organization?.logo" :src="job.organization.logo" alt="logo" />
|
||||
<div v-else class="logo-placeholder">{{ job.organization?.name?.charAt(0) }}</div>
|
||||
</div>
|
||||
<div class="company-info">
|
||||
<div class="company-name">{{ job.organization?.name }}</div>
|
||||
<div class="company-desc" v-if="job.organization?.description">
|
||||
{{ job.organization.description.slice(0, 40) }}{{ job.organization.description.length > 40 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider style="margin: 12px 0" />
|
||||
<div class="company-contact" v-if="job.organization?.email">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>{{ job.organization.email }}</span>
|
||||
</div>
|
||||
<el-button class="btn-apply-sm" @click="handleApply" :loading="applying" style="margin-top:12px;width:100%">
|
||||
立即投递
|
||||
</el-button>
|
||||
<p class="apply-hint-sm" v-if="applied">已投递成功</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getJob } from '@/api/jobs'
|
||||
import { getJob, toggleFavorite } from '@/api/jobs'
|
||||
import { applyJob } from '@/api/applications'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Location, OfficeBuilding, Star, Message } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const job = ref(null)
|
||||
const loading = ref(false)
|
||||
const applying = ref(false)
|
||||
const applied = ref(false)
|
||||
const collected = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
|
|
@ -44,22 +143,316 @@ onMounted(async () => {
|
|||
loading.value = false
|
||||
})
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return ''
|
||||
return new Date(dt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
async function toggleCollect() {
|
||||
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法收藏职位')
|
||||
const { data } = await toggleFavorite(job.value.id)
|
||||
collected.value = data.collected
|
||||
ElMessage.success(collected.value ? '已收藏,可在「关注职位」中查看' : '已取消收藏')
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
|
||||
applying.value = true
|
||||
try {
|
||||
await applyJob(job.value.id)
|
||||
applied.value = true
|
||||
ElMessage.success('投递成功!')
|
||||
} catch (e) {
|
||||
if (e.response?.status === 400) ElMessage.warning('您已投递过该职位')
|
||||
else ElMessage.error('投递失败,请先完善简历')
|
||||
if (e.response?.status === 400) {
|
||||
ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
|
||||
} else {
|
||||
ElMessage.error('投递失败,请先完善简历')
|
||||
}
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meta { color: #666; margin-top: 8px; }
|
||||
.description { line-height: 1.8; }
|
||||
.hint { color: #999; font-size: 13px; margin-top: 12px; }
|
||||
.success { color: #67c23a; }
|
||||
.job-detail-page {
|
||||
background: #f5f6fa;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
.job-banner {
|
||||
background: linear-gradient(135deg, #e8f4fd 0%, #d0e9f8 60%, #b8d9f0 100%);
|
||||
padding: 36px 0 32px;
|
||||
border-bottom: 1px solid #dce8f3;
|
||||
}
|
||||
.banner-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
.job-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0 0 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.job-salary {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #e63329;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.job-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.meta-divider {
|
||||
color: #ccc;
|
||||
}
|
||||
.banner-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.banner-date {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-collect {
|
||||
border-color: #e63329 !important;
|
||||
color: #e63329 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.btn-collect:hover {
|
||||
background: #fff0f0 !important;
|
||||
}
|
||||
.btn-apply {
|
||||
background: #e63329 !important;
|
||||
border-color: #e63329 !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
padding: 10px 28px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.btn-apply:hover {
|
||||
background: #c42820 !important;
|
||||
border-color: #c42820 !important;
|
||||
}
|
||||
.apply-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.apply-hint.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.job-body {
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
.body-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.body-left {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px 28px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
}
|
||||
.body-right {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Info tags */
|
||||
.info-tags {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 24px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tag-label {
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.tag-label::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #e63329;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-value.salary-val {
|
||||
color: #e63329;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title-bar {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #e63329;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.category-tag {
|
||||
display: inline-block;
|
||||
background: #fff0f0;
|
||||
color: #e63329;
|
||||
border: 1px solid #fad2d0;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.description-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
color: #444;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.location-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Company card */
|
||||
.company-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
}
|
||||
.company-card-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.company-logo-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.company-logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.company-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.logo-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.company-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.company-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.company-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.company-contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
.btn-apply-sm {
|
||||
background: #e63329 !important;
|
||||
border-color: #e63329 !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-apply-sm:hover {
|
||||
background: #c42820 !important;
|
||||
border-color: #c42820 !important;
|
||||
}
|
||||
.apply-hint-sm {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,35 @@
|
|||
<template>
|
||||
<div>我的投递 - 开发中</div>
|
||||
<div>
|
||||
<h2>我的投递</h2>
|
||||
<el-table :data="applications" v-loading="loading" border>
|
||||
<el-table-column prop="job_title" label="职位" />
|
||||
<el-table-column prop="company_name" label="公司" />
|
||||
<el-table-column prop="applied_at" label="投递时间" :formatter="(r,c,v) => v?.slice(0,10)" />
|
||||
<el-table-column prop="status" label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="applications.length === 0 && !loading" description="暂无投递记录" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getMyApplications } from '@/api/applications'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const STATUS_MAP = { pending:'待查看', viewed:'已查看', interviewing:'面试中', hired:'已录用', rejected:'已拒绝' }
|
||||
const STATUS_TYPE = { pending:'info', viewed:'primary', interviewing:'warning', hired:'success', rejected:'danger' }
|
||||
const statusLabel = s => STATUS_MAP[s] || s
|
||||
const statusType = s => STATUS_TYPE[s] || 'info'
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const { data } = await getMyApplications()
|
||||
applications.value = data.results
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,36 @@
|
|||
<template>
|
||||
<div>账号设置 - 开发中</div>
|
||||
<div>
|
||||
<h2>账号设置</h2>
|
||||
<el-card style="max-width:480px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { updateMe } from '@/api/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const form = ref({ email: '', phone: '' })
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
form.value.email = auth.user?.email || ''
|
||||
form.value.phone = auth.user?.phone || ''
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateMe({ email: form.value.email, phone: form.value.phone })
|
||||
ElMessage.success('保存成功')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,111 @@
|
|||
<template>
|
||||
<div>我的简历 - 开发中</div>
|
||||
<div v-loading="loading">
|
||||
<h2>我的简历</h2>
|
||||
<el-form :model="form" label-width="80px" v-if="form">
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>基本信息</template>
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="form.gender">
|
||||
<el-radio value="male">男</el-radio>
|
||||
<el-radio value="female">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="生日"><el-date-picker v-model="form.birthday" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>
|
||||
教育经历
|
||||
<el-button size="small" @click="addEducation" style="float:right">添加</el-button>
|
||||
</template>
|
||||
<div v-for="(edu, i) in form.education" :key="i" style="margin-bottom:8px">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><el-input v-model="edu.school" placeholder="学校名称" /></el-col>
|
||||
<el-col :span="6"><el-input v-model="edu.degree" placeholder="学历" /></el-col>
|
||||
<el-col :span="7"><el-input v-model="edu.major" placeholder="专业" /></el-col>
|
||||
<el-col :span="3"><el-button type="danger" link @click="form.education.splice(i,1)">删除</el-button></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>
|
||||
工作经历
|
||||
<el-button size="small" @click="addExperience" style="float:right">添加</el-button>
|
||||
</template>
|
||||
<div v-for="(exp, i) in form.experience" :key="i" style="margin-bottom:8px">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><el-input v-model="exp.company" placeholder="公司名称" /></el-col>
|
||||
<el-col :span="7"><el-input v-model="exp.position" placeholder="职位" /></el-col>
|
||||
<el-col :span="6"><el-input v-model="exp.duration" placeholder="时长(如:2年)" /></el-col>
|
||||
<el-col :span="3"><el-button type="danger" link @click="form.experience.splice(i,1)">删除</el-button></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>简历附件</template>
|
||||
<div v-if="form.attachment" style="margin-bottom:12px">
|
||||
<span style="margin-right:12px">当前附件:{{ attachmentName }}</span>
|
||||
<el-button type="primary" link :href="attachmentUrl" target="_blank" tag="a">查看附件</el-button>
|
||||
</div>
|
||||
<el-upload :http-request="uploadAttachment" name="attachment" accept=".pdf,.doc,.docx" :show-file-list="false">
|
||||
<el-button>{{ form.attachment ? '重新上传' : '上传简历(PDF/Word)' }}</el-button>
|
||||
</el-upload>
|
||||
</el-card>
|
||||
|
||||
<el-button type="primary" @click="save" :loading="saving">保存简历</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getMyResume, updateMyResume, uploadResumeAttachment } from '@/api/resumes'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const form = ref(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const { data } = await getMyResume()
|
||||
form.value = { ...data, education: data.education || [], experience: data.experience || [] }
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
const attachmentUrl = computed(() => form.value?.attachment || '')
|
||||
const attachmentName = computed(() => {
|
||||
if (!form.value?.attachment) return ''
|
||||
return decodeURIComponent(form.value.attachment.split('/').pop())
|
||||
})
|
||||
|
||||
const addEducation = () => form.value.education.push({ school: '', degree: '', major: '' })
|
||||
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })
|
||||
|
||||
async function uploadAttachment({ file }) {
|
||||
const fd = new FormData()
|
||||
fd.append('attachment', file)
|
||||
try {
|
||||
const { data } = await uploadResumeAttachment(fd)
|
||||
form.value.attachment = data.attachment
|
||||
ElMessage.success('附件上传成功')
|
||||
} catch {
|
||||
ElMessage.error('附件上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const { attachment, ...rest } = form.value
|
||||
await updateMyResume(rest)
|
||||
ElMessage.success('简历已保存')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8000', changeOrigin: true }
|
||||
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue