Compare commits

..

No commits in common. "0d7576dc85199e2b5cfa566b3960fe65756d341a" and "cbb0c2f2682ff4cb5a2b6e4554e12a5ddd1c8dbd" have entirely different histories.

32 changed files with 134 additions and 3439 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,51 +1,5 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models 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): class User(AbstractUser):
@ -54,9 +8,8 @@ class User(AbstractUser):
('admin', '公司管理员'), ('admin', '公司管理员'),
('seeker', '求职者'), ('seeker', '求职者'),
] ]
email = models.EmailField(unique=True) # 设置邮箱为唯一且必填
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='seeker') role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='seeker')
phone = models.CharField(max_length=20) phone = models.CharField(max_length=20, blank=True)
organization = models.ForeignKey( organization = models.ForeignKey(
'organizations.Organization', 'organizations.Organization',
null=True, blank=True, null=True, blank=True,

View File

@ -1,39 +1,18 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import VerificationCode
User = get_user_model() User = get_user_model()
class RegisterSerializer(serializers.Serializer): class RegisterSerializer(serializers.ModelSerializer):
"""密码注册 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) password = serializers.CharField(write_only=True, min_length=6)
def validate_username(self, value): class Meta:
"""验证用户名是否已存在""" model = User
if User.objects.filter(username=value).exists(): fields = ['username', 'email', 'phone', 'password']
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): 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): class UserSerializer(serializers.ModelSerializer):
@ -57,143 +36,3 @@ class AdminUserSerializer(serializers.ModelSerializer):
user.set_password(password) user.set_password(password)
user.save() user.save()
return user 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,18 +1,11 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import ( from .views import RegisterView, MeView, UserManageViewSet, UserDetailView
RegisterView, MeView, UserManageViewSet, UserDetailView,
SendCodeView, CustomTokenObtainPairView,
RequestResetPasswordView, ConfirmResetPasswordView
)
urlpatterns = [ urlpatterns = [
path('register/', RegisterView.as_view()), path('register/', RegisterView.as_view()),
path('send-code/', SendCodeView.as_view()), path('login/', TokenObtainPairView.as_view()),
path('login/', CustomTokenObtainPairView.as_view()),
path('token/refresh/', TokenRefreshView.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('me/', MeView.as_view()),
path('users/', UserManageViewSet.as_view()), path('users/', UserManageViewSet.as_view()),
path('users/<int:pk>/', UserDetailView.as_view()), path('users/<int:pk>/', UserDetailView.as_view()),

View File

@ -1,191 +1,18 @@
from rest_framework import generics, status from rest_framework import generics
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.mail import send_mail from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer
from django.conf import settings
from .models import VerificationCode
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, CodeLoginSerializer, PasswordLoginSerializer, ResetPasswordSerializer, ConfirmResetPasswordSerializer
from .permissions import IsSuperAdmin from .permissions import IsSuperAdmin
User = get_user_model() User = get_user_model()
class SendCodeView(APIView): class RegisterView(generics.CreateAPIView):
"""发送邮箱验证码(用于登入或密码重置)""" serializer_class = RegisterSerializer
permission_classes = [AllowAny] 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): class MeView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]

View File

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

View File

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

View File

@ -90,11 +90,3 @@ LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai' TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True USE_I18N = True
USE_TZ = 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 applyJob = (jobId) => client.post('/applications/apply/', { job: jobId })
export const getMyApplications = () => client.get('/applications/mine/') export const getMyApplications = () => client.get('/applications/mine/')
export const getManageApplications = (params = {}) => client.get('/applications/manage/', { params: { page: params.page || 1, ...params } }) export const getManageApplications = (params) => client.get('/applications/manage/', { params })
export const updateApplicationStatus = (id, data) => client.patch(`/applications/manage/${id}/status/`, data) export const updateApplicationStatus = (id, data) => client.patch(`/applications/manage/${id}/status/`, data)

View File

@ -1,11 +1,7 @@
import client from './client' import client from './client'
import axios from 'axios' import axios from 'axios'
export const sendCode = (email) => axios.post('/api/auth/send-code/', { email }) export const login = (data) => axios.post('/api/auth/login/', data)
export const loginApi = (data) => axios.post('/api/auth/login/', data) export const register = (data) => client.post('/auth/register/', data)
export const register = (data) => axios.post('/api/auth/register/', data)
export const getMe = () => client.get('/auth/me/') export const getMe = () => client.get('/auth/me/')
export const updateMe = (data) => client.patch('/auth/me/', data) 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 getJobs = (params) => client.get('/jobs/public/', { params })
export const getJob = (id) => client.get(`/jobs/public/${id}/`) export const getJob = (id) => client.get(`/jobs/public/${id}/`)
export const manageJobs = (params = {}) => client.get('/jobs/manage/', { params: { page: params.page || 1, ...params } }) export const manageJobs = (params) => client.get('/jobs/manage/', { params })
export const createJob = (data) => client.post('/jobs/manage/', data) export const createJob = (data) => client.post('/jobs/manage/', data)
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data) export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`) 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 getOrganizations = () => client.get('/organizations/public/')
export const getOrganization = (id) => client.get(`/organizations/public/${id}/`) export const getOrganization = (id) => client.get(`/organizations/public/${id}/`)
export const manageOrganizations = (page = 1) => client.get('/organizations/manage/', { params: { page } }) export const manageOrganizations = () => client.get('/organizations/manage/')
export const createOrganization = (data) => client.post('/organizations/manage/', data) export const createOrganization = (data) => client.post('/organizations/manage/', data)
export const updateOrganization = (id, data) => client.patch(`/organizations/manage/${id}/`, data) export const updateOrganization = (id, data) => client.patch(`/organizations/manage/${id}/`, data)
export const deleteOrganization = (id) => client.delete(`/organizations/manage/${id}/`) export const deleteOrganization = (id) => client.delete(`/organizations/manage/${id}/`)

View File

@ -1,13 +1,5 @@
<template> <template>
<div class="job-card-wrapper">
<el-card class="job-card" shadow="hover" @click="$router.push(`/jobs/${job.id}`)"> <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-title">{{ job.title }}</div>
<div class="job-meta"> <div class="job-meta">
<span>{{ job.company_name || job.organization_name }}</span> <span>{{ job.company_name || job.organization_name }}</span>
@ -16,87 +8,15 @@
<el-divider direction="vertical" /> <el-divider direction="vertical" />
<span class="salary">{{ job.salary }}</span> <span class="salary">{{ job.salary }}</span>
</div> </div>
<div class="job-footer">
<el-tag size="small">{{ job.category }}</el-tag> <el-tag size="small">{{ job.category }}</el-tag>
<span class="publish-time">{{ formatDate(job.created_at) }}</span>
</div>
</el-card> </el-card>
</div>
</template> </template>
<script setup> <script setup>
import { ElMessage } from 'element-plus' defineProps({ job: Object })
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> </script>
<style scoped> <style scoped>
.job-card-wrapper { .job-card { cursor: pointer; margin-bottom: 12px; }
position: relative; .job-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
margin-bottom: 12px; .job-meta { color: #666; font-size: 13px; margin-bottom: 8px; }
} .salary { color: #f56c6c; font-weight: 500; }
.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> </style>

View File

@ -1,22 +1,7 @@
<template> <template>
<el-container style="min-height: 100vh;"> <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;"> <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 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/jobs">职位管理</el-menu-item>
<el-menu-item index="/admin/applications">投递管理</el-menu-item> <el-menu-item index="/admin/applications">投递管理</el-menu-item>
@ -28,20 +13,8 @@
</el-aside> </el-aside>
<el-main><router-view /></el-main> <el-main><router-view /></el-main>
</el-container> </el-container>
</el-container>
</template> </template>
<script setup> <script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
function logout() {
localStorage.clear()
ElMessage.success('已退出登入')
router.push({ name: 'Login' })
}
</script> </script>

View File

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

View File

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

View File

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

View File

@ -1,538 +0,0 @@
<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,70 +25,16 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-dialog v-model="resumeVisible" title="简历详情" width="700px"> <el-dialog v-model="resumeVisible" title="简历详情" width="600px">
<div v-if="currentResume" class="resume-detail"> <div v-if="currentResume">
<!-- 基本信息 --> <p><strong>姓名</strong>{{ currentResume.name }}</p>
<div class="section"> <p><strong>性别</strong>{{ currentResume.gender }}</p>
<div class="section-title">基本信息</div> <el-divider>教育经历</el-divider>
<div class="info-grid"> <div v-for="(e, i) in currentResume.education" :key="i">{{ e.school }} · {{ e.degree }} · {{ e.major }}</div>
<div class="info-item"> <el-divider>工作经历</el-divider>
<span class="label">姓名</span> <div v-for="(e, i) in currentResume.experience" :key="i">{{ e.company }} · {{ e.position }} · {{ e.duration }}</div>
<span class="value">{{ currentResume.name || '-' }}</span> <div v-if="currentResume.attachment_url" style="margin-top:16px">
</div> <a :href="currentResume.attachment_url" target="_blank">下载简历附件</a>
<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>
</div> </div>
</el-dialog> </el-dialog>
@ -98,20 +44,12 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { getManageApplications, updateApplicationStatus } from '@/api/applications' import { getManageApplications, updateApplicationStatus } from '@/api/applications'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
const applications = ref([]) const applications = ref([])
const loading = ref(false) const loading = ref(false)
const resumeVisible = ref(false) const resumeVisible = ref(false)
const currentResume = ref(null) const currentResume = ref(null)
const genderMap = {
'male': '男',
'female': '女',
'other': '其他',
'': '-'
}
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = true
const { data } = await getManageApplications() const { data } = await getManageApplications()
@ -130,116 +68,4 @@ function viewResume(row) {
currentResume.value = row.resume_snapshot currentResume.value = row.resume_snapshot
resumeVisible.value = true 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> </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,15 +23,6 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </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-dialog v-model="dialogVisible" :title="editingJob ? '编辑职位' : '发布职位'" width="600px">
<el-form :model="form" label-width="90px"> <el-form :model="form" label-width="90px">
@ -87,22 +78,12 @@ const form = reactive({
title: '', category: '', location: '', salary: '', title: '', category: '', location: '', salary: '',
description: '', status: 'draft', organization_id: null description: '', status: 'draft', organization_id: null
}) })
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const fetchJobs = async (page = 1) => { const fetchJobs = async () => {
loading.value = true loading.value = true
try { const { data } = await manageJobs()
const { data } = await manageJobs(page)
jobs.value = data.results jobs.value = data.results
total.value = data.count
currentPage.value = page
} catch (error) {
ElMessage.error('加载职位列表失败,请重试')
} finally {
loading.value = false loading.value = false
}
} }
const fetchOrgs = async () => { const fetchOrgs = async () => {
@ -131,10 +112,6 @@ function openDialog(job = null) {
dialogVisible.value = true dialogVisible.value = true
} }
function handlePageChange(newPage) {
fetchJobs(newPage)
}
async function handleSave() { async function handleSave() {
if (auth.isSuperAdmin && !form.organization_id) { if (auth.isSuperAdmin && !form.organization_id) {
return ElMessage.warning('请选择所属公司') return ElMessage.warning('请选择所属公司')
@ -147,7 +124,7 @@ async function handleSave() {
else await createJob(payload) else await createJob(payload)
ElMessage.success('保存成功') ElMessage.success('保存成功')
dialogVisible.value = false dialogVisible.value = false
fetchJobs(1) fetchJobs()
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败') ElMessage.error(e.response?.data?.detail || '保存失败')
} finally { } finally {
@ -159,7 +136,7 @@ async function handleDelete(id) {
await ElMessageBox.confirm('确认删除该职位?', '提示', { type: 'warning' }) await ElMessageBox.confirm('确认删除该职位?', '提示', { type: 'warning' })
await deleteJob(id) await deleteJob(id)
ElMessage.success('已删除') ElMessage.success('已删除')
fetchJobs(1) fetchJobs()
} }
onMounted(() => { onMounted(() => {

View File

@ -4,39 +4,26 @@
<h2>组织架构管理</h2> <h2>组织架构管理</h2>
<el-button type="primary" @click="openDialog()">新增公司</el-button> <el-button type="primary" @click="openDialog()">新增公司</el-button>
</div> </div>
<el-table :data="orgs" border v-loading="loading"> <el-table :data="orgs" border>
<el-table-column prop="name" label="公司名称" /> <el-table-column prop="name" label="公司名称" />
<el-table-column label="上级公司"> <el-table-column label="上级公司">
<template #default="{ row }">{{ row.parent ? allOrgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template> <template #default="{ row }">{{ row.parent ? orgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="email" label="联系邮箱" /> <el-table-column prop="email" label="联系邮箱" />
<el-table-column label="状态"> <el-table-column label="状态">
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'启用':'停用' }}</el-tag></template> <template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'启用':'停用' }}</el-tag></template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180"> <el-table-column label="操作" width="120">
<template #default="{ row }"> <template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
<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-column>
</el-table> </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-dialog v-model="dialogVisible" :title="editing ? '编辑公司' : '新增公司'" width="480px">
<el-form :model="form" label-width="90px"> <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-input v-model="form.name" /></el-form-item>
<el-form-item label="上级公司"> <el-form-item label="上级公司">
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级"> <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-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item> <el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
@ -52,46 +39,18 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { manageOrganizations, createOrganization, updateOrganization, deleteOrganization } from '@/api/organizations' import { manageOrganizations, createOrganization, updateOrganization } from '@/api/organizations'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
const orgs = ref([]) const orgs = ref([])
const allOrgs = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editing = ref(null) const editing = ref(null)
const saving = ref(false) 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 form = reactive({ name: '', parent: null, email: '', description: '', is_active: true })
const fetchOrgs = async (page = 1) => { const fetchOrgs = async () => {
loading.value = true const { data } = await manageOrganizations()
try {
const { data } = await manageOrganizations(page)
orgs.value = data.results 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) { function openDialog(org = null) {
@ -108,35 +67,8 @@ async function save() {
else await createOrganization(form) else await createOrganization(form)
ElMessage.success('保存成功') ElMessage.success('保存成功')
dialogVisible.value = false dialogVisible.value = false
fetchOrgs(1) fetchOrgs()
fetchAllOrgs()
} catch { ElMessage.error('保存失败') } finally { saving.value = false } } 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> </script>

View File

@ -4,7 +4,7 @@
<h2>用户管理</h2> <h2>用户管理</h2>
<el-button type="primary" @click="openDialog()">新增管理员</el-button> <el-button type="primary" @click="openDialog()">新增管理员</el-button>
</div> </div>
<el-table :data="users" v-loading="loading" border> <el-table :data="users" border>
<el-table-column prop="username" label="用户名" /> <el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" /> <el-table-column prop="email" label="邮箱" />
<el-table-column label="角色"> <el-table-column label="角色">
@ -24,15 +24,6 @@
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template> <template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
</el-table-column> </el-table-column>
</el-table> </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-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新增管理员'" width="480px">
<el-form :model="form" label-width="90px"> <el-form :model="form" label-width="90px">
@ -71,31 +62,11 @@ const orgs = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editing = ref(null) const editing = ref(null)
const saving = ref(false) 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 form = reactive({ username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
const fetchUsers = async (page = 1) => { const fetchUsers = async () => { const { data } = await client.get('/auth/users/'); users.value = data.results || data }
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 } const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }
function handlePageChange(newPage) {
fetchUsers(newPage)
}
function openDialog(user = null) { function openDialog(user = null) {
editing.value = user editing.value = user
if (user) Object.assign(form, user) if (user) Object.assign(form, user)
@ -110,7 +81,7 @@ async function save() {
else await client.post('/auth/users/', form) else await client.post('/auth/users/', form)
ElMessage.success('保存成功') ElMessage.success('保存成功')
dialogVisible.value = false dialogVisible.value = false
fetchUsers(1) fetchUsers()
} catch { ElMessage.error('保存失败') } finally { saving.value = false } } catch { ElMessage.error('保存失败') } finally { saving.value = false }
} }
onMounted(() => { fetchUsers(); fetchOrgs() }) onMounted(() => { fetchUsers(); fetchOrgs() })

View File

@ -2,396 +2,45 @@
<div class="auth-page"> <div class="auth-page">
<el-card class="auth-card"> <el-card class="auth-card">
<h2>登录</h2> <h2>登录</h2>
<el-form :model="form" @submit.prevent="handleLogin">
<!-- 角色选择 --> <el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
<div class="role-tabs"> <el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item>
<button <el-button type="primary" native-type="submit" :loading="loading" style="width:100%">登录</el-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> </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> </div>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' 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 router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
// async function handleLogin() {
const startCountdown = () => { loading.value = true
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 { try {
await sendCode(codeForm.email) await auth.login(form.username, form.password)
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 const redirect = route.query.redirect
if (redirect) return router.push(redirect) if (redirect) return router.push(redirect)
router.push('/seeker/resume') if (auth.isSeeker) router.push('/seeker/resume')
} catch (err) { else router.push('/admin/jobs')
ElMessage.error(err.response?.data?.detail || err.response?.data?.username?.[0] || '登录失败') } catch {
ElMessage.error('用户名或密码错误')
} finally { } finally {
passwordLoading.value = false loading.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> </script>
<style scoped> <style scoped>
.auth-page { .auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
display: flex; .auth-card { width: 380px; }
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> </style>

View File

@ -3,165 +3,42 @@
<el-card class="auth-card"> <el-card class="auth-card">
<h2>求职者注册</h2> <h2>求职者注册</h2>
<el-form :model="form" @submit.prevent="handleRegister"> <el-form :model="form" @submit.prevent="handleRegister">
<!-- 邮箱 --> <el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
<el-form-item> <el-form-item><el-input v-model="form.email" placeholder="邮箱" /></el-form-item>
<el-input <el-form-item><el-input v-model="form.phone" placeholder="手机号(可选)" /></el-form-item>
v-model="form.email" <el-form-item><el-input v-model="form.password" type="password" placeholder="密码至少6位" show-password /></el-form-item>
type="email" <el-button type="primary" native-type="submit" :loading="loading" style="width:100%">注册</el-button>
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> </el-form>
<div style="margin-top:12px;text-align:center">
<div style="margin-top: 12px; text-align: center; font-size: 12px;"> 已有账号<router-link to="/login">立即登录</router-link>
已有账号<router-link to="/login" style="color: #409eff; text-decoration: none;">立即登录</router-link>
</div> </div>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { register } from '@/api/auth'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const form = reactive({ const form = reactive({ username: '', email: '', phone: '', password: '' })
email: '',
username: '',
phone: '',
password: '',
passwordConfirm: '',
})
const loading = ref(false) const loading = ref(false)
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
async function handleRegister() { 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 loading.value = true
try { try {
const response = await fetch('/api/auth/register/', { await register(form)
method: 'POST', ElMessage.success('注册成功,请登录')
headers: { router.push('/login')
'Content-Type': 'application/json', } catch (e) {
}, ElMessage.error(e.response?.data?.username?.[0] || '注册失败,请重试')
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 { } finally {
loading.value = false loading.value = false
} }
} }
</script> </script>
<style scoped> <style scoped>
.auth-page { .auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
display: flex; .auth-card { width: 380px; }
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> </style>

View File

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

View File

@ -1,39 +0,0 @@
<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,8 +16,7 @@ export default defineConfig({
}, },
server: { server: {
proxy: { 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 }
} }
} }
}) })