feat(notification-channels): 新增通知渠道管理功能

- 后端API新增评价方案列表接口及通知渠道相关接口
- 所有候选人相关API路径添加/api前缀
- 系统首页接口更新候选人路径为/api/candidates
- CandidateMapper和JobMapper排序逻辑调整以兼容MySQL null值排序
- 前端candidateApi接口路径添加/api前缀
- 新增notificationChannelApi管理通知渠道,包括增删改查、启用停用及招聘者绑定管理
- 路由新增通知渠道管理页面入口
- 实现NotificationChannels.vue通知渠道的增删改查、搜索筛选、分页、启用停用及招聘者绑定管理功能
- Recruiters.vue中新增通知渠道绑定对话框及绑定相关逻辑,支持招聘者绑定通知渠道管理
- controller/schemas.py新增分页参数PaginationParams及重建模型修正前向引用
- UI组件调整及新增对应表格列、表单校验规则和界面交互逻辑
This commit is contained in:
2026-03-25 11:14:51 +08:00
parent 6f3487a09a
commit 148f2cc4f6
10 changed files with 1042 additions and 70 deletions

View File

@@ -15,7 +15,7 @@ from ..schemas import (
from ...mapper.candidate_mapper import CandidateMapper
router = APIRouter(prefix="/candidates", tags=["候选人管理"])
router = APIRouter(prefix="/api/candidates", tags=["候选人管理"])
def _candidate_to_response(candidate) -> CandidateResponse:

View File

@@ -45,6 +45,45 @@ def _job_to_response(job: Job) -> JobPositionResponse:
)
@router.get("/schemas/list", response_model=BaseResponse[EvaluationSchemaListResponse])
async def list_evaluation_schemas(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量")
):
"""
获取评价方案列表
Args:
page: 页码
page_size: 每页数量
Returns:
BaseResponse[EvaluationSchemaListResponse]: 统一响应格式的评价方案列表
"""
try:
schema_mapper = EvaluationMapper()
schemas, total = schema_mapper.find_all_schemas(page=page, page_size=page_size)
items = [
EvaluationSchemaResponse(
id=schema.id,
name=schema.name,
description=schema.description,
dimensions=schema.dimensions,
weights=schema.weights,
is_default=schema.is_default,
created_at=schema.created_at,
updated_at=schema.updated_at
)
for schema in schemas
]
response = EvaluationSchemaListResponse(total=total, items=items)
return BaseResponse.success(data=response, msg="获取评价方案列表成功")
except Exception as e:
return BaseResponse.error(msg=f"获取评价方案列表失败: {str(e)}")
@router.get("", response_model=BaseResponse[JobPositionListResponse])
async def list_jobs(
source: Optional[str] = Query(None, description="平台来源"),
@@ -368,42 +407,3 @@ async def get_job_evaluation_schema(job_id: str):
return BaseResponse.success(data=response, msg="获取评价方案成功")
except Exception as e:
return BaseResponse.error(msg=f"获取评价方案失败: {str(e)}")
@router.get("/schemas/list", response_model=BaseResponse[EvaluationSchemaListResponse])
async def list_evaluation_schemas(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量")
):
"""
获取评价方案列表
Args:
page: 页码
page_size: 每页数量
Returns:
BaseResponse[EvaluationSchemaListResponse]: 统一响应格式的评价方案列表
"""
try:
schema_mapper = EvaluationMapper()
schemas, total = schema_mapper.find_all_schemas(page=page, page_size=page_size)
items = [
EvaluationSchemaResponse(
id=schema.id,
name=schema.name,
description=schema.description,
dimensions=schema.dimensions,
weights=schema.weights,
is_default=schema.is_default,
created_at=schema.created_at,
updated_at=schema.updated_at
)
for schema in schemas
]
response = EvaluationSchemaListResponse(total=total, items=items)
return BaseResponse.success(data=response, msg="获取评价方案列表成功")
except Exception as e:
return BaseResponse.error(msg=f"获取评价方案列表失败: {str(e)}")

View File

@@ -21,7 +21,7 @@ async def root():
"endpoints": {
"recruiters": "/api/recruiters",
"jobs": "/api/jobs",
"candidates": "/candidates",
"candidates": "/api/candidates",
"scheduler": "/api/scheduler",
"health": "/health"
}

View File

@@ -3,7 +3,7 @@ API共享Schema定义
集中定义所有API请求和响应的数据模型
"""
from typing import List, Optional, Any, TypeVar, Generic
from typing import List, Optional, Any, TypeVar, Generic, Dict
from datetime import datetime
from pydantic import BaseModel, Field
@@ -46,6 +46,8 @@ class PaginationData(BaseModel, Generic[T]):
# ============== 分页参数 ==============
class PaginationParams(BaseModel):
"""分页参数"""
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
@@ -424,3 +426,20 @@ class RecruiterWithChannelsResponse(BaseModel):
recruiter_id: str
recruiter_name: str
channels: List[RecruiterChannelBindingResponse]
# 重建所有模型以解决前向引用问题
BaseResponse.model_rebuild()
PaginationData.model_rebuild()
CandidateResponse.model_rebuild()
CandidateListResponse.model_rebuild()
JobPositionResponse.model_rebuild()
JobPositionListResponse.model_rebuild()
RecruiterResponse.model_rebuild()
RecruiterListResponse.model_rebuild()
EvaluationSchemaResponse.model_rebuild()
EvaluationSchemaListResponse.model_rebuild()
NotificationChannelResponse.model_rebuild()
NotificationChannelListResponse.model_rebuild()
RecruiterChannelBindingResponse.model_rebuild()
RecruiterChannelBindingListResponse.model_rebuild()

View File

@@ -262,9 +262,11 @@ class CandidateMapper:
count_stmt = count_stmt.where(*conditions)
total = session.execute(count_stmt).scalar()
# 分页查询按LLM评分降序再按创建时间倒序
# 分页查询按LLM评分降序NULL值在后,再按创建时间倒序
# 使用MySQL兼容的语法先按是否为NULL排序再按值排序
stmt = stmt.order_by(
CandidateModel.llm_score.desc().nullslast(),
CandidateModel.llm_score.is_(None).asc(), # NULL值在后
CandidateModel.llm_score.desc(),
CandidateModel.created_at.desc()
)
stmt = stmt.offset((page - 1) * page_size).limit(page_size)

View File

@@ -330,8 +330,12 @@ class JobMapper:
# 获取总数
total = session.execute(count_stmt).scalar()
# 分页查询,按最后同步时间降序
stmt = stmt.order_by(JobModel.last_sync_at.desc().nullslast())
# 分页查询,按最后同步时间降序NULL值在后
# 使用MySQL兼容的语法先按是否为NULL排序再按值排序
stmt = stmt.order_by(
JobModel.last_sync_at.is_(None).asc(), # NULL值在后
JobModel.last_sync_at.desc()
)
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
results = session.execute(stmt).scalars().all()

View File

@@ -88,22 +88,22 @@ export const jobApi = {
// 候选人管理 API
export const candidateApi = {
// 获取筛选通过的候选人
getFiltered: (params = {}) => api.get('/candidates/filtered', { params }),
getFiltered: (params = {}) => api.get('/api/candidates/filtered', { params }),
// 筛选候选人
filter: (data) => api.post('/candidates/filter', data),
filter: (data) => api.post('/api/candidates/filter', data),
// 获取候选人详情
getDetail: (id) => api.get(`/candidates/${id}`),
getDetail: (id) => api.get(`/api/candidates/${id}`),
// 标记候选人筛选状态
markFiltered: (data) => api.post('/candidates/mark-filtered', data),
markFiltered: (data) => api.post('/api/candidates/mark-filtered', data),
// 更新候选人评分
updateScore: (data) => api.post('/candidates/update-score', data),
updateScore: (data) => api.post('/api/candidates/update-score', data),
// 根据评分范围查询
getByScoreRange: (params) => api.get('/candidates/by-score-range', { params })
getByScoreRange: (params) => api.get('/api/candidates/by-score-range', { params })
}
// 定时任务管理 API
@@ -139,14 +139,53 @@ export const schedulerApi = {
stop: () => api.post('/api/scheduler/stop')
}
// 通知渠道管理 API
export const notificationChannelApi = {
// 获取渠道类型列表
getTypes: () => api.get('/api/notification-channels/types'),
// 获取通知渠道列表
getList: (params = {}) => api.get('/api/notification-channels', { params }),
// 获取通知渠道详情
getDetail: (id) => api.get(`/api/notification-channels/${id}`),
// 创建通知渠道
create: (data) => api.post('/api/notification-channels', data),
// 更新通知渠道
update: (id, data) => api.put(`/api/notification-channels/${id}`, data),
// 删除通知渠道
delete: (id) => api.delete(`/api/notification-channels/${id}`),
// 启用通知渠道
activate: (id) => api.post(`/api/notification-channels/${id}/activate`),
// 停用通知渠道
deactivate: (id) => api.post(`/api/notification-channels/${id}/deactivate`),
// 获取渠道绑定的招聘者列表
getChannelRecruiters: (id) => api.get(`/api/notification-channels/${id}/recruiters`),
// 绑定招聘者到渠道
bindRecruiter: (channelId, recruiterId, data) => api.post(`/api/notification-channels/${channelId}/recruiters/${recruiterId}`, data),
// 更新绑定配置
updateBinding: (channelId, recruiterId, data) => api.put(`/api/notification-channels/${channelId}/recruiters/${recruiterId}`, data),
// 解绑招聘者
unbindRecruiter: (channelId, recruiterId) => api.delete(`/api/notification-channels/${channelId}/recruiters/${recruiterId}`)
}
// 系统 API
export const systemApi = {
// 获取首页信息
getHome: () => api.get('/'),
// 健康检查
health: () => api.get('/health'),
// 获取API状态
getStatus: () => api.get('/api/status')
}

View File

@@ -36,6 +36,12 @@ const routes = [
name: 'Scheduler',
component: () => import('@/views/Scheduler.vue'),
meta: { title: '定时任务', icon: 'time' }
},
{
path: 'notification-channels',
name: 'NotificationChannels',
component: () => import('@/views/NotificationChannels.vue'),
meta: { title: '通知渠道', icon: 'notification' }
}
]
}

View File

@@ -0,0 +1,679 @@
<template>
<div class="channels-page">
<div class="page-header">
<h2 class="page-title">通知渠道管理</h2>
<t-button theme="primary" @click="showAddDialog">
<template #icon><t-icon name="add" /></template>
添加渠道
</t-button>
</div>
<!-- 搜索栏 -->
<t-card class="search-card" :bordered="false">
<t-form layout="inline" :data="searchForm">
<t-form-item label="渠道类型">
<t-select v-model="searchForm.channel_type" placeholder="全部" clearable style="width: 150px">
<t-option label="企业微信" value="wechat_work" />
<t-option label="钉钉" value="dingtalk" />
<t-option label="飞书" value="feishu" />
<t-option label="邮件" value="email" />
<t-option label="通用Webhook" value="webhook" />
</t-select>
</t-form-item>
<t-form-item>
<t-space>
<t-button theme="primary" @click="handleSearch">
<template #icon><t-icon name="search" /></template>
搜索
</t-button>
<t-button theme="default" @click="resetSearch">重置</t-button>
</t-space>
</t-form-item>
</t-form>
</t-card>
<!-- 数据表格 -->
<t-card :bordered="false">
<t-table
:data="channelList"
:columns="columns"
:loading="loading"
row-key="id"
stripe
/>
<!-- 分页 -->
<div class="pagination">
<t-pagination
v-model="pagination.page"
v-model:pageSize="pagination.pageSize"
:total="pagination.total"
:page-size-options="[10, 20, 50]"
@change="handlePageChange"
/>
</div>
</t-card>
<!-- 添加/编辑对话框 -->
<t-dialog
v-model:visible="dialogVisible"
:header="isEdit ? '编辑通知渠道' : '添加通知渠道'"
width="600px"
:confirm-btn="{ content: '确定', loading: submitting }"
:on-confirm="handleSubmit"
:on-close="() => dialogVisible = false"
>
<t-form ref="formRef" :data="form" :rules="rules" :label-width="120">
<t-form-item label="渠道名称" name="name">
<t-input v-model="form.name" placeholder="请输入渠道名称" />
</t-form-item>
<t-form-item label="渠道类型" name="channel_type">
<t-select v-model="form.channel_type" placeholder="请选择渠道类型" style="width: 100%" :disabled="isEdit">
<t-option label="企业微信" value="wechat_work" />
<t-option label="钉钉" value="dingtalk" />
<t-option label="飞书" value="feishu" />
<t-option label="邮件" value="email" />
<t-option label="通用Webhook" value="webhook" />
</t-select>
</t-form-item>
<t-form-item label="描述">
<t-textarea v-model="form.description" :rows="2" placeholder="请输入描述" />
</t-form-item>
<!-- 飞书配置 -->
<template v-if="form.channel_type === 'feishu'">
<t-form-item label="Webhook地址" name="config.feishu_webhook">
<t-input v-model="form.config.feishu_webhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." />
</t-form-item>
<t-form-item label="签名密钥">
<t-input v-model="form.config.feishu_secret" placeholder="可选,用于验证请求" />
</t-form-item>
</template>
<!-- 企业微信配置 -->
<template v-if="form.channel_type === 'wechat_work'">
<t-form-item label="Webhook地址" name="config.webhook_url">
<t-input v-model="form.config.webhook_url" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
</t-form-item>
</template>
<!-- 钉钉配置 -->
<template v-if="form.channel_type === 'dingtalk'">
<t-form-item label="Webhook地址" name="config.webhook_url">
<t-input v-model="form.config.webhook_url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." />
</t-form-item>
<t-form-item label="签名密钥">
<t-input v-model="form.config.sign_secret" placeholder="可选,用于钉钉安全设置" />
</t-form-item>
</template>
<!-- 邮件配置 -->
<template v-if="form.channel_type === 'email'">
<t-form-item label="SMTP服务器" name="config.smtp_host">
<t-input v-model="form.config.smtp_host" placeholder="smtp.example.com" />
</t-form-item>
<t-form-item label="SMTP端口" name="config.smtp_port">
<t-input-number v-model="form.config.smtp_port" :min="1" :max="65535" placeholder="587" />
</t-form-item>
<t-form-item label="用户名" name="config.username">
<t-input v-model="form.config.username" placeholder="邮箱地址" />
</t-form-item>
<t-form-item label="密码" name="config.password">
<t-input v-model="form.config.password" type="password" placeholder="邮箱密码或授权码" />
</t-form-item>
<t-form-item label="发件人名称">
<t-input v-model="form.config.sender_name" placeholder="显示的发件人名称" />
</t-form-item>
<t-form-item label="使用TLS">
<t-switch v-model="form.config.use_tls" />
</t-form-item>
</template>
<!-- 通用Webhook配置 -->
<template v-if="form.channel_type === 'webhook'">
<t-form-item label="Webhook地址" name="config.webhook_url">
<t-input v-model="form.config.webhook_url" placeholder="https://..." />
</t-form-item>
</template>
</t-form>
</t-dialog>
<!-- 绑定招聘者对话框 -->
<t-dialog
v-model:visible="bindDialogVisible"
header="绑定招聘者"
width="600px"
:confirm-btn="{ content: '确定', loading: binding }"
:on-confirm="confirmBind"
:on-close="() => bindDialogVisible = false"
>
<t-form :data="bindForm" :label-width="120">
<t-form-item label="选择招聘者" name="recruiter_id">
<t-select v-model="bindForm.recruiter_id" placeholder="请选择招聘者" style="width: 100%">
<t-option
v-for="recruiter in recruiterList"
:key="recruiter.id"
:label="recruiter.name"
:value="recruiter.id"
/>
</t-select>
</t-form-item>
<t-form-item label="启用通知">
<t-switch v-model="bindForm.is_enabled" />
</t-form-item>
<t-form-item label="新候选人通知">
<t-switch v-model="bindForm.notify_on_new_candidate" />
</t-form-item>
<t-form-item label="评价完成通知">
<t-switch v-model="bindForm.notify_on_evaluation" />
</t-form-item>
<t-form-item label="高分候选人通知">
<t-switch v-model="bindForm.notify_on_high_score" />
</t-form-item>
<t-form-item label="高分阈值" v-if="bindForm.notify_on_high_score">
<t-slider v-model="bindForm.high_score_threshold" :max="100" :step="1" />
<div class="score-value">{{ bindForm.high_score_threshold }} 分</div>
</t-form-item>
</t-form>
</t-dialog>
<!-- 管理绑定对话框 -->
<t-dialog
v-model:visible="manageBindDialogVisible"
header="管理绑定"
width="700px"
:footer="false"
>
<t-table
:data="currentBindings"
:columns="bindingColumns"
row-key="recruiter_id"
stripe
/>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { notificationChannelApi, recruiterApi } from '@/api/api'
const loading = ref(false)
const channelList = ref([])
const recruiterList = ref([])
// 表格列定义
const columns = [
{ colKey: 'name', title: '渠道名称', minWidth: 150 },
{
colKey: 'channel_type',
title: '类型',
width: 120,
cell: (h, { row }) => {
const typeMap = {
wechat_work: '企业微信',
dingtalk: '钉钉',
feishu: '飞书',
email: '邮件',
webhook: 'Webhook'
}
const themeMap = {
wechat_work: 'success',
dingtalk: 'primary',
feishu: 'warning',
email: 'default',
webhook: 'danger'
}
return h('t-tag', { theme: themeMap[row.channel_type] || 'default' }, typeMap[row.channel_type] || row.channel_type)
}
},
{
colKey: 'status',
title: '状态',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.status === 'active' ? 'success' : 'default', size: 'small' },
row.status === 'active' ? '启用' : '停用')
}
},
{ colKey: 'description', title: '描述', minWidth: 200, ellipsis: true },
{
colKey: 'recruiter_count',
title: '绑定招聘者',
width: 120,
cell: (h, { row }) => {
const count = row.recruiter_ids?.length || 0
return h('t-badge', { count: count, showZero: true }, `${count}`)
}
},
{
colKey: 'operation',
title: '操作',
width: 280,
fixed: 'right',
cell: (h, { row }) => {
return h('t-space', { size: 'small' }, {
default: () => [
h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'),
h('t-button', { size: 'small', theme: 'primary', onClick: () => handleBind(row) }, '绑定'),
h('t-button', { size: 'small', onClick: () => handleManageBind(row) }, '管理'),
h('t-button', {
size: 'small',
theme: row.status === 'active' ? 'warning' : 'success',
onClick: () => toggleStatus(row)
}, row.status === 'active' ? '停用' : '启用'),
h('t-button', { size: 'small', theme: 'danger', onClick: () => handleDelete(row) }, '删除')
]
})
}
}
]
// 绑定表格列
const bindingColumns = [
{ colKey: 'recruiter_id', title: '招聘者ID', minWidth: 200 },
{
colKey: 'is_enabled',
title: '启用',
width: 80,
cell: (h, { row }) => {
return h('t-tag', { theme: row.is_enabled ? 'success' : 'default', size: 'small' },
row.is_enabled ? '是' : '否')
}
},
{
colKey: 'notify_on_new_candidate',
title: '新候选人',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.notify_on_new_candidate ? 'success' : 'default', size: 'small' },
row.notify_on_new_candidate ? '通知' : '关闭')
}
},
{
colKey: 'notify_on_evaluation',
title: '评价完成',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.notify_on_evaluation ? 'success' : 'default', size: 'small' },
row.notify_on_evaluation ? '通知' : '关闭')
}
},
{
colKey: 'operation',
title: '操作',
width: 100,
cell: (h, { row }) => {
return h('t-button', {
size: 'small',
theme: 'danger',
onClick: () => handleUnbind(row)
}, '解绑')
}
}
]
// 搜索表单
const searchForm = reactive({
channel_type: ''
})
// 分页
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: '',
channel_type: '',
description: '',
config: {
webhook_url: '',
secret: '',
access_token: '',
sign_secret: '',
smtp_host: '',
smtp_port: 587,
username: '',
password: '',
use_tls: true,
sender_name: '',
feishu_webhook: '',
feishu_secret: ''
}
})
const rules = {
name: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
channel_type: [{ required: true, message: '请选择渠道类型', trigger: 'change' }]
}
const submitting = ref(false)
// 绑定对话框
const bindDialogVisible = ref(false)
const binding = ref(false)
const currentChannel = ref(null)
const bindForm = reactive({
recruiter_id: '',
is_enabled: true,
notify_on_new_candidate: true,
notify_on_evaluation: true,
notify_on_high_score: false,
high_score_threshold: 80
})
// 管理绑定对话框
const manageBindDialogVisible = ref(false)
const currentBindings = ref([])
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await notificationChannelApi.getList({
channel_type: searchForm.channel_type,
page: pagination.page,
page_size: pagination.pageSize
})
channelList.value = res.data?.items || []
pagination.total = res.data?.total || 0
} catch (error) {
MessagePlugin.error('加载数据失败: ' + error.message)
} finally {
loading.value = false
}
}
// 加载招聘者列表
const loadRecruiters = async () => {
try {
const res = await recruiterApi.getList({ page_size: 100 })
recruiterList.value = res.data?.items || []
} catch (error) {
console.error('加载招聘者失败:', error)
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
loadData()
}
const resetSearch = () => {
searchForm.channel_type = ''
handleSearch()
}
// 分页
const handlePageChange = () => {
loadData()
}
// 添加
const showAddDialog = () => {
isEdit.value = false
form.id = ''
form.name = ''
form.channel_type = ''
form.description = ''
resetConfig()
dialogVisible.value = true
}
const resetConfig = () => {
form.config = {
webhook_url: '',
secret: '',
access_token: '',
sign_secret: '',
smtp_host: '',
smtp_port: 587,
username: '',
password: '',
use_tls: true,
sender_name: '',
feishu_webhook: '',
feishu_secret: ''
}
}
// 编辑
const handleEdit = (row) => {
isEdit.value = true
form.id = row.id
form.name = row.name
form.channel_type = row.channel_type
form.description = row.description
// 合并配置
form.config = { ...form.config, ...row.config }
dialogVisible.value = true
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
// 构建提交数据
const submitData = {
name: form.name,
channel_type: form.channel_type,
description: form.description,
config: {}
}
// 根据类型添加配置
if (form.channel_type === 'feishu') {
submitData.config = {
feishu_webhook: form.config.feishu_webhook,
feishu_secret: form.config.feishu_secret
}
} else if (form.channel_type === 'wechat_work' || form.channel_type === 'dingtalk' || form.channel_type === 'webhook') {
submitData.config = {
webhook_url: form.config.webhook_url,
sign_secret: form.config.sign_secret
}
} else if (form.channel_type === 'email') {
submitData.config = {
smtp_host: form.config.smtp_host,
smtp_port: form.config.smtp_port,
username: form.config.username,
password: form.config.password,
use_tls: form.config.use_tls,
sender_name: form.config.sender_name
}
}
if (isEdit.value) {
await notificationChannelApi.update(form.id, submitData)
MessagePlugin.success('更新成功')
} else {
await notificationChannelApi.create(submitData)
MessagePlugin.success('创建成功')
}
dialogVisible.value = false
loadData()
} catch (error) {
MessagePlugin.error('提交失败: ' + error.message)
} finally {
submitting.value = false
}
}
// 删除
const handleDelete = async (row) => {
const confirmDia = DialogPlugin.confirm({
header: '确认删除',
body: '确定要删除该通知渠道吗?',
confirmBtn: '确定',
cancelBtn: '取消',
onConfirm: async () => {
try {
await notificationChannelApi.delete(row.id)
MessagePlugin.success('删除成功')
loadData()
confirmDia.destroy()
} catch (error) {
MessagePlugin.error('删除失败: ' + error.message)
}
}
})
}
// 切换状态
const toggleStatus = async (row) => {
try {
if (row.status === 'active') {
await notificationChannelApi.deactivate(row.id)
MessagePlugin.success('已停用')
} else {
await notificationChannelApi.activate(row.id)
MessagePlugin.success('已启用')
}
loadData()
} catch (error) {
MessagePlugin.error('操作失败: ' + error.message)
}
}
// 绑定招聘者
const handleBind = (row) => {
currentChannel.value = row
bindForm.recruiter_id = ''
bindForm.is_enabled = true
bindForm.notify_on_new_candidate = true
bindForm.notify_on_evaluation = true
bindForm.notify_on_high_score = false
bindForm.high_score_threshold = 80
bindDialogVisible.value = true
}
const confirmBind = async () => {
if (!bindForm.recruiter_id) {
MessagePlugin.warning('请选择招聘者')
return
}
binding.value = true
try {
await notificationChannelApi.bindRecruiter(
currentChannel.value.id,
bindForm.recruiter_id,
{
is_enabled: bindForm.is_enabled,
notify_on_new_candidate: bindForm.notify_on_new_candidate,
notify_on_evaluation: bindForm.notify_on_evaluation,
notify_on_high_score: bindForm.notify_on_high_score,
high_score_threshold: bindForm.high_score_threshold
}
)
MessagePlugin.success('绑定成功')
bindDialogVisible.value = false
loadData()
} catch (error) {
MessagePlugin.error('绑定失败: ' + error.message)
} finally {
binding.value = false
}
}
// 管理绑定
const handleManageBind = async (row) => {
currentChannel.value = row
try {
const res = await notificationChannelApi.getChannelRecruiters(row.id)
currentBindings.value = res.data?.items || []
manageBindDialogVisible.value = true
} catch (error) {
MessagePlugin.error('获取绑定列表失败: ' + error.message)
}
}
// 解绑
const handleUnbind = async (row) => {
const confirmDia = DialogPlugin.confirm({
header: '确认解绑',
body: '确定要解绑该招聘者吗?',
confirmBtn: '确定',
cancelBtn: '取消',
onConfirm: async () => {
try {
await notificationChannelApi.unbindRecruiter(currentChannel.value.id, row.recruiter_id)
MessagePlugin.success('解绑成功')
// 刷新绑定列表
const res = await notificationChannelApi.getChannelRecruiters(currentChannel.value.id)
currentBindings.value = res.data?.items || []
loadData()
confirmDia.destroy()
} catch (error) {
MessagePlugin.error('解绑失败: ' + error.message)
}
}
})
}
onMounted(() => {
loadData()
loadRecruiters()
})
</script>
<style scoped>
.channels-page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.search-card {
margin-bottom: 24px;
border-radius: 20px !important;
}
.search-card :deep(.t-card__body) {
padding: 20px 24px;
}
.pagination {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
.score-value {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
}
:deep(.t-table) {
border-radius: 16px !important;
}
:deep(.t-card__body) {
padding: 24px;
}
</style>

View File

@@ -98,21 +98,75 @@
</t-select>
</t-form-item>
<t-form-item label="WT Token" name="wt_token">
<t-textarea
v-model="registerForm.wt_token"
<t-textarea
v-model="registerForm.wt_token"
:rows="3"
placeholder="请输入WT Token系统将自动获取账号信息"
/>
</t-form-item>
</t-form>
</t-dialog>
<!-- 通知渠道绑定对话框 -->
<t-dialog
v-model:visible="channelBindDialogVisible"
header="绑定通知渠道"
width="700px"
:footer="false"
>
<t-space direction="vertical" style="width: 100%">
<t-card title="已绑定渠道" :bordered="false" size="small">
<t-table
:data="currentRecruiterChannels"
:columns="boundChannelColumns"
row-key="channel_id"
stripe
size="small"
/>
</t-card>
<t-card title="添加绑定" :bordered="false" size="small">
<t-form :data="channelBindForm" :label-width="100">
<t-form-item label="选择渠道">
<t-select v-model="channelBindForm.channel_id" placeholder="请选择通知渠道" style="width: 100%">
<t-option
v-for="channel in availableChannels"
:key="channel.id"
:label="channel.name"
:value="channel.id"
/>
</t-select>
</t-form-item>
<t-form-item label="启用通知">
<t-switch v-model="channelBindForm.is_enabled" />
</t-form-item>
<t-form-item label="新候选人通知">
<t-switch v-model="channelBindForm.notify_on_new_candidate" />
</t-form-item>
<t-form-item label="评价完成通知">
<t-switch v-model="channelBindForm.notify_on_evaluation" />
</t-form-item>
<t-form-item label="高分候选人通知">
<t-switch v-model="channelBindForm.notify_on_high_score" />
</t-form-item>
<t-form-item label="高分阈值" v-if="channelBindForm.notify_on_high_score">
<t-slider v-model="channelBindForm.high_score_threshold" :max="100" :step="1" />
<div class="score-value">{{ channelBindForm.high_score_threshold }} 分</div>
</t-form-item>
<t-form-item>
<t-button theme="primary" @click="confirmChannelBind" :loading="channelBinding">添加绑定</t-button>
</t-form-item>
</t-form>
</t-card>
</t-space>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { recruiterApi } from '@/api/api'
import { recruiterApi, notificationChannelApi } from '@/api/api'
import dayjs from 'dayjs'
const loading = ref(false)
@@ -160,20 +214,21 @@ const columns = [
width: 180,
cell: (h, { row }) => formatTime(row.last_sync_at)
},
{
colKey: 'operation',
title: '操作',
width: 280,
{
colKey: 'operation',
title: '操作',
width: 340,
fixed: 'right',
cell: (h, { row }) => {
return h('t-space', {}, {
return h('t-space', { size: 'small' }, {
default: () => [
h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'),
h('t-button', { size: 'small', theme: 'primary', onClick: () => handleSync(row) }, '同步'),
h('t-button', {
size: 'small',
h('t-button', { size: 'small', onClick: () => handleChannelBind(row) }, '通知渠道'),
h('t-button', {
size: 'small',
theme: row.status === 'active' ? 'warning' : 'success',
onClick: () => toggleStatus(row)
onClick: () => toggleStatus(row)
}, row.status === 'active' ? '停用' : '启用'),
h('t-button', { size: 'small', theme: 'danger', onClick: () => handleDelete(row) }, '删除')
]
@@ -182,6 +237,56 @@ const columns = [
}
]
// 已绑定渠道表格列
const boundChannelColumns = [
{ colKey: 'channel_name', title: '渠道名称', minWidth: 120 },
{
colKey: 'channel_type',
title: '类型',
width: 100,
cell: (h, { row }) => {
const typeMap = {
wechat_work: '企业微信',
dingtalk: '钉钉',
feishu: '飞书',
email: '邮件',
webhook: 'Webhook'
}
return typeMap[row.channel_type] || row.channel_type
}
},
{
colKey: 'is_enabled',
title: '启用',
width: 80,
cell: (h, { row }) => {
return h('t-tag', { theme: row.is_enabled ? 'success' : 'default', size: 'small' },
row.is_enabled ? '是' : '否')
}
},
{
colKey: 'notify_on_new_candidate',
title: '新候选人',
width: 90,
cell: (h, { row }) => {
return h('t-tag', { theme: row.notify_on_new_candidate ? 'success' : 'default', size: 'small' },
row.notify_on_new_candidate ? '通知' : '关闭')
}
},
{
colKey: 'operation',
title: '操作',
width: 80,
cell: (h, { row }) => {
return h('t-button', {
size: 'small',
theme: 'danger',
onClick: () => handleUnbindChannel(row)
}, '解绑')
}
}
]
// 搜索表单
const searchForm = reactive({
source: ''
@@ -226,6 +331,21 @@ const registerRules = {
const submitting = ref(false)
const registering = ref(false)
// 通知渠道绑定
const channelBindDialogVisible = ref(false)
const channelBinding = ref(false)
const currentRecruiter = ref(null)
const currentRecruiterChannels = ref([])
const availableChannels = ref([])
const channelBindForm = reactive({
channel_id: '',
is_enabled: true,
notify_on_new_candidate: true,
notify_on_evaluation: true,
notify_on_high_score: false,
high_score_threshold: 80
})
// 加载数据
const loadData = async () => {
loading.value = true
@@ -376,6 +496,103 @@ const formatTime = (time) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
}
// 通知渠道绑定相关方法
const handleChannelBind = async (row) => {
currentRecruiter.value = row
channelBindForm.channel_id = ''
channelBindForm.is_enabled = true
channelBindForm.notify_on_new_candidate = true
channelBindForm.notify_on_evaluation = true
channelBindForm.notify_on_high_score = false
channelBindForm.high_score_threshold = 80
try {
// 加载招聘者已绑定的渠道
const bindRes = await notificationChannelApi.getChannelRecruiters(row.id)
currentRecruiterChannels.value = bindRes.data?.items || []
// 加载所有可用渠道
const channelRes = await notificationChannelApi.getList({ page_size: 100 })
const allChannels = channelRes.data?.items || []
// 过滤掉已绑定的渠道
const boundIds = currentRecruiterChannels.value.map(b => b.channel_id)
availableChannels.value = allChannels.filter(c => !boundIds.includes(c.id))
channelBindDialogVisible.value = true
} catch (error) {
MessagePlugin.error('加载渠道信息失败: ' + error.message)
}
}
const confirmChannelBind = async () => {
if (!channelBindForm.channel_id) {
MessagePlugin.warning('请选择通知渠道')
return
}
channelBinding.value = true
try {
await notificationChannelApi.bindRecruiter(
channelBindForm.channel_id,
currentRecruiter.value.id,
{
is_enabled: channelBindForm.is_enabled,
notify_on_new_candidate: channelBindForm.notify_on_new_candidate,
notify_on_evaluation: channelBindForm.notify_on_evaluation,
notify_on_high_score: channelBindForm.notify_on_high_score,
high_score_threshold: channelBindForm.high_score_threshold
}
)
MessagePlugin.success('绑定成功')
// 刷新已绑定列表
const bindRes = await notificationChannelApi.getChannelRecruiters(currentRecruiter.value.id)
currentRecruiterChannels.value = bindRes.data?.items || []
// 刷新可用渠道列表
const channelRes = await notificationChannelApi.getList({ page_size: 100 })
const allChannels = channelRes.data?.items || []
const boundIds = currentRecruiterChannels.value.map(b => b.channel_id)
availableChannels.value = allChannels.filter(c => !boundIds.includes(c.id))
// 重置表单
channelBindForm.channel_id = ''
} catch (error) {
MessagePlugin.error('绑定失败: ' + error.message)
} finally {
channelBinding.value = false
}
}
const handleUnbindChannel = async (row) => {
const confirmDia = DialogPlugin.confirm({
header: '确认解绑',
body: `确定要解绑渠道 "${row.channel_name}" `,
confirmBtn: '确定',
cancelBtn: '取消',
onConfirm: async () => {
try {
await notificationChannelApi.unbindRecruiter(row.channel_id, currentRecruiter.value.id)
MessagePlugin.success('解绑成功')
// 刷新已绑定列表
const bindRes = await notificationChannelApi.getChannelRecruiters(currentRecruiter.value.id)
currentRecruiterChannels.value = bindRes.data?.items || []
// 刷新可用渠道列表
const channelRes = await notificationChannelApi.getList({ page_size: 100 })
const allChannels = channelRes.data?.items || []
const boundIds = currentRecruiterChannels.value.map(b => b.channel_id)
availableChannels.value = allChannels.filter(c => !boundIds.includes(c.id))
confirmDia.destroy()
} catch (error) {
MessagePlugin.error('解绑失败: ' + error.message)
}
}
})
}
onMounted(() => {
loadData()
})
@@ -432,4 +649,10 @@ onMounted(() => {
:deep(.t-card__body) {
padding: 24px;
}
.score-value {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
}
</style>