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 from ...mapper.candidate_mapper import CandidateMapper
router = APIRouter(prefix="/candidates", tags=["候选人管理"]) router = APIRouter(prefix="/api/candidates", tags=["候选人管理"])
def _candidate_to_response(candidate) -> CandidateResponse: 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]) @router.get("", response_model=BaseResponse[JobPositionListResponse])
async def list_jobs( async def list_jobs(
source: Optional[str] = Query(None, description="平台来源"), 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="获取评价方案成功") return BaseResponse.success(data=response, msg="获取评价方案成功")
except Exception as e: except Exception as e:
return BaseResponse.error(msg=f"获取评价方案失败: {str(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": { "endpoints": {
"recruiters": "/api/recruiters", "recruiters": "/api/recruiters",
"jobs": "/api/jobs", "jobs": "/api/jobs",
"candidates": "/candidates", "candidates": "/api/candidates",
"scheduler": "/api/scheduler", "scheduler": "/api/scheduler",
"health": "/health" "health": "/health"
} }

View File

@@ -3,7 +3,7 @@ API共享Schema定义
集中定义所有API请求和响应的数据模型 集中定义所有API请求和响应的数据模型
""" """
from typing import List, Optional, Any, TypeVar, Generic from typing import List, Optional, Any, TypeVar, Generic, Dict
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field 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: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量") page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
@@ -424,3 +426,20 @@ class RecruiterWithChannelsResponse(BaseModel):
recruiter_id: str recruiter_id: str
recruiter_name: str recruiter_name: str
channels: List[RecruiterChannelBindingResponse] 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) count_stmt = count_stmt.where(*conditions)
total = session.execute(count_stmt).scalar() total = session.execute(count_stmt).scalar()
# 分页查询按LLM评分降序再按创建时间倒序 # 分页查询按LLM评分降序NULL值在后,再按创建时间倒序
# 使用MySQL兼容的语法先按是否为NULL排序再按值排序
stmt = stmt.order_by( stmt = stmt.order_by(
CandidateModel.llm_score.desc().nullslast(), CandidateModel.llm_score.is_(None).asc(), # NULL值在后
CandidateModel.llm_score.desc(),
CandidateModel.created_at.desc() CandidateModel.created_at.desc()
) )
stmt = stmt.offset((page - 1) * page_size).limit(page_size) stmt = stmt.offset((page - 1) * page_size).limit(page_size)

View File

@@ -330,8 +330,12 @@ class JobMapper:
# 获取总数 # 获取总数
total = session.execute(count_stmt).scalar() total = session.execute(count_stmt).scalar()
# 分页查询,按最后同步时间降序 # 分页查询,按最后同步时间降序NULL值在后
stmt = stmt.order_by(JobModel.last_sync_at.desc().nullslast()) # 使用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) stmt = stmt.offset((page - 1) * page_size).limit(page_size)
results = session.execute(stmt).scalars().all() results = session.execute(stmt).scalars().all()

View File

@@ -88,22 +88,22 @@ export const jobApi = {
// 候选人管理 API // 候选人管理 API
export const candidateApi = { 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 // 定时任务管理 API
@@ -139,14 +139,53 @@ export const schedulerApi = {
stop: () => api.post('/api/scheduler/stop') 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 // 系统 API
export const systemApi = { export const systemApi = {
// 获取首页信息 // 获取首页信息
getHome: () => api.get('/'), getHome: () => api.get('/'),
// 健康检查 // 健康检查
health: () => api.get('/health'), health: () => api.get('/health'),
// 获取API状态 // 获取API状态
getStatus: () => api.get('/api/status') getStatus: () => api.get('/api/status')
} }

View File

@@ -36,6 +36,12 @@ const routes = [
name: 'Scheduler', name: 'Scheduler',
component: () => import('@/views/Scheduler.vue'), component: () => import('@/views/Scheduler.vue'),
meta: { title: '定时任务', icon: 'time' } 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-select>
</t-form-item> </t-form-item>
<t-form-item label="WT Token" name="wt_token"> <t-form-item label="WT Token" name="wt_token">
<t-textarea <t-textarea
v-model="registerForm.wt_token" v-model="registerForm.wt_token"
:rows="3" :rows="3"
placeholder="请输入WT Token系统将自动获取账号信息" placeholder="请输入WT Token系统将自动获取账号信息"
/> />
</t-form-item> </t-form-item>
</t-form> </t-form>
</t-dialog> </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> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, h } from 'vue' import { ref, reactive, onMounted, h } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next' import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { recruiterApi } from '@/api/api' import { recruiterApi, notificationChannelApi } from '@/api/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const loading = ref(false) const loading = ref(false)
@@ -160,20 +214,21 @@ const columns = [
width: 180, width: 180,
cell: (h, { row }) => formatTime(row.last_sync_at) cell: (h, { row }) => formatTime(row.last_sync_at)
}, },
{ {
colKey: 'operation', colKey: 'operation',
title: '操作', title: '操作',
width: 280, width: 340,
fixed: 'right', fixed: 'right',
cell: (h, { row }) => { cell: (h, { row }) => {
return h('t-space', {}, { return h('t-space', { size: 'small' }, {
default: () => [ default: () => [
h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'), h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'),
h('t-button', { size: 'small', theme: 'primary', onClick: () => handleSync(row) }, '同步'), h('t-button', { size: 'small', theme: 'primary', onClick: () => handleSync(row) }, '同步'),
h('t-button', { h('t-button', { size: 'small', onClick: () => handleChannelBind(row) }, '通知渠道'),
size: 'small', h('t-button', {
size: 'small',
theme: row.status === 'active' ? 'warning' : 'success', theme: row.status === 'active' ? 'warning' : 'success',
onClick: () => toggleStatus(row) onClick: () => toggleStatus(row)
}, row.status === 'active' ? '停用' : '启用'), }, row.status === 'active' ? '停用' : '启用'),
h('t-button', { size: 'small', theme: 'danger', onClick: () => handleDelete(row) }, '删除') 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({ const searchForm = reactive({
source: '' source: ''
@@ -226,6 +331,21 @@ const registerRules = {
const submitting = ref(false) const submitting = ref(false)
const registering = 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 () => { const loadData = async () => {
loading.value = true loading.value = true
@@ -376,6 +496,103 @@ const formatTime = (time) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-' 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(() => { onMounted(() => {
loadData() loadData()
}) })
@@ -432,4 +649,10 @@ onMounted(() => {
:deep(.t-card__body) { :deep(.t-card__body) {
padding: 24px; padding: 24px;
} }
.score-value {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
}
</style> </style>