feat(frontend): 添加前端基础框架与核心功能模块
- 初始化项目配置,新增npmrc和package.json文件 - 创建Vue 3应用入口,集成Element Plus UI库及图标插件 - 配置路由包括首页、招聘者管理、职位管理、候选人管理和定时任务五个模块 - 实现布局组件包含侧边栏菜单、顶部导航及主内容区 - 封装统一的API请求模块,包含招聘者、职位、候选人、定时任务和系统接口 - 开发Dashboard页面,实现系统概览及状态展示功能 - 开发候选人管理页面,包括搜索筛选、分页展示、详情查看及评分功能 - 开发职位管理页面,支持职位列表、搜索过滤、分页管理及关联评价方案 - 设计基础样式和交互效果,保证页面布局和风格一致性
This commit is contained in:
4
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/.npmrc
Normal file
4
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/.npmrc
Normal file
@@ -0,0 +1,4 @@
|
||||
# pnpm 配置
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
13
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/index.html
Normal file
13
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/index.html
Normal 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>
|
||||
26
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
Normal file
26
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
Normal file
18
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
Normal 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>
|
||||
154
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/api/api.js
Normal file
154
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/api/api.js
Normal 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
|
||||
@@ -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>
|
||||
22
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/main.js
Normal file
22
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/main.js
Normal 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')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
478
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/views/Jobs.vue
Normal file
478
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/views/Jobs.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
25
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/vite.config.js
Normal file
25
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user