feat(frontend): 添加前端基础框架与核心功能模块

- 初始化项目配置,新增npmrc和package.json文件
- 创建Vue 3应用入口,集成Element Plus UI库及图标插件
- 配置路由包括首页、招聘者管理、职位管理、候选人管理和定时任务五个模块
- 实现布局组件包含侧边栏菜单、顶部导航及主内容区
- 封装统一的API请求模块,包含招聘者、职位、候选人、定时任务和系统接口
- 开发Dashboard页面,实现系统概览及状态展示功能
- 开发候选人管理页面,包括搜索筛选、分页展示、详情查看及评分功能
- 开发职位管理页面,支持职位列表、搜索过滤、分页管理及关联评价方案
- 设计基础样式和交互效果,保证页面布局和风格一致性
This commit is contained in:
2026-03-24 19:32:46 +08:00
parent 6f1f438159
commit 498fd7a5e8
14 changed files with 2329 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# pnpm 配置
shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>简历智能体 - HR管理系统</title>
<link rel="icon" href="/favicon.ico">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "ylhp-hr-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"sass": "^1.70.0"
}
}

View File

@@ -0,0 +1,18 @@
<template>
<router-view />
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@@ -0,0 +1,154 @@
import axios from 'axios'
const api = axios.create({
baseURL: '',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 响应拦截器 - 统一处理
api.interceptors.response.use(
(response) => {
const data = response.data
if (data.code !== 200) {
return Promise.reject(new Error(data.msg || '请求失败'))
}
return data
},
(error) => {
return Promise.reject(error)
}
)
// 招聘者管理 API
export const recruiterApi = {
// 获取平台来源列表
getSources: () => api.get('/api/recruiters/sources'),
// 获取招聘者列表
getList: (params = {}) => api.get('/api/recruiters', { params }),
// 获取招聘者详情
getDetail: (id) => api.get(`/api/recruiters/${id}`),
// 创建招聘者
create: (data) => api.post('/api/recruiters', data),
// 自动注册
register: (data) => api.post('/api/recruiters/register', data),
// 更新招聘者
update: (id, data) => api.put(`/api/recruiters/${id}`, data),
// 删除招聘者
delete: (id) => api.delete(`/api/recruiters/${id}`),
// 启用招聘者
activate: (id) => api.post(`/api/recruiters/${id}/activate`),
// 停用招聘者
deactivate: (id) => api.post(`/api/recruiters/${id}/deactivate`),
// 同步招聘者
sync: (id) => api.post(`/api/recruiters/${id}/sync`)
}
// 职位管理 API
export const jobApi = {
// 获取职位列表
getList: (params = {}) => api.get('/api/jobs', { params }),
// 筛选职位
filter: (data) => api.post('/api/jobs/filter', data),
// 获取职位详情
getDetail: (id) => api.get(`/api/jobs/${id}`),
// 创建职位
create: (data) => api.post('/api/jobs', data),
// 更新职位
update: (id, data) => api.put(`/api/jobs/${id}`, data),
// 删除职位
delete: (id) => api.delete(`/api/jobs/${id}`),
// 关联评价方案
bindSchema: (id, schemaId) => api.post(`/api/jobs/${id}/bind-schema`, { evaluation_schema_id: schemaId }),
// 获取职位关联的评价方案
getSchema: (id) => api.get(`/api/jobs/${id}/schema`),
// 获取评价方案列表
getSchemaList: (params = {}) => api.get('/api/jobs/schemas/list', { params })
}
// 候选人管理 API
export const candidateApi = {
// 获取筛选通过的候选人
getFiltered: (params = {}) => api.get('/candidates/filtered', { params }),
// 筛选候选人
filter: (data) => api.post('/candidates/filter', data),
// 获取候选人详情
getDetail: (id) => api.get(`/candidates/${id}`),
// 标记候选人筛选状态
markFiltered: (data) => api.post('/candidates/mark-filtered', data),
// 更新候选人评分
updateScore: (data) => api.post('/candidates/update-score', data),
// 根据评分范围查询
getByScoreRange: (params) => api.get('/candidates/by-score-range', { params })
}
// 定时任务管理 API
export const schedulerApi = {
// 获取任务列表
getJobs: () => api.get('/api/scheduler/jobs'),
// 获取任务状态列表
getJobsStatus: () => api.get('/api/scheduler/jobs/status'),
// 获取单个任务状态
getJobStatus: (id) => api.get(`/api/scheduler/jobs/${id}/status`),
// 立即执行任务
runJob: (id) => api.post(`/api/scheduler/jobs/${id}/run`),
// 暂停任务
pauseJob: (id) => api.post(`/api/scheduler/jobs/${id}/pause`),
// 恢复任务
resumeJob: (id) => api.post(`/api/scheduler/jobs/${id}/resume`),
// 更新任务配置
updateConfig: (id, data) => api.put(`/api/scheduler/jobs/${id}/config`, data),
// 获取调度器状态
getStatus: () => api.get('/api/scheduler/status'),
// 启动调度器
start: () => api.post('/api/scheduler/start'),
// 停止调度器
stop: () => api.post('/api/scheduler/stop')
}
// 系统 API
export const systemApi = {
// 获取首页信息
getHome: () => api.get('/'),
// 健康检查
health: () => api.get('/health'),
// 获取API状态
getStatus: () => api.get('/api/status')
}
export default api

View File

@@ -0,0 +1,160 @@
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside width="220px" class="sidebar">
<div class="logo">
<el-icon size="28" color="#409EFF"><Briefcase /></el-icon>
<span class="logo-text">简历智能体</span>
</div>
<el-menu
:default-active="$route.path"
router
class="sidebar-menu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item v-for="route in menuRoutes" :key="route.path" :index="route.path">
<el-icon>
<component :is="route.meta.icon" />
</el-icon>
<span>{{ route.meta.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶部导航 -->
<el-header class="header">
<div class="header-left">
<breadcrumb />
</div>
<div class="header-right">
<el-tooltip content="系统状态">
<el-icon size="20" :class="systemStatus"><CircleCheck /></el-icon>
</el-tooltip>
<span class="version">v0.1.0</span>
</div>
</el-header>
<!-- 内容区 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import router from '@/router'
import { systemApi } from '@/api/api'
const $route = useRoute()
const systemStatus = ref('status-healthy')
// 菜单路由
const menuRoutes = computed(() => {
return router.getRoutes()
.find(r => r.path === '/')
?.children.filter(r => r.meta) || []
})
// 检查系统状态
const checkStatus = async () => {
try {
await systemApi.health()
systemStatus.value = 'status-healthy'
} catch {
systemStatus.value = 'status-error'
}
}
onMounted(() => {
checkStatus()
setInterval(checkStatus, 30000)
})
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.sidebar {
background-color: #304156;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border-bottom: 1px solid #1f2d3d;
}
.logo-text {
color: #fff;
font-size: 18px;
font-weight: 600;
}
.sidebar-menu {
border-right: none;
flex: 1;
}
.header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-healthy {
color: #67c23a;
}
.status-error {
color: #f56c6c;
}
.version {
color: #909399;
font-size: 14px;
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

View File

@@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/components/Layout.vue'
const routes = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
},
{
path: 'recruiters',
name: 'Recruiters',
component: () => import('@/views/Recruiters.vue'),
meta: { title: '招聘者管理', icon: 'UserFilled' }
},
{
path: 'jobs',
name: 'Jobs',
component: () => import('@/views/Jobs.vue'),
meta: { title: '职位管理', icon: 'Briefcase' }
},
{
path: 'candidates',
name: 'Candidates',
component: () => import('@/views/Candidates.vue'),
meta: { title: '候选人管理', icon: 'Avatar' }
},
{
path: 'scheduler',
name: 'Scheduler',
component: () => import('@/views/Scheduler.vue'),
meta: { title: '定时任务', icon: 'Clock' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@@ -0,0 +1,364 @@
<template>
<div class="candidates-page">
<div class="page-header">
<h2 class="page-title">候选人管理</h2>
</div>
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :inline="true" :model="searchForm">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="姓名/公司/职位" clearable />
</el-form-item>
<el-form-item label="LLM筛选">
<el-select v-model="searchForm.llm_filtered" placeholder="全部" clearable>
<el-option label="已通过" :value="true" />
<el-option label="未通过" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="评分范围">
<el-row :gutter="10">
<el-col :span="11">
<el-input-number v-model="searchForm.min_score" :min="0" :max="100" placeholder="最低" style="width: 100%" />
</el-col>
<el-col :span="2" style="text-align: center;">-</el-col>
<el-col :span="11">
<el-input-number v-model="searchForm.max_score" :min="0" :max="100" placeholder="最高" style="width: 100%" />
</el-col>
</el-row>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card>
<el-table :data="candidateList" v-loading="loading" stripe>
<el-table-column prop="name" label="姓名" width="100" fixed />
<el-table-column prop="gender" label="性别" width="70" />
<el-table-column prop="age" label="年龄" width="70" />
<el-table-column prop="current_company" label="当前公司" min-width="150" show-overflow-tooltip />
<el-table-column prop="current_position" label="当前职位" min-width="150" show-overflow-tooltip />
<el-table-column prop="education" label="学历" width="100" />
<el-table-column prop="location" label="地点" width="120" />
<el-table-column label="期望薪资" width="120">
<template #default="{ row }">
<span v-if="row.salary_min && row.salary_max">
{{ row.salary_min }}K-{{ row.salary_max }}K
</span>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="LLM评分" width="120">
<template #default="{ row }">
<div v-if="row.llm_score" class="score-display">
<el-progress
:percentage="Math.round(row.llm_score)"
:color="getScoreColor(row.llm_score)"
:stroke-width="8"
/>
</div>
<span v-else class="text-gray">未评分</span>
</template>
</el-table-column>
<el-table-column label="筛选状态" width="100">
<template #default="{ row }">
<el-tag :type="row.llm_filtered ? 'success' : 'info'" size="small">
{{ row.llm_filtered ? '已通过' : '未筛选' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button size="small" @click="handleView(row)">详情</el-button>
<el-button size="small" type="primary" @click="handleScore(row)">评分</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 候选人详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="候选人详情" width="700px">
<el-descriptions :column="2" border v-if="currentCandidate">
<el-descriptions-item label="姓名">{{ currentCandidate.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentCandidate.gender }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentCandidate.age }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ currentCandidate.education }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ currentCandidate.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ currentCandidate.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="当前公司" :span="2">{{ currentCandidate.current_company }}</el-descriptions-item>
<el-descriptions-item label="当前职位" :span="2">{{ currentCandidate.current_position }}</el-descriptions-item>
<el-descriptions-item label="地点">{{ currentCandidate.location }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ currentCandidate.source }}</el-descriptions-item>
<el-descriptions-item label="LLM评分" :span="2">
<div v-if="currentCandidate.llm_score" class="detail-score">
<span class="score-value">{{ currentCandidate.llm_score }}</span>
<el-tag :type="currentCandidate.llm_filtered ? 'success' : 'info'" size="small">
{{ currentCandidate.llm_filtered ? '已通过筛选' : '未通过筛选' }}
</el-tag>
</div>
<span v-else>未评分</span>
</el-descriptions-item>
<el-descriptions-item label="评分详情" :span="2" v-if="currentCandidate.llm_score_details">
<pre class="score-details">{{ JSON.stringify(currentCandidate.llm_score_details, null, 2) }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 评分对话框 -->
<el-dialog v-model="scoreDialogVisible" title="更新LLM评分" width="500px">
<el-form :model="scoreForm" :rules="scoreRules" ref="scoreFormRef" label-width="100px">
<el-form-item label="综合评分" prop="llm_score">
<el-slider v-model="scoreForm.llm_score" :max="100" show-stops :step="1" />
<div class="score-value">{{ scoreForm.llm_score }} </div>
</el-form-item>
<el-form-item label="评分详情">
<el-input
v-model="scoreForm.llm_score_details"
type="textarea"
:rows="4"
placeholder='{"专业能力": 85, "经验匹配": 90}'
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scoreDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitScore" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { candidateApi } from '@/api/api'
const loading = ref(false)
const candidateList = ref([])
// 搜索表单
const searchForm = reactive({
keyword: '',
llm_filtered: undefined,
min_score: undefined,
max_score: undefined
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 详情
const detailDialogVisible = ref(false)
const currentCandidate = ref(null)
// 评分
const scoreDialogVisible = ref(false)
const scoreFormRef = ref()
const scoreForm = reactive({
candidate_id: '',
llm_score: 70,
llm_score_details: ''
})
const scoreRules = {
llm_score: [{ required: true, message: '请输入评分', trigger: 'blur' }]
}
const submitting = ref(false)
// 加载数据
const loadData = async () => {
loading.value = true
try {
let res
if (searchForm.min_score !== undefined && searchForm.max_score !== undefined) {
// 使用评分范围查询
res = await candidateApi.getByScoreRange({
min_score: searchForm.min_score,
max_score: searchForm.max_score,
page: pagination.page,
page_size: pagination.pageSize
})
} else {
// 使用筛选查询
res = await candidateApi.filter({
keyword: searchForm.keyword,
llm_filtered: searchForm.llm_filtered,
page: pagination.page,
page_size: pagination.pageSize
})
}
candidateList.value = res.data?.items || []
pagination.total = res.data?.total || 0
} catch (error) {
ElMessage.error('加载数据失败: ' + error.message)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
loadData()
}
const resetSearch = () => {
searchForm.keyword = ''
searchForm.llm_filtered = undefined
searchForm.min_score = undefined
searchForm.max_score = undefined
handleSearch()
}
// 分页
const handleSizeChange = (size) => {
pagination.pageSize = size
loadData()
}
const handlePageChange = (page) => {
pagination.page = page
loadData()
}
// 查看详情
const handleView = async (row) => {
try {
const res = await candidateApi.getDetail(row.id)
currentCandidate.value = res.data
detailDialogVisible.value = true
} catch (error) {
ElMessage.error('获取详情失败: ' + error.message)
}
}
// 评分
const handleScore = (row) => {
scoreForm.candidate_id = row.id
scoreForm.llm_score = row.llm_score || 70
scoreForm.llm_score_details = row.llm_score_details
? JSON.stringify(row.llm_score_details, null, 2)
: ''
scoreDialogVisible.value = true
}
// 提交评分
const handleSubmitScore = async () => {
const valid = await scoreFormRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const data = {
candidate_id: scoreForm.candidate_id,
llm_score: scoreForm.llm_score,
llm_score_details: scoreForm.llm_score_details
? JSON.parse(scoreForm.llm_score_details)
: undefined
}
await candidateApi.updateScore(data)
ElMessage.success('评分更新成功')
scoreDialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('提交失败: ' + error.message)
} finally {
submitting.value = false
}
}
// 工具函数
const getScoreColor = (score) => {
if (score >= 80) return '#67C23A'
if (score >= 60) return '#E6A23C'
return '#F56C6C'
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.candidates-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.search-card {
margin-bottom: 20px;
}
.score-display {
width: 100px;
}
.text-gray {
color: #909399;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.detail-score {
display: flex;
align-items: center;
gap: 12px;
}
.score-value {
font-size: 24px;
font-weight: 700;
color: #409EFF;
}
.score-details {
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
max-height: 200px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="dashboard">
<h2 class="page-title">系统概览</h2>
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-row">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #409EFF;">
<el-icon size="32" color="#fff"><UserFilled /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recruiters }}</div>
<div class="stat-label">招聘者账号</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #67C23A;">
<el-icon size="32" color="#fff"><Briefcase /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.jobs }}</div>
<div class="stat-label">职位数量</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #E6A23C;">
<el-icon size="32" color="#fff"><Avatar /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.candidates }}</div>
<div class="stat-label">候选人</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #F56C6C;">
<el-icon size="32" color="#fff"><Star /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.evaluations }}</div>
<div class="stat-label">评价记录</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 快捷操作 -->
<el-row :gutter="20" class="action-row">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>快捷操作</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="$router.push('/recruiters')">
<el-icon><Plus /></el-icon>添加招聘者
</el-button>
<el-button type="success" @click="$router.push('/jobs')">
<el-icon><Plus /></el-icon>创建职位
</el-button>
<el-button type="warning" @click="$router.push('/scheduler')">
<el-icon><VideoPlay /></el-icon>启动任务
</el-button>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>系统状态</span>
</div>
</template>
<div class="system-status">
<div class="status-item">
<span class="status-label">API服务</span>
<el-tag :type="apiStatus === 'running' ? 'success' : 'danger'">
{{ apiStatus === 'running' ? '运行中' : '异常' }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">调度器</span>
<el-tag :type="schedulerStatus.running ? 'success' : 'info'">
{{ schedulerStatus.running ? '运行中' : '已停止' }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">任务数量</span>
<span class="status-value">{{ schedulerStatus.total_jobs || 0 }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { recruiterApi, schedulerApi, systemApi } from '@/api/api'
const stats = ref({
recruiters: 0,
jobs: 0,
candidates: 0,
evaluations: 0
})
const apiStatus = ref('running')
const schedulerStatus = ref({})
const loadStats = async () => {
try {
// 获取招聘者数量
const recruiterRes = await recruiterApi.getList()
stats.value.recruiters = recruiterRes.data?.total || 0
// 获取调度器状态
const schedulerRes = await schedulerApi.getStatus()
schedulerStatus.value = schedulerRes.data || {}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const checkApiStatus = async () => {
try {
await systemApi.health()
apiStatus.value = 'running'
} catch {
apiStatus.value = 'error'
}
}
onMounted(() => {
loadStats()
checkApiStatus()
})
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.page-title {
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.stat-row {
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
padding: 20px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #303133;
line-height: 1;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
.action-row {
margin-bottom: 24px;
}
.card-header {
font-weight: 600;
font-size: 16px;
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.system-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #EBEEF5;
}
.status-item:last-child {
border-bottom: none;
}
.status-label {
color: #606266;
}
.status-value {
font-weight: 600;
color: #409EFF;
}
</style>

View File

@@ -0,0 +1,478 @@
<template>
<div class="jobs-page">
<div class="page-header">
<h2 class="page-title">职位管理</h2>
<el-button type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>创建职位
</el-button>
</div>
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :inline="true" :model="searchForm">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="标题/部门" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="进行中" value="active" />
<el-option label="已暂停" value="paused" />
<el-option label="已关闭" value="closed" />
<el-option label="已归档" value="archived" />
</el-select>
</el-form-item>
<el-form-item label="评价方案">
<el-select v-model="searchForm.evaluation_schema_id" placeholder="全部" clearable>
<el-option
v-for="schema in schemaList"
:key="schema.id"
:label="schema.name"
:value="schema.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card>
<el-table :data="jobList" v-loading="loading" stripe>
<el-table-column prop="title" label="职位标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="department" label="部门" width="120" />
<el-table-column prop="location" label="地点" width="120" />
<el-table-column label="薪资范围" width="150">
<template #default="{ row }">
<span v-if="row.salary_min && row.salary_max">
{{ row.salary_min }}K - {{ row.salary_max }}K
</span>
<span v-else class="text-gray">面议</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="候选人" width="120">
<template #default="{ row }">
<el-badge :value="row.new_candidate_count" class="item" v-if="row.new_candidate_count > 0">
<span>{{ row.candidate_count || 0 }}</span>
</el-badge>
<span v-else>{{ row.candidate_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="评价方案" min-width="150">
<template #default="{ row }">
<div v-if="row.evaluation_schema_id" class="schema-tag">
<el-tag size="small" type="success">
{{ getSchemaName(row.evaluation_schema_id) }}
</el-tag>
<el-button
link
size="small"
type="primary"
@click="handleUnbindSchema(row)"
>
解除
</el-button>
</div>
<el-button v-else link size="small" type="primary" @click="handleBindSchema(row)">
关联方案
</el-button>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="最后同步" width="160">
<template #default="{ row }">
{{ formatTime(row.last_sync_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑职位' : '创建职位'"
width="600px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="职位标题" prop="title">
<el-input v-model="form.title" placeholder="请输入职位标题" />
</el-form-item>
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" />
</el-form-item>
<el-form-item label="工作地点" prop="location">
<el-input v-model="form.location" placeholder="请输入工作地点" />
</el-form-item>
<el-form-item label="薪资范围">
<el-row :gutter="10">
<el-col :span="11">
<el-input-number v-model="form.salary_min" :min="0" placeholder="最低" style="width: 100%" />
</el-col>
<el-col :span="2" style="text-align: center;">-</el-col>
<el-col :span="11">
<el-input-number v-model="form.salary_max" :min="0" placeholder="最高" style="width: 100%" />
</el-col>
</el-row>
</el-form-item>
<el-form-item label="评价方案">
<el-select v-model="form.evaluation_schema_id" placeholder="请选择评价方案" clearable style="width: 100%">
<el-option
v-for="schema in schemaList"
:key="schema.id"
:label="schema.name"
:value="schema.id"
/>
</el-select>
</el-form-item>
<el-form-item label="职位描述">
<el-input
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入职位描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
<!-- 关联评价方案对话框 -->
<el-dialog v-model="bindDialogVisible" title="关联评价方案" width="500px">
<el-form label-width="100px">
<el-form-item label="选择方案">
<el-select v-model="bindSchemaId" placeholder="请选择评价方案" style="width: 100%">
<el-option
v-for="schema in schemaList"
:key="schema.id"
:label="schema.name"
:value="schema.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmBindSchema" :loading="binding">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { jobApi } from '@/api/api'
import dayjs from 'dayjs'
const loading = ref(false)
const jobList = ref([])
const schemaList = ref([])
// 搜索表单
const searchForm = reactive({
keyword: '',
status: '',
evaluation_schema_id: ''
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 对话框
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref()
const form = reactive({
id: '',
title: '',
department: '',
location: '',
salary_min: undefined,
salary_max: undefined,
evaluation_schema_id: '',
description: '',
source: 'boss',
source_id: ''
})
const rules = {
title: [{ required: true, message: '请输入职位标题', trigger: 'blur' }]
}
// 关联方案
const bindDialogVisible = ref(false)
const bindSchemaId = ref('')
const currentJob = ref(null)
const binding = ref(false)
const submitting = ref(false)
// 加载职位数据
const loadData = async () => {
loading.value = true
try {
const res = await jobApi.filter({
keyword: searchForm.keyword,
status: searchForm.status,
evaluation_schema_id: searchForm.evaluation_schema_id,
page: pagination.page,
page_size: pagination.pageSize
})
jobList.value = res.data?.items || []
pagination.total = res.data?.total || 0
} catch (error) {
ElMessage.error('加载数据失败: ' + error.message)
} finally {
loading.value = false
}
}
// 加载评价方案
const loadSchemas = async () => {
try {
const res = await jobApi.getSchemaList({ page_size: 100 })
schemaList.value = res.data?.items || []
} catch (error) {
console.error('加载评价方案失败:', error)
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
loadData()
}
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
searchForm.evaluation_schema_id = ''
handleSearch()
}
// 分页
const handleSizeChange = (size) => {
pagination.pageSize = size
loadData()
}
const handlePageChange = (page) => {
pagination.page = page
loadData()
}
// 添加
const showAddDialog = () => {
isEdit.value = false
form.id = ''
form.title = ''
form.department = ''
form.location = ''
form.salary_min = undefined
form.salary_max = undefined
form.evaluation_schema_id = ''
form.description = ''
form.source_id = `manual_${Date.now()}`
dialogVisible.value = true
}
// 编辑
const handleEdit = (row) => {
isEdit.value = true
form.id = row.id
form.title = row.title
form.department = row.department
form.location = row.location
form.salary_min = row.salary_min
form.salary_max = row.salary_max
form.evaluation_schema_id = row.evaluation_schema_id
form.description = row.description
dialogVisible.value = true
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (isEdit.value) {
await jobApi.update(form.id, form)
ElMessage.success('更新成功')
} else {
await jobApi.create(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('提交失败: ' + error.message)
} finally {
submitting.value = false
}
}
// 删除
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该职位吗?', '提示', {
type: 'warning'
})
await jobApi.delete(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败: ' + error.message)
}
}
}
// 关联评价方案
const handleBindSchema = (row) => {
currentJob.value = row
bindSchemaId.value = row.evaluation_schema_id || ''
bindDialogVisible.value = true
}
const confirmBindSchema = async () => {
if (!bindSchemaId.value) {
ElMessage.warning('请选择评价方案')
return
}
binding.value = true
try {
await jobApi.bindSchema(currentJob.value.id, bindSchemaId.value)
ElMessage.success('关联成功')
bindDialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('关联失败: ' + error.message)
} finally {
binding.value = false
}
}
// 解除关联
const handleUnbindSchema = async (row) => {
try {
await ElMessageBox.confirm('确定要解除该职位的评价方案关联吗?', '提示', {
type: 'warning'
})
await jobApi.update(row.id, { evaluation_schema_id: null })
ElMessage.success('已解除关联')
loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败: ' + error.message)
}
}
}
// 工具函数
const getStatusType = (status) => {
const map = { active: 'success', paused: 'warning', closed: 'info', archived: 'danger' }
return map[status] || 'info'
}
const getStatusLabel = (status) => {
const map = { active: '进行中', paused: '已暂停', closed: '已关闭', archived: '已归档' }
return map[status] || status
}
const getSchemaName = (id) => {
const schema = schemaList.value.find(s => s.id === id)
return schema?.name || id
}
const formatTime = (time) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
}
onMounted(() => {
loadData()
loadSchemas()
})
</script>
<style scoped>
.jobs-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.search-card {
margin-bottom: 20px;
}
.schema-tag {
display: flex;
align-items: center;
gap: 8px;
}
.text-gray {
color: #909399;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,415 @@
<template>
<div class="recruiters-page">
<div class="page-header">
<h2 class="page-title">招聘者管理</h2>
<el-button type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>添加招聘者
</el-button>
</div>
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :inline="true" :model="searchForm">
<el-form-item label="平台来源">
<el-select v-model="searchForm.source" placeholder="全部" clearable>
<el-option label="Boss直聘" value="boss" />
<el-option label="猎聘" value="liepin" />
<el-option label="智联招聘" value="zhilian" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card>
<el-table :data="recruiterList" v-loading="loading" stripe>
<el-table-column prop="name" label="账号名称" min-width="150" />
<el-table-column prop="source" label="平台" width="100">
<template #default="{ row }">
<el-tag :type="getSourceType(row.source)">
{{ getSourceLabel(row.source) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="权益信息" min-width="200">
<template #default="{ row }">
<div v-if="row.privilege" class="privilege-info">
<div>VIP: {{ row.privilege.vip_level || '无' }}</div>
<div>剩余简历: {{ row.privilege.resume_view_count || 0 }}</div>
</div>
<span v-else class="text-gray">暂无权益信息</span>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="最后同步" width="180">
<template #default="{ row }">
{{ formatTime(row.last_sync_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="handleSync(row)">同步</el-button>
<el-button
size="small"
:type="row.status === 'active' ? 'warning' : 'success'"
@click="toggleStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑招聘者' : '添加招聘者'"
width="500px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="账号名称" prop="name">
<el-input v-model="form.name" placeholder="请输入账号名称" />
</el-form-item>
<el-form-item label="平台来源" prop="source">
<el-select v-model="form.source" placeholder="请选择平台" style="width: 100%">
<el-option label="Boss直聘" value="boss" />
<el-option label="猎聘" value="liepin" />
<el-option label="智联招聘" value="zhilian" />
</el-select>
</el-form-item>
<el-form-item label="WT Token" prop="wt_token">
<el-input
v-model="form.wt_token"
type="textarea"
:rows="3"
placeholder="请输入WT Token"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
<!-- 自动注册对话框 -->
<el-dialog
v-model="registerDialogVisible"
title="自动注册招聘者"
width="500px"
>
<el-form :model="registerForm" :rules="registerRules" ref="registerFormRef" label-width="100px">
<el-form-item label="平台来源" prop="source">
<el-select v-model="registerForm.source" placeholder="请选择平台" style="width: 100%">
<el-option label="Boss直聘" value="boss" />
</el-select>
</el-form-item>
<el-form-item label="WT Token" prop="wt_token">
<el-input
v-model="registerForm.wt_token"
type="textarea"
:rows="3"
placeholder="请输入WT Token系统将自动获取账号信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="registerDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleRegister" :loading="registering">
自动注册
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { recruiterApi } from '@/api/api'
import dayjs from 'dayjs'
const loading = ref(false)
const recruiterList = ref([])
// 搜索表单
const searchForm = reactive({
source: ''
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 对话框
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref()
const form = reactive({
id: '',
name: '',
source: 'boss',
wt_token: ''
})
const rules = {
name: [{ required: true, message: '请输入账号名称', trigger: 'blur' }],
source: [{ required: true, message: '请选择平台来源', trigger: 'change' }],
wt_token: [{ required: true, message: '请输入WT Token', trigger: 'blur' }]
}
// 自动注册
const registerDialogVisible = ref(false)
const registerFormRef = ref()
const registerForm = reactive({
source: 'boss',
wt_token: ''
})
const registerRules = {
source: [{ required: true, message: '请选择平台来源', trigger: 'change' }],
wt_token: [{ required: true, message: '请输入WT Token', trigger: 'blur' }]
}
const submitting = ref(false)
const registering = ref(false)
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await recruiterApi.getList({
source: searchForm.source,
page: pagination.page,
page_size: pagination.pageSize
})
recruiterList.value = res.data?.items || []
pagination.total = res.data?.total || 0
} catch (error) {
ElMessage.error('加载数据失败: ' + error.message)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
loadData()
}
const resetSearch = () => {
searchForm.source = ''
handleSearch()
}
// 分页
const handleSizeChange = (size) => {
pagination.pageSize = size
loadData()
}
const handlePageChange = (page) => {
pagination.page = page
loadData()
}
// 添加
const showAddDialog = () => {
isEdit.value = false
form.id = ''
form.name = ''
form.source = 'boss'
form.wt_token = ''
dialogVisible.value = true
}
// 编辑
const handleEdit = (row) => {
isEdit.value = true
form.id = row.id
form.name = row.name
form.source = row.source
form.wt_token = row.wt_token
dialogVisible.value = true
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (isEdit.value) {
await recruiterApi.update(form.id, form)
ElMessage.success('更新成功')
} else {
await recruiterApi.create(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('提交失败: ' + error.message)
} finally {
submitting.value = false
}
}
// 删除
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该招聘者吗?', '提示', {
type: 'warning'
})
await recruiterApi.delete(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败: ' + error.message)
}
}
}
// 同步
const handleSync = async (row) => {
try {
await recruiterApi.sync(row.id)
ElMessage.success('同步任务已触发')
} catch (error) {
ElMessage.error('同步失败: ' + error.message)
}
}
// 切换状态
const toggleStatus = async (row) => {
try {
if (row.status === 'active') {
await recruiterApi.deactivate(row.id)
ElMessage.success('已停用')
} else {
await recruiterApi.activate(row.id)
ElMessage.success('已启用')
}
loadData()
} catch (error) {
ElMessage.error('操作失败: ' + error.message)
}
}
// 自动注册
const handleRegister = async () => {
const valid = await registerFormRef.value?.validate().catch(() => false)
if (!valid) return
registering.value = true
try {
const res = await recruiterApi.register(registerForm)
if (res.data?.success) {
ElMessage.success('注册成功: ' + res.data?.message)
registerDialogVisible.value = false
loadData()
} else {
ElMessage.warning(res.data?.message || '注册失败')
}
} catch (error) {
ElMessage.error('注册失败: ' + error.message)
} finally {
registering.value = false
}
}
// 工具函数
const getSourceType = (source) => {
const map = { boss: 'danger', liepin: 'primary', zhilian: 'success' }
return map[source] || 'info'
}
const getSourceLabel = (source) => {
const map = { boss: 'Boss直聘', liepin: '猎聘', zhilian: '智联招聘' }
return map[source] || source
}
const formatTime = (time) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.recruiters-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.search-card {
margin-bottom: 20px;
}
.privilege-info {
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.text-gray {
color: #909399;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="scheduler-page">
<div class="page-header">
<h2 class="page-title">定时任务管理</h2>
<el-button-group>
<el-button type="success" @click="handleStart" :disabled="schedulerStatus.running">
<el-icon><VideoPlay /></el-icon>启动调度器
</el-button>
<el-button type="danger" @click="handleStop" :disabled="!schedulerStatus.running">
<el-icon><VideoPause /></el-icon>停止调度器
</el-button>
</el-button-group>
</div>
<!-- 调度器状态 -->
<el-row :gutter="20" class="status-row">
<el-col :span="6">
<el-card class="status-card">
<div class="status-icon" :class="schedulerStatus.running ? 'running' : 'stopped'">
<el-icon size="32" color="#fff"><Timer /></el-icon>
</div>
<div class="status-info">
<div class="status-value">{{ schedulerStatus.running ? '运行中' : '已停止' }}</div>
<div class="status-label">调度器状态</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="status-card">
<div class="status-icon" style="background: #409EFF;">
<el-icon size="32" color="#fff"><List /></el-icon>
</div>
<div class="status-info">
<div class="status-value">{{ schedulerStatus.total_jobs || 0 }}</div>
<div class="status-label">任务总数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="status-card">
<div class="status-icon" style="background: #67C23A;">
<el-icon size="32" color="#fff"><CircleCheck /></el-icon>
</div>
<div class="status-info">
<div class="status-value">{{ schedulerStatus.job_status_summary?.enabled || 0 }}</div>
<div class="status-label">已启用任务</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="status-card">
<div class="status-icon" style="background: #E6A23C;">
<el-icon size="32" color="#fff"><Loading /></el-icon>
</div>
<div class="status-info">
<div class="status-value">{{ schedulerStatus.job_status_summary?.running || 0 }}</div>
<div class="status-label">正在运行</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 任务列表 -->
<el-card>
<template #header>
<div class="card-header">
<span>任务列表</span>
<el-button type="primary" size="small" @click="loadData" :loading="loading">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</template>
<el-table :data="jobList" v-loading="loading" stripe>
<el-table-column prop="job_id" label="任务ID" min-width="150" />
<el-table-column prop="name" label="任务名称" min-width="150" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="运行状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_running ? 'warning' : 'info'" size="small" effect="dark">
{{ row.is_running ? '运行中' : '空闲' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行统计" min-width="200">
<template #default="{ row }">
<div class="stats">
<el-tag size="small" type="success">成功: {{ row.success_count || 0 }}</el-tag>
<el-tag size="small" type="danger">失败: {{ row.fail_count || 0 }}</el-tag>
<el-tag size="small" type="info">总计: {{ row.run_count || 0 }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="last_run_time" label="最后执行" width="160">
<template #default="{ row }">
{{ formatTime(row.last_run_time) }}
</template>
</el-table-column>
<el-table-column prop="next_run_time" label="下次执行" width="160">
<template #default="{ row }">
{{ formatTime(row.next_run_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button size="small" type="primary" @click="handleRun(row)" :loading="row.is_running">
<el-icon><VideoPlay /></el-icon>执行
</el-button>
<el-button
size="small"
:type="row.enabled ? 'warning' : 'success'"
@click="toggleJobStatus(row)"
:disabled="row.is_running"
>
{{ row.enabled ? '暂停' : '恢复' }}
</el-button>
<el-button size="small" @click="handleConfig(row)">配置</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 配置对话框 -->
<el-dialog v-model="configDialogVisible" title="任务配置" width="500px">
<el-form :model="configForm" label-width="120px">
<el-form-item label="任务ID">
<el-input v-model="configForm.job_id" disabled />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="configForm.enabled" />
</el-form-item>
<el-form-item label="执行间隔(分钟)">
<el-input-number v-model="configForm.interval_minutes" :min="1" :max="1440" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitConfig" :loading="configuring">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { schedulerApi } from '@/api/api'
import dayjs from 'dayjs'
const loading = ref(false)
const jobList = ref([])
const schedulerStatus = ref({})
// 配置对话框
const configDialogVisible = ref(false)
const configuring = ref(false)
const configForm = reactive({
job_id: '',
enabled: true,
interval_minutes: 30
})
// 加载数据
const loadData = async () => {
loading.value = true
try {
const [jobsRes, statusRes] = await Promise.all([
schedulerApi.getJobsStatus(),
schedulerApi.getStatus()
])
jobList.value = jobsRes.data || []
schedulerStatus.value = statusRes.data || {}
} catch (error) {
ElMessage.error('加载数据失败: ' + error.message)
} finally {
loading.value = false
}
}
// 启动调度器
const handleStart = async () => {
try {
await schedulerApi.start()
ElMessage.success('调度器已启动')
loadData()
} catch (error) {
ElMessage.error('启动失败: ' + error.message)
}
}
// 停止调度器
const handleStop = async () => {
try {
await ElMessageBox.confirm('确定要停止调度器吗?正在运行的任务将被中断。', '警告', {
type: 'warning'
})
await schedulerApi.stop()
ElMessage.success('调度器已停止')
loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('停止失败: ' + error.message)
}
}
}
// 立即执行任务
const handleRun = async (row) => {
try {
await schedulerApi.runJob(row.job_id)
ElMessage.success('任务已开始执行')
setTimeout(loadData, 1000)
} catch (error) {
ElMessage.error('执行失败: ' + error.message)
}
}
// 切换任务状态
const toggleJobStatus = async (row) => {
try {
if (row.enabled) {
await schedulerApi.pauseJob(row.job_id)
ElMessage.success('任务已暂停')
} else {
await schedulerApi.resumeJob(row.job_id)
ElMessage.success('任务已恢复')
}
loadData()
} catch (error) {
ElMessage.error('操作失败: ' + error.message)
}
}
// 打开配置对话框
const handleConfig = (row) => {
configForm.job_id = row.job_id
configForm.enabled = row.enabled
configForm.interval_minutes = 30
configDialogVisible.value = true
}
// 提交配置
const handleSubmitConfig = async () => {
configuring.value = true
try {
await schedulerApi.updateConfig(configForm.job_id, {
enabled: configForm.enabled,
interval_minutes: configForm.interval_minutes
})
ElMessage.success('配置已更新')
configDialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('更新失败: ' + error.message)
} finally {
configuring.value = false
}
}
// 工具函数
const formatTime = (time) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'
}
onMounted(() => {
loadData()
// 自动刷新
setInterval(loadData, 10000)
})
</script>
<style scoped>
.scheduler-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.status-row {
margin-bottom: 24px;
}
.status-card {
display: flex;
align-items: center;
padding: 20px;
}
.status-icon {
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.status-icon.running {
background: #67C23A;
}
.status-icon.stopped {
background: #F56C6C;
}
.status-info {
flex: 1;
}
.status-value {
font-size: 24px;
font-weight: 700;
color: #303133;
line-height: 1;
}
.status-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
},
'/candidates': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})