Compare commits

...

27 Commits

Author SHA1 Message Date
TianyangZhang 0d7576dc85 fix(frontend): 修复多处页面功能问题
- JobCard: 分享按钮移至卡片右上角,链接去掉 # 前缀
- JobDetailView: 修复已投递状态判断(job 字段为整数非对象),页面加载时同步收藏状态
- ApplicationManageView: 下载附件改用 Content-Type 判断扩展名,修复无扩展名文件下载为乱码问题
- 新增 SplashView 落地页(企业招聘门户风格)
- 新增 FavoritesView 我的收藏页面

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:29:54 +08:00
TianyangZhang 609aa05514 Revert "feat(admin): 为投递管理页面添加分页功能"
This reverts commit 00abe1da15.
2026-03-26 10:50:39 +08:00
TianyangZhang 5275dd211e feat(portal): 职位卡片添加发布时间和分享按钮
- 在职位卡片中显示发布时间(格式:YYYY-MM-DD)
- 添加分享图标按钮,点击复制职位详情页链接
- 分享按钮用 Element Plus 的 ShareDocument 图标代替
2026-03-25 17:13:01 +08:00
TianyangZhang 842704f095 Revert "feat(admin): 职位管理页面添加发布时间和分享按钮"
This reverts commit 10fa8aedfe.
2026-03-25 17:12:36 +08:00
TianyangZhang 10fa8aedfe feat(admin): 职位管理页面添加发布时间和分享按钮
- 添加发布时间列显示职位创建日期
- 添加分享按钮,点击复制职位详情页链接到剪贴板
- 支持 Clipboard API 和降级方案
2026-03-25 17:09:16 +08:00
TianyangZhang 724eef2b19 feat(admin): 为用户管理页面添加分页功能 2026-03-25 17:02:16 +08:00
TianyangZhang 00abe1da15 feat(admin): 为投递管理页面添加分页功能 2026-03-25 17:01:42 +08:00
TianyangZhang 85e5cbd9c9 feat(admin): 为职位管理页面添加分页功能 2026-03-25 17:01:06 +08:00
TianyangZhang 11c1104ab1 feat: 为部门管理页面添加完整的分页功能
- 添加分页状态管理(currentPage, pageSize, total, allOrgs)
- 实现两个数据加载函数(fetchOrgs 用于分页、fetchAllOrgs 用于下拉框)
- 添加分页事件处理函数 handlePageChange
- 实现删除功能及确认对话框 handleDelete
- 更新 UI 组件:表格 loading 状态、删除按钮、分页控件
- 修改 save() 函数完成后重置分页至第一页
- 更新 onMounted 初始化时同时加载分页数据和所有部门数据
- 下拉框改为使用 allOrgs 确保显示完整部门列表
2026-03-25 17:00:03 +08:00
TianyangZhang e42dbecf41 feat(api): 为 getManageApplications 添加分页参数 2026-03-25 16:59:15 +08:00
TianyangZhang df1c9b5d1d feat(api): 为 manageJobs 添加分页参数 2026-03-25 16:58:41 +08:00
TianyangZhang 3f62844d82 feat(api): 为 manageOrganizations 添加分页参数 2026-03-25 16:57:34 +08:00
TianyangZhang 9faa54481a docs: 修复分页实现计划,补充遗漏的功能和细节
- 修正 API 调用方式,使用 params 对象保持一致性
- 补充 Task 4 缺失的 fetchAllOrgs 和 handleDelete 功能
- 补充 Task 6 的 fetchApplications 函数新建说明
- 修复 Task 7 的 loading ref 位置和 API 调用方式
- 澄清投递管理的分页重置策略
2026-03-25 16:55:56 +08:00
TianyangZhang cc1a6459bd docs: 更新分页功能设计文档,扩展到所有管理表格页面 2026-03-25 16:53:16 +08:00
TianyangZhang 54230b6cfd fix(admin): 简化附件下载,直接使用Django媒体代理 2026-03-25 16:38:25 +08:00
TianyangZhang 79706dd840 fix(admin): 通过API端点下载简历附件,解决HTML问题 2026-03-25 16:35:43 +08:00
TianyangZhang a6ebd3af87 feat(admin): 添加顶部导航栏和退出登入按钮 2026-03-25 16:34:02 +08:00
TianyangZhang 1ec8734401 fix(admin): 修复简历附件下载问题,完善简历信息展示 2026-03-25 16:32:33 +08:00
TianyangZhang fbcd98dc46 fix: 投递后禁用重复点击,显示已投递提示
改动:
- 页面加载时检查用户是否已投递该职位
- 已投递的职位禁用投递按钮
- 按钮显示'已投递'而非'立即投递'
- 用户重复点击时显示警告提示'您已投递过该职位'
- 样式:禁用按钮改为灰色,cursor 改为 not-allowed

效果:
- 防止用户误操作或恶意重复投递
- 清晰提示用户已投递状态
- 即使刷新页面也能显示正确的投递状态

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 16:19:19 +08:00
TianyangZhang 911e872a4a feat: 前端认证系统重构 - 密码注册和多种登入方式
前端改动:

RegisterView.vue:
- 改为传统密码注册(邮箱+用户名+手机号+密码)
- 移除邮箱验证码逻辑
- 注册成功后自动登入并跳转到首页

LoginView.vue:
- 求职者和管理员两个角色选项卡
- 求职者支持两种登入方式:
  * 邮箱/用户名 + 密码
  * 邮箱 + 验证码(快速登入)
- 登入方式通过子选项卡切换
- 添加"忘记密码"链接指向密码重置页面
- 管理员仍使用用户名+密码登入

ResetPasswordView.vue (新建):
- 两步流程:
  1. 输入邮箱 → 获取验证码 → 输入验证码
  2. 输入新密码 → 确认密码 → 重置完成
- 验证码倒计时和重新获取
- 密码重置成功后跳转到登入页

API更新 (auth.js):
- 修改 register() 使用 axios 而非 client
- 新增 resetPassword() - 请求密码重置
- 新增 confirmResetPassword() - 确认密码重置

路由更新 (router/index.js):
- 新增 /forgot-password 路由

设计特点:
- 统一的多种登入方式UI
- 清晰的密码重置流程
- 保留邮箱验证码快速登入选项

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 16:15:58 +08:00
TianyangZhang 8a5ed86421 feat: 改为密码注册和多种密码登入方式,新增密码重置功能
后端改动:
- RegisterSerializer: 改为邮箱+用户名+手机号+密码注册
- CustomTokenObtainPairView: 支持三种登入方式
  * 邮箱验证码登入(验证码有效10分钟,失败5次锁定)
  * 邮箱+密码登入
  * 用户名+密码登入
- 新增 PasswordLoginSerializer: 支持邮箱或用户名登入
- 新增 ResetPasswordSerializer: 请求密码重置
- 新增 ConfirmResetPasswordSerializer: 确认密码重置
- 新增 RequestResetPasswordView: 发送密码重置验证码
- 新增 ConfirmResetPasswordView: 重置密码
- 更新 URLs: 添加 /reset-password/ 和 /confirm-reset-password/

功能特性:
- 注册时需设置密码
- 登入可用邮箱或用户名 + 密码(邮箱和用户名对应同一密码)
- 保留邮箱验证码快速登入
- 忘记密码可通过邮箱验证码重置

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 16:14:57 +08:00
TianyangZhang b6d5a51c3d fix: 邮箱字段设置为唯一且必填,改善错误提示
后端改动:
- User.email 设置为 unique=True,确保每个邮箱只能注册一次
- 这样 SendCodeView 能正确查找到已注册的邮箱

前端改动:
- LoginView 增加详细的错误日志输出
- 捕获更多错误信息类型帮助调试

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 16:09:48 +08:00
TianyangZhang 12697c5750 feat: 登入页面支持求职者和管理员两种角色
前端改动:
- LoginView.vue 添加角色选择(求职者/管理员)
- 求职者使用邮箱验证码登入
- 管理员使用用户名密码登入
- 两种方式在同一页面,通过角色选项卡切换

后端改动:
- CustomTokenObtainPairView 改为支持两种登入方式
- 若提供 email+code 则使用邮箱验证码登入
- 若提供 username+password 则使用用户名密码登入

设计:
- 求职者可自助注册和邮箱验证码登入
- 管理员由 superadmin 创建,使用用户名密码登入
- 两种登入都返回同样的 JWT token

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 15:59:30 +08:00
TianyangZhang 99220b6daf feat: 改为邮箱验证码注册,注册完成后自动登入
后端改动:
- RegisterSerializer 改为接收用户名+邮箱+手机号+验证码
- 验证邮箱是否已存在、用户名是否已存在
- 验证验证码有效性和正确性
- RegisterView 返回 JWT token,实现自动登入

前端改动:
- RegisterView.vue 改为邮箱验证码注册流程
- 保留用户名、邮箱、手机号字段
- 获取验证码后输入验证码完成注册
- 注册成功后自动保存 token 并跳转到首页

流程:用户名+邮箱+手机号 → 获取验证码 → 输入验证码 → 注册完成并自动登入 → 跳转首页

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 15:51:03 +08:00
TianyangZhang 72e7244ea0 feat: 将注册手机号改为必填项 2026-03-25 15:46:44 +08:00
TianyangZhang 2edc9beef3 feat: 实现邮箱验证码登入功能
后端改动:
- 新增 VerificationCode 模型,支持验证码有效期和重试限制
- 新增 SendCodeView 生成并发送邮箱验证码
- 自定义 TokenObtainPairView 支持邮箱+验证码登入
- 添加 SendCodeSerializer 和 LoginSerializer

前端改动:
- 改写 LoginView.vue 为单页面邮箱+验证码登入流程
- 修改 auth API,新增 sendCode() 和修改 loginApi()
- 更新 auth store 的 login 方法支持邮箱和验证码

功能特性:
- 验证码有效期 10 分钟
- 同一邮箱 5 次错误尝试后锁定 10 分钟
- 支持重新发送验证码
- 完全替换原有用户名密码登入方式

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 15:30:23 +08:00
TianyangZhang 1029bf812d style(PortalLayout): 将标题和页脚从红色改为黑金主题色
- 顶部公告条背景改为黑色
- 主导航背景改为黑色渐变
- 底线装饰改为金色渐变
- 页脚背景改为黑色,边框改为金色

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 15:18:40 +08:00
32 changed files with 3439 additions and 134 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ class ApplicationManageViewSet(viewsets.ReadOnlyModelViewSet):
job__organization=user.organization
).select_related('job__organization', 'applicant')
class ApplicationStatusUpdateView(generics.UpdateAPIView):
serializer_class = ApplicationStatusSerializer
permission_classes = [IsAdminOrSuperAdmin]

View File

@ -22,11 +22,18 @@ class Resume(models.Model):
def to_snapshot(self):
"""序列化为投递快照,与主表解耦"""
attachment_url = None
if self.attachment:
# 返回相对 URL前端会处理
attachment_url = self.attachment.url
return {
'name': self.name,
'gender': self.gender,
'birthday': str(self.birthday) if self.birthday else None,
'education': self.education,
'experience': self.experience,
'attachment_url': self.attachment.url if self.attachment else None,
'email': self.user.email,
'phone': self.user.phone,
'attachment_url': attachment_url,
}

View File

@ -90,3 +90,11 @@ LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
# Email Configuration
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@offer.com')
EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com')
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,13 @@
<template>
<div class="job-card-wrapper">
<el-card class="job-card" shadow="hover" @click="$router.push(`/jobs/${job.id}`)">
<el-button
class="share-btn"
type="primary"
text
@click.stop="shareJob"
:icon="Share"
/>
<div class="job-title">{{ job.title }}</div>
<div class="job-meta">
<span>{{ job.company_name || job.organization_name }}</span>
@ -8,15 +16,87 @@
<el-divider direction="vertical" />
<span class="salary">{{ job.salary }}</span>
</div>
<div class="job-footer">
<el-tag size="small">{{ job.category }}</el-tag>
<span class="publish-time">{{ formatDate(job.created_at) }}</span>
</div>
</el-card>
</div>
</template>
<script setup>
defineProps({ job: Object })
import { ElMessage } from 'element-plus'
import { Share } from '@element-plus/icons-vue'
const props = defineProps({ job: Object })
function formatDate(dateStr) {
if (!dateStr) return ''
return dateStr.slice(0, 10)
}
function shareJob() {
//
const jobUrl = `${window.location.origin}/jobs/${props.job.id}`
//
navigator.clipboard.writeText(jobUrl).then(() => {
ElMessage.success('已复制职位链接')
}).catch(() => {
//
const textArea = document.createElement('textarea')
textArea.value = jobUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
ElMessage.success('已复制职位链接')
})
}
</script>
<style scoped>
.job-card { cursor: pointer; margin-bottom: 12px; }
.job-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.job-meta { color: #666; font-size: 13px; margin-bottom: 8px; }
.salary { color: #f56c6c; font-weight: 500; }
.job-card-wrapper {
position: relative;
margin-bottom: 12px;
}
.job-card {
cursor: pointer;
position: relative;
}
.job-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.job-meta {
color: #666;
font-size: 13px;
margin-bottom: 12px;
}
.job-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.publish-time {
color: #999;
font-size: 12px;
}
.salary {
color: #f56c6c;
font-weight: 500;
}
.share-btn {
position: absolute;
top: 12px;
right: 12px;
z-index: 1;
}
</style>

View File

@ -1,7 +1,22 @@
<template>
<el-container style="min-height: 100vh;">
<el-header style="background:#001529;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #333;">
<div style="color:#fff;font-weight:bold;font-size:16px;">管理后台</div>
<el-dropdown>
<div style="color:#fff;cursor:pointer;display:flex;align-items:center;gap:8px;">
<span>{{ auth.user?.email }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="logout">退出登入</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-container>
<el-aside width="200px" style="background:#001529;">
<div style="padding:20px;color:#fff;font-weight:bold;font-size:16px;">管理后台</div>
<div style="padding:20px;color:#fff;font-weight:bold;font-size:16px;">菜单</div>
<el-menu router :default-active="$route.path" background-color="#001529" text-color="#fff" active-text-color="#409eff">
<el-menu-item index="/admin/jobs">职位管理</el-menu-item>
<el-menu-item index="/admin/applications">投递管理</el-menu-item>
@ -13,8 +28,20 @@
</el-aside>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const auth = useAuthStore()
function logout() {
localStorage.clear()
ElMessage.success('已退出登入')
router.push({ name: 'Login' })
}
</script>

View File

@ -50,7 +50,7 @@
<button class="btn-ghost">登录</button>
</router-link>
<router-link to="/register">
<button class="btn-primary">立即注册</button>
<button class="btn-ghost">立即注册</button>
</router-link>
</template>
<template v-else>
@ -118,7 +118,7 @@ a { text-decoration: none; }
/* 顶部公告条 */
.top-bar {
background: #8B4545;
background: #1A1A1A;
color: rgba(255,255,255,0.75);
font-size: 12px;
letter-spacing: 0.05em;
@ -144,7 +144,7 @@ a { text-decoration: none; }
/* 主导航 */
.main-header {
background: linear-gradient(180deg, #C17A7A 0%, #A85555 100%);
background: linear-gradient(180deg, #1A1A1A 0%, #0F0F0F 100%);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
position: sticky;
top: 0;
@ -271,11 +271,11 @@ a { text-decoration: none; }
}
.user-name { color: rgba(255,255,255,0.85); font-size: 13px; }
/* 红色底线装饰 */
.header-underline { height: 3px; background: #8B4545; }
/* 底线装饰 */
.header-underline { height: 3px; background: #0F0F0F; }
.underline-fill {
height: 100%;
background: linear-gradient(90deg, #FFFFFF 0%, #EF9A9A 40%, #D4A95D 100%);
background: linear-gradient(90deg, #C8973A 0%, #D4AF37 100%);
}
/* 内容区 */
@ -289,8 +289,8 @@ a { text-decoration: none; }
/* 页脚 */
.portal-footer {
background: #8B4545;
border-top: 3px solid #FFFFFF;
background: #1A1A1A;
border-top: 3px solid #C8973A;
padding: 20px 0;
}
.footer-inner {

View File

@ -18,6 +18,7 @@ const routes = [
},
{ path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') },
{ path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') },
{ path: '/forgot-password', name: 'ResetPassword', component: () => import('@/views/auth/ResetPasswordView.vue') },
// 求职者中心
{
path: '/seeker',

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { login as loginApi, getMe } from '@/api/auth'
import { loginApi, getMe } from '@/api/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
@ -13,8 +13,8 @@ export const useAuthStore = defineStore('auth', {
isSeeker: s => s.user?.role === 'seeker',
},
actions: {
async login(username, password) {
const { data } = await loginApi({ username, password })
async login(email, code) {
const { data } = await loginApi({ email, code })
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await this.fetchMe()

View File

@ -0,0 +1,538 @@
<template>
<div class="splash-page">
<!-- 顶部导航 -->
<header class="splash-header">
<div class="header-inner">
<div class="logo">
<div class="logo-icon"></div>
<div class="logo-text">
<span class="logo-title">集团招聘平台</span>
<span class="logo-en">GROUP TALENT RECRUITMENT</span>
</div>
</div>
<nav class="header-nav">
<span class="nav-item" @click="router.push('/home')">首页</span>
<span class="nav-item" @click="router.push('/jobs')">职位列表</span>
<span class="nav-item" @click="router.push('/companies')">公司介绍</span>
</nav>
<div class="header-actions">
<button class="btn-outline" @click="router.push('/login')">登录</button>
<button class="btn-solid" @click="router.push('/register')">立即注册</button>
</div>
</div>
</header>
<!-- 英雄区 -->
<section class="hero">
<div class="hero-bg">
<div class="hero-overlay"></div>
<!-- 建筑风格装饰线条 -->
<div class="building-lines">
<div class="line l1"></div>
<div class="line l2"></div>
<div class="line l3"></div>
<div class="line l4"></div>
<div class="line l5"></div>
<div class="line l6"></div>
<div class="line l7"></div>
<div class="line l8"></div>
</div>
<!-- 光晕效果 -->
<div class="hero-glow"></div>
</div>
<div class="hero-content">
<div class="hero-badge">2026届校园招聘</div>
<h1 class="hero-slogan">人才创造美好未来</h1>
<p class="hero-sub">与优秀的人一起做有价值的事</p>
<div class="hero-btns">
<button class="btn-primary-lg" @click="router.push('/jobs')">
浏览职位
<span class="btn-arrow"></span>
</button>
<button class="btn-secondary-lg" @click="router.push('/register')">
立即投递
</button>
</div>
</div>
<!-- 滚动指示 -->
<div class="scroll-hint">
<span>向下探索</span>
<div class="scroll-arrow"></div>
</div>
</section>
<!-- 数据亮点 -->
<section class="stats-section">
<div class="stats-inner">
<div class="stat-item">
<div class="stat-num">500<span class="stat-plus">+</span></div>
<div class="stat-label">招聘职位</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-num">28<span class="stat-plus">+</span></div>
<div class="stat-label">合作企业</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-num">10000<span class="stat-plus">+</span></div>
<div class="stat-label">在职员工</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-num">30<span class="stat-plus">+</span></div>
<div class="stat-label">城市布局</div>
</div>
</div>
</section>
<!-- 招聘亮点 -->
<section class="features-section">
<div class="features-inner">
<div class="section-label">为什么加入我们</div>
<h2 class="section-title">筑梦平台共创未来</h2>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">🏛</div>
<h3>央企背景</h3>
<p>国有企业稳定可靠福利完善五险一金补充公积金</p>
</div>
<div class="feature-card">
<div class="feature-icon">🚀</div>
<h3>成长空间</h3>
<p>完善的晋升体系多维度培训专属导师带教快速成长</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌍</div>
<h3>全国布局</h3>
<p>覆盖30+城市多地工作机会灵活选择工作地点</p>
</div>
<div class="feature-card">
<div class="feature-icon">💡</div>
<h3>创新氛围</h3>
<p>鼓励创新探索科研立项支持专利奖励技术驱动发展</p>
</div>
</div>
</div>
</section>
<!-- 底部 -->
<footer class="splash-footer">
<div class="footer-inner">
<div class="footer-logo">
<div class="logo-icon sm"></div>
<span>集团招聘平台</span>
</div>
<p class="footer-copy">Copyright © 集团招聘平台 · All Rights Reserved · 为国家建设输送优秀人才</p>
<div class="footer-links">
<span @click="router.push('/jobs')">职位列表</span>
<span @click="router.push('/companies')">公司介绍</span>
<span @click="router.push('/login')">登录</span>
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<style scoped>
* { box-sizing: border-box; }
.splash-page {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #0a0a0a;
color: #fff;
overflow-x: hidden;
}
/* ── 顶部导航 ── */
.splash-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: 0 48px;
height: 64px;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.header-inner {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.logo-icon {
width: 38px;
height: 38px;
background: linear-gradient(135deg, #B8860B, #8B6407);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 900;
color: #fff;
}
.logo-icon.sm { width: 28px; height: 28px; font-size: 13px; border-radius: 6px; }
.logo-text { display: flex; flex-direction: column; }
.logo-title { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.logo-en { font-size: 10px; color: rgba(255,255,255,0.45); letter-spacing: 0.1em; }
.header-nav { display: flex; gap: 36px; }
.nav-item {
font-size: 14px;
color: rgba(255,255,255,0.8);
cursor: pointer;
transition: color 0.2s;
letter-spacing: 0.03em;
}
.nav-item:hover { color: #D4AF37; }
.header-actions { display: flex; gap: 12px; }
.btn-outline {
padding: 7px 20px;
border: 1px solid rgba(255,255,255,0.4);
background: transparent;
color: rgba(255,255,255,0.85);
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.btn-outline:hover { border-color: #D4AF37; color: #D4AF37; }
.btn-solid {
padding: 7px 20px;
background: linear-gradient(135deg, #B8860B, #8B6407);
border: none;
color: #fff;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
font-weight: 600;
font-family: inherit;
transition: opacity 0.2s;
}
.btn-solid:hover { opacity: 0.88; }
/* ── 英雄区 ── */
.hero {
position: relative;
height: 100vh;
min-height: 640px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
/* 模拟建筑照片的深色调背景 */
background:
linear-gradient(160deg,
#0d1b2a 0%,
#1a2744 25%,
#0f1f35 50%,
#0d1520 75%,
#060e18 100%
);
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0,0,0,0.2) 0%,
rgba(0,0,0,0.1) 40%,
rgba(0,0,0,0.5) 100%
);
}
/* 建筑风格竖线装饰 */
.building-lines {
position: absolute;
inset: 0;
overflow: hidden;
}
.line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: linear-gradient(
to bottom,
transparent,
rgba(184, 134, 11, 0.15) 30%,
rgba(184, 134, 11, 0.08) 70%,
transparent
);
}
.l1 { left: 8%; }
.l2 { left: 18%; }
.l3 { left: 30%; }
.l4 { left: 42%; }
.l5 { left: 58%; }
.l6 { left: 70%; }
.l7 { left: 82%; }
.l8 { left: 92%; }
/* 中心光晕 */
.hero-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
width: 600px;
height: 400px;
background: radial-gradient(
ellipse,
rgba(184, 134, 11, 0.18) 0%,
rgba(184, 134, 11, 0.05) 50%,
transparent 70%
);
pointer-events: none;
}
.hero-content {
position: relative;
z-index: 2;
text-align: center;
padding: 0 24px;
max-width: 800px;
}
.hero-badge {
display: inline-block;
padding: 5px 18px;
border: 1px solid rgba(184, 134, 11, 0.6);
border-radius: 20px;
font-size: 13px;
color: #D4AF37;
letter-spacing: 0.15em;
margin-bottom: 28px;
background: rgba(184, 134, 11, 0.08);
}
.hero-slogan {
font-size: 56px;
font-weight: 900;
color: #fff;
letter-spacing: 0.12em;
margin: 0 0 16px;
text-shadow: 0 2px 20px rgba(0,0,0,0.4);
line-height: 1.15;
}
.hero-sub {
font-size: 18px;
color: rgba(255,255,255,0.65);
margin: 0 0 48px;
letter-spacing: 0.06em;
}
.hero-btns {
display: flex;
gap: 16px;
justify-content: center;
}
.btn-primary-lg {
padding: 14px 42px;
background: linear-gradient(135deg, #B8860B, #D4AF37);
border: none;
color: #fff;
border-radius: 4px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(184,134,11,0.4);
}
.btn-primary-lg:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(184,134,11,0.5);
}
.btn-arrow { font-size: 18px; }
.btn-secondary-lg {
padding: 14px 42px;
background: transparent;
border: 1px solid rgba(255,255,255,0.45);
color: rgba(255,255,255,0.85);
border-radius: 4px;
font-size: 16px;
cursor: pointer;
font-family: inherit;
letter-spacing: 0.05em;
transition: all 0.2s;
}
.btn-secondary-lg:hover {
border-color: rgba(255,255,255,0.8);
color: #fff;
background: rgba(255,255,255,0.06);
}
.scroll-hint {
position: absolute;
bottom: 36px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: rgba(255,255,255,0.35);
font-size: 12px;
letter-spacing: 0.1em;
}
.scroll-arrow {
font-size: 18px;
animation: bounce 1.6s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(6px); }
}
/* ── 数据亮点 ── */
.stats-section {
background: linear-gradient(135deg, #111 0%, #1a1a1a 100%);
border-top: 1px solid rgba(184,134,11,0.25);
border-bottom: 1px solid rgba(184,134,11,0.25);
padding: 48px 0;
}
.stats-inner {
max-width: 1000px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 48px;
}
.stat-item { text-align: center; }
.stat-num {
font-size: 44px;
font-weight: 900;
color: #D4AF37;
line-height: 1;
margin-bottom: 8px;
font-variant-numeric: tabular-nums;
}
.stat-plus { font-size: 24px; }
.stat-label { font-size: 14px; color: rgba(255,255,255,0.5); letter-spacing: 0.08em; }
.stat-divider {
width: 1px;
height: 48px;
background: rgba(184,134,11,0.2);
}
/* ── 特色区 ── */
.features-section {
background: #0d0d0d;
padding: 96px 48px;
}
.features-inner { max-width: 1100px; margin: 0 auto; }
.section-label {
text-align: center;
font-size: 13px;
color: #D4AF37;
letter-spacing: 0.2em;
margin-bottom: 12px;
text-transform: uppercase;
}
.section-title {
text-align: center;
font-size: 34px;
font-weight: 800;
color: #fff;
margin: 0 0 56px;
letter-spacing: 0.08em;
}
.features-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.feature-card {
background: #161616;
border: 1px solid rgba(184,134,11,0.15);
border-radius: 8px;
padding: 36px 24px;
text-align: center;
transition: border-color 0.25s, transform 0.25s;
}
.feature-card:hover {
border-color: rgba(184,134,11,0.5);
transform: translateY(-4px);
}
.feature-icon {
font-size: 36px;
margin-bottom: 16px;
}
.feature-card h3 {
font-size: 17px;
font-weight: 700;
color: #fff;
margin: 0 0 12px;
letter-spacing: 0.04em;
}
.feature-card p {
font-size: 13px;
color: rgba(255,255,255,0.45);
line-height: 1.8;
margin: 0;
}
/* ── 底部 ── */
.splash-footer {
background: #060606;
border-top: 1px solid rgba(255,255,255,0.06);
padding: 32px 48px;
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(255,255,255,0.5);
}
.footer-copy {
font-size: 12px;
color: rgba(255,255,255,0.25);
margin: 0;
}
.footer-links {
display: flex;
gap: 24px;
}
.footer-links span {
font-size: 12px;
color: rgba(255,255,255,0.35);
cursor: pointer;
transition: color 0.2s;
}
.footer-links span:hover { color: #D4AF37; }
</style>

View File

@ -25,16 +25,70 @@
</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>
<el-dialog v-model="resumeVisible" title="简历详情" width="700px">
<div v-if="currentResume" class="resume-detail">
<!-- 基本信息 -->
<div class="section">
<div class="section-title">基本信息</div>
<div class="info-grid">
<div class="info-item">
<span class="label">姓名</span>
<span class="value">{{ currentResume.name || '-' }}</span>
</div>
<div class="info-item">
<span class="label">性别</span>
<span class="value">{{ getGenderLabel(currentResume.gender) }}</span>
</div>
<div class="info-item">
<span class="label">邮箱</span>
<span class="value">{{ currentResume.email || '-' }}</span>
</div>
<div class="info-item">
<span class="label">手机</span>
<span class="value">{{ currentResume.phone || '-' }}</span>
</div>
<div class="info-item">
<span class="label">生日</span>
<span class="value">{{ currentResume.birthday || '-' }}</span>
</div>
</div>
</div>
<!-- 教育经历 -->
<div class="section">
<div class="section-title">教育经历</div>
<div v-if="currentResume.education?.length" class="list-items">
<div v-for="(e, i) in currentResume.education" :key="i" class="list-item">
<div class="item-header">{{ e.school }}</div>
<div class="item-detail">{{ e.degree }} · {{ e.major }}</div>
<div class="item-detail">{{ e.start_year }} - {{ e.end_year }}</div>
</div>
</div>
<div v-else style="color: #999; font-size: 12px;">暂无教育经历</div>
</div>
<!-- 工作经历 -->
<div class="section">
<div class="section-title">工作经历</div>
<div v-if="currentResume.experience?.length" class="list-items">
<div v-for="(e, i) in currentResume.experience" :key="i" class="list-item">
<div class="item-header">{{ e.company }} - {{ e.position }}</div>
<div class="item-detail">{{ e.duration }}</div>
<div class="item-detail" v-if="e.description">{{ e.description }}</div>
</div>
</div>
<div v-else style="color: #999; font-size: 12px;">暂无工作经历</div>
</div>
<!-- 附件 -->
<div class="section" v-if="currentResume.attachment_url">
<div class="section-title">附件</div>
<div style="margin-top: 8px;">
<el-button type="primary" @click="downloadAttachment">
<el-icon><Download /></el-icon>
下载简历附件
</el-button>
</div>
</div>
</div>
</el-dialog>
@ -44,12 +98,20 @@
import { ref, onMounted } from 'vue'
import { getManageApplications, updateApplicationStatus } from '@/api/applications'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
const applications = ref([])
const loading = ref(false)
const resumeVisible = ref(false)
const currentResume = ref(null)
const genderMap = {
'male': '男',
'female': '女',
'other': '其他',
'': '-'
}
onMounted(async () => {
loading.value = true
const { data } = await getManageApplications()
@ -68,4 +130,116 @@ function viewResume(row) {
currentResume.value = row.resume_snapshot
resumeVisible.value = true
}
function getGenderLabel(gender) {
return genderMap[gender] || '-'
}
async function downloadAttachment() {
if (!currentResume.value?.attachment_url) {
ElMessage.warning('附件不可用')
return
}
try {
// /media/media/
let url = currentResume.value.attachment_url.replace(/\/media\/media\//, '/media/')
// fetch+blob
const response = await fetch(url)
if (!response.ok) throw new Error('请求失败')
// Content-Type
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim()
const mimeMap = {
'application/pdf': 'pdf',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'image/jpeg': 'jpg',
'image/png': 'png',
}
let ext = mimeMap[contentType]
if (!ext) {
const urlExt = url.split('.').pop().split('?')[0]
ext = /^[a-zA-Z0-9]{1,5}$/.test(urlExt) ? urlExt : 'pdf'
}
const filename = `${currentResume.value.name}_resume.${ext}`
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
ElMessage.success('下载已启动')
} catch {
ElMessage.error('下载失败,请重试')
}
}
</script>
<style scoped>
.section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 8px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.info-item {
display: flex;
align-items: center;
font-size: 13px;
}
.info-item .label {
color: #666;
min-width: 50px;
font-weight: 500;
}
.info-item .value {
color: #333;
flex: 1;
}
.list-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.list-item {
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
border-left: 3px solid #409eff;
}
.item-header {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
margin-bottom: 4px;
}
.item-detail {
font-size: 12px;
color: #666;
line-height: 1.5;
}
</style>

View File

@ -23,6 +23,15 @@
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
<el-dialog v-model="dialogVisible" :title="editingJob ? '编辑职位' : '发布职位'" width="600px">
<el-form :model="form" label-width="90px">
@ -78,13 +87,23 @@ const form = reactive({
title: '', category: '', location: '', salary: '',
description: '', status: 'draft', organization_id: null
})
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const fetchJobs = async () => {
const fetchJobs = async (page = 1) => {
loading.value = true
const { data } = await manageJobs()
try {
const { data } = await manageJobs(page)
jobs.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载职位列表失败,请重试')
} finally {
loading.value = false
}
}
const fetchOrgs = async () => {
const { data } = await getOrganizations()
@ -112,6 +131,10 @@ function openDialog(job = null) {
dialogVisible.value = true
}
function handlePageChange(newPage) {
fetchJobs(newPage)
}
async function handleSave() {
if (auth.isSuperAdmin && !form.organization_id) {
return ElMessage.warning('请选择所属公司')
@ -124,7 +147,7 @@ async function handleSave() {
else await createJob(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchJobs()
fetchJobs(1)
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
@ -136,7 +159,7 @@ async function handleDelete(id) {
await ElMessageBox.confirm('确认删除该职位?', '提示', { type: 'warning' })
await deleteJob(id)
ElMessage.success('已删除')
fetchJobs()
fetchJobs(1)
}
onMounted(() => {

View File

@ -4,26 +4,39 @@
<h2>组织架构管理</h2>
<el-button type="primary" @click="openDialog()">新增公司</el-button>
</div>
<el-table :data="orgs" border>
<el-table :data="orgs" border v-loading="loading">
<el-table-column prop="name" label="公司名称" />
<el-table-column label="上级公司">
<template #default="{ row }">{{ row.parent ? orgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
<template #default="{ row }">{{ row.parent ? allOrgs.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 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>
<div style="margin-top:16px;text-align:right">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[20]"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<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-option v-for="o in allOrgs" :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>
@ -39,18 +52,46 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { manageOrganizations, createOrganization, updateOrganization } from '@/api/organizations'
import { ElMessage } from 'element-plus'
import { manageOrganizations, createOrganization, updateOrganization, deleteOrganization } from '@/api/organizations'
import { ElMessage, ElMessageBox } from 'element-plus'
const orgs = ref([])
const allOrgs = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const saving = ref(false)
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const form = reactive({ name: '', parent: null, email: '', description: '', is_active: true })
const fetchOrgs = async () => {
const { data } = await manageOrganizations()
const fetchOrgs = async (page = 1) => {
loading.value = true
try {
const { data } = await manageOrganizations(page)
orgs.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载部门列表失败,请重试')
} finally {
loading.value = false
}
}
const fetchAllOrgs = async () => {
try {
let allResults = []
for (let i = 1; i <= 5; i++) {
const { data } = await manageOrganizations(i)
allResults = allResults.concat(data.results)
if (i === Math.ceil(data.count / 20)) break
}
allOrgs.value = allResults
} catch (error) {
ElMessage.error('加载部门列表失败')
}
}
function openDialog(org = null) {
@ -67,8 +108,35 @@ async function save() {
else await createOrganization(form)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchOrgs()
fetchOrgs(1)
fetchAllOrgs()
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
}
onMounted(fetchOrgs)
function handlePageChange(newPage) {
fetchOrgs(newPage)
}
async function handleDelete(id) {
try {
await ElMessageBox.confirm(
'此操作将永久删除该部门,是否继续?',
'警告',
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
)
await deleteOrganization(id)
ElMessage.success('删除成功')
fetchOrgs(1)
fetchAllOrgs()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
onMounted(() => {
fetchOrgs()
fetchAllOrgs()
})
</script>

View File

@ -4,7 +4,7 @@
<h2>用户管理</h2>
<el-button type="primary" @click="openDialog()">新增管理员</el-button>
</div>
<el-table :data="users" border>
<el-table :data="users" v-loading="loading" border>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="角色">
@ -24,6 +24,15 @@
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next, jumper"
/>
</div>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新增管理员'" width="480px">
<el-form :model="form" label-width="90px">
@ -62,11 +71,31 @@ const orgs = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const saving = ref(false)
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
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 fetchUsers = async (page = 1) => {
loading.value = true
try {
const { data } = await client.get('/auth/users/', { params: { page } })
users.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载用户列表失败,请重试')
} finally {
loading.value = false
}
}
const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }
function handlePageChange(newPage) {
fetchUsers(newPage)
}
function openDialog(user = null) {
editing.value = user
if (user) Object.assign(form, user)
@ -81,7 +110,7 @@ async function save() {
else await client.post('/auth/users/', form)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchUsers()
fetchUsers(1)
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
}
onMounted(() => { fetchUsers(); fetchOrgs() })

View File

@ -2,45 +2,396 @@
<div class="auth-page">
<el-card class="auth-card">
<h2>登录</h2>
<el-form :model="form" @submit.prevent="handleLogin">
<el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">登录</el-button>
<!-- 角色选择 -->
<div class="role-tabs">
<button
:class="['role-tab', { active: role === 'seeker' }]"
@click="role = 'seeker'"
>
求职者
</button>
<button
:class="['role-tab', { active: role === 'admin' }]"
@click="role = 'admin'"
>
管理员
</button>
</div>
<!-- 求职者登入 -->
<div v-if="role === 'seeker'" class="login-container">
<!-- 登入方式选择 -->
<div class="method-tabs">
<button
:class="['method-tab', { active: seekerMethod === 'password' }]"
@click="seekerMethod = 'password'"
>
邮箱/用户名登入
</button>
<button
:class="['method-tab', { active: seekerMethod === 'code' }]"
@click="seekerMethod = 'code'"
>
验证码登入
</button>
</div>
<!-- 密码登入 -->
<el-form v-if="seekerMethod === 'password'" :model="passwordForm" @submit.prevent="handlePasswordLogin">
<el-form-item>
<el-input
v-model="passwordForm.username"
placeholder="邮箱或用户名"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="passwordForm.password"
type="password"
placeholder="密码"
show-password
/>
</el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="passwordLoading"
style="width: 100%"
>
登录
</el-button>
<div style="text-align: right; margin-top: 8px;">
<router-link to="/forgot-password" style="color: #909399; font-size: 12px; text-decoration: none;">
忘记密码
</router-link>
</div>
</el-form>
<div style="margin-top:12px;text-align:center">
没有账号<router-link to="/register">立即注册</router-link>
<!-- 验证码登入 -->
<el-form v-if="seekerMethod === 'code'" @submit.prevent="handleCodeLogin">
<!-- 邮箱输入 -->
<el-form-item>
<el-input
v-model="codeForm.email"
type="email"
placeholder="请输入邮箱"
:disabled="codeSent"
/>
</el-form-item>
<!-- 获取验证码按钮 -->
<el-form-item v-if="!codeSent">
<el-button
type="info"
@click="handleSendCode"
:loading="sendingCode"
style="width: 100%"
>
获取验证码
</el-button>
</el-form-item>
<!-- 验证码输入 -->
<el-form-item v-if="codeSent">
<div style="display: flex; gap: 8px;">
<el-input
v-model="codeForm.code"
placeholder="请输入验证码6位"
maxlength="6"
style="flex: 1"
/>
<el-button
type="info"
@click="handleResendCode"
:disabled="codeCountdown > 0"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
</el-button>
</div>
</el-form-item>
<!-- 登录按钮 -->
<el-button
v-if="codeSent"
type="primary"
native-type="submit"
:loading="codeLoading"
style="width: 100%"
>
登录
</el-button>
</el-form>
</div>
<!-- 管理员登入 -->
<el-form v-if="role === 'admin'" :model="adminForm" @submit.prevent="handleAdminLogin">
<el-form-item>
<el-input
v-model="adminForm.username"
placeholder="用户名"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="adminForm.password"
type="password"
placeholder="密码"
show-password
/>
</el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="adminLoading"
style="width: 100%"
>
登录
</el-button>
</el-form>
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
<span v-if="role === 'seeker'">
没有账号<router-link to="/register" style="color: #409eff; text-decoration: none;">立即注册</router-link>
</span>
</div>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { sendCode, loginApi } from '@/api/auth'
const role = ref('seeker')
const seekerMethod = ref('password')
//
const passwordForm = reactive({ username: '', password: '' })
const passwordLoading = ref(false)
//
const codeForm = reactive({ email: '', code: '' })
const codeSent = ref(false)
const sendingCode = ref(false)
const codeLoading = ref(false)
const codeCountdown = ref(0)
//
const adminForm = reactive({ username: '', password: '' })
const adminLoading = ref(false)
const form = reactive({ username: '', password: '' })
const loading = ref(false)
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
async function handleLogin() {
loading.value = true
//
const startCountdown = () => {
codeCountdown.value = 60
const interval = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(interval)
}, 1000)
}
//
async function handleSendCode() {
if (!codeForm.email) {
ElMessage.warning('请输入邮箱')
return
}
sendingCode.value = true
try {
await auth.login(form.username, form.password)
await sendCode(codeForm.email)
ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true
startCountdown()
} catch (err) {
ElMessage.error(err.response?.data?.email?.[0] || err.response?.data?.error || '发送失败')
} finally {
sendingCode.value = false
}
}
//
async function handleResendCode() {
codeForm.code = ''
await handleSendCode()
}
//
async function handlePasswordLogin() {
if (!passwordForm.username || !passwordForm.password) {
ElMessage.warning('请输入邮箱/用户名和密码')
return
}
passwordLoading.value = true
try {
const { data } = await loginApi({
username: passwordForm.username,
password: passwordForm.password,
})
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe()
ElMessage.success('登录成功')
const redirect = route.query.redirect
if (redirect) return router.push(redirect)
if (auth.isSeeker) router.push('/seeker/resume')
else router.push('/admin/jobs')
} catch {
ElMessage.error('用户名或密码错误')
router.push('/seeker/resume')
} catch (err) {
ElMessage.error(err.response?.data?.detail || err.response?.data?.username?.[0] || '登录失败')
} finally {
loading.value = false
passwordLoading.value = false
}
}
//
async function handleCodeLogin() {
if (!codeForm.code || codeForm.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return
}
codeLoading.value = true
try {
const { data } = await loginApi({
email: codeForm.email,
code: codeForm.code,
})
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe()
ElMessage.success('登录成功')
const redirect = route.query.redirect
if (redirect) return router.push(redirect)
router.push('/seeker/resume')
} catch (err) {
ElMessage.error(err.response?.data?.code?.[0] || '登录失败')
} finally {
codeLoading.value = false
}
}
//
async function handleAdminLogin() {
if (!adminForm.username || !adminForm.password) {
ElMessage.warning('请输入用户名和密码')
return
}
adminLoading.value = true
try {
const { data } = await loginApi({
username: adminForm.username,
password: adminForm.password,
})
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe()
ElMessage.success('登录成功')
const redirect = route.query.redirect
if (redirect) return router.push(redirect)
router.push('/admin/jobs')
} catch (err) {
ElMessage.error(err.response?.data?.detail || '用户名或密码错误')
} finally {
adminLoading.value = false
}
}
</script>
<style scoped>
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
.auth-card { width: 380px; }
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.auth-card {
width: 380px;
}
h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
font-weight: 600;
}
.role-tabs {
display: flex;
gap: 12px;
margin-bottom: 24px;
background: #f5f7fa;
padding: 4px;
border-radius: 4px;
}
.role-tab {
flex: 1;
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #606266;
border-radius: 3px;
transition: all 0.2s;
}
.role-tab:hover {
background: rgba(255, 255, 255, 0.5);
}
.role-tab.active {
background: #fff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.method-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
background: #f5f7fa;
padding: 4px;
border-radius: 4px;
}
.method-tab {
flex: 1;
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
color: #606266;
border-radius: 3px;
transition: all 0.2s;
}
.method-tab:hover {
background: rgba(255, 255, 255, 0.5);
}
.method-tab.active {
background: #fff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.login-container {
width: 100%;
}
</style>

View File

@ -3,42 +3,165 @@
<el-card class="auth-card">
<h2>求职者注册</h2>
<el-form :model="form" @submit.prevent="handleRegister">
<el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
<el-form-item><el-input v-model="form.email" placeholder="邮箱" /></el-form-item>
<el-form-item><el-input v-model="form.phone" placeholder="手机号(可选)" /></el-form-item>
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码至少6位" show-password /></el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">注册</el-button>
<!-- 邮箱 -->
<el-form-item>
<el-input
v-model="form.email"
type="email"
placeholder="请输入邮箱"
/>
</el-form-item>
<!-- 用户名 -->
<el-form-item>
<el-input
v-model="form.username"
placeholder="请输入用户名"
/>
</el-form-item>
<!-- 手机号 -->
<el-form-item>
<el-input
v-model="form.phone"
placeholder="请输入手机号"
/>
</el-form-item>
<!-- 密码 -->
<el-form-item>
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码至少6位"
show-password
/>
</el-form-item>
<!-- 确认密码 -->
<el-form-item>
<el-input
v-model="form.passwordConfirm"
type="password"
placeholder="请确认密码"
show-password
/>
</el-form-item>
<!-- 注册按钮 -->
<el-button
type="primary"
native-type="submit"
:loading="loading"
style="width: 100%"
>
注册
</el-button>
</el-form>
<div style="margin-top:12px;text-align:center">
已有账号<router-link to="/login">立即登录</router-link>
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
已有账号<router-link to="/login" style="color: #409eff; text-decoration: none;">立即登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { register } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const form = reactive({ username: '', email: '', phone: '', password: '' })
const form = reactive({
email: '',
username: '',
phone: '',
password: '',
passwordConfirm: '',
})
const loading = ref(false)
const router = useRouter()
const auth = useAuthStore()
async function handleRegister() {
//
if (!form.email) {
ElMessage.warning('请输入邮箱')
return
}
if (!form.username) {
ElMessage.warning('请输入用户名')
return
}
if (!form.phone) {
ElMessage.warning('请输入手机号')
return
}
if (!form.password || form.password.length < 6) {
ElMessage.warning('请输入至少6位密码')
return
}
if (form.password !== form.passwordConfirm) {
ElMessage.warning('两次输入的密码不一致')
return
}
loading.value = true
try {
await register(form)
ElMessage.success('注册成功,请登录')
router.push('/login')
} catch (e) {
ElMessage.error(e.response?.data?.username?.[0] || '注册失败,请重试')
const response = await fetch('/api/auth/register/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: form.email,
username: form.username,
phone: form.phone,
password: form.password,
}),
})
if (!response.ok) {
const data = await response.json()
const errorMsg = data.username?.[0] || data.email?.[0] || data.phone?.[0] || data.password?.[0] || '注册失败'
ElMessage.error(errorMsg)
return
}
const data = await response.json()
//
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe()
ElMessage.success('注册成功')
router.push('/home')
} catch (err) {
ElMessage.error('注册失败,请稍后重试')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
.auth-card { width: 380px; }
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.auth-card {
width: 380px;
}
h2 {
text-align: center;
margin-bottom: 24px;
color: #333;
font-weight: 600;
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div class="auth-page">
<el-card class="auth-card">
<h2>重置密码</h2>
<el-form @submit.prevent="handleResetPassword">
<!-- 第一步邮箱验证 -->
<div v-if="step === 1">
<el-form-item>
<el-input
v-model="form.email"
type="email"
placeholder="请输入邮箱"
:disabled="codeSent"
/>
</el-form-item>
<el-form-item v-if="!codeSent">
<el-button
type="info"
@click="handleSendCode"
:loading="sendingCode"
style="width: 100%"
>
获取验证码
</el-button>
</el-form-item>
<el-form-item v-if="codeSent">
<div style="display: flex; gap: 8px;">
<el-input
v-model="form.code"
placeholder="请输入验证码6位"
maxlength="6"
style="flex: 1"
/>
<el-button
type="info"
@click="handleResendCode"
:disabled="codeCountdown > 0"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
</el-button>
</div>
</el-form-item>
<el-button
v-if="codeSent"
type="primary"
@click="goToStep2"
style="width: 100%"
>
下一步
</el-button>
</div>
<!-- 第二步重置密码 -->
<div v-if="step === 2">
<div style="margin-bottom: 16px; padding: 8px 12px; background: #f0f9ff; border-radius: 4px; color: #606266; font-size: 12px;">
邮箱<span style="font-weight: 600;">{{ form.email }}</span>
</div>
<el-form-item>
<el-input
v-model="form.password"
type="password"
placeholder="请输入新密码至少6位"
show-password
/>
</el-form-item>
<el-form-item>
<el-input
v-model="form.passwordConfirm"
type="password"
placeholder="请确认新密码"
show-password
/>
</el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="resetLoading"
style="width: 100%"
>
重置密码
</el-button>
<el-button
@click="goBackToStep1"
style="width: 100%; margin-top: 8px;"
>
上一步
</el-button>
</div>
</el-form>
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
<router-link to="/login" style="color: #409eff; text-decoration: none;">返回登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const step = ref(1)
const form = reactive({
email: '',
code: '',
password: '',
passwordConfirm: '',
})
const codeSent = ref(false)
const sendingCode = ref(false)
const resetLoading = ref(false)
const codeCountdown = ref(0)
const router = useRouter()
//
const startCountdown = () => {
codeCountdown.value = 60
const interval = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(interval)
}, 1000)
}
//
async function handleSendCode() {
if (!form.email) {
ElMessage.warning('请输入邮箱')
return
}
sendingCode.value = true
try {
const response = await fetch('/api/auth/reset-password/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: form.email }),
})
if (!response.ok) {
const data = await response.json()
ElMessage.error(data.email?.[0] || '发送失败')
return
}
ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true
startCountdown()
} catch (err) {
ElMessage.error('发送失败,请稍后重试')
} finally {
sendingCode.value = false
}
}
//
async function handleResendCode() {
form.code = ''
await handleSendCode()
}
//
function goToStep2() {
if (!form.code || form.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return
}
step.value = 2
}
//
function goBackToStep1() {
step.value = 1
}
//
async function handleResetPassword() {
if (!form.password || form.password.length < 6) {
ElMessage.warning('请输入至少6位密码')
return
}
if (form.password !== form.passwordConfirm) {
ElMessage.warning('两次输入的密码不一致')
return
}
resetLoading.value = true
try {
const response = await fetch('/api/auth/confirm-reset-password/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.email,
code: form.code,
new_password: form.password,
}),
})
if (!response.ok) {
const data = await response.json()
const errorMsg = data.code?.[0] || data.new_password?.[0] || '重置失败'
ElMessage.error(errorMsg)
return
}
ElMessage.success('密码重置成功,请使用新密码登入')
router.push('/login')
} catch (err) {
ElMessage.error('密码重置失败,请稍后重试')
} finally {
resetLoading.value = false
}
}
</script>
<style scoped>
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.auth-card {
width: 380px;
}
h2 {
text-align: center;
margin-bottom: 24px;
color: #333;
font-weight: 600;
}
</style>

View File

@ -25,8 +25,13 @@
<el-icon><Star /></el-icon>
{{ collected ? '已收藏' : '收藏' }}
</el-button>
<el-button class="btn-apply" @click="handleApply" :loading="applying">
立即投递
<el-button
class="btn-apply"
@click="handleApply"
:loading="applying"
:disabled="applied"
>
{{ applied ? '已投递' : '立即投递' }}
</el-button>
</div>
<p class="apply-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
@ -107,8 +112,14 @@
<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
class="btn-apply-sm"
@click="handleApply"
:loading="applying"
:disabled="applied"
style="margin-top:12px;width:100%"
>
{{ applied ? '已投递' : '立即投递' }}
</el-button>
<p class="apply-hint-sm" v-if="applied">已投递成功</p>
</div>
@ -121,8 +132,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJob, toggleFavorite } from '@/api/jobs'
import { applyJob } from '@/api/applications'
import { getJob, toggleFavorite, getMyFavorites } from '@/api/jobs'
import { applyJob, getMyApplications } from '@/api/applications'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { Location, OfficeBuilding, Star, Message } from '@element-plus/icons-vue'
@ -138,9 +149,26 @@ const collected = ref(false)
onMounted(async () => {
loading.value = true
try {
const { data } = await getJob(route.params.id)
job.value = data
//
if (auth.isLoggedIn && auth.isSeeker) {
try {
const [{ data: applications }, { data: favorites }] = await Promise.all([
getMyApplications(),
getMyFavorites(),
])
applied.value = applications.results?.some(app => app.job === parseInt(route.params.id)) || false
collected.value = favorites.results?.some(f => f.job.id === parseInt(route.params.id)) || false
} catch (e) {
console.error('Failed to fetch user state:', e)
}
}
} finally {
loading.value = false
}
})
function formatDate(dt) {
@ -159,6 +187,8 @@ async function toggleCollect() {
async function handleApply() {
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
if (applied.value) return ElMessage.warning('您已投递过该职位')
applying.value = true
try {
await applyJob(job.value.id)
@ -166,6 +196,7 @@ async function handleApply() {
ElMessage.success('投递成功!')
} catch (e) {
if (e.response?.status === 400) {
applied.value = true
ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
} else {
ElMessage.error('投递失败,请先完善简历')
@ -255,10 +286,16 @@ async function handleApply() {
padding: 10px 28px;
font-size: 15px;
}
.btn-apply:hover {
.btn-apply:hover:not(:disabled) {
background: #c42820 !important;
border-color: #c42820 !important;
}
.btn-apply:disabled {
background: #ccc !important;
border-color: #ccc !important;
color: #fff !important;
cursor: not-allowed !important;
}
.apply-hint {
font-size: 12px;
color: #999;
@ -445,10 +482,16 @@ async function handleApply() {
color: #fff !important;
font-weight: 600;
}
.btn-apply-sm:hover {
.btn-apply-sm:hover:not(:disabled) {
background: #c42820 !important;
border-color: #c42820 !important;
}
.btn-apply-sm:disabled {
background: #ccc !important;
border-color: #ccc !important;
color: #fff !important;
cursor: not-allowed !important;
}
.apply-hint-sm {
text-align: center;
font-size: 12px;

View File

@ -0,0 +1,39 @@
<template>
<div>
<h2>我的收藏</h2>
<el-table :data="favorites" v-loading="loading" border>
<el-table-column prop="job.title" label="职位" />
<el-table-column prop="job.organization_name" label="公司" />
<el-table-column prop="job.location" label="地点" />
<el-table-column prop="job.salary" label="薪资" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="primary" size="small" @click="router.push({ name: 'JobDetail', params: { id: row.job.id } })">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="favorites.length === 0 && !loading" description="暂无收藏职位" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getMyFavorites } from '@/api/jobs'
const router = useRouter()
const favorites = ref([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const { data } = await getMyFavorites()
favorites.value = data.results ?? data
} catch (e) {
console.error('Failed to fetch favorites:', e)
} finally {
loading.value = false
}
})
</script>

View File

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