Compare commits
27 Commits
cbb0c2f268
...
0d7576dc85
| Author | SHA1 | Date |
|---|---|---|
|
|
0d7576dc85 | |
|
|
609aa05514 | |
|
|
5275dd211e | |
|
|
842704f095 | |
|
|
10fa8aedfe | |
|
|
724eef2b19 | |
|
|
00abe1da15 | |
|
|
85e5cbd9c9 | |
|
|
11c1104ab1 | |
|
|
e42dbecf41 | |
|
|
df1c9b5d1d | |
|
|
3f62844d82 | |
|
|
9faa54481a | |
|
|
cc1a6459bd | |
|
|
54230b6cfd | |
|
|
79706dd840 | |
|
|
a6ebd3af87 | |
|
|
1ec8734401 | |
|
|
fbcd98dc46 | |
|
|
911e872a4a | |
|
|
8a5ed86421 | |
|
|
b6d5a51c3d | |
|
|
12697c5750 | |
|
|
99220b6daf | |
|
|
72e7244ea0 | |
|
|
2edc9beef3 | |
|
|
1029bf812d |
|
|
@ -0,0 +1,701 @@
|
|||
# 管理后台分页功能实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为管理后台的所有表格页面(职位管理、投递管理、组织架构、用户管理)添加统一的分页功能。
|
||||
|
||||
**Architecture:**
|
||||
- 后端已支持分页(DRF 的 `PageNumberPagination`,每页 20 条)
|
||||
- 前端 API 层:修改各模块的 fetch 函数,接受 `page` 参数
|
||||
- 前端组件层:添加分页状态管理、分页事件处理、UI 控件,共 4 个页面
|
||||
- 每个组件遵循相同的模式,便于维护和扩展
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API、Element Plus (el-pagination)、DRF pagination
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 前端 API 层(4 个文件修改)
|
||||
- `offer_frontend/src/api/organizations.js` - 修改 `manageOrganizations(page=1)`
|
||||
- `offer_frontend/src/api/jobs.js` - 修改 `manageJobs(page=1)`
|
||||
- `offer_frontend/src/api/applications.js` - 修改 `getManageApplications(page=1)`
|
||||
- `offer_frontend/src/api/client.js` 或页面内 - 用户管理 API 调用
|
||||
|
||||
### 前端组件层(4 个页面修改)
|
||||
- `offer_frontend/src/views/admin/OrganizationManageView.vue`
|
||||
- `offer_frontend/src/views/admin/JobManageView.vue`
|
||||
- `offer_frontend/src/views/admin/ApplicationManageView.vue`
|
||||
- `offer_frontend/src/views/admin/UserManageView.vue`
|
||||
|
||||
---
|
||||
|
||||
## 实现任务
|
||||
|
||||
### Task 1: 修改 organizations.js - 支持分页参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/api/organizations.js`
|
||||
|
||||
- [ ] **Step 1: 修改 manageOrganizations 函数**
|
||||
|
||||
编辑 `offer_frontend/src/api/organizations.js`,将 `manageOrganizations` 函数改为(使用 params 对象方式保持与其他 API 的一致性):
|
||||
|
||||
```javascript
|
||||
export const manageOrganizations = (page = 1) =>
|
||||
client.get('/organizations/manage/', { params: { page } })
|
||||
```
|
||||
|
||||
> 注:这个改动方式与 jobs.js 和 applications.js 中已有的 `{ params: { ... } }` 方式保持一致
|
||||
|
||||
- [ ] **Step 2: 验证其他导出函数不受影响**
|
||||
|
||||
检查文件中的其他函数(`getOrganizations`, `createOrganization` 等)保持不变。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/api/organizations.js
|
||||
git commit -m "feat(api): 为 manageOrganizations 添加分页参数"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改 jobs.js - 支持分页参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/api/jobs.js`
|
||||
|
||||
- [ ] **Step 1: 修改 manageJobs 函数**
|
||||
|
||||
编辑 `offer_frontend/src/api/jobs.js`,找到 `manageJobs` 函数(通常已有 params 参数),改为支持分页:
|
||||
|
||||
```javascript
|
||||
export const manageJobs = (page = 1, params = {}) =>
|
||||
client.get('/jobs/manage/', { params: { page, ...params } })
|
||||
```
|
||||
|
||||
> 如果现有函数签名已经是 `(params = {})` 的形式,则改为:
|
||||
> ```javascript
|
||||
> export const manageJobs = (params = {}) =>
|
||||
> client.get('/jobs/manage/', { params: { page: params.page || 1, ...params } })
|
||||
> ```
|
||||
|
||||
- [ ] **Step 2: 验证其他导出函数**
|
||||
|
||||
检查 `getJobs`, `createJob`, `updateJob`, `deleteJob` 等函数保持不变。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/api/jobs.js
|
||||
git commit -m "feat(api): 为 manageJobs 添加分页参数"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 修改 applications.js - 支持分页参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/api/applications.js`
|
||||
|
||||
- [ ] **Step 1: 修改 getManageApplications 函数**
|
||||
|
||||
编辑 `offer_frontend/src/api/applications.js`,找到 `getManageApplications` 函数,改为(支持分页 + 保留其他参数):
|
||||
|
||||
```javascript
|
||||
export const getManageApplications = (params = {}) =>
|
||||
client.get('/applications/manage/', { params: { page: params.page || 1, ...params } })
|
||||
```
|
||||
|
||||
> 调用时改为:`getManageApplications({ page: currentPage.value })`
|
||||
|
||||
- [ ] **Step 2: 验证其他函数**
|
||||
|
||||
确保 `applyJob`, `myApplications`, `updateApplicationStatus` 等函数不变。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/api/applications.js
|
||||
git commit -m "feat(api): 为 getManageApplications 添加分页参数"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修改 OrganizationManageView.vue - 添加分页
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/views/admin/OrganizationManageView.vue`
|
||||
|
||||
**Step 4.1: 导入所需函数**
|
||||
|
||||
- [ ] 在 `<script setup>` 导入部分添加(如果还没有):
|
||||
|
||||
```javascript
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { deleteOrganization } from '@/api/organizations'
|
||||
```
|
||||
|
||||
**Step 4.2: 添加分页和数据获取状态**
|
||||
|
||||
- [ ] 在 `const form = reactive(...)` 之后添加以下状态变量:
|
||||
|
||||
```javascript
|
||||
const allOrgs = ref([]) // 所有部门(用于下拉框)
|
||||
const currentPage = ref(1) // 当前页码
|
||||
const pageSize = ref(20) // 每页行数
|
||||
const total = ref(0) // 总条数
|
||||
```
|
||||
|
||||
**Step 4.3: 修改 fetchOrgs 函数**
|
||||
|
||||
- [ ] 将现有的 `fetchOrgs` 函数替换为:
|
||||
|
||||
```javascript
|
||||
const fetchOrgs = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await manageOrganizations(page)
|
||||
orgs.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载部门列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4.4: 新增 fetchAllOrgs 函数**
|
||||
|
||||
- [ ] 在 `fetchOrgs` 函数之后添加(用于获取所有部门给下拉框使用):
|
||||
|
||||
```javascript
|
||||
const fetchAllOrgs = async () => {
|
||||
try {
|
||||
// 预加载前几页,保证下拉框有足够的数据
|
||||
let allResults = []
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const { data } = await manageOrganizations(i)
|
||||
allResults = allResults.concat(data.results)
|
||||
if (i === Math.ceil(data.count / 20)) break // 已加载全部
|
||||
}
|
||||
allOrgs.value = allResults
|
||||
} catch (error) {
|
||||
ElMessage.error('加载部门列表失败')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4.5: 添加分页事件处理函数**
|
||||
|
||||
- [ ] 在 `fetchAllOrgs` 函数之后添加:
|
||||
|
||||
```javascript
|
||||
function handlePageChange(newPage) {
|
||||
fetchOrgs(newPage)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4.6: 新增删除函数**
|
||||
|
||||
- [ ] 添加 `handleDelete()` 函数:
|
||||
|
||||
```javascript
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该部门?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await deleteOrganization(id)
|
||||
ElMessage.success('已删除')
|
||||
currentPage.value = 1
|
||||
fetchOrgs(1)
|
||||
fetchAllOrgs()
|
||||
} catch (error) {
|
||||
if (error.name !== 'ElMessageBoxCancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4.7: 修改保存逻辑**
|
||||
|
||||
- [ ] 找到 `save()` 函数,修改最后的调用为:
|
||||
|
||||
```javascript
|
||||
currentPage.value = 1
|
||||
fetchOrgs(1)
|
||||
fetchAllOrgs()
|
||||
```
|
||||
|
||||
**Step 4.8: 修改初始化**
|
||||
|
||||
- [ ] 修改 `onMounted` 钩子:
|
||||
|
||||
```javascript
|
||||
onMounted(() => {
|
||||
fetchOrgs()
|
||||
fetchAllOrgs()
|
||||
})
|
||||
```
|
||||
|
||||
**Step 4.9: 修改表格的下拉框**
|
||||
|
||||
- [ ] 找到表单中的"上级公司"下拉框,修改为使用 `allOrgs`:
|
||||
|
||||
```vue
|
||||
<el-form-item label="上级公司">
|
||||
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
|
||||
<el-option v-for="o in allOrgs" :key="o.id" :value="o.id" :label="o.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
**Step 4.10: 修改表格模板**
|
||||
|
||||
- [ ] 找到 `<el-table :data="orgs"` 这一行,改为:
|
||||
|
||||
```vue
|
||||
<el-table :data="orgs" v-loading="loading" border>
|
||||
```
|
||||
|
||||
**Step 4.11: 添加删除按钮到操作列**
|
||||
|
||||
- [ ] 找到操作列(`<el-table-column label="操作"`),修改为:
|
||||
|
||||
```vue
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
**Step 4.12: 添加分页控件**
|
||||
|
||||
- [ ] 在 `</el-table>` 之后、`<el-dialog` 之前添加:
|
||||
|
||||
```vue
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/views/admin/OrganizationManageView.vue
|
||||
git commit -m "feat(admin): 为部门管理页面添加分页、删除功能
|
||||
|
||||
- 添加分页状态管理(currentPage, pageSize, total)
|
||||
- 修改 fetchOrgs 支持分页参数
|
||||
- 新增 fetchAllOrgs 用于下拉框完整数据
|
||||
- 新增 handleDelete 删除功能
|
||||
- 修改下拉框使用 allOrgs 而非 orgs
|
||||
- 添加分页控件和 loading 指示
|
||||
- 删除操作后重置分页和刷新数据"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 修改 JobManageView.vue - 添加分页
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/views/admin/JobManageView.vue`
|
||||
|
||||
**Step 5.1: 添加分页状态**
|
||||
|
||||
- [ ] 在 `<script setup>` 中添加分页相关状态(在 `const form = reactive(...)` 之后):
|
||||
|
||||
```javascript
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
```
|
||||
|
||||
**Step 5.2: 修改 fetchJobs 函数**
|
||||
|
||||
- [ ] 替换现有的 `fetchJobs` 函数为:
|
||||
|
||||
```javascript
|
||||
const fetchJobs = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await manageJobs(page)
|
||||
jobs.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载职位列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5.3: 添加分页事件处理**
|
||||
|
||||
- [ ] 在 `fetchJobs` 之后添加:
|
||||
|
||||
```javascript
|
||||
function handlePageChange(newPage) {
|
||||
fetchJobs(newPage)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5.4: 修改 handleSave 函数**
|
||||
|
||||
- [ ] 找到 `handleSave()` 函数中的 `fetchJobs()` 调用,改为:
|
||||
|
||||
```javascript
|
||||
currentPage.value = 1
|
||||
fetchJobs(1)
|
||||
```
|
||||
|
||||
**Step 5.5: 修改表格 v-loading**
|
||||
|
||||
- [ ] 找到 `<el-table :data="jobs"` 这一行,确保有 `v-loading="loading"`(可能已有)
|
||||
|
||||
**Step 5.6: 添加分页控件**
|
||||
|
||||
- [ ] 在 `</el-table>` 之后添加:
|
||||
|
||||
```vue
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/views/admin/JobManageView.vue
|
||||
git commit -m "feat(admin): 为职位管理页面添加分页功能"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 修改 ApplicationManageView.vue - 添加分页
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/views/admin/ApplicationManageView.vue`
|
||||
|
||||
**Step 6.1: 添加分页状态**
|
||||
|
||||
- [ ] 在 `<script setup>` 中添加(在 `const currentResume = ref(null)` 之后):
|
||||
|
||||
```javascript
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
```
|
||||
|
||||
**Step 6.2: 新建 fetchApplications 函数**
|
||||
|
||||
- [ ] 在 `onMounted` 钩子之前添加新函数:
|
||||
|
||||
```javascript
|
||||
const fetchApplications = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getManageApplications({ page })
|
||||
applications.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载投递列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6.3: 添加分页事件处理**
|
||||
|
||||
- [ ] 在 `fetchApplications` 之后添加函数:
|
||||
|
||||
```javascript
|
||||
function handlePageChange(newPage) {
|
||||
fetchApplications(newPage)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6.4: 修改 updateStatus 函数**
|
||||
|
||||
- [ ] 找到 `updateStatus()` 函数,修改最后为(停留在当前页,重新加载):
|
||||
|
||||
```javascript
|
||||
fetchApplications(currentPage.value)
|
||||
```
|
||||
|
||||
**Step 6.5: 修改 onMounted**
|
||||
|
||||
- [ ] 修改 `onMounted` 钩子中的初始化调用为:
|
||||
|
||||
```javascript
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
```
|
||||
|
||||
**Step 6.6: 修改表格**
|
||||
|
||||
- [ ] 找到 `<el-table :data="applications"` 这一行,确保有 `v-loading="loading"`:
|
||||
|
||||
```vue
|
||||
<el-table :data="applications" v-loading="loading" border>
|
||||
```
|
||||
|
||||
**Step 6.7: 添加分页控件**
|
||||
|
||||
- [ ] 在 `</el-table>` 之后、`<el-dialog` 之前添加:
|
||||
|
||||
```vue
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/views/admin/ApplicationManageView.vue
|
||||
git commit -m "feat(admin): 为投递管理页面添加分页功能
|
||||
|
||||
- 新增 fetchApplications 函数支持分页
|
||||
- 修改 updateStatus 保留当前页并重新加载数据
|
||||
- 添加分页状态和事件处理
|
||||
- 添加分页控件和 loading 指示"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 修改 UserManageView.vue - 添加分页
|
||||
|
||||
**Files:**
|
||||
- Modify: `offer_frontend/src/views/admin/UserManageView.vue`
|
||||
|
||||
**Step 7.1: 添加分页相关状态**
|
||||
|
||||
- [ ] 在 `<script setup>` 中添加(在 `const saving = ref(false)` 之后):
|
||||
|
||||
```javascript
|
||||
const loading = ref(false) // 注意:该页面原本没有 loading ref,需要新添加
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
```
|
||||
|
||||
**Step 7.2: 修改 fetchUsers 函数**
|
||||
|
||||
- [ ] 替换现有的 `fetchUsers` 函数为:
|
||||
|
||||
```javascript
|
||||
const fetchUsers = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await client.get('/auth/users/', { params: { page } })
|
||||
users.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载用户列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**: 假设后端已启用分页,响应格式为 `{ count, results, ... }`。如果返回格式不同,需要相应调整。
|
||||
|
||||
**Step 7.3: 添加分页事件处理**
|
||||
|
||||
- [ ] 添加函数:
|
||||
|
||||
```javascript
|
||||
function handlePageChange(newPage) {
|
||||
fetchUsers(newPage)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 7.4: 修改 save 函数**
|
||||
|
||||
- [ ] 找到 `save()` 函数,修改其中的 `fetchUsers()` 调用为:
|
||||
|
||||
```javascript
|
||||
currentPage.value = 1
|
||||
fetchUsers(1)
|
||||
```
|
||||
|
||||
**Step 7.5: 修改 onMounted**
|
||||
|
||||
- [ ] 确保 `onMounted` 中调用为:
|
||||
|
||||
```javascript
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
fetchOrgs()
|
||||
})
|
||||
```
|
||||
|
||||
**Step 7.6: 修改表格模板**
|
||||
|
||||
- [ ] 找到 `<el-table :data="users"` 这一行,改为:
|
||||
|
||||
```vue
|
||||
<el-table :data="users" v-loading="loading" border>
|
||||
```
|
||||
|
||||
**Step 7.7: 添加分页控件**
|
||||
|
||||
- [ ] 在 `</el-table>` 之后、`<el-dialog` 之前添加:
|
||||
|
||||
```vue
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Commit**
|
||||
|
||||
```bash
|
||||
git add offer_frontend/src/views/admin/UserManageView.vue
|
||||
git commit -m "feat(admin): 为用户管理页面添加分页功能
|
||||
|
||||
- 新增 loading ref
|
||||
- 修改 fetchUsers 支持分页参数
|
||||
- 添加分页状态和事件处理
|
||||
- 添加分页控件和 loading 指示
|
||||
- 新增用户后重置分页到第1页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 功能测试
|
||||
|
||||
**Files:**
|
||||
- Test: 手动测试前端功能
|
||||
|
||||
- [ ] **Step 1: 启动前端开发服务器**
|
||||
|
||||
```bash
|
||||
cd offer_frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 测试部门管理页面分页**
|
||||
|
||||
- 访问 `/admin/organizations`
|
||||
- 验证首次加载显示第 1 页,总数正确
|
||||
- 点击下一页,数据更新
|
||||
- 点击最后一页,显示剩余数据
|
||||
- 新增部门后,页面返回第 1 页
|
||||
|
||||
- [ ] **Step 3: 测试职位管理页面分页**
|
||||
|
||||
- 访问 `/admin/jobs`
|
||||
- 验证分页功能(翻页、跳转)
|
||||
- 新增/编辑职位后返回第 1 页
|
||||
|
||||
- [ ] **Step 4: 测试投递管理页面分页**
|
||||
|
||||
- 访问 `/admin/applications`
|
||||
- 验证分页功能(翻页、跳转)
|
||||
- **关键**: 修改投递状态(如改为"已查看")后,页面停留在当前页并重新加载当前页数据(不重置分页)
|
||||
- 验证修改生效
|
||||
|
||||
- [ ] **Step 5: 测试用户管理页面分页**
|
||||
|
||||
- 访问 `/admin/users`
|
||||
- 验证分页功能
|
||||
- 新增/编辑用户后返回第 1 页
|
||||
|
||||
- [ ] **Step 6: 错误处理测试**
|
||||
|
||||
- 模拟网络错误(F12 打开控制台,修改请求),验证是否显示错误提示
|
||||
- 点击分页按钮重试,是否能恢复
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "test: 手动验证所有管理表格分页功能"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
✅ 所有 4 个管理表格都支持分页:
|
||||
- 表格显示当前页数据(最多 20 条)
|
||||
- 底部分页控件正常工作(上一页、下一页、页码跳转)
|
||||
- 总数显示正确
|
||||
- 新增/编辑/删除后自动返回第 1 页
|
||||
- 网络错误时显示错误提示,允许重试
|
||||
- 加载中显示 loading 指示器
|
||||
|
||||
✅ 代码质量:
|
||||
- 所有改动遵循现有代码风格
|
||||
- 分页逻辑与组件逻辑清晰分离
|
||||
- 无性能问题(不应有多余的 API 调用)
|
||||
|
||||
✅ Git 提交:
|
||||
- 每个页面一个 commit
|
||||
- API 层改动一个 commit
|
||||
- Commit message 清晰规范
|
||||
|
||||
---
|
||||
|
||||
## 已知限制与后续优化
|
||||
|
||||
1. **下拉框数据**(仅部门管理):当前下拉框显示当前页的部门列表。如果部门数量超过 100,部分部门无法在下拉框中选择。可在后续优化为搜索型下拉框或添加"加载更多"。
|
||||
|
||||
2. **搜索/过滤**:当前不支持与搜索结合的分页。如需要,可在分页基础上添加搜索字段和 API 参数。
|
||||
|
||||
3. **代码复用**:4 个页面的分页逻辑基本相同。可在后续抽象为 Vue Composable(如 `usePagination`),进一步 DRY。
|
||||
|
||||
---
|
||||
|
||||
## 检查清单
|
||||
|
||||
完成实现前,请验证:
|
||||
|
||||
- [ ] 所有 API 函数已更新,支持 `page` 参数
|
||||
- [ ] 所有页面的分页状态变量已添加
|
||||
- [ ] 所有页面的 fetch 函数已修改支持分页
|
||||
- [ ] 所有页面的模板已添加 `<el-pagination>` 控件
|
||||
- [ ] 所有 CRUD 操作后都重置到第 1 页
|
||||
- [ ] 所有页面都有 loading 状态指示
|
||||
- [ ] Git 提交消息遵循约定式提交规范
|
||||
- [ ] 手动测试通过(所有 4 个页面)
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
# 管理后台分页功能设计文档
|
||||
|
||||
**日期**: 2026-03-25
|
||||
**功能**: 为管理后台所有表格页面(职位管理、投递管理、组织架构、用户管理)添加分页功能
|
||||
**优先级**: 标准
|
||||
**涉及页面**:
|
||||
- 职位管理 (JobManageView.vue)
|
||||
- 投递管理 (ApplicationManageView.vue)
|
||||
- 组织架构 (OrganizationManageView.vue)
|
||||
- 用户管理 (UserManageView.vue)
|
||||
|
||||
## 概述
|
||||
|
||||
当前所有管理后台表格一次性加载所有数据到表格中。为了改善用户体验和减少初始加载时间,为所有管理表格页面实现统一的标准分页功能。后端已配置 `PageNumberPagination`(每页 20 条),前端只需统一接入分页参数和 UI 组件。
|
||||
|
||||
## 背景
|
||||
|
||||
- **现状**: 所有管理表格页面(职位、投递、部门、用户)都直接加载所有数据到表格,无分页
|
||||
- **问题**: 数据量增多时,页面加载缓慢,表格拥挤,用户体验差
|
||||
- **后端支持**: Django REST Framework 已全局配置分页(`DEFAULT_PAGE_SIZE: 20`),自动处理 `?page=1` 参数
|
||||
- **目标**: 为所有管理表格统一添加分页功能,提升用户体验
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 1. 后端 API 层
|
||||
|
||||
**无需改动**。`OrganizationManageViewSet` 继承 DRF 的 `ModelViewSet`,已自动支持 `PageNumberPagination`。
|
||||
|
||||
**API 响应格式**(现有):
|
||||
```json
|
||||
{
|
||||
"count": 50,
|
||||
"next": "http://.../?page=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{ "id": 1, "name": "集团", "email": "...", "is_active": true, "parent": null },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 前端 API 层修改
|
||||
|
||||
**文件**: `offer_frontend/src/api/organizations.js`
|
||||
|
||||
修改 `manageOrganizations` 函数,支持可选的页码参数:
|
||||
|
||||
```javascript
|
||||
export const manageOrganizations = (page = 1) =>
|
||||
client.get(`/organizations/manage/?page=${page}`)
|
||||
```
|
||||
|
||||
**调用示例**:
|
||||
```javascript
|
||||
// 获取第 1 页
|
||||
const { data } = await manageOrganizations(1)
|
||||
// 获取第 2 页
|
||||
const { data } = await manageOrganizations(2)
|
||||
```
|
||||
|
||||
### 3. 前端组件修改
|
||||
|
||||
**文件**: `offer_frontend/src/views/admin/OrganizationManageView.vue`
|
||||
|
||||
#### 3.1 状态管理
|
||||
|
||||
新增和保留响应式状态:
|
||||
```javascript
|
||||
const orgs = ref([]) // 当前页部门列表
|
||||
const allOrgs = ref([]) // 所有部门列表(用于下拉框)
|
||||
const loading = ref(false) // 当前页加载状态
|
||||
const currentPage = ref(1) // 当前页码
|
||||
const pageSize = ref(20) // 每页行数(与后端 PAGE_SIZE 一致)
|
||||
const total = ref(0) // 总记录数
|
||||
```
|
||||
|
||||
#### 3.2 修改 fetchOrgs 函数
|
||||
|
||||
用于加载分页数据:
|
||||
```javascript
|
||||
const fetchOrgs = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await manageOrganizations(page)
|
||||
orgs.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载部门列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 新增 fetchAllOrgs 函数(用于下拉框)
|
||||
|
||||
为了支持下拉框选择任意部门,需要单独加载**所有部门**(不分页):
|
||||
```javascript
|
||||
const fetchAllOrgs = async () => {
|
||||
try {
|
||||
// 调用同一个 API,但不传 page 参数时默认返回第 1 页
|
||||
// 实际需要:修改后端 API 或调用一个无分页的端点
|
||||
// 临时方案:调用 getOrganizations() 获取公开的组织(如果有)
|
||||
// 或者:预加载足够多的页(假设部门不超过 500,调用 3-4 次)
|
||||
const { data } = await manageOrganizations(1)
|
||||
// 如果只有少量部门,可以一次性加载最多 5 页
|
||||
let allResults = data.results
|
||||
for (let i = 2; i <= Math.ceil(data.count / 20) && i <= 5; i++) {
|
||||
const { data: nextData } = await manageOrganizations(i)
|
||||
allResults = allResults.concat(nextData.results)
|
||||
}
|
||||
allOrgs.value = allResults
|
||||
} catch (error) {
|
||||
ElMessage.error('加载部门列表失败')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 这是一个临时方案。如果部门数量可能超过 100,建议后端新增一个 `list_all` 参数或单独的无分页 API 端点。
|
||||
|
||||
#### 3.4 添加分页事件处理
|
||||
|
||||
```javascript
|
||||
function handlePageChange(newPage) {
|
||||
fetchOrgs(newPage)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.5 修改保存后的逻辑
|
||||
|
||||
无论新增还是编辑,保存成功后都重置分页状态并返回第 1 页(保持用户体验一致):
|
||||
|
||||
```javascript
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) await updateOrganization(editing.value.id, form)
|
||||
else await createOrganization(form)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
currentPage.value = 1 // 重置到第 1 页
|
||||
fetchOrgs(1) // 重新加载第 1 页(明确传入页码)
|
||||
fetchAllOrgs() // 刷新下拉框数据
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.6 新增删除功能
|
||||
|
||||
添加 `handleDelete()` 函数:
|
||||
|
||||
```javascript
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该部门?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await deleteOrganization(id)
|
||||
ElMessage.success('已删除')
|
||||
currentPage.value = 1 // 删除后回到第 1 页
|
||||
fetchOrgs(1) // 重新加载第 1 页(明确传入页码)
|
||||
fetchAllOrgs() // 刷新下拉框数据
|
||||
} catch (error) {
|
||||
// ElMessageBox.confirm 取消时会抛出 ElMessageBoxCancel 异常,需要判断
|
||||
if (error.name !== 'ElMessageBoxCancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.7 初始化时加载所有数据
|
||||
|
||||
修改 `onMounted` 钩子:
|
||||
|
||||
```javascript
|
||||
onMounted(() => {
|
||||
fetchOrgs() // 加载分页数据(第 1 页)
|
||||
fetchAllOrgs() // 加载所有部门用于下拉框
|
||||
})
|
||||
```
|
||||
|
||||
### 4. UI 层修改
|
||||
|
||||
#### 4.1 表格加载状态
|
||||
|
||||
修改表格标签,添加 `v-loading` 指令:
|
||||
|
||||
```vue
|
||||
<el-table :data="orgs" v-loading="loading" border>
|
||||
```
|
||||
|
||||
#### 4.2 操作列添加删除按钮
|
||||
|
||||
修改操作列,添加删除按钮:
|
||||
|
||||
```vue
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
#### 4.3 下拉框使用所有部门列表
|
||||
|
||||
修改编辑对话框中的"上级公司"选择,使用 `allOrgs` 而非 `orgs`:
|
||||
|
||||
```vue
|
||||
<el-form-item label="上级公司">
|
||||
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
|
||||
<el-option v-for="o in allOrgs" :key="o.id" :value="o.id" :label="o.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
#### 4.4 表格下方添加分页控件
|
||||
|
||||
```vue
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**样式说明**:
|
||||
- `layout="total, prev, pager, next, jumper"` - 显示总数、前一页、页码、后一页、跳转输入框
|
||||
- 右对齐,与表格宽度对称
|
||||
- `margin-top: 16px` 与表格距离保持一致
|
||||
|
||||
### 5. 数据流
|
||||
|
||||
```
|
||||
用户交互
|
||||
↓
|
||||
点击分页按钮 → handlePageChange(newPage)
|
||||
↓
|
||||
fetchOrgs(newPage) → manageOrganizations(newPage)
|
||||
↓
|
||||
API 返回 { count, results }
|
||||
↓
|
||||
更新 orgs、total、currentPage
|
||||
↓
|
||||
表格重新渲染
|
||||
```
|
||||
|
||||
### 6. 错误处理
|
||||
|
||||
- **加载失败**: 显示 ElMessage.error(),保留当前页状态
|
||||
- **删除部门**: 删除后调用 `currentPage.value = 1; fetchOrgs()` 回到第 1 页(避免最后一条删除后页码超出范围)
|
||||
- **网络异常**: 用户可点击分页按钮重试
|
||||
|
||||
### 7. 边界情况
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| 初始化(无数据) | `currentPage=1, total=0, orgs=[]`,分页组件自动隐藏 |
|
||||
| 新增部门后 | 重置到第 1 页,重新加载分页数据和下拉框数据 |
|
||||
| 编辑部门后 | 重置到第 1 页,重新加载分页数据和下拉框数据(保持一致体验) |
|
||||
| 删除部门后 | 重置到第 1 页,重新加载分页数据和下拉框数据 |
|
||||
| 当前页无数据 | 自动回到第 1 页(由分页组件自动处理) |
|
||||
| 用户取消删除确认 | 不执行任何操作,保留当前状态 |
|
||||
|
||||
### 8. 下拉框数据一致性
|
||||
|
||||
**问题**: 分页后,表格显示当前页的 20 条部门,但"父公司"下拉框需要显示所有部门(因为用户可能需要将某个部门的父级设置为其他页的部门)
|
||||
|
||||
**解决方案**:
|
||||
- 维护单独的 `allOrgs` 数组,通过 `fetchAllOrgs()` 函数加载
|
||||
- 表格显示分页数据 `orgs`(当前页)
|
||||
- 下拉框显示所有数据 `allOrgs`(所有页)
|
||||
- 新增/编辑/删除后同时刷新两个列表
|
||||
|
||||
**临时方案说明**:
|
||||
当前方案通过预加载前几页(最多 5 页、共 100 条)来获取所有部门。若部门数量可能超过 100,建议后续优化为:
|
||||
1. 后端添加 `?list_all=true` 参数,返回无分页的完整列表
|
||||
2. 或单独新增一个无分页的 API 端点(如 `/organizations/manage/all/`)
|
||||
|
||||
**后续优化**: 若需要搜索功能,改为搜索型下拉框(`el-select` + `filterable`)
|
||||
|
||||
## 实现清单
|
||||
|
||||
**后端** (无需改动):
|
||||
- [x] Django REST Framework 已支持 PageNumberPagination
|
||||
|
||||
**前端 - API 层** (各修改一次):
|
||||
- [ ] `organizations.js` - `manageOrganizations()` 添加 `page` 参数
|
||||
- [ ] `jobs.js` - `manageJobs()` 添加 `page` 参数
|
||||
- [ ] `applications.js` - `getManageApplications()` 添加 `page` 参数
|
||||
- [ ] `/auth/users/` 调用位置 - 传入 `page` 参数
|
||||
|
||||
**前端 - 组件层** (共 4 个页面):
|
||||
- [ ] **OrganizationManageView.vue** (部门管理)
|
||||
- [ ] 添加分页状态和函数
|
||||
- [ ] 修改表格和操作列
|
||||
- [ ] 添加分页控件
|
||||
|
||||
- [ ] **JobManageView.vue** (职位管理)
|
||||
- [ ] 添加分页状态和函数
|
||||
- [ ] 修改表格和操作列
|
||||
- [ ] 添加分页控件
|
||||
|
||||
- [ ] **ApplicationManageView.vue** (投递管理)
|
||||
- [ ] 添加分页状态和函数
|
||||
- [ ] 修改表格
|
||||
- [ ] 添加分页控件
|
||||
|
||||
- [ ] **UserManageView.vue** (用户管理)
|
||||
- [ ] 添加分页状态和函数
|
||||
- [ ] 修改表格和操作列
|
||||
- [ ] 添加分页控件
|
||||
|
||||
**测试** (每个页面):
|
||||
- [ ] 验证分页功能(第 1 页、中间页、最后一页)
|
||||
- [ ] 验证新增/编辑/删除后的分页重置
|
||||
- [ ] 验证网络错误恢复
|
||||
|
||||
## 性能考量
|
||||
|
||||
- **API 调用**: 每次页码变更触发一次 API 调用(频率不高,通常用户不会频繁翻页)
|
||||
- **数据量**: 每次最多 20 条记录,前端内存占用低
|
||||
- **首屏加载**: 从一次性加载 N 条改为加载 20 条,性能改善明显
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
无破坏性改动:
|
||||
- API 默认参数 `page=1`,未传参时返回第 1 页
|
||||
- 现有其他功能不受影响
|
||||
|
||||
## 测试场景
|
||||
|
||||
1. **初次加载**
|
||||
- 验证第 1 页正确显示(最多 20 条)
|
||||
- 验证总数、页码正确
|
||||
- 验证下拉框加载所有部门
|
||||
|
||||
2. **翻页**
|
||||
- 点击下一页、上一页、特定页码,数据正确更新
|
||||
- 验证 loading 状态在加载期间显示
|
||||
|
||||
3. **跳转输入**
|
||||
- 直接输入页码(如第 5 页),正确跳转
|
||||
- 输入超出范围的页码(如第 100 页),无报错
|
||||
|
||||
4. **新增部门**
|
||||
- 新增后,回到第 1 页并显示新部门
|
||||
- 验证下拉框立即更新,新部门可被选为父级
|
||||
|
||||
5. **编辑部门**
|
||||
- 编辑后,回到第 1 页,验证修改内容生效
|
||||
- 下拉框同时更新
|
||||
|
||||
6. **删除部门**
|
||||
- 点击删除,出现确认对话框
|
||||
- 确认删除后,回到第 1 页,验证被删除部门消失
|
||||
- 下拉框同时移除该部门
|
||||
- 取消删除,页面无变化
|
||||
|
||||
7. **边界情况**
|
||||
- 当前为最后一页,删除该页最后一条,自动回到第 1 页
|
||||
- 删除所有部门,表格显示空,分页组件隐藏
|
||||
|
||||
8. **错误恢复**
|
||||
- 网络超时,显示错误提示,保留当前页状态
|
||||
- 点击分页按钮重试,正确加载
|
||||
|
||||
9. **下拉框完整性**
|
||||
- 在下拉框中能选到所有部门(不仅仅是当前页)
|
||||
- 搜索或滚动下拉框,能找到目标部门
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 为其他管理页面(职位、用户)添加相同的分页逻辑
|
||||
- 考虑将分页逻辑抽象为可复用的 Composable
|
||||
- 支持自定义每页行数选项
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 4.2.20 on 2026-03-25 07:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VerificationCode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('code', models.CharField(max_length=6)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('is_verified', models.BooleanField(default=False)),
|
||||
('attempts', models.IntegerField(default=0)),
|
||||
('locked_until', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '验证码',
|
||||
'verbose_name_plural': '验证码',
|
||||
'unique_together': {('email', 'code')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.20 on 2026-03-25 07:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_verificationcode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='phone',
|
||||
field=models.CharField(max_length=20),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.20 on 2026-03-25 08:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_alter_user_phone'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, unique=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,5 +1,51 @@
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
class VerificationCode(models.Model):
|
||||
"""邮箱验证码模型"""
|
||||
email = models.EmailField()
|
||||
code = models.CharField(max_length=6)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
attempts = models.IntegerField(default=0)
|
||||
locked_until = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '验证码'
|
||||
verbose_name_plural = '验证码'
|
||||
unique_together = [['email', 'code']]
|
||||
|
||||
def is_valid(self):
|
||||
"""检查验证码是否有效"""
|
||||
if self.is_verified:
|
||||
return False
|
||||
if self.locked_until and timezone.now() < self.locked_until:
|
||||
return False
|
||||
# 检查10分钟内有效
|
||||
if (timezone.now() - self.created_at).total_seconds() > 600:
|
||||
return False
|
||||
return True
|
||||
|
||||
def increment_attempts(self):
|
||||
"""增加失败尝试次数,5次后锁定"""
|
||||
self.attempts += 1
|
||||
if self.attempts >= 5:
|
||||
self.locked_until = timezone.now() + timezone.timedelta(minutes=10)
|
||||
self.save()
|
||||
|
||||
def mark_as_verified(self):
|
||||
"""标记为已使用"""
|
||||
self.is_verified = True
|
||||
self.save()
|
||||
|
||||
@staticmethod
|
||||
def generate_code():
|
||||
"""生成6位数字验证码"""
|
||||
return ''.join(random.choices(string.digits, k=6))
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
|
|
@ -8,8 +54,9 @@ class User(AbstractUser):
|
|||
('admin', '公司管理员'),
|
||||
('seeker', '求职者'),
|
||||
]
|
||||
email = models.EmailField(unique=True) # 设置邮箱为唯一且必填
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='seeker')
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
phone = models.CharField(max_length=20)
|
||||
organization = models.ForeignKey(
|
||||
'organizations.Organization',
|
||||
null=True, blank=True,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,39 @@
|
|||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import VerificationCode
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
"""密码注册 serializer"""
|
||||
username = serializers.CharField(max_length=150)
|
||||
email = serializers.EmailField()
|
||||
phone = serializers.CharField(max_length=20)
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'email', 'phone', 'password']
|
||||
def validate_username(self, value):
|
||||
"""验证用户名是否已存在"""
|
||||
if User.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError('用户名已存在')
|
||||
return value
|
||||
|
||||
def validate_email(self, value):
|
||||
"""验证邮箱是否已存在"""
|
||||
if User.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError('邮箱已被注册')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
return User.objects.create_user(**validated_data, role='seeker')
|
||||
"""创建用户"""
|
||||
user = User.objects.create_user(
|
||||
username=validated_data['username'],
|
||||
email=validated_data['email'],
|
||||
phone=validated_data['phone'],
|
||||
password=validated_data['password'],
|
||||
role='seeker'
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -36,3 +57,143 @@ class AdminUserSerializer(serializers.ModelSerializer):
|
|||
user.set_password(password)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class SendCodeSerializer(serializers.Serializer):
|
||||
"""发送验证码 serializer"""
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""验证邮箱是否存在于系统"""
|
||||
if not User.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError('该邮箱未在系统中注册')
|
||||
return value
|
||||
|
||||
|
||||
class CodeLoginSerializer(serializers.Serializer):
|
||||
"""邮箱验证码登入 serializer"""
|
||||
email = serializers.EmailField()
|
||||
code = serializers.CharField(max_length=6, min_length=6)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""验证邮箱和验证码"""
|
||||
email = attrs.get('email')
|
||||
code = attrs.get('code')
|
||||
|
||||
# 检查用户是否存在
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError('用户不存在')
|
||||
|
||||
# 检查验证码
|
||||
try:
|
||||
vc = VerificationCode.objects.filter(email=email).latest('created_at')
|
||||
except VerificationCode.DoesNotExist:
|
||||
raise serializers.ValidationError('请先获取验证码')
|
||||
|
||||
# 检查是否被锁定
|
||||
if vc.locked_until:
|
||||
from django.utils import timezone
|
||||
if timezone.now() < vc.locked_until:
|
||||
raise serializers.ValidationError('验证码错误次数过多,请10分钟后重试')
|
||||
|
||||
# 检查验证码是否有效
|
||||
if not vc.is_valid():
|
||||
raise serializers.ValidationError('验证码已过期或已使用')
|
||||
|
||||
# 验证码是否正确
|
||||
if vc.code != code:
|
||||
vc.increment_attempts()
|
||||
raise serializers.ValidationError('验证码错误')
|
||||
|
||||
attrs['user'] = user
|
||||
attrs['vc'] = vc
|
||||
return attrs
|
||||
|
||||
|
||||
class PasswordLoginSerializer(serializers.Serializer):
|
||||
"""邮箱/用户名 + 密码登入 serializer"""
|
||||
username = serializers.CharField(required=False, allow_blank=True)
|
||||
email = serializers.EmailField(required=False, allow_blank=True)
|
||||
password = serializers.CharField()
|
||||
|
||||
def validate(self, attrs):
|
||||
"""验证用户名/邮箱和密码"""
|
||||
username = attrs.get('username')
|
||||
email = attrs.get('email')
|
||||
password = attrs.get('password')
|
||||
|
||||
if not username and not email:
|
||||
raise serializers.ValidationError('请输入用户名或邮箱')
|
||||
|
||||
# 查找用户
|
||||
user = None
|
||||
if username:
|
||||
user = User.objects.filter(username=username).first()
|
||||
elif email:
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
if not user:
|
||||
raise serializers.ValidationError('用户不存在')
|
||||
|
||||
# 验证密码
|
||||
if not user.check_password(password):
|
||||
raise serializers.ValidationError('密码错误')
|
||||
|
||||
attrs['user'] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
"""请求密码重置 serializer"""
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""验证邮箱是否存在"""
|
||||
if not User.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError('该邮箱未在系统中注册')
|
||||
return value
|
||||
|
||||
|
||||
class ConfirmResetPasswordSerializer(serializers.Serializer):
|
||||
"""确认密码重置 serializer"""
|
||||
email = serializers.EmailField()
|
||||
code = serializers.CharField(max_length=6, min_length=6)
|
||||
new_password = serializers.CharField(write_only=True, min_length=6)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""验证邮箱、验证码和新密码"""
|
||||
email = attrs.get('email')
|
||||
code = attrs.get('code')
|
||||
|
||||
# 检查用户是否存在
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError('用户不存在')
|
||||
|
||||
# 检查验证码
|
||||
try:
|
||||
vc = VerificationCode.objects.filter(email=email).latest('created_at')
|
||||
except VerificationCode.DoesNotExist:
|
||||
raise serializers.ValidationError({'code': '请先获取验证码'})
|
||||
|
||||
# 检查是否被锁定
|
||||
if vc.locked_until:
|
||||
from django.utils import timezone
|
||||
if timezone.now() < vc.locked_until:
|
||||
raise serializers.ValidationError('验证码错误次数过多,请10分钟后重试')
|
||||
|
||||
# 检查验证码是否有效
|
||||
if not vc.is_valid():
|
||||
raise serializers.ValidationError({'code': '验证码已过期或已使用'})
|
||||
|
||||
# 验证码是否正确
|
||||
if vc.code != code:
|
||||
vc.increment_attempts()
|
||||
raise serializers.ValidationError({'code': '验证码错误'})
|
||||
|
||||
attrs['user'] = user
|
||||
attrs['vc'] = vc
|
||||
return attrs
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from .views import (
|
||||
RegisterView, MeView, UserManageViewSet, UserDetailView,
|
||||
SendCodeView, CustomTokenObtainPairView,
|
||||
RequestResetPasswordView, ConfirmResetPasswordView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', RegisterView.as_view()),
|
||||
path('login/', TokenObtainPairView.as_view()),
|
||||
path('send-code/', SendCodeView.as_view()),
|
||||
path('login/', CustomTokenObtainPairView.as_view()),
|
||||
path('token/refresh/', TokenRefreshView.as_view()),
|
||||
path('reset-password/', RequestResetPasswordView.as_view()),
|
||||
path('confirm-reset-password/', ConfirmResetPasswordView.as_view()),
|
||||
path('me/', MeView.as_view()),
|
||||
path('users/', UserManageViewSet.as_view()),
|
||||
path('users/<int:pk>/', UserDetailView.as_view()),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,191 @@
|
|||
from rest_framework import generics
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||
from django.contrib.auth import get_user_model
|
||||
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from .models import VerificationCode
|
||||
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, CodeLoginSerializer, PasswordLoginSerializer, ResetPasswordSerializer, ConfirmResetPasswordSerializer
|
||||
from .permissions import IsSuperAdmin
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class RegisterView(generics.CreateAPIView):
|
||||
serializer_class = RegisterSerializer
|
||||
class SendCodeView(APIView):
|
||||
"""发送邮箱验证码(用于登入或密码重置)"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = SendCodeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
# 清除旧验证码
|
||||
VerificationCode.objects.filter(email=email).delete()
|
||||
|
||||
# 生成新验证码
|
||||
code = VerificationCode.generate_code()
|
||||
vc = VerificationCode.objects.create(email=email, code=code)
|
||||
|
||||
# 发送邮件
|
||||
try:
|
||||
send_mail(
|
||||
subject='【集团招聘平台】验证码',
|
||||
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟,请勿泄露给他人。',
|
||||
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
|
||||
recipient_list=[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
vc.delete()
|
||||
return Response(
|
||||
{'error': '邮件发送失败,请稍后重试'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({'message': '验证码已发送到您的邮箱'})
|
||||
|
||||
|
||||
class RequestResetPasswordView(APIView):
|
||||
"""请求密码重置(发送验证码)"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ResetPasswordSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
# 使用 SendCodeView 的逻辑发送验证码
|
||||
VerificationCode.objects.filter(email=email).delete()
|
||||
code = VerificationCode.generate_code()
|
||||
vc = VerificationCode.objects.create(email=email, code=code)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject='【集团招聘平台】密码重置验证码',
|
||||
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟,请勿泄露给他人。',
|
||||
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
|
||||
recipient_list=[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
vc.delete()
|
||||
return Response(
|
||||
{'error': '邮件发送失败,请稍后重试'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({'message': '验证码已发送到您的邮箱'})
|
||||
|
||||
|
||||
class ConfirmResetPasswordView(APIView):
|
||||
"""确认密码重置"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ConfirmResetPasswordSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = serializer.validated_data['user']
|
||||
vc = serializer.validated_data['vc']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
|
||||
# 设置新密码
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# 标记验证码为已使用
|
||||
vc.mark_as_verified()
|
||||
|
||||
return Response({'message': '密码重置成功,请使用新密码登入'})
|
||||
|
||||
|
||||
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
"""自定义登入视图,支持三种方式:邮箱验证码、邮箱密码、用户名密码"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# 判断登入方式
|
||||
has_code = 'code' in request.data
|
||||
has_email = 'email' in request.data
|
||||
has_username = 'username' in request.data
|
||||
has_password = 'password' in request.data
|
||||
|
||||
if has_email and has_code:
|
||||
# 邮箱验证码登入
|
||||
return self._login_with_code(request)
|
||||
elif (has_email or has_username) and has_password:
|
||||
# 邮箱或用户名 + 密码登入
|
||||
return self._login_with_password(request)
|
||||
else:
|
||||
return Response(
|
||||
{'error': '请提供正确的登入方式'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def _login_with_code(self, request):
|
||||
"""邮箱验证码登入"""
|
||||
serializer = CodeLoginSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = serializer.validated_data['user']
|
||||
vc = serializer.validated_data['vc']
|
||||
|
||||
# 标记验证码为已使用
|
||||
vc.mark_as_verified()
|
||||
|
||||
# 生成 JWT token
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return Response({
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
def _login_with_password(self, request):
|
||||
"""邮箱或用户名 + 密码登入"""
|
||||
serializer = PasswordLoginSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = serializer.validated_data['user']
|
||||
|
||||
# 生成 JWT token
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return Response({
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RegisterView(APIView):
|
||||
"""密码注册"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = RegisterSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# 创建用户
|
||||
user = serializer.save()
|
||||
|
||||
# 生成 JWT token
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
},
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class ApplicationManageViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
job__organization=user.organization
|
||||
).select_related('job__organization', 'applicant')
|
||||
|
||||
|
||||
class ApplicationStatusUpdateView(generics.UpdateAPIView):
|
||||
serializer_class = ApplicationStatusSerializer
|
||||
permission_classes = [IsAdminOrSuperAdmin]
|
||||
|
|
|
|||
|
|
@ -22,11 +22,18 @@ class Resume(models.Model):
|
|||
|
||||
def to_snapshot(self):
|
||||
"""序列化为投递快照,与主表解耦"""
|
||||
attachment_url = None
|
||||
if self.attachment:
|
||||
# 返回相对 URL,前端会处理
|
||||
attachment_url = self.attachment.url
|
||||
|
||||
return {
|
||||
'name': self.name,
|
||||
'gender': self.gender,
|
||||
'birthday': str(self.birthday) if self.birthday else None,
|
||||
'education': self.education,
|
||||
'experience': self.experience,
|
||||
'attachment_url': self.attachment.url if self.attachment else None,
|
||||
'email': self.user.email,
|
||||
'phone': self.user.phone,
|
||||
'attachment_url': attachment_url,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,3 +90,11 @@ LANGUAGE_CODE = 'zh-hans'
|
|||
TIME_ZONE = 'Asia/Shanghai'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Email Configuration
|
||||
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@offer.com')
|
||||
EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com')
|
||||
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
|
||||
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
|
||||
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
|
||||
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import client from './client'
|
|||
|
||||
export const applyJob = (jobId) => client.post('/applications/apply/', { job: jobId })
|
||||
export const getMyApplications = () => client.get('/applications/mine/')
|
||||
export const getManageApplications = (params) => client.get('/applications/manage/', { params })
|
||||
export const getManageApplications = (params = {}) => client.get('/applications/manage/', { params: { page: params.page || 1, ...params } })
|
||||
export const updateApplicationStatus = (id, data) => client.patch(`/applications/manage/${id}/status/`, data)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import client from './client'
|
||||
import axios from 'axios'
|
||||
|
||||
export const login = (data) => axios.post('/api/auth/login/', data)
|
||||
export const register = (data) => client.post('/auth/register/', data)
|
||||
export const sendCode = (email) => axios.post('/api/auth/send-code/', { email })
|
||||
export const loginApi = (data) => axios.post('/api/auth/login/', data)
|
||||
export const register = (data) => axios.post('/api/auth/register/', data)
|
||||
export const getMe = () => client.get('/auth/me/')
|
||||
export const updateMe = (data) => client.patch('/auth/me/', data)
|
||||
export const resetPassword = (email) => axios.post('/api/auth/reset-password/', { email })
|
||||
export const confirmResetPassword = (email, code, newPassword) =>
|
||||
axios.post('/api/auth/confirm-reset-password/', { email, code, new_password: newPassword })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import client from './client'
|
|||
|
||||
export const getJobs = (params) => client.get('/jobs/public/', { params })
|
||||
export const getJob = (id) => client.get(`/jobs/public/${id}/`)
|
||||
export const manageJobs = (params) => client.get('/jobs/manage/', { params })
|
||||
export const manageJobs = (params = {}) => client.get('/jobs/manage/', { params: { page: params.page || 1, ...params } })
|
||||
export const createJob = (data) => client.post('/jobs/manage/', data)
|
||||
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
|
||||
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import client from './client'
|
|||
|
||||
export const getOrganizations = () => client.get('/organizations/public/')
|
||||
export const getOrganization = (id) => client.get(`/organizations/public/${id}/`)
|
||||
export const manageOrganizations = () => client.get('/organizations/manage/')
|
||||
export const manageOrganizations = (page = 1) => client.get('/organizations/manage/', { params: { page } })
|
||||
export const createOrganization = (data) => client.post('/organizations/manage/', data)
|
||||
export const updateOrganization = (id, data) => client.patch(`/organizations/manage/${id}/`, data)
|
||||
export const deleteOrganization = (id) => client.delete(`/organizations/manage/${id}/`)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<div class="job-card-wrapper">
|
||||
<el-card class="job-card" shadow="hover" @click="$router.push(`/jobs/${job.id}`)">
|
||||
<el-button
|
||||
class="share-btn"
|
||||
type="primary"
|
||||
text
|
||||
@click.stop="shareJob"
|
||||
:icon="Share"
|
||||
/>
|
||||
<div class="job-title">{{ job.title }}</div>
|
||||
<div class="job-meta">
|
||||
<span>{{ job.company_name || job.organization_name }}</span>
|
||||
|
|
@ -8,15 +16,87 @@
|
|||
<el-divider direction="vertical" />
|
||||
<span class="salary">{{ job.salary }}</span>
|
||||
</div>
|
||||
<div class="job-footer">
|
||||
<el-tag size="small">{{ job.category }}</el-tag>
|
||||
<span class="publish-time">{{ formatDate(job.created_at) }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
defineProps({ job: Object })
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Share } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({ job: Object })
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return dateStr.slice(0, 10)
|
||||
}
|
||||
|
||||
function shareJob() {
|
||||
// 生成职位详情页链接
|
||||
const jobUrl = `${window.location.origin}/jobs/${props.job.id}`
|
||||
|
||||
// 复制到剪贴板
|
||||
navigator.clipboard.writeText(jobUrl).then(() => {
|
||||
ElMessage.success('已复制职位链接')
|
||||
}).catch(() => {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = jobUrl
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
ElMessage.success('已复制职位链接')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.job-card { cursor: pointer; margin-bottom: 12px; }
|
||||
.job-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
||||
.job-meta { color: #666; font-size: 13px; margin-bottom: 8px; }
|
||||
.salary { color: #f56c6c; font-weight: 500; }
|
||||
.job-card-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.job-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.publish-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.salary {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
<template>
|
||||
<el-container style="min-height: 100vh;">
|
||||
<el-header style="background:#001529;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #333;">
|
||||
<div style="color:#fff;font-weight:bold;font-size:16px;">管理后台</div>
|
||||
<el-dropdown>
|
||||
<div style="color:#fff;cursor:pointer;display:flex;align-items:center;gap:8px;">
|
||||
<span>{{ auth.user?.email }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="logout">退出登入</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-container>
|
||||
<el-aside width="200px" style="background:#001529;">
|
||||
<div style="padding:20px;color:#fff;font-weight:bold;font-size:16px;">管理后台</div>
|
||||
<div style="padding:20px;color:#fff;font-weight:bold;font-size:16px;">菜单</div>
|
||||
<el-menu router :default-active="$route.path" background-color="#001529" text-color="#fff" active-text-color="#409eff">
|
||||
<el-menu-item index="/admin/jobs">职位管理</el-menu-item>
|
||||
<el-menu-item index="/admin/applications">投递管理</el-menu-item>
|
||||
|
|
@ -13,8 +28,20 @@
|
|||
</el-aside>
|
||||
<el-main><router-view /></el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function logout() {
|
||||
localStorage.clear()
|
||||
ElMessage.success('已退出登入')
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
<button class="btn-ghost">登录</button>
|
||||
</router-link>
|
||||
<router-link to="/register">
|
||||
<button class="btn-primary">立即注册</button>
|
||||
<button class="btn-ghost">立即注册</button>
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
@ -118,7 +118,7 @@ a { text-decoration: none; }
|
|||
|
||||
/* 顶部公告条 */
|
||||
.top-bar {
|
||||
background: #8B4545;
|
||||
background: #1A1A1A;
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
|
|
@ -144,7 +144,7 @@ a { text-decoration: none; }
|
|||
|
||||
/* 主导航 */
|
||||
.main-header {
|
||||
background: linear-gradient(180deg, #C17A7A 0%, #A85555 100%);
|
||||
background: linear-gradient(180deg, #1A1A1A 0%, #0F0F0F 100%);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -271,11 +271,11 @@ a { text-decoration: none; }
|
|||
}
|
||||
.user-name { color: rgba(255,255,255,0.85); font-size: 13px; }
|
||||
|
||||
/* 红色底线装饰 */
|
||||
.header-underline { height: 3px; background: #8B4545; }
|
||||
/* 底线装饰 */
|
||||
.header-underline { height: 3px; background: #0F0F0F; }
|
||||
.underline-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FFFFFF 0%, #EF9A9A 40%, #D4A95D 100%);
|
||||
background: linear-gradient(90deg, #C8973A 0%, #D4AF37 100%);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
|
|
@ -289,8 +289,8 @@ a { text-decoration: none; }
|
|||
|
||||
/* 页脚 */
|
||||
.portal-footer {
|
||||
background: #8B4545;
|
||||
border-top: 3px solid #FFFFFF;
|
||||
background: #1A1A1A;
|
||||
border-top: 3px solid #C8973A;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.footer-inner {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const routes = [
|
|||
},
|
||||
{ path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') },
|
||||
{ path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') },
|
||||
{ path: '/forgot-password', name: 'ResetPassword', component: () => import('@/views/auth/ResetPasswordView.vue') },
|
||||
// 求职者中心
|
||||
{
|
||||
path: '/seeker',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { login as loginApi, getMe } from '@/api/auth'
|
||||
import { loginApi, getMe } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
|
|
@ -13,8 +13,8 @@ export const useAuthStore = defineStore('auth', {
|
|||
isSeeker: s => s.user?.role === 'seeker',
|
||||
},
|
||||
actions: {
|
||||
async login(username, password) {
|
||||
const { data } = await loginApi({ username, password })
|
||||
async login(email, code) {
|
||||
const { data } = await loginApi({ email, code })
|
||||
localStorage.setItem('access_token', data.access)
|
||||
localStorage.setItem('refresh_token', data.refresh)
|
||||
await this.fetchMe()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,538 @@
|
|||
<template>
|
||||
<div class="splash-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="splash-header">
|
||||
<div class="header-inner">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">招</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">集团招聘平台</span>
|
||||
<span class="logo-en">GROUP TALENT RECRUITMENT</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<span class="nav-item" @click="router.push('/home')">首页</span>
|
||||
<span class="nav-item" @click="router.push('/jobs')">职位列表</span>
|
||||
<span class="nav-item" @click="router.push('/companies')">公司介绍</span>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="btn-outline" @click="router.push('/login')">登录</button>
|
||||
<button class="btn-solid" @click="router.push('/register')">立即注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 英雄区 -->
|
||||
<section class="hero">
|
||||
<div class="hero-bg">
|
||||
<div class="hero-overlay"></div>
|
||||
<!-- 建筑风格装饰线条 -->
|
||||
<div class="building-lines">
|
||||
<div class="line l1"></div>
|
||||
<div class="line l2"></div>
|
||||
<div class="line l3"></div>
|
||||
<div class="line l4"></div>
|
||||
<div class="line l5"></div>
|
||||
<div class="line l6"></div>
|
||||
<div class="line l7"></div>
|
||||
<div class="line l8"></div>
|
||||
</div>
|
||||
<!-- 光晕效果 -->
|
||||
<div class="hero-glow"></div>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-badge">2026届校园招聘</div>
|
||||
<h1 class="hero-slogan">人才创造美好未来</h1>
|
||||
<p class="hero-sub">与优秀的人一起,做有价值的事</p>
|
||||
<div class="hero-btns">
|
||||
<button class="btn-primary-lg" @click="router.push('/jobs')">
|
||||
浏览职位
|
||||
<span class="btn-arrow">→</span>
|
||||
</button>
|
||||
<button class="btn-secondary-lg" @click="router.push('/register')">
|
||||
立即投递
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 滚动指示 -->
|
||||
<div class="scroll-hint">
|
||||
<span>向下探索</span>
|
||||
<div class="scroll-arrow">↓</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 数据亮点 -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-inner">
|
||||
<div class="stat-item">
|
||||
<div class="stat-num">500<span class="stat-plus">+</span></div>
|
||||
<div class="stat-label">招聘职位</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num">28<span class="stat-plus">+</span></div>
|
||||
<div class="stat-label">合作企业</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num">10000<span class="stat-plus">+</span></div>
|
||||
<div class="stat-label">在职员工</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-num">30<span class="stat-plus">+</span></div>
|
||||
<div class="stat-label">城市布局</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 招聘亮点 -->
|
||||
<section class="features-section">
|
||||
<div class="features-inner">
|
||||
<div class="section-label">为什么加入我们</div>
|
||||
<h2 class="section-title">筑梦平台,共创未来</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🏛</div>
|
||||
<h3>央企背景</h3>
|
||||
<p>国有企业,稳定可靠,福利完善,五险一金,补充公积金</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚀</div>
|
||||
<h3>成长空间</h3>
|
||||
<p>完善的晋升体系,多维度培训,专属导师带教,快速成长</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌍</div>
|
||||
<h3>全国布局</h3>
|
||||
<p>覆盖30+城市,多地工作机会,灵活选择工作地点</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💡</div>
|
||||
<h3>创新氛围</h3>
|
||||
<p>鼓励创新探索,科研立项支持,专利奖励,技术驱动发展</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部 -->
|
||||
<footer class="splash-footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-logo">
|
||||
<div class="logo-icon sm">招</div>
|
||||
<span>集团招聘平台</span>
|
||||
</div>
|
||||
<p class="footer-copy">Copyright © 集团招聘平台 · All Rights Reserved · 为国家建设输送优秀人才</p>
|
||||
<div class="footer-links">
|
||||
<span @click="router.push('/jobs')">职位列表</span>
|
||||
<span @click="router.push('/companies')">公司介绍</span>
|
||||
<span @click="router.push('/login')">登录</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.splash-page {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── 顶部导航 ── */
|
||||
.splash-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
padding: 0 48px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.header-inner {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: linear-gradient(135deg, #B8860B, #8B6407);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
}
|
||||
.logo-icon.sm { width: 28px; height: 28px; font-size: 13px; border-radius: 6px; }
|
||||
.logo-text { display: flex; flex-direction: column; }
|
||||
.logo-title { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
|
||||
.logo-en { font-size: 10px; color: rgba(255,255,255,0.45); letter-spacing: 0.1em; }
|
||||
|
||||
.header-nav { display: flex; gap: 36px; }
|
||||
.nav-item {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.nav-item:hover { color: #D4AF37; }
|
||||
|
||||
.header-actions { display: flex; gap: 12px; }
|
||||
.btn-outline {
|
||||
padding: 7px 20px;
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.85);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-outline:hover { border-color: #D4AF37; color: #D4AF37; }
|
||||
.btn-solid {
|
||||
padding: 7px 20px;
|
||||
background: linear-gradient(135deg, #B8860B, #8B6407);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-solid:hover { opacity: 0.88; }
|
||||
|
||||
/* ── 英雄区 ── */
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
min-height: 640px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* 模拟建筑照片的深色调背景 */
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
#0d1b2a 0%,
|
||||
#1a2744 25%,
|
||||
#0f1f35 50%,
|
||||
#0d1520 75%,
|
||||
#060e18 100%
|
||||
);
|
||||
}
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0.2) 0%,
|
||||
rgba(0,0,0,0.1) 40%,
|
||||
rgba(0,0,0,0.5) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 建筑风格竖线装饰 */
|
||||
.building-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(184, 134, 11, 0.15) 30%,
|
||||
rgba(184, 134, 11, 0.08) 70%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.l1 { left: 8%; }
|
||||
.l2 { left: 18%; }
|
||||
.l3 { left: 30%; }
|
||||
.l4 { left: 42%; }
|
||||
.l5 { left: 58%; }
|
||||
.l6 { left: 70%; }
|
||||
.l7 { left: 82%; }
|
||||
.l8 { left: 92%; }
|
||||
|
||||
/* 中心光晕 */
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -60%);
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background: radial-gradient(
|
||||
ellipse,
|
||||
rgba(184, 134, 11, 0.18) 0%,
|
||||
rgba(184, 134, 11, 0.05) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
max-width: 800px;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
padding: 5px 18px;
|
||||
border: 1px solid rgba(184, 134, 11, 0.6);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #D4AF37;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 28px;
|
||||
background: rgba(184, 134, 11, 0.08);
|
||||
}
|
||||
.hero-slogan {
|
||||
font-size: 56px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 0.12em;
|
||||
margin: 0 0 16px;
|
||||
text-shadow: 0 2px 20px rgba(0,0,0,0.4);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 18px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
margin: 0 0 48px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.hero-btns {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-primary-lg {
|
||||
padding: 14px 42px;
|
||||
background: linear-gradient(135deg, #B8860B, #D4AF37);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 4px 20px rgba(184,134,11,0.4);
|
||||
}
|
||||
.btn-primary-lg:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(184,134,11,0.5);
|
||||
}
|
||||
.btn-arrow { font-size: 18px; }
|
||||
.btn-secondary-lg {
|
||||
padding: 14px 42px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
color: rgba(255,255,255,0.85);
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-secondary-lg:hover {
|
||||
border-color: rgba(255,255,255,0.8);
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.scroll-hint {
|
||||
position: absolute;
|
||||
bottom: 36px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.scroll-arrow {
|
||||
font-size: 18px;
|
||||
animation: bounce 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(6px); }
|
||||
}
|
||||
|
||||
/* ── 数据亮点 ── */
|
||||
.stats-section {
|
||||
background: linear-gradient(135deg, #111 0%, #1a1a1a 100%);
|
||||
border-top: 1px solid rgba(184,134,11,0.25);
|
||||
border-bottom: 1px solid rgba(184,134,11,0.25);
|
||||
padding: 48px 0;
|
||||
}
|
||||
.stats-inner {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 48px;
|
||||
}
|
||||
.stat-item { text-align: center; }
|
||||
.stat-num {
|
||||
font-size: 44px;
|
||||
font-weight: 900;
|
||||
color: #D4AF37;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-plus { font-size: 24px; }
|
||||
.stat-label { font-size: 14px; color: rgba(255,255,255,0.5); letter-spacing: 0.08em; }
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
background: rgba(184,134,11,0.2);
|
||||
}
|
||||
|
||||
/* ── 特色区 ── */
|
||||
.features-section {
|
||||
background: #0d0d0d;
|
||||
padding: 96px 48px;
|
||||
}
|
||||
.features-inner { max-width: 1100px; margin: 0 auto; }
|
||||
.section-label {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #D4AF37;
|
||||
letter-spacing: 0.2em;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
margin: 0 0 56px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.feature-card {
|
||||
background: #161616;
|
||||
border: 1px solid rgba(184,134,11,0.15);
|
||||
border-radius: 8px;
|
||||
padding: 36px 24px;
|
||||
text-align: center;
|
||||
transition: border-color 0.25s, transform 0.25s;
|
||||
}
|
||||
.feature-card:hover {
|
||||
border-color: rgba(184,134,11,0.5);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
.feature-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.feature-card h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.feature-card p {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.45);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 底部 ── */
|
||||
.splash-footer {
|
||||
background: #060606;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 32px 48px;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.footer-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
.footer-copy {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.25);
|
||||
margin: 0;
|
||||
}
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
.footer-links span {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.footer-links span:hover { color: #D4AF37; }
|
||||
</style>
|
||||
|
|
@ -25,16 +25,70 @@
|
|||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="resumeVisible" title="简历详情" width="600px">
|
||||
<div v-if="currentResume">
|
||||
<p><strong>姓名:</strong>{{ currentResume.name }}</p>
|
||||
<p><strong>性别:</strong>{{ currentResume.gender }}</p>
|
||||
<el-divider>教育经历</el-divider>
|
||||
<div v-for="(e, i) in currentResume.education" :key="i">{{ e.school }} · {{ e.degree }} · {{ e.major }}</div>
|
||||
<el-divider>工作经历</el-divider>
|
||||
<div v-for="(e, i) in currentResume.experience" :key="i">{{ e.company }} · {{ e.position }} · {{ e.duration }}</div>
|
||||
<div v-if="currentResume.attachment_url" style="margin-top:16px">
|
||||
<a :href="currentResume.attachment_url" target="_blank">下载简历附件</a>
|
||||
<el-dialog v-model="resumeVisible" title="简历详情" width="700px">
|
||||
<div v-if="currentResume" class="resume-detail">
|
||||
<!-- 基本信息 -->
|
||||
<div class="section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">姓名:</span>
|
||||
<span class="value">{{ currentResume.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">性别:</span>
|
||||
<span class="value">{{ getGenderLabel(currentResume.gender) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">邮箱:</span>
|
||||
<span class="value">{{ currentResume.email || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">手机:</span>
|
||||
<span class="value">{{ currentResume.phone || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">生日:</span>
|
||||
<span class="value">{{ currentResume.birthday || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 教育经历 -->
|
||||
<div class="section">
|
||||
<div class="section-title">教育经历</div>
|
||||
<div v-if="currentResume.education?.length" class="list-items">
|
||||
<div v-for="(e, i) in currentResume.education" :key="i" class="list-item">
|
||||
<div class="item-header">{{ e.school }}</div>
|
||||
<div class="item-detail">{{ e.degree }} · {{ e.major }}</div>
|
||||
<div class="item-detail">{{ e.start_year }} - {{ e.end_year }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="color: #999; font-size: 12px;">暂无教育经历</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作经历 -->
|
||||
<div class="section">
|
||||
<div class="section-title">工作经历</div>
|
||||
<div v-if="currentResume.experience?.length" class="list-items">
|
||||
<div v-for="(e, i) in currentResume.experience" :key="i" class="list-item">
|
||||
<div class="item-header">{{ e.company }} - {{ e.position }}</div>
|
||||
<div class="item-detail">{{ e.duration }}</div>
|
||||
<div class="item-detail" v-if="e.description">{{ e.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="color: #999; font-size: 12px;">暂无工作经历</div>
|
||||
</div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<div class="section" v-if="currentResume.attachment_url">
|
||||
<div class="section-title">附件</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<el-button type="primary" @click="downloadAttachment">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载简历附件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
|
@ -44,12 +98,20 @@
|
|||
import { ref, onMounted } from 'vue'
|
||||
import { getManageApplications, updateApplicationStatus } from '@/api/applications'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(false)
|
||||
const resumeVisible = ref(false)
|
||||
const currentResume = ref(null)
|
||||
|
||||
const genderMap = {
|
||||
'male': '男',
|
||||
'female': '女',
|
||||
'other': '其他',
|
||||
'': '-'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const { data } = await getManageApplications()
|
||||
|
|
@ -68,4 +130,116 @@ function viewResume(row) {
|
|||
currentResume.value = row.resume_snapshot
|
||||
resumeVisible.value = true
|
||||
}
|
||||
|
||||
function getGenderLabel(gender) {
|
||||
return genderMap[gender] || '-'
|
||||
}
|
||||
|
||||
async function downloadAttachment() {
|
||||
if (!currentResume.value?.attachment_url) {
|
||||
ElMessage.warning('附件不可用')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 修正 /media/media/ 双重前缀问题
|
||||
let url = currentResume.value.attachment_url.replace(/\/media\/media\//, '/media/')
|
||||
|
||||
// 用 fetch+blob 确保触发下载而不是浏览器预览
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('请求失败')
|
||||
|
||||
// 优先用 Content-Type 判断扩展名,避免无扩展名文件名导致乱码
|
||||
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim()
|
||||
const mimeMap = {
|
||||
'application/pdf': 'pdf',
|
||||
'application/msword': 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
}
|
||||
let ext = mimeMap[contentType]
|
||||
if (!ext) {
|
||||
const urlExt = url.split('.').pop().split('?')[0]
|
||||
ext = /^[a-zA-Z0-9]{1,5}$/.test(urlExt) ? urlExt : 'pdf'
|
||||
}
|
||||
const filename = `${currentResume.value.name}_resume.${ext}`
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
ElMessage.success('下载已启动')
|
||||
} catch {
|
||||
ElMessage.error('下载失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
color: #666;
|
||||
min-width: 50px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@
|
|||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingJob ? '编辑职位' : '发布职位'" width="600px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
|
|
@ -78,13 +87,23 @@ const form = reactive({
|
|||
title: '', category: '', location: '', salary: '',
|
||||
description: '', status: 'draft', organization_id: null
|
||||
})
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const fetchJobs = async () => {
|
||||
const fetchJobs = async (page = 1) => {
|
||||
loading.value = true
|
||||
const { data } = await manageJobs()
|
||||
try {
|
||||
const { data } = await manageJobs(page)
|
||||
jobs.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载职位列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await getOrganizations()
|
||||
|
|
@ -112,6 +131,10 @@ function openDialog(job = null) {
|
|||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
fetchJobs(newPage)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (auth.isSuperAdmin && !form.organization_id) {
|
||||
return ElMessage.warning('请选择所属公司')
|
||||
|
|
@ -124,7 +147,7 @@ async function handleSave() {
|
|||
else await createJob(payload)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchJobs()
|
||||
fetchJobs(1)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
|
|
@ -136,7 +159,7 @@ async function handleDelete(id) {
|
|||
await ElMessageBox.confirm('确认删除该职位?', '提示', { type: 'warning' })
|
||||
await deleteJob(id)
|
||||
ElMessage.success('已删除')
|
||||
fetchJobs()
|
||||
fetchJobs(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
|
|
@ -4,26 +4,39 @@
|
|||
<h2>组织架构管理</h2>
|
||||
<el-button type="primary" @click="openDialog()">新增公司</el-button>
|
||||
</div>
|
||||
<el-table :data="orgs" border>
|
||||
<el-table :data="orgs" border v-loading="loading">
|
||||
<el-table-column prop="name" label="公司名称" />
|
||||
<el-table-column label="上级公司">
|
||||
<template #default="{ row }">{{ row.parent ? orgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
|
||||
<template #default="{ row }">{{ row.parent ? allOrgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="联系邮箱" />
|
||||
<el-table-column label="状态">
|
||||
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'启用':'停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:16px;text-align:right">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20]"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑公司' : '新增公司'" width="480px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="公司名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="上级公司">
|
||||
<el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
|
||||
<el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
|
||||
<el-option v-for="o in allOrgs" :key="o.id" :value="o.id" :label="o.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
|
|
@ -39,18 +52,46 @@
|
|||
</template>
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { manageOrganizations, createOrganization, updateOrganization } from '@/api/organizations'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { manageOrganizations, createOrganization, updateOrganization, deleteOrganization } from '@/api/organizations'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const orgs = ref([])
|
||||
const allOrgs = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const form = reactive({ name: '', parent: null, email: '', description: '', is_active: true })
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await manageOrganizations()
|
||||
const fetchOrgs = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await manageOrganizations(page)
|
||||
orgs.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载部门列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllOrgs = async () => {
|
||||
try {
|
||||
let allResults = []
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const { data } = await manageOrganizations(i)
|
||||
allResults = allResults.concat(data.results)
|
||||
if (i === Math.ceil(data.count / 20)) break
|
||||
}
|
||||
allOrgs.value = allResults
|
||||
} catch (error) {
|
||||
ElMessage.error('加载部门列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(org = null) {
|
||||
|
|
@ -67,8 +108,35 @@ async function save() {
|
|||
else await createOrganization(form)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchOrgs()
|
||||
fetchOrgs(1)
|
||||
fetchAllOrgs()
|
||||
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(fetchOrgs)
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
fetchOrgs(newPage)
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'此操作将永久删除该部门,是否继续?',
|
||||
'警告',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
await deleteOrganization(id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchOrgs(1)
|
||||
fetchAllOrgs()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrgs()
|
||||
fetchAllOrgs()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="openDialog()">新增管理员</el-button>
|
||||
</div>
|
||||
<el-table :data="users" border>
|
||||
<el-table :data="users" v-loading="loading" border>
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="角色">
|
||||
|
|
@ -24,6 +24,15 @@
|
|||
<template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新增管理员'" width="480px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
|
|
@ -62,11 +71,31 @@ const orgs = ref([])
|
|||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const form = reactive({ username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
|
||||
|
||||
const fetchUsers = async () => { const { data } = await client.get('/auth/users/'); users.value = data.results || data }
|
||||
const fetchUsers = async (page = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await client.get('/auth/users/', { params: { page } })
|
||||
users.value = data.results
|
||||
total.value = data.count
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
ElMessage.error('加载用户列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
fetchUsers(newPage)
|
||||
}
|
||||
|
||||
function openDialog(user = null) {
|
||||
editing.value = user
|
||||
if (user) Object.assign(form, user)
|
||||
|
|
@ -81,7 +110,7 @@ async function save() {
|
|||
else await client.post('/auth/users/', form)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
fetchUsers()
|
||||
fetchUsers(1)
|
||||
} catch { ElMessage.error('保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => { fetchUsers(); fetchOrgs() })
|
||||
|
|
|
|||
|
|
@ -2,45 +2,396 @@
|
|||
<div class="auth-page">
|
||||
<el-card class="auth-card">
|
||||
<h2>登录</h2>
|
||||
<el-form :model="form" @submit.prevent="handleLogin">
|
||||
<el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
|
||||
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">登录</el-button>
|
||||
|
||||
<!-- 角色选择 -->
|
||||
<div class="role-tabs">
|
||||
<button
|
||||
:class="['role-tab', { active: role === 'seeker' }]"
|
||||
@click="role = 'seeker'"
|
||||
>
|
||||
求职者
|
||||
</button>
|
||||
<button
|
||||
:class="['role-tab', { active: role === 'admin' }]"
|
||||
@click="role = 'admin'"
|
||||
>
|
||||
管理员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 求职者登入 -->
|
||||
<div v-if="role === 'seeker'" class="login-container">
|
||||
<!-- 登入方式选择 -->
|
||||
<div class="method-tabs">
|
||||
<button
|
||||
:class="['method-tab', { active: seekerMethod === 'password' }]"
|
||||
@click="seekerMethod = 'password'"
|
||||
>
|
||||
邮箱/用户名登入
|
||||
</button>
|
||||
<button
|
||||
:class="['method-tab', { active: seekerMethod === 'code' }]"
|
||||
@click="seekerMethod = 'code'"
|
||||
>
|
||||
验证码登入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码登入 -->
|
||||
<el-form v-if="seekerMethod === 'password'" :model="passwordForm" @submit.prevent="handlePasswordLogin">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="passwordForm.username"
|
||||
placeholder="邮箱或用户名"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="passwordForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="passwordLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
<div style="text-align: right; margin-top: 8px;">
|
||||
<router-link to="/forgot-password" style="color: #909399; font-size: 12px; text-decoration: none;">
|
||||
忘记密码?
|
||||
</router-link>
|
||||
</div>
|
||||
</el-form>
|
||||
<div style="margin-top:12px;text-align:center">
|
||||
没有账号?<router-link to="/register">立即注册</router-link>
|
||||
|
||||
<!-- 验证码登入 -->
|
||||
<el-form v-if="seekerMethod === 'code'" @submit.prevent="handleCodeLogin">
|
||||
<!-- 邮箱输入 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="codeForm.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
:disabled="codeSent"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 获取验证码按钮 -->
|
||||
<el-form-item v-if="!codeSent">
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleSendCode"
|
||||
:loading="sendingCode"
|
||||
style="width: 100%"
|
||||
>
|
||||
获取验证码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<el-form-item v-if="codeSent">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-input
|
||||
v-model="codeForm.code"
|
||||
placeholder="请输入验证码(6位)"
|
||||
maxlength="6"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleResendCode"
|
||||
:disabled="codeCountdown > 0"
|
||||
>
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-button
|
||||
v-if="codeSent"
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="codeLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 管理员登入 -->
|
||||
<el-form v-if="role === 'admin'" :model="adminForm" @submit.prevent="handleAdminLogin">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="adminForm.username"
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="adminForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="adminLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
|
||||
<span v-if="role === 'seeker'">
|
||||
没有账号?<router-link to="/register" style="color: #409eff; text-decoration: none;">立即注册</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { sendCode, loginApi } from '@/api/auth'
|
||||
|
||||
const role = ref('seeker')
|
||||
const seekerMethod = ref('password')
|
||||
|
||||
// 密码登入
|
||||
const passwordForm = reactive({ username: '', password: '' })
|
||||
const passwordLoading = ref(false)
|
||||
|
||||
// 验证码登入
|
||||
const codeForm = reactive({ email: '', code: '' })
|
||||
const codeSent = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const codeLoading = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
|
||||
// 管理员登入
|
||||
const adminForm = reactive({ username: '', password: '' })
|
||||
const adminLoading = ref(false)
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
// 倒计时逻辑
|
||||
const startCountdown = () => {
|
||||
codeCountdown.value = 60
|
||||
const interval = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) clearInterval(interval)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
async function handleSendCode() {
|
||||
if (!codeForm.email) {
|
||||
ElMessage.warning('请输入邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await auth.login(form.username, form.password)
|
||||
await sendCode(codeForm.email)
|
||||
ElMessage.success('验证码已发送到您的邮箱')
|
||||
codeSent.value = true
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.email?.[0] || err.response?.data?.error || '发送失败')
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重新发送验证码
|
||||
async function handleResendCode() {
|
||||
codeForm.code = ''
|
||||
await handleSendCode()
|
||||
}
|
||||
|
||||
// 密码登入
|
||||
async function handlePasswordLogin() {
|
||||
if (!passwordForm.username || !passwordForm.password) {
|
||||
ElMessage.warning('请输入邮箱/用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
const { data } = await loginApi({
|
||||
username: passwordForm.username,
|
||||
password: passwordForm.password,
|
||||
})
|
||||
localStorage.setItem('access_token', data.access)
|
||||
localStorage.setItem('refresh_token', data.refresh)
|
||||
await auth.fetchMe()
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect
|
||||
if (redirect) return router.push(redirect)
|
||||
if (auth.isSeeker) router.push('/seeker/resume')
|
||||
else router.push('/admin/jobs')
|
||||
} catch {
|
||||
ElMessage.error('用户名或密码错误')
|
||||
router.push('/seeker/resume')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || err.response?.data?.username?.[0] || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码登入
|
||||
async function handleCodeLogin() {
|
||||
if (!codeForm.code || codeForm.code.length !== 6) {
|
||||
ElMessage.warning('请输入正确的6位验证码')
|
||||
return
|
||||
}
|
||||
|
||||
codeLoading.value = true
|
||||
try {
|
||||
const { data } = await loginApi({
|
||||
email: codeForm.email,
|
||||
code: codeForm.code,
|
||||
})
|
||||
localStorage.setItem('access_token', data.access)
|
||||
localStorage.setItem('refresh_token', data.refresh)
|
||||
await auth.fetchMe()
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect
|
||||
if (redirect) return router.push(redirect)
|
||||
router.push('/seeker/resume')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.code?.[0] || '登录失败')
|
||||
} finally {
|
||||
codeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员登入
|
||||
async function handleAdminLogin() {
|
||||
if (!adminForm.username || !adminForm.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
adminLoading.value = true
|
||||
try {
|
||||
const { data } = await loginApi({
|
||||
username: adminForm.username,
|
||||
password: adminForm.password,
|
||||
})
|
||||
localStorage.setItem('access_token', data.access)
|
||||
localStorage.setItem('refresh_token', data.refresh)
|
||||
await auth.fetchMe()
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect
|
||||
if (redirect) return router.push(redirect)
|
||||
router.push('/admin/jobs')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.detail || '用户名或密码错误')
|
||||
} finally {
|
||||
adminLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
|
||||
.auth-card { width: 380px; }
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
background: #f5f7fa;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.role-tab {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.role-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.role-tab.active {
|
||||
background: #fff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.method-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
background: #f5f7fa;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.method-tab {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.method-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.method-tab.active {
|
||||
background: #fff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,42 +3,165 @@
|
|||
<el-card class="auth-card">
|
||||
<h2>求职者注册</h2>
|
||||
<el-form :model="form" @submit.prevent="handleRegister">
|
||||
<el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
|
||||
<el-form-item><el-input v-model="form.email" placeholder="邮箱" /></el-form-item>
|
||||
<el-form-item><el-input v-model="form.phone" placeholder="手机号(可选)" /></el-form-item>
|
||||
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码(至少6位)" show-password /></el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">注册</el-button>
|
||||
<!-- 邮箱 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 手机号 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.passwordConfirm"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
style="width: 100%"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div style="margin-top:12px;text-align:center">
|
||||
已有账号?<router-link to="/login">立即登录</router-link>
|
||||
|
||||
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
|
||||
已有账号?<router-link to="/login" style="color: #409eff; text-decoration: none;">立即登录</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { register } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const form = reactive({ username: '', email: '', phone: '', password: '' })
|
||||
const form = reactive({
|
||||
email: '',
|
||||
username: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
async function handleRegister() {
|
||||
// 验证字段
|
||||
if (!form.email) {
|
||||
ElMessage.warning('请输入邮箱')
|
||||
return
|
||||
}
|
||||
if (!form.username) {
|
||||
ElMessage.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!form.phone) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.password || form.password.length < 6) {
|
||||
ElMessage.warning('请输入至少6位密码')
|
||||
return
|
||||
}
|
||||
if (form.password !== form.passwordConfirm) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await register(form)
|
||||
ElMessage.success('注册成功,请登录')
|
||||
router.push('/login')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.username?.[0] || '注册失败,请重试')
|
||||
const response = await fetch('/api/auth/register/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
phone: form.phone,
|
||||
password: form.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
const errorMsg = data.username?.[0] || data.email?.[0] || data.phone?.[0] || data.password?.[0] || '注册失败'
|
||||
ElMessage.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 自动登入用户
|
||||
localStorage.setItem('access_token', data.access)
|
||||
localStorage.setItem('refresh_token', data.refresh)
|
||||
await auth.fetchMe()
|
||||
|
||||
ElMessage.success('注册成功')
|
||||
router.push('/home')
|
||||
} catch (err) {
|
||||
ElMessage.error('注册失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
|
||||
.auth-card { width: 380px; }
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
<template>
|
||||
<div class="auth-page">
|
||||
<el-card class="auth-card">
|
||||
<h2>重置密码</h2>
|
||||
|
||||
<el-form @submit.prevent="handleResetPassword">
|
||||
<!-- 第一步:邮箱验证 -->
|
||||
<div v-if="step === 1">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
:disabled="codeSent"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!codeSent">
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleSendCode"
|
||||
:loading="sendingCode"
|
||||
style="width: 100%"
|
||||
>
|
||||
获取验证码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="codeSent">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="请输入验证码(6位)"
|
||||
maxlength="6"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleResendCode"
|
||||
:disabled="codeCountdown > 0"
|
||||
>
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
v-if="codeSent"
|
||||
type="primary"
|
||||
@click="goToStep2"
|
||||
style="width: 100%"
|
||||
>
|
||||
下一步
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:重置密码 -->
|
||||
<div v-if="step === 2">
|
||||
<div style="margin-bottom: 16px; padding: 8px 12px; background: #f0f9ff; border-radius: 4px; color: #606266; font-size: 12px;">
|
||||
邮箱:<span style="font-weight: 600;">{{ form.email }}</span>
|
||||
</div>
|
||||
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.passwordConfirm"
|
||||
type="password"
|
||||
placeholder="请确认新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="resetLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
重置密码
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
@click="goBackToStep1"
|
||||
style="width: 100%; margin-top: 8px;"
|
||||
>
|
||||
上一步
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
|
||||
<router-link to="/login" style="color: #409eff; text-decoration: none;">返回登录</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const step = ref(1)
|
||||
const form = reactive({
|
||||
email: '',
|
||||
code: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
})
|
||||
|
||||
const codeSent = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 倒计时
|
||||
const startCountdown = () => {
|
||||
codeCountdown.value = 60
|
||||
const interval = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) clearInterval(interval)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
async function handleSendCode() {
|
||||
if (!form.email) {
|
||||
ElMessage.warning('请输入邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: form.email }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
ElMessage.error(data.email?.[0] || '发送失败')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('验证码已发送到您的邮箱')
|
||||
codeSent.value = true
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
ElMessage.error('发送失败,请稍后重试')
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重新发送验证码
|
||||
async function handleResendCode() {
|
||||
form.code = ''
|
||||
await handleSendCode()
|
||||
}
|
||||
|
||||
// 下一步
|
||||
function goToStep2() {
|
||||
if (!form.code || form.code.length !== 6) {
|
||||
ElMessage.warning('请输入正确的6位验证码')
|
||||
return
|
||||
}
|
||||
step.value = 2
|
||||
}
|
||||
|
||||
// 上一步
|
||||
function goBackToStep1() {
|
||||
step.value = 1
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
async function handleResetPassword() {
|
||||
if (!form.password || form.password.length < 6) {
|
||||
ElMessage.warning('请输入至少6位密码')
|
||||
return
|
||||
}
|
||||
if (form.password !== form.passwordConfirm) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
resetLoading.value = true
|
||||
try {
|
||||
const response = await fetch('/api/auth/confirm-reset-password/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: form.email,
|
||||
code: form.code,
|
||||
new_password: form.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
const errorMsg = data.code?.[0] || data.new_password?.[0] || '重置失败'
|
||||
ElMessage.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('密码重置成功,请使用新密码登入')
|
||||
router.push('/login')
|
||||
} catch (err) {
|
||||
ElMessage.error('密码重置失败,请稍后重试')
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -25,8 +25,13 @@
|
|||
<el-icon><Star /></el-icon>
|
||||
{{ collected ? '已收藏' : '收藏' }}
|
||||
</el-button>
|
||||
<el-button class="btn-apply" @click="handleApply" :loading="applying">
|
||||
立即投递
|
||||
<el-button
|
||||
class="btn-apply"
|
||||
@click="handleApply"
|
||||
:loading="applying"
|
||||
:disabled="applied"
|
||||
>
|
||||
{{ applied ? '已投递' : '立即投递' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="apply-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||
|
|
@ -107,8 +112,14 @@
|
|||
<el-icon><Message /></el-icon>
|
||||
<span>{{ job.organization.email }}</span>
|
||||
</div>
|
||||
<el-button class="btn-apply-sm" @click="handleApply" :loading="applying" style="margin-top:12px;width:100%">
|
||||
立即投递
|
||||
<el-button
|
||||
class="btn-apply-sm"
|
||||
@click="handleApply"
|
||||
:loading="applying"
|
||||
:disabled="applied"
|
||||
style="margin-top:12px;width:100%"
|
||||
>
|
||||
{{ applied ? '已投递' : '立即投递' }}
|
||||
</el-button>
|
||||
<p class="apply-hint-sm" v-if="applied">已投递成功</p>
|
||||
</div>
|
||||
|
|
@ -121,8 +132,8 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getJob, toggleFavorite } from '@/api/jobs'
|
||||
import { applyJob } from '@/api/applications'
|
||||
import { getJob, toggleFavorite, getMyFavorites } from '@/api/jobs'
|
||||
import { applyJob, getMyApplications } from '@/api/applications'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Location, OfficeBuilding, Star, Message } from '@element-plus/icons-vue'
|
||||
|
|
@ -138,9 +149,26 @@ const collected = ref(false)
|
|||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getJob(route.params.id)
|
||||
job.value = data
|
||||
|
||||
// 如果用户已登录,检查是否已投递、已收藏
|
||||
if (auth.isLoggedIn && auth.isSeeker) {
|
||||
try {
|
||||
const [{ data: applications }, { data: favorites }] = await Promise.all([
|
||||
getMyApplications(),
|
||||
getMyFavorites(),
|
||||
])
|
||||
applied.value = applications.results?.some(app => app.job === parseInt(route.params.id)) || false
|
||||
collected.value = favorites.results?.some(f => f.job.id === parseInt(route.params.id)) || false
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch user state:', e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dt) {
|
||||
|
|
@ -159,6 +187,8 @@ async function toggleCollect() {
|
|||
async function handleApply() {
|
||||
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
|
||||
if (applied.value) return ElMessage.warning('您已投递过该职位')
|
||||
|
||||
applying.value = true
|
||||
try {
|
||||
await applyJob(job.value.id)
|
||||
|
|
@ -166,6 +196,7 @@ async function handleApply() {
|
|||
ElMessage.success('投递成功!')
|
||||
} catch (e) {
|
||||
if (e.response?.status === 400) {
|
||||
applied.value = true
|
||||
ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
|
||||
} else {
|
||||
ElMessage.error('投递失败,请先完善简历')
|
||||
|
|
@ -255,10 +286,16 @@ async function handleApply() {
|
|||
padding: 10px 28px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.btn-apply:hover {
|
||||
.btn-apply:hover:not(:disabled) {
|
||||
background: #c42820 !important;
|
||||
border-color: #c42820 !important;
|
||||
}
|
||||
.btn-apply:disabled {
|
||||
background: #ccc !important;
|
||||
border-color: #ccc !important;
|
||||
color: #fff !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
.apply-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
|
@ -445,10 +482,16 @@ async function handleApply() {
|
|||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-apply-sm:hover {
|
||||
.btn-apply-sm:hover:not(:disabled) {
|
||||
background: #c42820 !important;
|
||||
border-color: #c42820 !important;
|
||||
}
|
||||
.btn-apply-sm:disabled {
|
||||
background: #ccc !important;
|
||||
border-color: #ccc !important;
|
||||
color: #fff !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
.apply-hint-sm {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2>我的收藏</h2>
|
||||
<el-table :data="favorites" v-loading="loading" border>
|
||||
<el-table-column prop="job.title" label="职位" />
|
||||
<el-table-column prop="job.organization_name" label="公司" />
|
||||
<el-table-column prop="job.location" label="地点" />
|
||||
<el-table-column prop="job.salary" label="薪资" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="router.push({ name: 'JobDetail', params: { id: row.job.id } })">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="favorites.length === 0 && !loading" description="暂无收藏职位" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getMyFavorites } from '@/api/jobs'
|
||||
|
||||
const router = useRouter()
|
||||
const favorites = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getMyFavorites()
|
||||
favorites.value = data.results ?? data
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch favorites:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -16,7 +16,8 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }
|
||||
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
||||
'/media': { target: 'http://127.0.0.1:8000', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue