Compare commits

..

6 Commits

Author SHA1 Message Date
af11f8ad48 refactor(scheduler): 优化爬取和简历处理流程,改为异步线程池执行
- 将手动触发爬取任务改为使用FastAPI后台任务执行
- 在职位处理逻辑中,将获取候选人列表改为线程池异步调用,避免阻塞事件循环
- 在候选人处理流程中,将获取简历详情改为线程池异步调用
- 在入库操作中使用线程池异步执行,提升处理性能
- 在Boss爬取任务中,将获取职位列表和获取候选人操作改为线程池异步调用
- 统一改造调用同步爬虫方法为异步线程池调用,提升整体异步性能和响应速度
2026-03-25 11:50:34 +08:00
fc24e3a37b fix(routes): 优化路由导入错误处理并调整职位需求转换
- 优化 job 路由导入时的异常捕获,添加错误信息和堆栈打印
- 修改职位接口中 requirements 字段的转换逻辑,支持多种对象转换为字典
- 修正 job 模块中 CandidateSource 引用路径
- 移除职位管理页面中“创建职位”按钮相关代码
2026-03-25 11:24:30 +08:00
148f2cc4f6 feat(notification-channels): 新增通知渠道管理功能
- 后端API新增评价方案列表接口及通知渠道相关接口
- 所有候选人相关API路径添加/api前缀
- 系统首页接口更新候选人路径为/api/candidates
- CandidateMapper和JobMapper排序逻辑调整以兼容MySQL null值排序
- 前端candidateApi接口路径添加/api前缀
- 新增notificationChannelApi管理通知渠道,包括增删改查、启用停用及招聘者绑定管理
- 路由新增通知渠道管理页面入口
- 实现NotificationChannels.vue通知渠道的增删改查、搜索筛选、分页、启用停用及招聘者绑定管理功能
- Recruiters.vue中新增通知渠道绑定对话框及绑定相关逻辑,支持招聘者绑定通知渠道管理
- controller/schemas.py新增分页参数PaginationParams及重建模型修正前向引用
- UI组件调整及新增对应表格列、表单校验规则和界面交互逻辑
2026-03-25 11:14:51 +08:00
6f3487a09a refactor(mapper): 优化对象递归转换为JSON序列化格式
- 支持基本类型(str, int, float, bool)直接返回
- 支持SDK返回的模型对象通过__dict__递归转换
- 跳过私有属性和方法避免序列化异常
- 其他对象转换为字符串保证兼容性

feat(service): 新增候选人入库标识

- IngestionResult添加is_new字段区分新增或更新
- success_result方法新增is_new参数支持自定义设置
- duplicate_result默认is_new为False明确重复非新增

refactor(frontend): 重构侧边栏菜单布局和样式

- 简化侧边栏logo结构,调整图标大小和颜色
- 替换t-menu为div循环渲染自定义菜单项
- 菜单项支持点击事件,应用激活状态样式
- 添加蓝色指示器显示当前激活菜单项
- 优化侧边栏宽度固定,主布局采用flex布局
- 美化升级卡片视觉,调整间距和阴影统一风格
2026-03-25 10:48:38 +08:00
eedaac69b0 feat(notification): 新增通知渠道及绑定管理功能
- 新增数据库表 notification_channels, recruiter_channel_bindings 支持多渠道通知绑定
- 在 notifications 表中新增 channel_id 关联通知渠道
- 增加默认通知渠道示例数据插入脚本(企业微信、钉钉、飞书)
- 实现 NotificationChannel 和 RecruiterChannelBinding 两个ORM模型及关联关系
- 增加通知渠道管理API,支持增删改查及启用停用操作
- 实现通知渠道类型枚举及配置验证
- 新增招聘者与通知渠道绑定管理路由,支持绑定关系创建、更新和删除
- 在招聘者模块中集成通知渠道绑定管理相关接口
- 增加对应的请求参数、响应模型及数据校验模型
- 更新数据库配置和依赖注入,支持通知渠道服务
- 完善接口响应的错误处理和成功提示信息
- 保证所有新增代码符合项目代码风格和结构规范
2026-03-25 10:39:33 +08:00
91b6808d45 refactor(controller): 优化分页参数定义,移除通用响应类
- 移除了旧版通用响应 APIResponse 类
- 简化分页参数 PaginationParams 的实现
- 增加分页参数的默认值和限制描述
- 提升代码简洁性和可维护性
2026-03-24 20:03:31 +08:00
35 changed files with 4626 additions and 1048 deletions

View File

@@ -163,13 +163,50 @@ CREATE TABLE IF NOT EXISTS evaluations (
); );
-- ============================================ -- ============================================
-- 7. 通知记录 -- 7. 通知渠道
-- ============================================
CREATE TABLE IF NOT EXISTS notification_channels (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL, -- 渠道名称
channel_type VARCHAR(32) NOT NULL, -- 渠道类型: wechat_work, dingtalk, email, feishu, webhook
config JSON NOT NULL, -- 渠道配置JSON
status VARCHAR(32) DEFAULT 'active', -- active, inactive
description TEXT, -- 描述
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_channel_type (channel_type),
INDEX idx_status (status)
);
-- ============================================
-- 8. 招聘者与通知渠道绑定关系表(多对多)
-- ============================================
CREATE TABLE IF NOT EXISTS recruiter_channel_bindings (
id VARCHAR(64) PRIMARY KEY,
recruiter_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
is_enabled TINYINT(1) DEFAULT 1, -- 是否启用该渠道
notify_on_new_candidate TINYINT(1) DEFAULT 1, -- 新候选人时通知
notify_on_evaluation TINYINT(1) DEFAULT 1, -- 完成评价时通知
notify_on_high_score TINYINT(1) DEFAULT 0, -- 高分候选人时通知
high_score_threshold INT DEFAULT 85, -- 高分阈值
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recruiter_id) REFERENCES recruiters(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE,
UNIQUE KEY uk_recruiter_channel (recruiter_id, channel_id),
INDEX idx_recruiter_id (recruiter_id),
INDEX idx_channel_id (channel_id)
);
-- ============================================
-- 9. 通知记录表
-- ============================================ -- ============================================
CREATE TABLE IF NOT EXISTS notifications ( CREATE TABLE IF NOT EXISTS notifications (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
candidate_id VARCHAR(64) NOT NULL, candidate_id VARCHAR(64) NOT NULL,
evaluation_id VARCHAR(64), evaluation_id VARCHAR(64),
channel VARCHAR(32) NOT NULL, -- WECHAT_WORK, DINGTALK, EMAIL, WEBHOOK channel_id VARCHAR(64), -- 关联的通知渠道ID
channel VARCHAR(32) NOT NULL, -- 渠道类型: WECHAT_WORK, DINGTALK, EMAIL, FEISHU, WEBHOOK
content TEXT, content TEXT,
status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED
error_message TEXT, error_message TEXT,
@@ -177,13 +214,36 @@ CREATE TABLE IF NOT EXISTS notifications (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE, FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE,
FOREIGN KEY (evaluation_id) REFERENCES evaluations(id) ON DELETE SET NULL, FOREIGN KEY (evaluation_id) REFERENCES evaluations(id) ON DELETE SET NULL,
FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE SET NULL,
INDEX idx_candidate_id (candidate_id), INDEX idx_candidate_id (candidate_id),
INDEX idx_channel_id (channel_id),
INDEX idx_status (status), INDEX idx_status (status),
INDEX idx_created_at (created_at) INDEX idx_created_at (created_at)
); );
-- ============================================ -- ============================================
-- 8. 插入默认评价方案 -- 10. 插入默认通知渠道(可选)
-- ============================================
INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
('demo_wechat', '企业微信通知', 'wechat_work',
'{"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "secret": ""}',
'企业微信机器人通知渠道(示例配置)')
ON DUPLICATE KEY UPDATE name = VALUES(name);
INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
('demo_dingtalk', '钉钉通知', 'dingtalk',
'{"access_token": "YOUR_ACCESS_TOKEN", "sign_secret": ""}',
'钉钉机器人通知渠道(示例配置)')
ON DUPLICATE KEY UPDATE name = VALUES(name);
INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
('demo_feishu', '飞书通知', 'feishu',
'{"feishu_webhook": "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", "feishu_secret": ""}',
'飞书机器人通知渠道(示例配置)')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- ============================================
-- 11. 插入默认评价方案
-- ============================================ -- ============================================
INSERT INTO evaluation_schemas (id, name, description, dimensions, weights, is_default) VALUES INSERT INTO evaluation_schemas (id, name, description, dimensions, weights, is_default) VALUES
('general', '通用评价方案', '适用于各类岗位的通用评价方案', ('general', '通用评价方案', '适用于各类岗位的通用评价方案',

View File

@@ -16,7 +16,7 @@ if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path)) sys.path.insert(0, str(src_path))
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI, BackgroundTasks
from cn.yinlihupo.ylhp_hr_2_0.controller.api import create_app from cn.yinlihupo.ylhp_hr_2_0.controller.api import create_app
from cn.yinlihupo.ylhp_hr_2_0.service.scheduler import get_scheduler from cn.yinlihupo.ylhp_hr_2_0.service.scheduler import get_scheduler
@@ -61,10 +61,10 @@ def create_combined_app(enable_scheduler: bool = True) -> FastAPI:
return {"success": True, "message": f"Job {job_id} resumed"} return {"success": True, "message": f"Job {job_id} resumed"}
@app.post("/api/scheduler/trigger/crawl") @app.post("/api/scheduler/trigger/crawl")
async def trigger_crawl(): async def trigger_crawl(background_tasks: BackgroundTasks):
"""手动触发爬取任务""" """手动触发爬取任务"""
scheduler = get_scheduler() scheduler = get_scheduler()
asyncio.create_task(scheduler._crawl_boss()) background_tasks.add_task(scheduler._crawl_boss)
return {"success": True, "message": "Crawl task triggered"} return {"success": True, "message": "Crawl task triggered"}
return app return app

View File

@@ -1,8 +1,8 @@
"""Database configuration using SQLAlchemy""" """Database configuration using SQLAlchemy"""
from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON, Boolean
from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from typing import Optional from typing import Optional, List
Base = declarative_base() Base = declarative_base()
@@ -33,6 +33,9 @@ class RecruiterModel(Base):
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关联关系
channel_bindings = relationship("RecruiterChannelBindingModel", back_populates="recruiter", cascade="all, delete-orphan")
class CandidateModel(Base): class CandidateModel(Base):
"""候选人主表""" """候选人主表"""
@@ -139,6 +142,48 @@ class EvaluationModel(Base):
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
class NotificationChannelModel(Base):
"""通知渠道表"""
__tablename__ = 'notification_channels'
id = Column(String(64), primary_key=True)
name = Column(String(128), nullable=False) # 渠道名称
channel_type = Column(String(32), nullable=False) # 渠道类型: wechat_work, dingtalk, email, feishu, webhook
config = Column(JSON, nullable=False) # 渠道配置JSON
status = Column(String(32), default='active') # active, inactive
description = Column(Text) # 描述
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关联关系
recruiter_bindings = relationship("RecruiterChannelBindingModel", back_populates="channel", cascade="all, delete-orphan")
class RecruiterChannelBindingModel(Base):
"""招聘者与通知渠道绑定关系表(多对多)"""
__tablename__ = 'recruiter_channel_bindings'
id = Column(String(64), primary_key=True)
recruiter_id = Column(String(64), ForeignKey('recruiters.id'), nullable=False)
channel_id = Column(String(64), ForeignKey('notification_channels.id'), nullable=False)
is_enabled = Column(Boolean, default=True) # 是否启用该渠道
notify_on_new_candidate = Column(Boolean, default=True) # 新候选人时通知
notify_on_evaluation = Column(Boolean, default=True) # 完成评价时通知
notify_on_high_score = Column(Boolean, default=False) # 高分候选人时通知
high_score_threshold = Column(Integer, default=85) # 高分阈值
created_at = Column(DateTime, server_default=func.now())
# 关联关系
recruiter = relationship("RecruiterModel", back_populates="channel_bindings")
channel = relationship("NotificationChannelModel", back_populates="recruiter_bindings")
# 唯一约束:一个招聘者不能重复绑定同一个渠道
__table_args__ = (
# 使用唯一约束防止重复绑定
{'sqlite_autoincrement': True},
)
class NotificationModel(Base): class NotificationModel(Base):
"""通知记录表""" """通知记录表"""
__tablename__ = 'notifications' __tablename__ = 'notifications'
@@ -146,7 +191,8 @@ class NotificationModel(Base):
id = Column(String(64), primary_key=True) id = Column(String(64), primary_key=True)
candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False) candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False)
evaluation_id = Column(String(64), ForeignKey('evaluations.id')) evaluation_id = Column(String(64), ForeignKey('evaluations.id'))
channel = Column(String(32), nullable=False) channel_id = Column(String(64), ForeignKey('notification_channels.id')) # 关联渠道ID
channel = Column(String(32), nullable=False) # 渠道类型(冗余字段,方便查询)
content = Column(Text) content = Column(Text)
status = Column(String(32), default='PENDING') status = Column(String(32), default='PENDING')
error_message = Column(Text) error_message = Column(Text)

View File

@@ -10,7 +10,7 @@ FastAPI主应用入口
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .routes import recruiter_router, scheduler_router, candidate_router, job_router from .routes import recruiter_router, scheduler_router, candidate_router, job_router, notification_channel_router
from .routes.system import router as system_router from .routes.system import router as system_router
@@ -49,6 +49,9 @@ def create_app() -> FastAPI:
# 职位管理路由 # 职位管理路由
app.include_router(job_router) app.include_router(job_router)
# 通知渠道管理路由
app.include_router(notification_channel_router)
return app return app

View File

@@ -6,6 +6,7 @@ API路由模块
- scheduler: 定时任务管理 - scheduler: 定时任务管理
- system: 系统接口 - system: 系统接口
- candidate: 候选人管理 - candidate: 候选人管理
- notification_channel: 通知渠道管理
""" """
from .recruiter import router as recruiter_router from .recruiter import router as recruiter_router
@@ -31,14 +32,24 @@ except ImportError:
try: try:
from .job import router as job_router from .job import router as job_router
except ImportError: except ImportError as e:
import traceback
print(f"[ERROR] Failed to import job router: {e}")
traceback.print_exc()
from fastapi import APIRouter from fastapi import APIRouter
job_router = APIRouter() job_router = APIRouter()
try:
from .notification_channel import router as notification_channel_router
except ImportError:
from fastapi import APIRouter
notification_channel_router = APIRouter()
__all__ = [ __all__ = [
"recruiter_router", "recruiter_router",
"scheduler_router", "scheduler_router",
"system_router", "system_router",
"candidate_router", "candidate_router",
"job_router" "job_router",
"notification_channel_router"
] ]

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

@@ -14,7 +14,7 @@ from ..schemas import (
EvaluationSchemaResponse, EvaluationSchemaListResponse EvaluationSchemaResponse, EvaluationSchemaListResponse
) )
from ...domain.job import Job from ...domain.job import Job
from ...domain.enums import CandidateSource from ...domain.candidate import CandidateSource
from ...mapper.job_mapper import JobMapper from ...mapper.job_mapper import JobMapper
from ...mapper.evaluation_mapper import EvaluationMapper from ...mapper.evaluation_mapper import EvaluationMapper
@@ -24,6 +24,16 @@ router = APIRouter(prefix="/api/jobs", tags=["职位管理"])
def _job_to_response(job: Job) -> JobPositionResponse: def _job_to_response(job: Job) -> JobPositionResponse:
"""将领域实体转换为响应模型""" """将领域实体转换为响应模型"""
# 将 JobRequirement 对象转换为字典
requirements_dict = None
if job.requirements is not None:
if hasattr(job.requirements, 'to_dict'):
requirements_dict = job.requirements.to_dict()
elif hasattr(job.requirements, '__dict__'):
requirements_dict = job.requirements.__dict__
else:
requirements_dict = job.requirements
return JobPositionResponse( return JobPositionResponse(
id=job.id, id=job.id,
title=job.title, title=job.title,
@@ -35,7 +45,7 @@ def _job_to_response(job: Job) -> JobPositionResponse:
location=job.location, location=job.location,
salary_min=job.salary_min, salary_min=job.salary_min,
salary_max=job.salary_max, salary_max=job.salary_max,
requirements=job.requirements, requirements=requirements_dict,
description=job.description, description=job.description,
candidate_count=job.candidate_count, candidate_count=job.candidate_count,
new_candidate_count=job.new_candidate_count, new_candidate_count=job.new_candidate_count,
@@ -45,6 +55,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 +417,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

@@ -0,0 +1,463 @@
"""
通知渠道管理路由
提供通知渠道的CRUD操作和与招聘者的绑定管理
"""
from typing import List, Optional
from fastapi import APIRouter, Depends
from ..schemas import (
BaseResponse, ChannelTypeInfo,
NotificationChannelCreate, NotificationChannelUpdate,
NotificationChannelResponse, NotificationChannelListResponse,
RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate,
RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse,
RecruiterWithChannelsResponse
)
from ...domain.notification_channel import (
NotificationChannel, ChannelType, ChannelStatus,
ChannelConfig, RecruiterChannelBinding
)
from ...mapper.notification_channel_mapper import NotificationChannelMapper
from ...mapper.recruiter_mapper import RecruiterMapper
from ...config.settings import get_settings
router = APIRouter(prefix="/api/notification-channels", tags=["通知渠道"])
def get_channel_mapper():
"""依赖注入获取NotificationChannelMapper"""
settings = get_settings()
return NotificationChannelMapper(db_url=settings.db_url)
def get_recruiter_mapper():
"""依赖注入获取RecruiterMapper"""
settings = get_settings()
return RecruiterMapper(db_url=settings.db_url)
def _build_channel_response(channel: NotificationChannel, mapper: NotificationChannelMapper = None) -> NotificationChannelResponse:
"""构建通知渠道响应"""
# 获取关联的招聘者ID
recruiter_ids = channel.recruiter_ids if channel.recruiter_ids else []
# 如果没有 recruiter_ids 但传入了 mapper则查询
if not recruiter_ids and mapper:
bindings = mapper.find_bindings_by_channel(channel.id)
recruiter_ids = [b.recruiter_id for b in bindings]
return NotificationChannelResponse(
id=channel.id,
name=channel.name,
channel_type=channel.channel_type.value,
config=channel.config.to_dict() if channel.config else {},
status=channel.status.value,
description=channel.description,
recruiter_ids=recruiter_ids,
created_at=channel.created_at,
updated_at=channel.updated_at
)
def _build_binding_response(binding: RecruiterChannelBinding, channel: NotificationChannel = None) -> RecruiterChannelBindingResponse:
"""构建绑定关系响应"""
return RecruiterChannelBindingResponse(
recruiter_id=binding.recruiter_id,
channel_id=binding.channel_id,
channel_name=channel.name if channel else None,
channel_type=channel.channel_type.value if channel else None,
is_enabled=binding.is_enabled,
notify_on_new_candidate=binding.notify_on_new_candidate,
notify_on_evaluation=binding.notify_on_evaluation,
notify_on_high_score=binding.notify_on_high_score,
high_score_threshold=binding.high_score_threshold,
created_at=binding.created_at
)
@router.get("/types", response_model=BaseResponse[List[ChannelTypeInfo]])
async def get_channel_types():
"""获取支持的通知渠道类型列表"""
types = [
ChannelTypeInfo(
value="wechat_work",
label="企业微信",
description="通过企业微信机器人Webhook发送通知"
),
ChannelTypeInfo(
value="dingtalk",
label="钉钉",
description="通过钉钉机器人Webhook发送通知"
),
ChannelTypeInfo(
value="feishu",
label="飞书",
description="通过飞书机器人Webhook发送通知"
),
ChannelTypeInfo(
value="email",
label="邮件",
description="通过SMTP发送邮件通知"
),
ChannelTypeInfo(
value="webhook",
label="通用Webhook",
description="通过自定义Webhook发送通知"
)
]
return BaseResponse.success(data=types, msg="获取通知渠道类型列表成功")
@router.get("", response_model=BaseResponse[NotificationChannelListResponse])
async def list_channels(
channel_type: Optional[str] = None,
include_bindings: bool = False,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""
获取通知渠道列表
Args:
channel_type: 按类型筛选 (wechat_work, dingtalk, email, feishu, webhook)
include_bindings: 是否包含关联的招聘者信息
"""
try:
if channel_type:
try:
ct = ChannelType(channel_type.lower())
channels = mapper.find_by_type(ct)
except ValueError:
return BaseResponse.error(msg=f"无效的渠道类型: {channel_type}", code=400)
else:
channels = mapper.find_all(include_bindings=include_bindings)
items = [_build_channel_response(c, mapper) for c in channels]
response = NotificationChannelListResponse(total=len(items), items=items)
return BaseResponse.success(data=response, msg="获取通知渠道列表成功")
except Exception as e:
return BaseResponse.error(msg=f"获取通知渠道列表失败: {str(e)}")
@router.get("/{channel_id}", response_model=BaseResponse[NotificationChannelResponse])
async def get_channel(
channel_id: str,
include_bindings: bool = True,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""获取单个通知渠道详情"""
try:
channel = mapper.find_by_id(channel_id, include_bindings=include_bindings)
if not channel:
return BaseResponse.error(msg="通知渠道不存在", code=404)
return BaseResponse.success(data=_build_channel_response(channel, mapper), msg="获取通知渠道详情成功")
except Exception as e:
return BaseResponse.error(msg=f"获取通知渠道详情失败: {str(e)}")
@router.post("", response_model=BaseResponse[NotificationChannelResponse])
async def create_channel(
data: NotificationChannelCreate,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""创建通知渠道"""
try:
# 验证渠道类型
try:
channel_type = ChannelType(data.channel_type.lower())
except ValueError:
return BaseResponse.error(msg=f"无效的渠道类型: {data.channel_type}", code=400)
# 构建配置
config = ChannelConfig(
webhook_url=data.config.webhook_url,
secret=data.config.secret,
access_token=data.config.access_token,
sign_secret=data.config.sign_secret,
smtp_host=data.config.smtp_host,
smtp_port=data.config.smtp_port,
username=data.config.username,
password=data.config.password,
use_tls=data.config.use_tls,
sender_name=data.config.sender_name,
feishu_webhook=data.config.feishu_webhook,
feishu_secret=data.config.feishu_secret,
custom_headers=data.config.custom_headers or {},
custom_payload_template=data.config.custom_payload_template
)
# 创建渠道实体
channel = NotificationChannel(
name=data.name,
channel_type=channel_type,
config=config,
description=data.description,
status=ChannelStatus.ACTIVE
)
# 验证配置
is_valid, error_msg = channel.validate_config()
if not is_valid:
return BaseResponse.error(msg=f"配置验证失败: {error_msg}", code=400)
# 保存
saved_channel = mapper.save(channel)
return BaseResponse.success(data=_build_channel_response(saved_channel), msg="创建通知渠道成功")
except Exception as e:
return BaseResponse.error(msg=f"创建通知渠道失败: {str(e)}")
@router.put("/{channel_id}", response_model=BaseResponse[NotificationChannelResponse])
async def update_channel(
channel_id: str,
data: NotificationChannelUpdate,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""更新通知渠道"""
try:
channel = mapper.find_by_id(channel_id)
if not channel:
return BaseResponse.error(msg="通知渠道不存在", code=404)
# 更新字段
if data.name:
channel.name = data.name
if data.description is not None:
channel.description = data.description
if data.status:
try:
channel.status = ChannelStatus(data.status.lower())
except ValueError:
return BaseResponse.error(msg=f"无效的状态: {data.status}", code=400)
if data.config:
# 更新配置
if data.config.webhook_url is not None:
channel.config.webhook_url = data.config.webhook_url
if data.config.secret is not None:
channel.config.secret = data.config.secret
if data.config.access_token is not None:
channel.config.access_token = data.config.access_token
if data.config.sign_secret is not None:
channel.config.sign_secret = data.config.sign_secret
if data.config.smtp_host is not None:
channel.config.smtp_host = data.config.smtp_host
if data.config.smtp_port is not None:
channel.config.smtp_port = data.config.smtp_port
if data.config.username is not None:
channel.config.username = data.config.username
if data.config.password is not None:
channel.config.password = data.config.password
if data.config.use_tls is not None:
channel.config.use_tls = data.config.use_tls
if data.config.sender_name is not None:
channel.config.sender_name = data.config.sender_name
if data.config.feishu_webhook is not None:
channel.config.feishu_webhook = data.config.feishu_webhook
if data.config.feishu_secret is not None:
channel.config.feishu_secret = data.config.feishu_secret
if data.config.custom_headers is not None:
channel.config.custom_headers = data.config.custom_headers
if data.config.custom_payload_template is not None:
channel.config.custom_payload_template = data.config.custom_payload_template
# 验证配置
is_valid, error_msg = channel.validate_config()
if not is_valid:
return BaseResponse.error(msg=f"配置验证失败: {error_msg}", code=400)
updated = mapper.save(channel)
return BaseResponse.success(data=_build_channel_response(updated), msg="更新通知渠道成功")
except Exception as e:
return BaseResponse.error(msg=f"更新通知渠道失败: {str(e)}")
@router.delete("/{channel_id}", response_model=BaseResponse[dict])
async def delete_channel(
channel_id: str,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""删除通知渠道"""
try:
channel = mapper.find_by_id(channel_id)
if not channel:
return BaseResponse.error(msg="通知渠道不存在", code=404)
success = mapper.delete(channel_id)
if success:
return BaseResponse.success(data={"deleted_id": channel_id}, msg="删除通知渠道成功")
else:
return BaseResponse.error(msg="删除通知渠道失败")
except Exception as e:
return BaseResponse.error(msg=f"删除通知渠道失败: {str(e)}")
@router.post("/{channel_id}/activate", response_model=BaseResponse[dict])
async def activate_channel(
channel_id: str,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""启用通知渠道"""
try:
success = mapper.update_status(channel_id, ChannelStatus.ACTIVE)
if not success:
return BaseResponse.error(msg="通知渠道不存在", code=404)
return BaseResponse.success(data={"channel_id": channel_id}, msg="启用通知渠道成功")
except Exception as e:
return BaseResponse.error(msg=f"启用通知渠道失败: {str(e)}")
@router.post("/{channel_id}/deactivate", response_model=BaseResponse[dict])
async def deactivate_channel(
channel_id: str,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""停用通知渠道"""
try:
success = mapper.update_status(channel_id, ChannelStatus.INACTIVE)
if not success:
return BaseResponse.error(msg="通知渠道不存在", code=404)
return BaseResponse.success(data={"channel_id": channel_id}, msg="停用通知渠道成功")
except Exception as e:
return BaseResponse.error(msg=f"停用通知渠道失败: {str(e)}")
# ==================== 绑定关系管理 ====================
@router.get("/{channel_id}/recruiters", response_model=BaseResponse[RecruiterChannelBindingListResponse])
async def get_channel_recruiters(
channel_id: str,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""获取绑定到该通知渠道的所有招聘者"""
try:
channel = mapper.find_by_id(channel_id)
if not channel:
return BaseResponse.error(msg="通知渠道不存在", code=404)
bindings = mapper.find_bindings_by_channel(channel_id)
# 获取渠道信息用于响应
items = []
for binding in bindings:
items.append(_build_binding_response(binding, channel))
response = RecruiterChannelBindingListResponse(total=len(items), items=items)
return BaseResponse.success(data=response, msg="获取渠道绑定的招聘者列表成功")
except Exception as e:
return BaseResponse.error(msg=f"获取渠道绑定的招聘者列表失败: {str(e)}")
@router.post("/{channel_id}/recruiters/{recruiter_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
async def bind_recruiter(
channel_id: str,
recruiter_id: str,
data: RecruiterChannelBindingCreate,
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper),
recruiter_mapper: RecruiterMapper = Depends(get_recruiter_mapper)
):
"""绑定招聘者到通知渠道"""
try:
# 验证渠道存在
channel = channel_mapper.find_by_id(channel_id)
if not channel:
return BaseResponse.error(msg="通知渠道不存在", code=404)
# 验证招聘者存在
recruiter = recruiter_mapper.find_by_id(recruiter_id)
if not recruiter:
return BaseResponse.error(msg="招聘者不存在", code=404)
# 创建绑定关系
binding = RecruiterChannelBinding(
recruiter_id=recruiter_id,
channel_id=channel_id,
is_enabled=data.is_enabled,
notify_on_new_candidate=data.notify_on_new_candidate,
notify_on_evaluation=data.notify_on_evaluation,
notify_on_high_score=data.notify_on_high_score,
high_score_threshold=data.high_score_threshold
)
saved_binding = channel_mapper.bind_recruiter(binding)
return BaseResponse.success(
data=_build_binding_response(saved_binding, channel),
msg=f"绑定招聘者 '{recruiter.name}' 到通知渠道 '{channel.name}' 成功"
)
except Exception as e:
return BaseResponse.error(msg=f"绑定招聘者失败: {str(e)}")
@router.put("/{channel_id}/recruiters/{recruiter_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
async def update_binding(
channel_id: str,
recruiter_id: str,
data: RecruiterChannelBindingUpdate,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""更新绑定配置"""
try:
# 检查绑定是否存在
existing = mapper.get_binding(recruiter_id, channel_id)
if not existing:
return BaseResponse.error(msg="绑定关系不存在", code=404)
# 构建更新参数
update_data = {}
if data.is_enabled is not None:
update_data['is_enabled'] = data.is_enabled
if data.notify_on_new_candidate is not None:
update_data['notify_on_new_candidate'] = data.notify_on_new_candidate
if data.notify_on_evaluation is not None:
update_data['notify_on_evaluation'] = data.notify_on_evaluation
if data.notify_on_high_score is not None:
update_data['notify_on_high_score'] = data.notify_on_high_score
if data.high_score_threshold is not None:
update_data['high_score_threshold'] = data.high_score_threshold
if not update_data:
return BaseResponse.error(msg="没有提供要更新的字段", code=400)
success = mapper.update_binding(recruiter_id, channel_id, **update_data)
if not success:
return BaseResponse.error(msg="更新绑定配置失败")
# 获取更新后的绑定
updated = mapper.get_binding(recruiter_id, channel_id)
channel = mapper.find_by_id(channel_id)
return BaseResponse.success(
data=_build_binding_response(updated, channel),
msg="更新绑定配置成功"
)
except Exception as e:
return BaseResponse.error(msg=f"更新绑定配置失败: {str(e)}")
@router.delete("/{channel_id}/recruiters/{recruiter_id}", response_model=BaseResponse[dict])
async def unbind_recruiter(
channel_id: str,
recruiter_id: str,
mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""解绑招聘者与通知渠道"""
try:
success = mapper.unbind_recruiter(recruiter_id, channel_id)
if not success:
return BaseResponse.error(msg="绑定关系不存在", code=404)
return BaseResponse.success(
data={"recruiter_id": recruiter_id, "channel_id": channel_id},
msg="解绑成功"
)
except Exception as e:
return BaseResponse.error(msg=f"解绑失败: {str(e)}")

View File

@@ -12,13 +12,17 @@ from ..schemas import (
RecruiterCreate, RecruiterRegister, RecruiterUpdate, RecruiterCreate, RecruiterRegister, RecruiterUpdate,
RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse, RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse,
RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo, RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo,
BaseResponse RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate,
RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse,
RecruiterWithChannelsResponse, BaseResponse
) )
from ...domain.candidate import CandidateSource from ...domain.candidate import CandidateSource
from ...domain.recruiter import Recruiter, RecruiterStatus from ...domain.recruiter import Recruiter, RecruiterStatus
from ...domain.notification_channel import RecruiterChannelBinding
from ...service.recruiter_service import RecruiterService from ...service.recruiter_service import RecruiterService
from ...service.crawler import BossCrawler from ...service.crawler import BossCrawler
from ...mapper.recruiter_mapper import RecruiterMapper from ...mapper.recruiter_mapper import RecruiterMapper
from ...mapper.notification_channel_mapper import NotificationChannelMapper
from ...config.settings import get_settings from ...config.settings import get_settings
@@ -32,6 +36,12 @@ def get_recruiter_service():
return RecruiterService(mapper=mapper) return RecruiterService(mapper=mapper)
def get_channel_mapper():
"""依赖注入获取NotificationChannelMapper"""
settings = get_settings()
return NotificationChannelMapper(db_url=settings.db_url)
def _build_recruiter_response(recruiter: Recruiter) -> RecruiterResponse: def _build_recruiter_response(recruiter: Recruiter) -> RecruiterResponse:
"""构建招聘者账号响应""" """构建招聘者账号响应"""
# 构建权益信息 # 构建权益信息
@@ -357,3 +367,164 @@ async def sync_recruiter(
) )
except Exception as e: except Exception as e:
return BaseResponse.error(msg=f"同步账号失败: {str(e)}") return BaseResponse.error(msg=f"同步账号失败: {str(e)}")
# ==================== 通知渠道绑定管理 ====================
def _build_binding_response(binding, channel=None) -> RecruiterChannelBindingResponse:
"""构建绑定关系响应"""
return RecruiterChannelBindingResponse(
recruiter_id=binding.recruiter_id,
channel_id=binding.channel_id,
channel_name=channel.name if channel else None,
channel_type=channel.channel_type.value if channel else None,
is_enabled=binding.is_enabled,
notify_on_new_candidate=binding.notify_on_new_candidate,
notify_on_evaluation=binding.notify_on_evaluation,
notify_on_high_score=binding.notify_on_high_score,
high_score_threshold=binding.high_score_threshold,
created_at=binding.created_at
)
@router.get("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingListResponse])
async def get_recruiter_channels(
recruiter_id: str,
service: RecruiterService = Depends(get_recruiter_service),
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""获取招聘者绑定的所有通知渠道"""
try:
recruiter = service.get_recruiter(recruiter_id)
if not recruiter:
return BaseResponse.error(msg="招聘者不存在", code=404)
bindings = channel_mapper.find_bindings_by_recruiter(recruiter_id)
items = []
for binding in bindings:
channel = channel_mapper.find_by_id(binding.channel_id)
items.append(_build_binding_response(binding, channel))
response = RecruiterChannelBindingListResponse(total=len(items), items=items)
return BaseResponse.success(data=response, msg="获取招聘者绑定的通知渠道成功")
except Exception as e:
return BaseResponse.error(msg=f"获取招聘者绑定的通知渠道失败: {str(e)}")
@router.post("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingResponse])
async def bind_channel_to_recruiter(
recruiter_id: str,
data: RecruiterChannelBindingCreate,
service: RecruiterService = Depends(get_recruiter_service),
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""为招聘者绑定通知渠道"""
try:
# 验证招聘者存在
recruiter = service.get_recruiter(recruiter_id)
if not recruiter:
return BaseResponse.error(msg="招聘者不存在", code=404)
# 验证渠道存在
channel = channel_mapper.find_by_id(data.channel_id)
if not channel:
return BaseResponse.error(msg="通知渠道不存在", code=404)
# 创建绑定关系
binding = RecruiterChannelBinding(
recruiter_id=recruiter_id,
channel_id=data.channel_id,
is_enabled=data.is_enabled,
notify_on_new_candidate=data.notify_on_new_candidate,
notify_on_evaluation=data.notify_on_evaluation,
notify_on_high_score=data.notify_on_high_score,
high_score_threshold=data.high_score_threshold
)
saved_binding = channel_mapper.bind_recruiter(binding)
return BaseResponse.success(
data=_build_binding_response(saved_binding, channel),
msg=f"成功为招聘者 '{recruiter.name}' 绑定通知渠道 '{channel.name}'"
)
except Exception as e:
return BaseResponse.error(msg=f"绑定通知渠道失败: {str(e)}")
@router.put("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
async def update_recruiter_channel_binding(
recruiter_id: str,
channel_id: str,
data: RecruiterChannelBindingUpdate,
service: RecruiterService = Depends(get_recruiter_service),
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""更新招聘者的通知渠道绑定配置"""
try:
# 验证招聘者存在
recruiter = service.get_recruiter(recruiter_id)
if not recruiter:
return BaseResponse.error(msg="招聘者不存在", code=404)
# 检查绑定是否存在
existing = channel_mapper.get_binding(recruiter_id, channel_id)
if not existing:
return BaseResponse.error(msg="绑定关系不存在", code=404)
# 构建更新参数
update_data = {}
if data.is_enabled is not None:
update_data['is_enabled'] = data.is_enabled
if data.notify_on_new_candidate is not None:
update_data['notify_on_new_candidate'] = data.notify_on_new_candidate
if data.notify_on_evaluation is not None:
update_data['notify_on_evaluation'] = data.notify_on_evaluation
if data.notify_on_high_score is not None:
update_data['notify_on_high_score'] = data.notify_on_high_score
if data.high_score_threshold is not None:
update_data['high_score_threshold'] = data.high_score_threshold
if not update_data:
return BaseResponse.error(msg="没有提供要更新的字段", code=400)
success = channel_mapper.update_binding(recruiter_id, channel_id, **update_data)
if not success:
return BaseResponse.error(msg="更新绑定配置失败")
# 获取更新后的绑定
updated = channel_mapper.get_binding(recruiter_id, channel_id)
channel = channel_mapper.find_by_id(channel_id)
return BaseResponse.success(
data=_build_binding_response(updated, channel),
msg="更新绑定配置成功"
)
except Exception as e:
return BaseResponse.error(msg=f"更新绑定配置失败: {str(e)}")
@router.delete("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[dict])
async def unbind_channel_from_recruiter(
recruiter_id: str,
channel_id: str,
service: RecruiterService = Depends(get_recruiter_service),
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
):
"""解绑招聘者的通知渠道"""
try:
# 验证招聘者存在
recruiter = service.get_recruiter(recruiter_id)
if not recruiter:
return BaseResponse.error(msg="招聘者不存在", code=404)
success = channel_mapper.unbind_recruiter(recruiter_id, channel_id)
if not success:
return BaseResponse.error(msg="绑定关系不存在", code=404)
return BaseResponse.success(
data={"recruiter_id": recruiter_id, "channel_id": channel_id},
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
@@ -45,14 +45,7 @@ class PaginationData(BaseModel, Generic[T]):
items: List[T] = Field(..., description="数据列表") items: List[T] = Field(..., description="数据列表")
# ============== 通用响应 (兼容旧代码) ============== # ============== 分页参数 ==============
class APIResponse(BaseModel):
"""通用API响应"""
success: bool
message: str
data: Optional[dict] = None
class PaginationParams(BaseModel): class PaginationParams(BaseModel):
"""分页参数""" """分页参数"""
@@ -314,3 +307,139 @@ class EvaluationSchemaListResponse(BaseModel):
"""评价方案列表响应""" """评价方案列表响应"""
total: int total: int
items: List[EvaluationSchemaResponse] items: List[EvaluationSchemaResponse]
# ============== 通知渠道 ==============
class ChannelTypeInfo(BaseModel):
"""通知渠道类型信息"""
value: str = Field(..., description="类型值")
label: str = Field(..., description="显示名称")
description: Optional[str] = Field(None, description="描述")
class ChannelConfigBase(BaseModel):
"""通知渠道配置基础"""
# 企业微信/通用Webhook配置
webhook_url: Optional[str] = Field(None, description="Webhook地址")
secret: Optional[str] = Field(None, description="密钥")
# 钉钉配置
access_token: Optional[str] = Field(None, description="钉钉Access Token")
sign_secret: Optional[str] = Field(None, description="钉钉签名密钥")
# 邮件配置
smtp_host: Optional[str] = Field(None, description="SMTP服务器")
smtp_port: Optional[int] = Field(None, description="SMTP端口")
username: Optional[str] = Field(None, description="邮箱用户名")
password: Optional[str] = Field(None, description="邮箱密码")
use_tls: bool = Field(True, description="是否使用TLS")
sender_name: Optional[str] = Field(None, description="发件人名称")
# 飞书配置
feishu_webhook: Optional[str] = Field(None, description="飞书Webhook地址")
feishu_secret: Optional[str] = Field(None, description="飞书密钥")
# 通用配置
custom_headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
custom_payload_template: Optional[str] = Field(None, description="自定义Payload模板")
class NotificationChannelCreate(BaseModel):
"""创建通知渠道请求"""
name: str = Field(..., description="渠道名称")
channel_type: str = Field(..., description="渠道类型: wechat_work, dingtalk, email, feishu, webhook")
config: ChannelConfigBase = Field(..., description="渠道配置")
description: Optional[str] = Field(None, description="渠道描述")
class NotificationChannelUpdate(BaseModel):
"""更新通知渠道请求"""
name: Optional[str] = Field(None, description="渠道名称")
config: Optional[ChannelConfigBase] = Field(None, description="渠道配置")
description: Optional[str] = Field(None, description="渠道描述")
status: Optional[str] = Field(None, description="状态: active, inactive")
class NotificationChannelResponse(BaseModel):
"""通知渠道响应"""
id: str
name: str
channel_type: str
config: Dict[str, Any]
status: str
description: Optional[str] = None
recruiter_ids: List[str] = Field(default_factory=list, description="关联的招聘者ID列表")
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class NotificationChannelListResponse(BaseModel):
"""通知渠道列表响应"""
total: int
items: List[NotificationChannelResponse]
# ============== 招聘者与通知渠道绑定 ==============
class RecruiterChannelBindingCreate(BaseModel):
"""创建招聘者与通知渠道绑定请求"""
channel_id: str = Field(..., description="通知渠道ID")
is_enabled: bool = Field(True, description="是否启用")
notify_on_new_candidate: bool = Field(True, description="新候选人时通知")
notify_on_evaluation: bool = Field(True, description="完成评价时通知")
notify_on_high_score: bool = Field(False, description="高分候选人时通知")
high_score_threshold: int = Field(85, ge=0, le=100, description="高分阈值")
class RecruiterChannelBindingUpdate(BaseModel):
"""更新绑定配置请求"""
is_enabled: Optional[bool] = Field(None, description="是否启用")
notify_on_new_candidate: Optional[bool] = Field(None, description="新候选人时通知")
notify_on_evaluation: Optional[bool] = Field(None, description="完成评价时通知")
notify_on_high_score: Optional[bool] = Field(None, description="高分候选人时通知")
high_score_threshold: Optional[int] = Field(None, ge=0, le=100, description="高分阈值")
class RecruiterChannelBindingResponse(BaseModel):
"""招聘者与通知渠道绑定响应"""
recruiter_id: str
channel_id: str
channel_name: Optional[str] = Field(None, description="渠道名称")
channel_type: Optional[str] = Field(None, description="渠道类型")
is_enabled: bool
notify_on_new_candidate: bool
notify_on_evaluation: bool
notify_on_high_score: bool
high_score_threshold: int
created_at: Optional[datetime] = None
class RecruiterChannelBindingListResponse(BaseModel):
"""绑定列表响应"""
total: int
items: List[RecruiterChannelBindingResponse]
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

@@ -0,0 +1,194 @@
"""Notification channel entity definitions"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Dict, Any, List
from enum import Enum
class ChannelType(Enum):
"""通知渠道类型"""
WECHAT_WORK = "wechat_work" # 企业微信
DINGTALK = "dingtalk" # 钉钉
EMAIL = "email" # 邮件
FEISHU = "feishu" # 飞书
WEBHOOK = "webhook" # 通用Webhook
class ChannelStatus(Enum):
"""通知渠道状态"""
ACTIVE = "active" # 启用
INACTIVE = "inactive" # 停用
@dataclass
class ChannelConfig:
"""通知渠道配置"""
# 企业微信配置
webhook_url: Optional[str] = None # Webhook地址
secret: Optional[str] = None # 密钥
# 钉钉配置
access_token: Optional[str] = None # Access Token
sign_secret: Optional[str] = None # 签名密钥
# 邮件配置
smtp_host: Optional[str] = None # SMTP服务器
smtp_port: Optional[int] = None # SMTP端口
username: Optional[str] = None # 用户名
password: Optional[str] = None # 密码
use_tls: bool = True # 是否使用TLS
sender_name: Optional[str] = None # 发件人名称
# 飞书配置
feishu_webhook: Optional[str] = None # 飞书Webhook
feishu_secret: Optional[str] = None # 飞书密钥
# 通用Webhook配置
custom_headers: Optional[Dict[str, str]] = field(default_factory=dict) # 自定义请求头
custom_payload_template: Optional[str] = None # 自定义payload模板
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"webhook_url": self.webhook_url,
"secret": self.secret,
"access_token": self.access_token,
"sign_secret": self.sign_secret,
"smtp_host": self.smtp_host,
"smtp_port": self.smtp_port,
"username": self.username,
"password": self.password,
"use_tls": self.use_tls,
"sender_name": self.sender_name,
"feishu_webhook": self.feishu_webhook,
"feishu_secret": self.feishu_secret,
"custom_headers": self.custom_headers,
"custom_payload_template": self.custom_payload_template
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ChannelConfig":
"""从字典创建"""
if not data:
return cls()
return cls(
webhook_url=data.get("webhook_url"),
secret=data.get("secret"),
access_token=data.get("access_token"),
sign_secret=data.get("sign_secret"),
smtp_host=data.get("smtp_host"),
smtp_port=data.get("smtp_port"),
username=data.get("username"),
password=data.get("password"),
use_tls=data.get("use_tls", True),
sender_name=data.get("sender_name"),
feishu_webhook=data.get("feishu_webhook"),
feishu_secret=data.get("feishu_secret"),
custom_headers=data.get("custom_headers", {}),
custom_payload_template=data.get("custom_payload_template")
)
@dataclass
class NotificationChannel:
"""通知渠道实体"""
id: Optional[str] = None
name: str = "" # 渠道名称
channel_type: ChannelType = ChannelType.WEBHOOK
config: ChannelConfig = field(default_factory=ChannelConfig)
status: ChannelStatus = ChannelStatus.ACTIVE
description: Optional[str] = None # 描述
# 关联的招聘者ID列表多对多关系
recruiter_ids: List[str] = field(default_factory=list)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.now()
if self.updated_at is None:
self.updated_at = datetime.now()
if self.config is None:
self.config = ChannelConfig()
def is_active(self) -> bool:
"""检查渠道是否启用"""
return self.status == ChannelStatus.ACTIVE
def get_config_for_type(self) -> Dict[str, Any]:
"""根据渠道类型返回相关配置"""
config_dict = self.config.to_dict()
if self.channel_type == ChannelType.WECHAT_WORK:
return {
"webhook_url": config_dict.get("webhook_url"),
"secret": config_dict.get("secret")
}
elif self.channel_type == ChannelType.DINGTALK:
return {
"access_token": config_dict.get("access_token"),
"sign_secret": config_dict.get("sign_secret")
}
elif self.channel_type == ChannelType.EMAIL:
return {
"smtp_host": config_dict.get("smtp_host"),
"smtp_port": config_dict.get("smtp_port"),
"username": config_dict.get("username"),
"password": config_dict.get("password"),
"use_tls": config_dict.get("use_tls"),
"sender_name": config_dict.get("sender_name")
}
elif self.channel_type == ChannelType.FEISHU:
return {
"webhook_url": config_dict.get("feishu_webhook"),
"secret": config_dict.get("feishu_secret")
}
else:
return {
"webhook_url": config_dict.get("webhook_url"),
"custom_headers": config_dict.get("custom_headers"),
"custom_payload_template": config_dict.get("custom_payload_template")
}
def validate_config(self) -> tuple[bool, str]:
"""验证配置是否完整"""
config = self.get_config_for_type()
if self.channel_type == ChannelType.WECHAT_WORK:
if not config.get("webhook_url"):
return False, "企业微信Webhook地址不能为空"
elif self.channel_type == ChannelType.DINGTALK:
if not config.get("access_token"):
return False, "钉钉Access Token不能为空"
elif self.channel_type == ChannelType.EMAIL:
required = ["smtp_host", "smtp_port", "username", "password"]
for field_name in required:
if not config.get(field_name):
return False, f"邮件配置缺少必填项: {field_name}"
elif self.channel_type == ChannelType.FEISHU:
if not config.get("webhook_url"):
return False, "飞书Webhook地址不能为空"
elif self.channel_type == ChannelType.WEBHOOK:
if not config.get("webhook_url"):
return False, "Webhook地址不能为空"
return True, "配置验证通过"
@dataclass
class RecruiterChannelBinding:
"""招聘者与通知渠道绑定关系"""
recruiter_id: str
channel_id: str
is_enabled: bool = True # 是否启用该渠道
notify_on_new_candidate: bool = True # 新候选人时通知
notify_on_evaluation: bool = True # 完成评价时通知
notify_on_high_score: bool = False # 高分候选人时通知
high_score_threshold: int = 85 # 高分阈值
created_at: Optional[datetime] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.now()

View File

@@ -253,8 +253,8 @@ class ResumeProcessJob:
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id}, " print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id}, "
f"评价方案: {job.evaluation_schema_id or 'general'})") f"评价方案: {job.evaluation_schema_id or 'general'})")
# 获取候选人列表 # 获取候选人列表(在线程池中执行,避免阻塞事件循环)
candidates = crawler.get_candidates(job.source_id, page=1) candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
if not candidates: if not candidates:
result.message = "该职位下没有候选人" result.message = "该职位下没有候选人"
@@ -359,8 +359,8 @@ class ResumeProcessJob:
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id})") print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id})")
# 获取候选人列表 # 获取候选人列表(在线程池中执行,避免阻塞事件循环)
candidates = crawler.get_candidates(job.source_id, page=1) candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
if not candidates: if not candidates:
result.message = "该职位下没有候选人" result.message = "该职位下没有候选人"
@@ -425,8 +425,8 @@ class ResumeProcessJob:
print(f"[{datetime.now()}] 处理候选人: {candidate.name}") print(f"[{datetime.now()}] 处理候选人: {candidate.name}")
try: try:
# 获取简历详情 # 获取简历详情(在线程池中执行,避免阻塞事件循环)
resume = crawler.get_resume_detail(candidate) resume = await asyncio.to_thread(crawler.get_resume_detail, candidate)
if not resume: if not resume:
print(f"[{datetime.now()}] 候选人 {candidate.name} 无法获取简历详情") print(f"[{datetime.now()}] 候选人 {candidate.name} 无法获取简历详情")
@@ -435,10 +435,11 @@ class ResumeProcessJob:
# 构建原始数据 # 构建原始数据
raw_data = self._build_raw_data(candidate, resume, job) raw_data = self._build_raw_data(candidate, resume, job)
# 统一入库 # 统一入库(在线程池中执行,避免阻塞事件循环)
ingestion_result = self.ingestion_service.ingest( ingestion_result = await asyncio.to_thread(
source=candidate.source, self.ingestion_service.ingest,
raw_data=raw_data candidate.source,
raw_data
) )
if not ingestion_result.success: if not ingestion_result.success:

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

@@ -0,0 +1,376 @@
"""Notification channel data mapper using SQLAlchemy"""
from typing import List, Optional
from datetime import datetime
import uuid
from sqlalchemy import select, update, delete, and_
from sqlalchemy.orm import Session, joinedload
from ..domain.notification_channel import (
NotificationChannel, ChannelType, ChannelStatus,
ChannelConfig, RecruiterChannelBinding
)
from ..config.database import (
get_db_manager, NotificationChannelModel, RecruiterChannelBindingModel
)
class NotificationChannelMapper:
"""通知渠道数据访问 - SQLAlchemy实现"""
def __init__(self, db_url: Optional[str] = None):
self.db_manager = get_db_manager(db_url)
# 确保表存在
self.db_manager.create_tables()
def _get_session(self) -> Session:
"""获取数据库会话"""
return self.db_manager.get_session()
def _model_to_entity(self, model: NotificationChannelModel, include_bindings: bool = True) -> NotificationChannel:
"""将模型转换为实体"""
channel = NotificationChannel(
id=model.id,
name=model.name,
channel_type=ChannelType(model.channel_type),
config=ChannelConfig.from_dict(model.config) if model.config else ChannelConfig(),
status=ChannelStatus(model.status) if model.status else ChannelStatus.ACTIVE,
description=model.description,
created_at=model.created_at,
updated_at=model.updated_at
)
if include_bindings and hasattr(model, 'recruiter_bindings'):
channel.recruiter_ids = [
binding.recruiter_id
for binding in model.recruiter_bindings
]
return channel
def _entity_to_model(self, entity: NotificationChannel) -> NotificationChannelModel:
"""将实体转换为模型"""
return NotificationChannelModel(
id=entity.id or str(uuid.uuid4()),
name=entity.name,
channel_type=entity.channel_type.value,
config=entity.config.to_dict() if entity.config else {},
status=entity.status.value,
description=entity.description
)
def _binding_model_to_entity(self, model: RecruiterChannelBindingModel) -> RecruiterChannelBinding:
"""将绑定模型转换为实体"""
return RecruiterChannelBinding(
recruiter_id=model.recruiter_id,
channel_id=model.channel_id,
is_enabled=model.is_enabled,
notify_on_new_candidate=model.notify_on_new_candidate,
notify_on_evaluation=model.notify_on_evaluation,
notify_on_high_score=model.notify_on_high_score,
high_score_threshold=model.high_score_threshold,
created_at=model.created_at
)
def save(self, channel: NotificationChannel) -> NotificationChannel:
"""保存通知渠道"""
session = self._get_session()
try:
if channel.id:
# 更新
stmt = (
update(NotificationChannelModel)
.where(NotificationChannelModel.id == channel.id)
.values(
name=channel.name,
channel_type=channel.channel_type.value,
config=channel.config.to_dict() if channel.config else {},
status=channel.status.value,
description=channel.description,
updated_at=datetime.now()
)
)
session.execute(stmt)
else:
# 插入
channel.id = str(uuid.uuid4())
model = self._entity_to_model(channel)
session.add(model)
session.commit()
return channel
finally:
session.close()
def find_by_id(self, channel_id: str, include_bindings: bool = False) -> Optional[NotificationChannel]:
"""根据ID查询通知渠道"""
session = self._get_session()
try:
query = select(NotificationChannelModel).where(NotificationChannelModel.id == channel_id)
if include_bindings:
query = query.options(joinedload(NotificationChannelModel.recruiter_bindings))
result = session.execute(query)
model = result.unique().scalar_one_or_none()
return self._model_to_entity(model, include_bindings) if model else None
finally:
session.close()
def find_all(self, include_bindings: bool = False) -> List[NotificationChannel]:
"""查询所有通知渠道"""
session = self._get_session()
try:
query = select(NotificationChannelModel).order_by(NotificationChannelModel.created_at.desc())
if include_bindings:
query = query.options(joinedload(NotificationChannelModel.recruiter_bindings))
result = session.execute(query)
models = result.unique().scalars().all()
return [self._model_to_entity(m, include_bindings) for m in models]
finally:
session.close()
def find_by_type(self, channel_type: ChannelType) -> List[NotificationChannel]:
"""根据类型查询通知渠道"""
session = self._get_session()
try:
result = session.execute(
select(NotificationChannelModel)
.where(NotificationChannelModel.channel_type == channel_type.value)
.order_by(NotificationChannelModel.created_at.desc())
)
models = result.scalars().all()
return [self._model_to_entity(m) for m in models]
finally:
session.close()
def find_active(self) -> List[NotificationChannel]:
"""查询所有启用的通知渠道"""
session = self._get_session()
try:
result = session.execute(
select(NotificationChannelModel)
.where(NotificationChannelModel.status == 'active')
.order_by(NotificationChannelModel.created_at.desc())
)
models = result.scalars().all()
return [self._model_to_entity(m) for m in models]
finally:
session.close()
def delete(self, channel_id: str) -> bool:
"""删除通知渠道"""
session = self._get_session()
try:
# 先删除关联的绑定关系
session.execute(
delete(RecruiterChannelBindingModel)
.where(RecruiterChannelBindingModel.channel_id == channel_id)
)
# 再删除渠道
stmt = delete(NotificationChannelModel).where(NotificationChannelModel.id == channel_id)
result = session.execute(stmt)
session.commit()
return result.rowcount > 0
finally:
session.close()
def update_status(self, channel_id: str, status: ChannelStatus) -> bool:
"""更新渠道状态"""
session = self._get_session()
try:
stmt = (
update(NotificationChannelModel)
.where(NotificationChannelModel.id == channel_id)
.values(status=status.value, updated_at=datetime.now())
)
result = session.execute(stmt)
session.commit()
return result.rowcount > 0
finally:
session.close()
# ==================== 绑定关系操作 ====================
def bind_recruiter(self, binding: RecruiterChannelBinding) -> RecruiterChannelBinding:
"""绑定招聘者与通知渠道"""
session = self._get_session()
try:
# 检查是否已存在绑定
existing = session.execute(
select(RecruiterChannelBindingModel)
.where(
and_(
RecruiterChannelBindingModel.recruiter_id == binding.recruiter_id,
RecruiterChannelBindingModel.channel_id == binding.channel_id
)
)
).scalar_one_or_none()
if existing:
# 更新现有绑定
stmt = (
update(RecruiterChannelBindingModel)
.where(RecruiterChannelBindingModel.id == existing.id)
.values(
is_enabled=binding.is_enabled,
notify_on_new_candidate=binding.notify_on_new_candidate,
notify_on_evaluation=binding.notify_on_evaluation,
notify_on_high_score=binding.notify_on_high_score,
high_score_threshold=binding.high_score_threshold
)
)
session.execute(stmt)
binding.created_at = existing.created_at
else:
# 创建新绑定
model = RecruiterChannelBindingModel(
id=str(uuid.uuid4()),
recruiter_id=binding.recruiter_id,
channel_id=binding.channel_id,
is_enabled=binding.is_enabled,
notify_on_new_candidate=binding.notify_on_new_candidate,
notify_on_evaluation=binding.notify_on_evaluation,
notify_on_high_score=binding.notify_on_high_score,
high_score_threshold=binding.high_score_threshold
)
session.add(model)
session.commit()
return binding
finally:
session.close()
def unbind_recruiter(self, recruiter_id: str, channel_id: str) -> bool:
"""解绑招聘者与通知渠道"""
session = self._get_session()
try:
stmt = (
delete(RecruiterChannelBindingModel)
.where(
and_(
RecruiterChannelBindingModel.recruiter_id == recruiter_id,
RecruiterChannelBindingModel.channel_id == channel_id
)
)
)
result = session.execute(stmt)
session.commit()
return result.rowcount > 0
finally:
session.close()
def find_bindings_by_recruiter(self, recruiter_id: str) -> List[RecruiterChannelBinding]:
"""查询招聘者的所有绑定关系"""
session = self._get_session()
try:
result = session.execute(
select(RecruiterChannelBindingModel)
.where(RecruiterChannelBindingModel.recruiter_id == recruiter_id)
)
models = result.scalars().all()
return [self._binding_model_to_entity(m) for m in models]
finally:
session.close()
def find_bindings_by_channel(self, channel_id: str) -> List[RecruiterChannelBinding]:
"""查询通知渠道的所有绑定关系"""
session = self._get_session()
try:
result = session.execute(
select(RecruiterChannelBindingModel)
.where(RecruiterChannelBindingModel.channel_id == channel_id)
)
models = result.scalars().all()
return [self._binding_model_to_entity(m) for m in models]
finally:
session.close()
def find_channels_by_recruiter(self, recruiter_id: str, only_enabled: bool = True) -> List[NotificationChannel]:
"""查询招聘者绑定的所有通知渠道"""
session = self._get_session()
try:
query = (
select(NotificationChannelModel)
.join(RecruiterChannelBindingModel)
.where(RecruiterChannelBindingModel.recruiter_id == recruiter_id)
)
if only_enabled:
query = query.where(RecruiterChannelBindingModel.is_enabled == True)
query = query.order_by(NotificationChannelModel.created_at.desc())
result = session.execute(query)
models = result.scalars().all()
return [self._model_to_entity(m) for m in models]
finally:
session.close()
def find_recruiters_by_channel(self, channel_id: str, only_enabled: bool = True) -> List[str]:
"""查询绑定到指定渠道的所有招聘者ID"""
session = self._get_session()
try:
query = (
select(RecruiterChannelBindingModel.recruiter_id)
.where(RecruiterChannelBindingModel.channel_id == channel_id)
)
if only_enabled:
query = query.where(RecruiterChannelBindingModel.is_enabled == True)
result = session.execute(query)
return [row[0] for row in result.all()]
finally:
session.close()
def update_binding(self, recruiter_id: str, channel_id: str, **kwargs) -> bool:
"""更新绑定关系配置"""
session = self._get_session()
try:
allowed_fields = [
'is_enabled', 'notify_on_new_candidate',
'notify_on_evaluation', 'notify_on_high_score', 'high_score_threshold'
]
update_values = {k: v for k, v in kwargs.items() if k in allowed_fields}
if not update_values:
return False
stmt = (
update(RecruiterChannelBindingModel)
.where(
and_(
RecruiterChannelBindingModel.recruiter_id == recruiter_id,
RecruiterChannelBindingModel.channel_id == channel_id
)
)
.values(**update_values)
)
result = session.execute(stmt)
session.commit()
return result.rowcount > 0
finally:
session.close()
def get_binding(self, recruiter_id: str, channel_id: str) -> Optional[RecruiterChannelBinding]:
"""获取单个绑定关系"""
session = self._get_session()
try:
result = session.execute(
select(RecruiterChannelBindingModel)
.where(
and_(
RecruiterChannelBindingModel.recruiter_id == recruiter_id,
RecruiterChannelBindingModel.channel_id == channel_id
)
)
)
model = result.scalar_one_or_none()
return self._binding_model_to_entity(model) if model else None
finally:
session.close()

View File

@@ -41,6 +41,9 @@ class ResumeMapper:
"""递归转换对象为可JSON序列化的格式""" """递归转换对象为可JSON序列化的格式"""
if obj is None: if obj is None:
return None return None
# 处理基本类型
if isinstance(obj, (str, int, float, bool)):
return obj
# 处理枚举类型 # 处理枚举类型
if hasattr(obj, 'value'): if hasattr(obj, 'value'):
return obj.value return obj.value
@@ -65,7 +68,17 @@ class ResumeMapper:
value = getattr(obj, field_name) value = getattr(obj, field_name)
result[field_name] = self._convert_to_serializable(value) result[field_name] = self._convert_to_serializable(value)
return result return result
return obj # 处理一般对象如SDK返回的Model对象
if hasattr(obj, '__dict__'):
result = {}
for key, value in obj.__dict__.items():
# 跳过私有属性和方法
if key.startswith('_'):
continue
result[key] = self._convert_to_serializable(value)
return result
# 最后尝试转换为字符串
return str(obj)
def _entity_to_model(self, entity: Resume) -> ResumeModel: def _entity_to_model(self, entity: Resume) -> ResumeModel:
"""将实体转换为模型""" """将实体转换为模型"""

View File

@@ -20,14 +20,16 @@ class IngestionResult:
errors: list = None errors: list = None
is_duplicate: bool = False is_duplicate: bool = False
existing_candidate_id: Optional[str] = None existing_candidate_id: Optional[str] = None
is_new: bool = True # 是否为新增候选人False表示更新
@classmethod @classmethod
def success_result(cls, candidate_id: str, message: str = "") -> "IngestionResult": def success_result(cls, candidate_id: str, message: str = "", is_new: bool = True) -> "IngestionResult":
"""创建成功结果""" """创建成功结果"""
return cls( return cls(
success=True, success=True,
candidate_id=candidate_id, candidate_id=candidate_id,
message=message or "入库成功" message=message or "入库成功",
is_new=is_new
) )
@classmethod @classmethod
@@ -50,7 +52,8 @@ class IngestionResult:
success=True, # 重复不算失败 success=True, # 重复不算失败
is_duplicate=True, is_duplicate=True,
existing_candidate_id=existing_id, existing_candidate_id=existing_id,
message=message or "候选人已存在" message=message or "候选人已存在",
is_new=False # 重复候选人不算新增
) )

View File

@@ -4,6 +4,7 @@ from .base_channel import NotificationChannel, NotificationMessage, SendResult
from .wechat_work_channel import WeChatWorkChannel from .wechat_work_channel import WeChatWorkChannel
from .dingtalk_channel import DingTalkChannel from .dingtalk_channel import DingTalkChannel
from .email_channel import EmailChannel from .email_channel import EmailChannel
from .feishu_channel import FeishuChannel, FeishuTextChannel
__all__ = [ __all__ = [
"NotificationChannel", "NotificationChannel",
@@ -12,4 +13,6 @@ __all__ = [
"WeChatWorkChannel", "WeChatWorkChannel",
"DingTalkChannel", "DingTalkChannel",
"EmailChannel", "EmailChannel",
"FeishuChannel",
"FeishuTextChannel",
] ]

View File

@@ -0,0 +1,281 @@
"""Feishu (Lark) notification channel"""
import json
import base64
import hashlib
import hmac
import time
from typing import Optional, Dict, Any
from .base_channel import NotificationChannel, NotificationMessage, SendResult
from ....domain.enums import ChannelType
class FeishuChannel(NotificationChannel):
"""
飞书通知渠道
通过飞书机器人 Webhook 发送消息
支持加签验证
"""
def __init__(self, webhook_url: str, secret: Optional[str] = None):
"""
初始化飞书渠道
Args:
webhook_url: 飞书机器人 Webhook 地址
secret: 飞书机器人密钥(用于加签验证)
"""
self.webhook_url = webhook_url
self.secret = secret
@property
def channel_type(self) -> ChannelType:
return ChannelType.WEBHOOK # 使用通用WEBHOOK类型实际通过配置区分
def is_configured(self) -> bool:
"""检查是否已配置"""
return bool(self.webhook_url)
def _generate_sign(self, timestamp: int) -> str:
"""
生成飞书签名
飞书加签算法:
1. 将 timestamp + "\n" + secret 作为签名字符串
2. 使用 HMAC-SHA256 算法计算签名
3. 对结果进行 Base64 编码
"""
if not self.secret:
return ""
string_to_sign = f"{timestamp}\n{self.secret}"
hmac_code = hmac.new(
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode('utf-8')
return sign
async def send(self, message: NotificationMessage) -> SendResult:
"""发送飞书消息"""
if not self.is_configured():
return SendResult(
success=False,
error_message="Webhook URL not configured"
)
try:
payload = self._build_payload(message)
# 发送请求
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
self.webhook_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
result = await response.json()
if result.get("code") == 0:
return SendResult(
success=True,
message_id=result.get("data", {}).get("message_id"),
response_data=result
)
else:
return SendResult(
success=False,
error_message=f"Feishu API error: {result.get('msg')}"
)
except Exception as e:
return SendResult(
success=False,
error_message=f"Failed to send Feishu message: {str(e)}"
)
def _build_payload(self, message: NotificationMessage) -> Dict[str, Any]:
"""构建飞书消息体"""
timestamp = int(time.time())
sign = self._generate_sign(timestamp)
# 构建卡片消息
card_content = self._build_card_content(message)
payload = {
"timestamp": timestamp,
"msg_type": "interactive",
"card": card_content
}
if sign:
payload["sign"] = sign
return payload
def _build_card_content(self, message: NotificationMessage) -> Dict[str, Any]:
"""构建飞书卡片消息内容"""
elements = []
# 标题
if message.title:
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"**{message.title}**"
}
})
# 内容
if message.content:
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": message.content
}
})
# 候选人信息
if message.candidate:
candidate = message.candidate
info_lines = ["**候选人信息:**"]
info_lines.append(f"• 姓名:{candidate.name}")
if candidate.age:
info_lines.append(f"• 年龄:{candidate.age}")
if candidate.work_years:
info_lines.append(f"• 工作年限:{candidate.work_years}")
if candidate.current_company:
info_lines.append(f"• 当前公司:{candidate.current_company}")
if candidate.current_position:
info_lines.append(f"• 当前职位:{candidate.current_position}")
if candidate.phone:
info_lines.append(f"• 联系方式:{candidate.phone}")
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": "\n".join(info_lines)
}
})
# 评价信息
if message.evaluation:
evaluation = message.evaluation
eval_lines = ["**AI 评价:**"]
eval_lines.append(f"• 综合评分:**{evaluation.overall_score}/100**")
if evaluation.recommendation:
eval_lines.append(f"• 推荐意见:{self._format_recommendation(evaluation.recommendation.value)}")
if evaluation.summary:
eval_lines.append(f"• 评价摘要:{evaluation.summary}")
if evaluation.strengths:
eval_lines.append(f"• 优势:{', '.join(evaluation.strengths[:3])}")
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": "\n".join(eval_lines)
}
})
# 添加分隔线
if len(elements) > 0:
elements.append({"tag": "hr"})
# 添加时间戳
from datetime import datetime
elements.append({
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
}
]
})
return {
"config": {
"wide_screen_mode": True
},
"header": {
"template": "blue",
"title": {
"tag": "plain_text",
"content": "简历智能体通知"
}
},
"elements": elements
}
def _format_recommendation(self, value: str) -> str:
"""格式化推荐意见"""
mapping = {
"strong_recommend": "**强烈推荐** 🌟",
"recommend": "**推荐** ✅",
"consider": "**考虑** 🤔",
"not_recommend": "**不推荐** ❌"
}
return mapping.get(value, value)
class FeishuTextChannel(FeishuChannel):
"""
飞书文本消息渠道
发送纯文本消息(更简洁)
"""
def _build_payload(self, message: NotificationMessage) -> Dict[str, Any]:
"""构建文本消息体"""
timestamp = int(time.time())
sign = self._generate_sign(timestamp)
# 构建文本内容
content = self._format_text_content(message)
payload = {
"timestamp": timestamp,
"msg_type": "text",
"content": {
"text": content
}
}
if sign:
payload["sign"] = sign
return payload
def _format_text_content(self, message: NotificationMessage) -> str:
"""格式化文本内容"""
lines = []
if message.title:
lines.append(f"{message.title}")
if message.content:
lines.append(message.content)
if message.candidate:
candidate = message.candidate
lines.append(f"\n候选人:{candidate.name}")
if candidate.current_company:
lines.append(f"公司:{candidate.current_company}")
if candidate.current_position:
lines.append(f"职位:{candidate.current_position}")
if candidate.phone:
lines.append(f"电话:{candidate.phone}")
if message.evaluation:
evaluation = message.evaluation
lines.append(f"\nAI评分{evaluation.overall_score}/100")
if evaluation.recommendation:
lines.append(f"推荐:{self._format_recommendation(evaluation.recommendation.value)}")
return "\n".join(lines)

View File

@@ -132,8 +132,8 @@ class ResumeEvaluationService:
print(f"[ResumeEvaluationService] 开始处理候选人: {candidate.name}") print(f"[ResumeEvaluationService] 开始处理候选人: {candidate.name}")
try: try:
# 1. 获取简历详情 - 同步操作 # 1. 获取简历详情 - 在线程池中异步执行
resume = self._get_resume(crawler, candidate) resume = await self._get_resume(crawler, candidate)
if not resume: if not resume:
result.success = False result.success = False
result.status = "failed" result.status = "failed"
@@ -192,9 +192,9 @@ class ResumeEvaluationService:
return result return result
def _get_resume(self, crawler: BaseCrawler, candidate: Candidate) -> Optional[Resume]: async def _get_resume(self, crawler: BaseCrawler, candidate: Candidate) -> Optional[Resume]:
""" """
获取简历详情 获取简历详情(在线程池中执行,避免阻塞事件循环)
Args: Args:
crawler: 爬虫实例 crawler: 爬虫实例
@@ -203,8 +203,9 @@ class ResumeEvaluationService:
Returns: Returns:
Resume: 简历对象 Resume: 简历对象
""" """
import asyncio
try: try:
return crawler.get_resume_detail(candidate) return await asyncio.to_thread(crawler.get_resume_detail, candidate)
except Exception as e: except Exception as e:
print(f"[ResumeEvaluationService] 获取简历失败: {e}") print(f"[ResumeEvaluationService] 获取简历失败: {e}")
return None return None

View File

@@ -110,22 +110,22 @@ class CrawlScheduler:
if not crawler: if not crawler:
continue continue
# 获取职位列表 # 获取职位列表(在线程池中执行,避免阻塞事件循环)
jobs = crawler.get_jobs() jobs = await asyncio.to_thread(crawler.get_jobs)
print(f"[{datetime.now()}] 找到 {len(jobs)} 个职位") print(f"[{datetime.now()}] 找到 {len(jobs)} 个职位")
# 遍历职位爬取候选人 # 遍历职位爬取候选人
for job in jobs: for job in jobs:
print(f"[{datetime.now()}] 爬取职位: {job.title}") print(f"[{datetime.now()}] 爬取职位: {job.title}")
# 爬取候选人 # 爬取候选人(在线程池中执行,避免阻塞事件循环)
candidates = crawler.get_candidates(job.source_id, page=1) candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
print(f"[{datetime.now()}] 职位 '{job.title}' 找到 {len(candidates)} 个候选人") print(f"[{datetime.now()}] 职位 '{job.title}' 找到 {len(candidates)} 个候选人")
for candidate in candidates[:10]: # 每职位限制10个候选人 for candidate in candidates[:10]: # 每职位限制10个候选人
try: try:
# 获取简历详情 # 获取简历详情(在线程池中执行,避免阻塞事件循环)
resume = crawler.get_resume_detail(candidate) resume = await asyncio.to_thread(crawler.get_resume_detail, candidate)
if not resume: if not resume:
continue continue
@@ -145,8 +145,12 @@ class CrawlScheduler:
"resumeText": resume.raw_content, "resumeText": resume.raw_content,
} }
# 入库 # 入库(在线程池中执行,避免阻塞事件循环)
result = self.app.ingestion_service.ingest(CandidateSource.BOSS, raw_data) result = await asyncio.to_thread(
self.app.ingestion_service.ingest,
CandidateSource.BOSS,
raw_data
)
print(f"[{datetime.now()}] 候选人 {candidate.name} 入库: {result.message}") print(f"[{datetime.now()}] 候选人 {candidate.name} 入库: {result.message}")
# 触发分析 # 触发分析

View File

@@ -10,17 +10,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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", "@element-plus/icons-vue": "^2.3.0",
"dayjs": "^1.11.0" "axios": "^1.6.0",
"dayjs": "^1.11.0",
"element-plus": "^2.5.0",
"pinia": "^2.1.0",
"tdesign-icons-vue-next": "^0.4.2",
"tdesign-vue-next": "^1.18.6",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0", "sass": "^1.70.0",
"sass": "^1.70.0" "vite": "^5.0.0"
} }
} }

View File

@@ -23,6 +23,12 @@ importers:
pinia: pinia:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.3.1(vue@3.5.30) version: 2.3.1(vue@3.5.30)
tdesign-icons-vue-next:
specifier: ^0.4.2
version: 0.4.2(vue@3.5.30)
tdesign-vue-next:
specifier: ^1.18.6
version: 1.18.6(vue@3.5.30)
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.30 version: 3.5.30
@@ -55,6 +61,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.0': '@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -300,6 +310,9 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@rollup/rollup-android-arm-eabi@4.60.0': '@rollup/rollup-android-arm-eabi@4.60.0':
resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
cpu: [arm] cpu: [arm]
@@ -437,6 +450,15 @@ packages:
'@types/lodash@4.17.24': '@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/tinycolor2@1.4.6':
resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/web-bluetooth@0.0.20': '@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -647,6 +669,9 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -695,10 +720,31 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
tdesign-icons-vue-next@0.4.2:
resolution: {integrity: sha512-mTPk1ApcCA9oxDiSs9ttMdd09H8ICBooZIr2bwDEELnYr60sYSUbvWojQ2tp84MUAMuw21HgyVyGkT49db0GFg==}
peerDependencies:
vue: ^3.0.0
tdesign-vue-next@1.18.6:
resolution: {integrity: sha512-oc7wOE5awfWd0/mqCVOESv3rg1Nh6HJGr9vAvgjUkRTp6/KS9gexiKU2qR1hBKqh6cmBQkbFMWmX8MLnJJ6zLQ==}
engines: {node: '>= 18'}
peerDependencies:
vue: '>=3.1.0'
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
vite@5.4.21: vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -767,6 +813,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/runtime@7.29.2': {}
'@babel/types@7.29.0': '@babel/types@7.29.0':
dependencies: dependencies:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
@@ -921,6 +969,8 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6
optional: true optional: true
'@popperjs/core@2.11.8': {}
'@rollup/rollup-android-arm-eabi@4.60.0': '@rollup/rollup-android-arm-eabi@4.60.0':
optional: true optional: true
@@ -1006,6 +1056,12 @@ snapshots:
'@types/lodash@4.17.24': {} '@types/lodash@4.17.24': {}
'@types/sortablejs@1.15.9': {}
'@types/tinycolor2@1.4.6': {}
'@types/validator@13.15.10': {}
'@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.20': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(sass@1.98.0))(vue@3.5.30)': '@vitejs/plugin-vue@5.2.4(vite@5.4.21(sass@1.98.0))(vue@3.5.30)':
@@ -1271,6 +1327,8 @@ snapshots:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
mitt@3.0.1: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
node-addon-api@7.1.1: node-addon-api@7.1.1:
@@ -1340,8 +1398,36 @@ snapshots:
optionalDependencies: optionalDependencies:
'@parcel/watcher': 2.5.6 '@parcel/watcher': 2.5.6
sortablejs@1.15.7: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
tdesign-icons-vue-next@0.4.2(vue@3.5.30):
dependencies:
'@babel/runtime': 7.29.2
vue: 3.5.30
tdesign-vue-next@1.18.6(vue@3.5.30):
dependencies:
'@babel/runtime': 7.29.2
'@popperjs/core': 2.11.8
'@types/lodash-es': 4.17.12
'@types/sortablejs': 1.15.9
'@types/tinycolor2': 1.4.6
'@types/validator': 13.15.10
dayjs: 1.11.20
lodash-es: 4.17.23
mitt: 3.0.1
sortablejs: 1.15.7
tdesign-icons-vue-next: 0.4.2(vue@3.5.30)
tinycolor2: 1.6.0
validator: 13.15.26
vue: 3.5.30
tinycolor2@1.6.0: {}
validator@13.15.26: {}
vite@5.4.21(sass@1.98.0): vite@5.4.21(sass@1.98.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5

View File

@@ -3,6 +3,40 @@
</template> </template>
<style> <style>
:root {
/* 主题色 */
--primary-color: #5B6CFF;
--primary-light: #7C8AFF;
--primary-gradient: linear-gradient(135deg, #5B6CFF 0%, #8B5CF6 100%);
/* 功能色 */
--success-color: #10B981;
--warning-color: #F59E0B;
--danger-color: #EF4444;
--info-color: #6366F1;
/* 背景色 */
--bg-color: #F5F7FB;
--card-bg: #FFFFFF;
--sidebar-bg: #FFFFFF;
/* 文字色 */
--text-primary: #1F2937;
--text-secondary: #6B7280;
--text-muted: #9CA3AF;
/* 边框 */
--border-color: #E5E7EB;
--border-radius-sm: 8px;
--border-radius: 16px;
--border-radius-lg: 24px;
/* 阴影 */
--shadow-sm: 0 2px 8px rgba(99, 102, 241, 0.08);
--shadow: 0 4px 20px rgba(99, 102, 241, 0.1);
--shadow-lg: 0 8px 30px rgba(99, 102, 241, 0.15);
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -10,9 +44,123 @@
} }
body { body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', font-family: 'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif; 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
}
/* 全局 TDesign 样式覆盖 */
.t-card {
border-radius: var(--border-radius) !important;
box-shadow: var(--shadow) !important;
border: none !important;
transition: all 0.3s ease !important;
}
.t-card:hover {
box-shadow: var(--shadow-lg) !important;
transform: translateY(-2px);
}
.t-button {
border-radius: var(--border-radius-sm) !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
.t-button--theme-primary {
background: var(--primary-gradient) !important;
border: none !important;
}
.t-button--theme-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(91, 108, 255, 0.4) !important;
}
.t-button--theme-success {
background: linear-gradient(135deg, #10B981 0%, #34D399 100%) !important;
border: none !important;
}
.t-button--theme-warning {
background: linear-gradient(135deg, #F59E0B 0%, #FBBF24 100%) !important;
border: none !important;
}
.t-button--theme-danger {
background: linear-gradient(135deg, #EF4444 0%, #F87171 100%) !important;
border: none !important;
}
.t-input,
.t-input__inner,
.t-select .t-input {
border-radius: var(--border-radius-sm) !important;
}
.t-input:focus-within {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(91, 108, 255, 0.1) !important;
}
.t-table {
border-radius: var(--border-radius) !important;
overflow: hidden;
}
.t-table th {
background: #F9FAFB !important;
font-weight: 600 !important;
color: var(--text-secondary) !important;
}
.t-dialog {
border-radius: var(--border-radius-lg) !important;
}
.t-dialog__header {
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0 !important;
}
.t-tag {
border-radius: 20px !important;
font-weight: 500 !important;
}
.t-pagination .t-pagination__number,
.t-pagination .t-pagination__btn {
border-radius: var(--border-radius-sm) !important;
}
.t-menu {
border-radius: var(--border-radius) !important;
}
.t-menu__item {
border-radius: var(--border-radius-sm) !important;
margin: 4px 8px !important;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #9CA3AF;
} }
</style> </style>

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,6 +139,45 @@ 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 = {
// 获取首页信息 // 获取首页信息

View File

@@ -1,63 +1,80 @@
<template> <template>
<el-container class="layout-container"> <t-layout class="layout-container">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<el-aside width="220px" class="sidebar"> <t-aside width="240px" class="sidebar">
<div class="logo"> <div class="logo">
<el-icon size="28" color="#409EFF"><Briefcase /></el-icon> <t-icon name="briefcase" size="28px" color="#5B6CFF" />
<span class="logo-text">简历智能体</span> <span class="logo-text">简历智能体</span>
</div> </div>
<el-menu <div class="menu-container">
:default-active="$route.path" <div
router v-for="route in menuRoutes"
class="sidebar-menu" :key="route.path"
background-color="#304156" class="menu-item"
text-color="#bfcbd9" :class="{ active: $route.path === route.path }"
active-text-color="#409EFF" @click="handleMenuChange(route.path)"
> >
<el-menu-item v-for="route in menuRoutes" :key="route.path" :index="route.path"> <t-icon :name="route.meta.icon" size="22px" />
<el-icon> <span class="menu-text">{{ route.meta.title }}</span>
<component :is="route.meta.icon" /> <div class="active-indicator"></div>
</el-icon> </div>
<span>{{ route.meta.title }}</span> </div>
</el-menu-item> </t-aside>
</el-menu>
</el-aside>
<!-- 主内容区 --> <!-- 主内容区 -->
<el-container> <t-layout class="main-layout">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<el-header class="header"> <t-header class="header">
<div class="header-left"> <div class="header-left">
<breadcrumb /> <div class="search-box">
<t-icon name="search" class="search-icon" />
<input type="text" placeholder="搜索..." class="search-input" />
</div>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-tooltip content="系统状态"> <div class="header-action">
<el-icon size="20" :class="systemStatus"><CircleCheck /></el-icon> <t-badge :count="3" :offset="[-2, 2]">
</el-tooltip> <div class="action-icon">
<span class="version">v0.1.0</span> <t-icon name="notification" size="20px" />
</div>
</t-badge>
</div>
<div class="header-action">
<div class="action-icon">
<t-icon name="setting" size="20px" />
</div>
</div>
<div class="status-indicator" :class="systemStatus">
<span class="status-dot"></span>
<span class="status-text">{{ systemStatus === 'status-healthy' ? '系统正常' : '系统异常' }}</span>
</div>
<div class="user-avatar">
<t-avatar size="36px" style="background: var(--primary-gradient);">HR</t-avatar>
</div>
</div> </div>
</el-header> </t-header>
<!-- 内容区 --> <!-- 内容区 -->
<el-main class="main-content"> <t-content class="main-content">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade-slide" mode="out-in">
<component :is="Component" /> <component :is="Component" />
</transition> </transition>
</router-view> </router-view>
</el-main> </t-content>
</el-container> </t-layout>
</el-container> </t-layout>
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import router from '@/router' import router from '@/router'
import { systemApi } from '@/api/api' import { systemApi } from '@/api/api'
const $route = useRoute() const $route = useRoute()
const $router = useRouter()
const systemStatus = ref('status-healthy') const systemStatus = ref('status-healthy')
// 菜单路由 // 菜单路由
@@ -67,6 +84,11 @@ const menuRoutes = computed(() => {
?.children.filter(r => r.meta) || [] ?.children.filter(r => r.meta) || []
}) })
// 菜单切换
const handleMenuChange = (value) => {
$router.push(value)
}
// 检查系统状态 // 检查系统状态
const checkStatus = async () => { const checkStatus = async () => {
try { try {
@@ -86,41 +108,179 @@ onMounted(() => {
<style scoped> <style scoped>
.layout-container { .layout-container {
height: 100vh; height: 100vh;
background: var(--bg-color);
display: flex;
} }
.sidebar { .sidebar {
background-color: #304156; width: 240px !important;
min-width: 240px !important;
max-width: 240px !important;
flex-shrink: 0 !important;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
padding: 24px 0;
box-shadow: var(--shadow);
border-radius: 0 24px 24px 0;
z-index: 10;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
padding: 0 28px;
margin-bottom: 32px;
white-space: nowrap;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
/* 菜单容器 */
.menu-container {
flex: 1;
padding: 0 16px;
overflow-y: auto;
overflow-x: hidden;
}
/* 菜单项 */
.menu-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
margin-bottom: 8px;
border-radius: 12px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
white-space: nowrap;
}
.menu-item:hover {
color: var(--primary-color);
background: rgba(91, 108, 255, 0.06);
}
.menu-item.active {
color: var(--primary-color);
background: rgba(91, 108, 255, 0.1);
font-weight: 600;
}
/* 右侧蓝色指示器 */
.active-indicator {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 0;
background: var(--primary-color);
border-radius: 4px 0 0 4px;
transition: height 0.3s ease;
}
.menu-item.active .active-indicator {
height: 24px;
}
.menu-text {
font-size: 15px;
}
/* 升级卡片 */
.upgrade-card {
background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%);
border-radius: 20px;
padding: 24px 16px;
text-align: center;
margin: 16px;
}
.upgrade-icon {
color: var(--primary-color);
margin-bottom: 12px;
opacity: 0.6;
}
.upgrade-text {
font-size: 14px;
color: var(--text-primary);
margin-bottom: 4px;
}
.upgrade-text .highlight {
color: var(--primary-color);
font-weight: 700;
}
.upgrade-desc {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 16px;
}
.upgrade-btn {
border-radius: 12px !important;
}
/* 主布局 */
.main-layout {
flex: 1;
min-width: 0;
background: var(--bg-color);
display: flex; display: flex;
flex-direction: column; 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 { .header {
background-color: #fff; background: transparent;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 16px 32px;
height: 72px;
}
.header-left {
flex: 1;
}
.search-box {
display: flex;
align-items: center;
background: var(--card-bg);
border-radius: 12px;
padding: 10px 16px;
max-width: 320px;
box-shadow: var(--shadow-sm);
}
.search-icon {
color: var(--text-muted);
margin-right: 10px;
}
.search-input {
border: none;
outline: none;
background: transparent;
font-size: 14px;
width: 100%;
color: var(--text-primary);
}
.search-input::placeholder {
color: var(--text-muted);
} }
.header-right { .header-right {
@@ -129,32 +289,82 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
.status-healthy { .header-action {
color: #67c23a; cursor: pointer;
} }
.status-error { .action-icon {
color: #f56c6c; width: 40px;
height: 40px;
background: var(--card-bg);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
} }
.version { .action-icon:hover {
color: #909399; color: var(--primary-color);
font-size: 14px; box-shadow: var(--shadow);
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--card-bg);
border-radius: 20px;
box-shadow: var(--shadow-sm);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-healthy .status-dot {
background: var(--success-color);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
}
.status-error .status-dot {
background: var(--danger-color);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.status-text {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.user-avatar {
cursor: pointer;
} }
.main-content { .main-content {
background-color: #f0f2f5; padding: 0 32px 32px;
padding: 20px;
overflow-y: auto; overflow-y: auto;
} }
.fade-enter-active, /* 页面切换动画 */
.fade-leave-active { .fade-slide-enter-active,
transition: opacity 0.3s ease; .fade-slide-leave-active {
transition: all 0.3s ease;
} }
.fade-enter-from, .fade-slide-enter-from {
.fade-leave-to {
opacity: 0; opacity: 0;
transform: translateY(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-20px);
} }
</style> </style>

View File

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

View File

@@ -11,31 +11,37 @@ const routes = [
path: 'dashboard', path: 'dashboard',
name: 'Dashboard', name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'), component: () => import('@/views/Dashboard.vue'),
meta: { title: '首页', icon: 'HomeFilled' } meta: { title: '首页', icon: 'home' }
}, },
{ {
path: 'recruiters', path: 'recruiters',
name: 'Recruiters', name: 'Recruiters',
component: () => import('@/views/Recruiters.vue'), component: () => import('@/views/Recruiters.vue'),
meta: { title: '招聘者管理', icon: 'UserFilled' } meta: { title: '招聘者管理', icon: 'user' }
}, },
{ {
path: 'jobs', path: 'jobs',
name: 'Jobs', name: 'Jobs',
component: () => import('@/views/Jobs.vue'), component: () => import('@/views/Jobs.vue'),
meta: { title: '职位管理', icon: 'Briefcase' } meta: { title: '职位管理', icon: 'briefcase' }
}, },
{ {
path: 'candidates', path: 'candidates',
name: 'Candidates', name: 'Candidates',
component: () => import('@/views/Candidates.vue'), component: () => import('@/views/Candidates.vue'),
meta: { title: '候选人管理', icon: 'Avatar' } meta: { title: '候选人管理', icon: 'user-circle' }
}, },
{ {
path: 'scheduler', path: 'scheduler',
name: 'Scheduler', name: 'Scheduler',
component: () => import('@/views/Scheduler.vue'), component: () => import('@/views/Scheduler.vue'),
meta: { title: '定时任务', icon: 'Clock' } meta: { title: '定时任务', icon: 'time' }
},
{
path: 'notification-channels',
name: 'NotificationChannels',
component: () => import('@/views/NotificationChannels.vue'),
meta: { title: '通知渠道', icon: 'notification' }
} }
] ]
} }

View File

@@ -5,160 +5,189 @@
</div> </div>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<el-card class="search-card"> <t-card class="search-card" :bordered="false">
<el-form :inline="true" :model="searchForm"> <t-form layout="inline" :data="searchForm">
<el-form-item label="关键词"> <t-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="姓名/公司/职位" clearable /> <t-input v-model="searchForm.keyword" placeholder="姓名/公司/职位" clearable style="width: 180px" />
</el-form-item> </t-form-item>
<el-form-item label="LLM筛选"> <t-form-item label="LLM筛选">
<el-select v-model="searchForm.llm_filtered" placeholder="全部" clearable> <t-select v-model="searchForm.llm_filtered" placeholder="全部" clearable style="width: 150px">
<el-option label="已通过" :value="true" /> <t-option label="已通过" :value="true" />
<el-option label="未通过" :value="false" /> <t-option label="未通过" :value="false" />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item label="评分范围"> <t-form-item label="评分范围">
<el-row :gutter="10"> <t-input-group>
<el-col :span="11"> <t-input-number v-model="searchForm.min_score" :min="0" :max="100" placeholder="最低" style="width: 100px" />
<el-input-number v-model="searchForm.min_score" :min="0" :max="100" placeholder="最低" style="width: 100%" /> <span style="padding: 0 8px">-</span>
</el-col> <t-input-number v-model="searchForm.max_score" :min="0" :max="100" placeholder="最高" style="width: 100px" />
<el-col :span="2" style="text-align: center;">-</el-col> </t-input-group>
<el-col :span="11"> </t-form-item>
<el-input-number v-model="searchForm.max_score" :min="0" :max="100" placeholder="最高" style="width: 100%" /> <t-form-item>
</el-col> <t-space>
</el-row> <t-button theme="primary" @click="handleSearch">
</el-form-item> <template #icon><t-icon name="search" /></template>
<el-form-item> 搜索
<el-button type="primary" @click="handleSearch"> </t-button>
<el-icon><Search /></el-icon>搜索 <t-button theme="default" @click="resetSearch">重置</t-button>
</el-button> </t-space>
<el-button @click="resetSearch">重置</el-button> </t-form-item>
</el-form-item> </t-form>
</el-form> </t-card>
</el-card>
<!-- 数据表格 --> <!-- 数据表格 -->
<el-card> <t-card :bordered="false">
<el-table :data="candidateList" v-loading="loading" stripe> <t-table
<el-table-column prop="name" label="姓名" width="100" fixed /> :data="candidateList"
<el-table-column prop="gender" label="性别" width="70" /> :columns="columns"
<el-table-column prop="age" label="年龄" width="70" /> :loading="loading"
<el-table-column prop="current_company" label="当前公司" min-width="150" show-overflow-tooltip /> row-key="id"
<el-table-column prop="current_position" label="当前职位" min-width="150" show-overflow-tooltip /> stripe
<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"> <div class="pagination">
<el-pagination <t-pagination
v-model:current-page="pagination.page" v-model="pagination.page"
v-model:page-size="pagination.pageSize" v-model:pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
layout="total, sizes, prev, pager, next" :page-size-options="[10, 20, 50]"
:page-sizes="[10, 20, 50]" @change="handlePageChange"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/> />
</div> </div>
</el-card> </t-card>
<!-- 候选人详情对话框 --> <!-- 候选人详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="候选人详情" width="700px"> <t-dialog
<el-descriptions :column="2" border v-if="currentCandidate"> v-model:visible="detailDialogVisible"
<el-descriptions-item label="姓名">{{ currentCandidate.name }}</el-descriptions-item> header="候选人详情"
<el-descriptions-item label="性别">{{ currentCandidate.gender }}</el-descriptions-item> width="700px"
<el-descriptions-item label="年龄">{{ currentCandidate.age }}</el-descriptions-item> :footer="false"
<el-descriptions-item label="学历">{{ currentCandidate.education }}</el-descriptions-item> >
<el-descriptions-item label="电话">{{ currentCandidate.phone || '-' }}</el-descriptions-item> <t-descriptions :column="2" bordered v-if="currentCandidate">
<el-descriptions-item label="邮箱">{{ currentCandidate.email || '-' }}</el-descriptions-item> <t-descriptions-item label="姓名">{{ currentCandidate.name }}</t-descriptions-item>
<el-descriptions-item label="当前公司" :span="2">{{ currentCandidate.current_company }}</el-descriptions-item> <t-descriptions-item label="性别">{{ currentCandidate.gender }}</t-descriptions-item>
<el-descriptions-item label="当前职位" :span="2">{{ currentCandidate.current_position }}</el-descriptions-item> <t-descriptions-item label="年龄">{{ currentCandidate.age }}</t-descriptions-item>
<el-descriptions-item label="地点">{{ currentCandidate.location }}</el-descriptions-item> <t-descriptions-item label="学历">{{ currentCandidate.education }}</t-descriptions-item>
<el-descriptions-item label="来源">{{ currentCandidate.source }}</el-descriptions-item> <t-descriptions-item label="电话">{{ currentCandidate.phone || '-' }}</t-descriptions-item>
<el-descriptions-item label="LLM评分" :span="2"> <t-descriptions-item label="邮箱">{{ currentCandidate.email || '-' }}</t-descriptions-item>
<t-descriptions-item label="当前公司" :span="2">{{ currentCandidate.current_company }}</t-descriptions-item>
<t-descriptions-item label="当前职位" :span="2">{{ currentCandidate.current_position }}</t-descriptions-item>
<t-descriptions-item label="地点">{{ currentCandidate.location }}</t-descriptions-item>
<t-descriptions-item label="来源">{{ currentCandidate.source }}</t-descriptions-item>
<t-descriptions-item label="LLM评分" :span="2">
<div v-if="currentCandidate.llm_score" class="detail-score"> <div v-if="currentCandidate.llm_score" class="detail-score">
<span class="score-value">{{ currentCandidate.llm_score }}</span> <span class="score-value">{{ currentCandidate.llm_score }}</span>
<el-tag :type="currentCandidate.llm_filtered ? 'success' : 'info'" size="small"> <t-tag :theme="currentCandidate.llm_filtered ? 'success' : 'default'" size="small">
{{ currentCandidate.llm_filtered ? '已通过筛选' : '未通过筛选' }} {{ currentCandidate.llm_filtered ? '已通过筛选' : '未通过筛选' }}
</el-tag> </t-tag>
</div> </div>
<span v-else>未评分</span> <span v-else>未评分</span>
</el-descriptions-item> </t-descriptions-item>
<el-descriptions-item label="评分详情" :span="2" v-if="currentCandidate.llm_score_details"> <t-descriptions-item label="评分详情" :span="2" v-if="currentCandidate.llm_score_details">
<pre class="score-details">{{ JSON.stringify(currentCandidate.llm_score_details, null, 2) }}</pre> <pre class="score-details">{{ JSON.stringify(currentCandidate.llm_score_details, null, 2) }}</pre>
</el-descriptions-item> </t-descriptions-item>
</el-descriptions> </t-descriptions>
</el-dialog> </t-dialog>
<!-- 评分对话框 --> <!-- 评分对话框 -->
<el-dialog v-model="scoreDialogVisible" title="更新LLM评分" width="500px"> <t-dialog
<el-form :model="scoreForm" :rules="scoreRules" ref="scoreFormRef" label-width="100px"> v-model:visible="scoreDialogVisible"
<el-form-item label="综合评分" prop="llm_score"> header="更新LLM评分"
<el-slider v-model="scoreForm.llm_score" :max="100" show-stops :step="1" /> width="500px"
:confirm-btn="{ content: '确定', loading: submitting }"
:on-confirm="handleSubmitScore"
:on-close="() => scoreDialogVisible = false"
>
<t-form ref="scoreFormRef" :data="scoreForm" :rules="scoreRules" :label-width="100">
<t-form-item label="综合评分" name="llm_score">
<t-slider v-model="scoreForm.llm_score" :max="100" :step="1" />
<div class="score-value">{{ scoreForm.llm_score }} 分</div> <div class="score-value">{{ scoreForm.llm_score }} 分</div>
</el-form-item> </t-form-item>
<el-form-item label="评分详情"> <t-form-item label="评分详情">
<el-input <t-textarea
v-model="scoreForm.llm_score_details" v-model="scoreForm.llm_score_details"
type="textarea"
:rows="4" :rows="4"
placeholder='{"专业能力": 85, "经验匹配": 90}' placeholder='{"专业能力": 85, "经验匹配": 90}'
/> />
</el-form-item> </t-form-item>
</el-form> </t-form>
<template #footer> </t-dialog>
<el-button @click="scoreDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitScore" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, h } from 'vue'
import { ElMessage } from 'element-plus' import { MessagePlugin } from 'tdesign-vue-next'
import { candidateApi } from '@/api/api' import { candidateApi } from '@/api/api'
const loading = ref(false) const loading = ref(false)
const candidateList = ref([]) const candidateList = ref([])
// 表格列定义
const columns = [
{ colKey: 'name', title: '姓名', width: 100, fixed: 'left' },
{ colKey: 'gender', title: '性别', width: 70 },
{ colKey: 'age', title: '年龄', width: 70 },
{ colKey: 'current_company', title: '当前公司', minWidth: 150, ellipsis: true },
{ colKey: 'current_position', title: '当前职位', minWidth: 150, ellipsis: true },
{ colKey: 'education', title: '学历', width: 100 },
{ colKey: 'location', title: '地点', width: 120 },
{
colKey: 'salary',
title: '期望薪资',
width: 120,
cell: (h, { row }) => {
if (row.salary_min && row.salary_max) {
return `${row.salary_min}K-${row.salary_max}K`
}
return h('span', { class: 'text-gray' }, '-')
}
},
{
colKey: 'llm_score',
title: 'LLM评分',
width: 150,
cell: (h, { row }) => {
if (row.llm_score) {
const color = getScoreColor(row.llm_score)
return h('div', { class: 'score-display' }, [
h('t-progress', {
percentage: Math.round(row.llm_score),
color: color,
strokeWidth: 8,
theme: 'line'
})
])
}
return h('span', { class: 'text-gray' }, '未评分')
}
},
{
colKey: 'llm_filtered',
title: '筛选状态',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.llm_filtered ? 'success' : 'default', size: 'small' },
row.llm_filtered ? '已通过' : '未筛选')
}
},
{
colKey: 'operation',
title: '操作',
width: 180,
fixed: 'right',
cell: (h, { row }) => {
return h('t-space', {}, {
default: () => [
h('t-button', { size: 'small', onClick: () => handleView(row) }, '详情'),
h('t-button', { size: 'small', theme: 'primary', onClick: () => handleScore(row) }, '评分')
]
})
}
}
]
// 搜索表单 // 搜索表单
const searchForm = reactive({ const searchForm = reactive({
keyword: '', keyword: '',
@@ -216,7 +245,7 @@ const loadData = async () => {
candidateList.value = res.data?.items || [] candidateList.value = res.data?.items || []
pagination.total = res.data?.total || 0 pagination.total = res.data?.total || 0
} catch (error) { } catch (error) {
ElMessage.error('加载数据失败: ' + error.message) MessagePlugin.error('加载数据失败: ' + error.message)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -237,13 +266,7 @@ const resetSearch = () => {
} }
// 分页 // 分页
const handleSizeChange = (size) => { const handlePageChange = () => {
pagination.pageSize = size
loadData()
}
const handlePageChange = (page) => {
pagination.page = page
loadData() loadData()
} }
@@ -254,7 +277,7 @@ const handleView = async (row) => {
currentCandidate.value = res.data currentCandidate.value = res.data
detailDialogVisible.value = true detailDialogVisible.value = true
} catch (error) { } catch (error) {
ElMessage.error('获取详情失败: ' + error.message) MessagePlugin.error('获取详情失败: ' + error.message)
} }
} }
@@ -283,11 +306,11 @@ const handleSubmitScore = async () => {
: undefined : undefined
} }
await candidateApi.updateScore(data) await candidateApi.updateScore(data)
ElMessage.success('评分更新成功') MessagePlugin.success('评分更新成功')
scoreDialogVisible.value = false scoreDialogVisible.value = false
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('提交失败: ' + error.message) MessagePlugin.error('提交失败: ' + error.message)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -295,9 +318,9 @@ const handleSubmitScore = async () => {
// 工具函数 // 工具函数
const getScoreColor = (score) => { const getScoreColor = (score) => {
if (score >= 80) return '#67C23A' if (score >= 80) return '#00A870'
if (score >= 60) return '#E6A23C' if (score >= 60) return '#EBB105'
return '#F56C6C' return '#E34D59'
} }
onMounted(() => { onMounted(() => {
@@ -307,36 +330,42 @@ onMounted(() => {
<style scoped> <style scoped>
.candidates-page { .candidates-page {
padding: 20px; max-width: 1400px;
margin: 0 auto;
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 24px;
} }
.page-title { .page-title {
font-size: 20px; font-size: 24px;
font-weight: 600; font-weight: 700;
color: #303133; color: var(--text-primary);
} }
.search-card { .search-card {
margin-bottom: 20px; margin-bottom: 24px;
border-radius: 20px !important;
}
.search-card :deep(.t-card__body) {
padding: 20px 24px;
} }
.score-display { .score-display {
width: 100px; width: 120px;
} }
.text-gray { .text-gray {
color: #909399; color: var(--text-muted);
} }
.pagination { .pagination {
margin-top: 20px; margin-top: 24px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -350,15 +379,29 @@ onMounted(() => {
.score-value { .score-value {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #409EFF; color: var(--primary-color);
} }
.score-details { .score-details {
background: #f5f7fa; background: #F9FAFB;
padding: 12px; padding: 16px;
border-radius: 4px; border-radius: 12px;
font-size: 12px; font-size: 12px;
max-height: 200px; max-height: 200px;
overflow: auto; overflow: auto;
font-family: 'Monaco', 'Consolas', monospace;
}
:deep(.t-table) {
border-radius: 16px !important;
}
:deep(.t-card__body) {
padding: 24px;
}
:deep(.t-descriptions) {
border-radius: 16px !important;
overflow: hidden;
} }
</style> </style>

View File

@@ -1,115 +1,194 @@
<template> <template>
<div class="dashboard"> <div class="dashboard">
<h2 class="page-title">系统概览</h2> <div class="page-header">
<div>
<h1 class="page-title">欢迎回来 👋</h1>
<p class="page-subtitle">这是您的 HR 自动化系统概览</p>
</div>
<div class="header-actions">
<t-button theme="default" variant="outline">
<template #icon><t-icon name="download" /></template>
导出报告
</t-button>
</div>
</div>
<!-- 统计卡片 --> <!-- 统计卡片 -->
<el-row :gutter="20" class="stat-row"> <div class="stat-grid">
<el-col :span="6"> <div class="stat-card stat-card-blue">
<el-card class="stat-card"> <div class="stat-header">
<div class="stat-icon" style="background: #409EFF;"> <div class="stat-icon blue">
<el-icon size="32" color="#fff"><UserFilled /></el-icon> <t-icon name="user" size="24px" />
</div> </div>
<div class="stat-info"> <div class="stat-trend up">
<div class="stat-value">{{ stats.recruiters }}</div> <t-icon name="arrow-up" size="14px" />
<div class="stat-label">招聘者账号</div> <span>12%</span>
</div> </div>
</el-card> </div>
</el-col> <div class="stat-value">{{ stats.recruiters }}</div>
<div class="stat-label">招聘者账号</div>
<div class="stat-progress">
<div class="progress-bar blue" style="width: 75%"></div>
</div>
</div>
<el-col :span="6"> <div class="stat-card stat-card-green">
<el-card class="stat-card"> <div class="stat-header">
<div class="stat-icon" style="background: #67C23A;"> <div class="stat-icon green">
<el-icon size="32" color="#fff"><Briefcase /></el-icon> <t-icon name="briefcase" size="24px" />
</div> </div>
<div class="stat-info"> <div class="stat-trend up">
<div class="stat-value">{{ stats.jobs }}</div> <t-icon name="arrow-up" size="14px" />
<div class="stat-label">职位数量</div> <span>8%</span>
</div> </div>
</el-card> </div>
</el-col> <div class="stat-value">{{ stats.jobs }}</div>
<div class="stat-label">职位数量</div>
<div class="stat-progress">
<div class="progress-bar green" style="width: 60%"></div>
</div>
</div>
<el-col :span="6"> <div class="stat-card stat-card-orange">
<el-card class="stat-card"> <div class="stat-header">
<div class="stat-icon" style="background: #E6A23C;"> <div class="stat-icon orange">
<el-icon size="32" color="#fff"><Avatar /></el-icon> <t-icon name="user-circle" size="24px" />
</div> </div>
<div class="stat-info"> <div class="stat-trend up">
<div class="stat-value">{{ stats.candidates }}</div> <t-icon name="arrow-up" size="14px" />
<div class="stat-label">候选人</div> <span>24%</span>
</div> </div>
</el-card> </div>
</el-col> <div class="stat-value">{{ stats.candidates }}</div>
<div class="stat-label">候选人</div>
<div class="stat-progress">
<div class="progress-bar orange" style="width: 85%"></div>
</div>
</div>
<el-col :span="6"> <div class="stat-card stat-card-pink">
<el-card class="stat-card"> <div class="stat-header">
<div class="stat-icon" style="background: #F56C6C;"> <div class="stat-icon pink">
<el-icon size="32" color="#fff"><Star /></el-icon> <t-icon name="star" size="24px" />
</div> </div>
<div class="stat-info"> <div class="stat-trend down">
<div class="stat-value">{{ stats.evaluations }}</div> <t-icon name="arrow-down" size="14px" />
<div class="stat-label">评价记录</div> <span>3%</span>
</div> </div>
</el-card> </div>
</el-col> <div class="stat-value">{{ stats.evaluations }}</div>
</el-row> <div class="stat-label">评价记录</div>
<div class="stat-progress">
<div class="progress-bar pink" style="width: 45%"></div>
</div>
</div>
</div>
<!-- 快捷操作 --> <!-- 主内容区 -->
<el-row :gutter="20" class="action-row"> <div class="content-grid">
<el-col :span="12"> <!-- 快捷操作 -->
<el-card> <t-card class="action-card">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>快捷操作</span> <span class="card-title">快捷操作</span>
<t-tag theme="primary" variant="light">常用</t-tag>
</div>
</template>
<div class="quick-actions">
<div class="action-item" @click="$router.push('/recruiters')">
<div class="action-icon-wrapper blue">
<t-icon name="user-add" size="24px" />
</div> </div>
</template> <div class="action-info">
<div class="quick-actions"> <span class="action-name">添加招聘者</span>
<el-button type="primary" @click="$router.push('/recruiters')"> <span class="action-desc">创建新的招聘账号</span>
<el-icon><Plus /></el-icon>添加招聘者 </div>
</el-button> <t-icon name="chevron-right" class="action-arrow" />
<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> </div>
</el-card> <div class="action-item" @click="$router.push('/jobs')">
</el-col> <div class="action-icon-wrapper green">
<t-icon name="add-circle" size="24px" />
</div>
<div class="action-info">
<span class="action-name">创建职位</span>
<span class="action-desc">发布新的招聘职位</span>
</div>
<t-icon name="chevron-right" class="action-arrow" />
</div>
<div class="action-item" @click="$router.push('/scheduler')">
<div class="action-icon-wrapper orange">
<t-icon name="play-circle" size="24px" />
</div>
<div class="action-info">
<span class="action-name">启动任务</span>
<span class="action-desc">开始自动化流程</span>
</div>
<t-icon name="chevron-right" class="action-arrow" />
</div>
</div>
</t-card>
<el-col :span="12"> <!-- 系统状态 -->
<el-card> <t-card class="status-card">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>系统状态</span> <span class="card-title">系统状态</span>
</div> <div class="live-indicator">
</template> <span class="live-dot"></span>
<div class="system-status"> <span>实时</span>
<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>
</div> </div>
</el-card> </template>
</el-col> <div class="system-status">
</el-row> <div class="status-item">
<div class="status-left">
<div class="status-icon-wrapper" :class="apiStatus === 'running' ? 'online' : 'offline'">
<t-icon name="server" size="20px" />
</div>
<div class="status-info">
<span class="status-name">API 服务</span>
<span class="status-desc">后端接口服务</span>
</div>
</div>
<t-tag :theme="apiStatus === 'running' ? 'success' : 'danger'" variant="light" shape="round">
{{ apiStatus === 'running' ? '运行中' : '异常' }}
</t-tag>
</div>
<div class="status-item">
<div class="status-left">
<div class="status-icon-wrapper" :class="schedulerStatus.running ? 'online' : 'offline'">
<t-icon name="time" size="20px" />
</div>
<div class="status-info">
<span class="status-name">调度器</span>
<span class="status-desc">定时任务服务</span>
</div>
</div>
<t-tag :theme="schedulerStatus.running ? 'success' : 'default'" variant="light" shape="round">
{{ schedulerStatus.running ? '运行中' : '已停止' }}
</t-tag>
</div>
<div class="status-item">
<div class="status-left">
<div class="status-icon-wrapper online">
<t-icon name="task" size="20px" />
</div>
<div class="status-info">
<span class="status-name">任务数量</span>
<span class="status-desc">当前任务总数</span>
</div>
</div>
<span class="status-count">{{ schedulerStatus.total_jobs || 0 }}</span>
</div>
</div>
</t-card>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { MessagePlugin } from 'tdesign-vue-next'
import { recruiterApi, schedulerApi, systemApi } from '@/api/api' import { recruiterApi, schedulerApi, systemApi } from '@/api/api'
const stats = ref({ const stats = ref({
@@ -124,11 +203,9 @@ const schedulerStatus = ref({})
const loadStats = async () => { const loadStats = async () => {
try { try {
// 获取招聘者数量
const recruiterRes = await recruiterApi.getList() const recruiterRes = await recruiterApi.getList()
stats.value.recruiters = recruiterRes.data?.total || 0 stats.value.recruiters = recruiterRes.data?.total || 0
// 获取调度器状态
const schedulerRes = await schedulerApi.getStatus() const schedulerRes = await schedulerApi.getStatus()
schedulerStatus.value = schedulerRes.data || {} schedulerStatus.value = schedulerRes.data || {}
} catch (error) { } catch (error) {
@@ -153,66 +230,246 @@ onMounted(() => {
<style scoped> <style scoped>
.dashboard { .dashboard {
padding: 20px; max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
} }
.page-title { .page-title {
margin-bottom: 24px; font-size: 28px;
font-size: 24px; font-weight: 700;
font-weight: 600; color: var(--text-primary);
color: #303133; margin-bottom: 8px;
} }
.stat-row { .page-subtitle {
margin-bottom: 24px; font-size: 14px;
color: var(--text-muted);
}
.header-actions {
display: flex;
gap: 12px;
}
/* 统计卡片网格 */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 32px;
} }
.stat-card { .stat-card {
background: var(--card-bg);
border-radius: 20px;
padding: 24px;
box-shadow: var(--shadow);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
border-radius: 50%;
opacity: 0.1;
transform: translate(30%, -30%);
}
.stat-card-blue::before { background: #5B6CFF; }
.stat-card-green::before { background: #10B981; }
.stat-card-orange::before { background: #F59E0B; }
.stat-card-pink::before { background: #EC4899; }
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.stat-header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 20px; margin-bottom: 16px;
} }
.stat-icon { .stat-icon {
width: 64px; width: 48px;
height: 64px; height: 48px;
border-radius: 8px; border-radius: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 16px; color: #fff;
} }
.stat-info { .stat-icon.blue { background: linear-gradient(135deg, #5B6CFF 0%, #8B5CF6 100%); }
flex: 1; .stat-icon.green { background: linear-gradient(135deg, #10B981 0%, #34D399 100%); }
.stat-icon.orange { background: linear-gradient(135deg, #F59E0B 0%, #FBBF24 100%); }
.stat-icon.pink { background: linear-gradient(135deg, #EC4899 0%, #F472B6 100%); }
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
}
.stat-trend.up {
background: rgba(16, 185, 129, 0.1);
color: #10B981;
}
.stat-trend.down {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
} }
.stat-value { .stat-value {
font-size: 28px; font-size: 36px;
font-weight: 700; font-weight: 700;
color: #303133; color: var(--text-primary);
line-height: 1; margin-bottom: 4px;
} }
.stat-label { .stat-label {
font-size: 14px; font-size: 14px;
color: #909399; color: var(--text-muted);
margin-top: 8px; margin-bottom: 16px;
} }
.action-row { .stat-progress {
margin-bottom: 24px; height: 6px;
background: #F3F4F6;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.progress-bar.blue { background: linear-gradient(90deg, #5B6CFF 0%, #8B5CF6 100%); }
.progress-bar.green { background: linear-gradient(90deg, #10B981 0%, #34D399 100%); }
.progress-bar.orange { background: linear-gradient(90deg, #F59E0B 0%, #FBBF24 100%); }
.progress-bar.pink { background: linear-gradient(90deg, #EC4899 0%, #F472B6 100%); }
/* 内容网格 */
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
} }
.card-header { .card-header {
font-weight: 600; display: flex;
font-size: 16px; justify-content: space-between;
align-items: center;
} }
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
/* 快捷操作 */
.quick-actions { .quick-actions {
display: flex; display: flex;
gap: 12px; flex-direction: column;
flex-wrap: wrap; gap: 16px;
}
.action-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #F9FAFB;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
}
.action-item:hover {
background: #F3F4F6;
transform: translateX(4px);
}
.action-icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.action-icon-wrapper.blue { background: linear-gradient(135deg, #5B6CFF 0%, #8B5CF6 100%); }
.action-icon-wrapper.green { background: linear-gradient(135deg, #10B981 0%, #34D399 100%); }
.action-icon-wrapper.orange { background: linear-gradient(135deg, #F59E0B 0%, #FBBF24 100%); }
.action-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.action-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.action-desc {
font-size: 13px;
color: var(--text-muted);
}
.action-arrow {
color: var(--text-muted);
}
/* 系统状态 */
.live-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--success-color);
font-weight: 500;
}
.live-dot {
width: 8px;
height: 8px;
background: var(--success-color);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }
.system-status { .system-status {
@@ -225,20 +482,65 @@ onMounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 0; padding: 16px;
border-bottom: 1px solid #EBEEF5; background: #F9FAFB;
border-radius: 16px;
} }
.status-item:last-child { .status-left {
border-bottom: none; display: flex;
align-items: center;
gap: 12px;
} }
.status-label { .status-icon-wrapper {
color: #606266; width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
} }
.status-value { .status-icon-wrapper.online {
background: linear-gradient(135deg, #10B981 0%, #34D399 100%);
}
.status-icon-wrapper.offline {
background: linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%);
}
.status-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.status-name {
font-size: 14px;
font-weight: 600; font-weight: 600;
color: #409EFF; color: var(--text-primary);
}
.status-desc {
font-size: 12px;
color: var(--text-muted);
}
.status-count {
font-size: 20px;
font-weight: 700;
color: var(--primary-color);
}
@media (max-width: 1200px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.content-grid {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@@ -2,203 +2,140 @@
<div class="jobs-page"> <div class="jobs-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">职位管理</h2> <h2 class="page-title">职位管理</h2>
<el-button type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>创建职位
</el-button>
</div> </div>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<el-card class="search-card"> <t-card class="search-card" :bordered="false">
<el-form :inline="true" :model="searchForm"> <t-form layout="inline" :data="searchForm">
<el-form-item label="关键词"> <t-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="标题/部门" clearable /> <t-input v-model="searchForm.keyword" placeholder="标题/部门" clearable style="width: 180px" />
</el-form-item> </t-form-item>
<el-form-item label="状态"> <t-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable> <t-select v-model="searchForm.status" placeholder="全部" clearable style="width: 150px">
<el-option label="进行中" value="active" /> <t-option label="进行中" value="active" />
<el-option label="已暂停" value="paused" /> <t-option label="已暂停" value="paused" />
<el-option label="已关闭" value="closed" /> <t-option label="已关闭" value="closed" />
<el-option label="已归档" value="archived" /> <t-option label="已归档" value="archived" />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item label="评价方案"> <t-form-item label="评价方案">
<el-select v-model="searchForm.evaluation_schema_id" placeholder="全部" clearable> <t-select v-model="searchForm.evaluation_schema_id" placeholder="全部" clearable style="width: 180px">
<el-option <t-option
v-for="schema in schemaList" v-for="schema in schemaList"
:key="schema.id" :key="schema.id"
:label="schema.name" :label="schema.name"
:value="schema.id" :value="schema.id"
/> />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item> <t-form-item>
<el-button type="primary" @click="handleSearch"> <t-space>
<el-icon><Search /></el-icon>搜索 <t-button theme="primary" @click="handleSearch">
</el-button> <template #icon><t-icon name="search" /></template>
<el-button @click="resetSearch">重置</el-button> 搜索
</el-form-item> </t-button>
</el-form> <t-button theme="default" @click="resetSearch">重置</t-button>
</el-card> </t-space>
</t-form-item>
</t-form>
</t-card>
<!-- 数据表格 --> <!-- 数据表格 -->
<el-card> <t-card :bordered="false">
<el-table :data="jobList" v-loading="loading" stripe> <t-table
<el-table-column prop="title" label="职位标题" min-width="200" show-overflow-tooltip /> :data="jobList"
<el-table-column prop="department" label="部门" width="120" /> :columns="columns"
<el-table-column prop="location" label="地点" width="120" /> :loading="loading"
<el-table-column label="薪资范围" width="150"> row-key="id"
<template #default="{ row }"> stripe
<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"> <div class="pagination">
<el-pagination <t-pagination
v-model:current-page="pagination.page" v-model="pagination.page"
v-model:page-size="pagination.pageSize" v-model:pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
layout="total, sizes, prev, pager, next" :page-size-options="[10, 20, 50]"
:page-sizes="[10, 20, 50]" @change="handlePageChange"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/> />
</div> </div>
</el-card> </t-card>
<!-- 添加/编辑对话框 --> <!-- 添加/编辑对话框 -->
<el-dialog <t-dialog
v-model="dialogVisible" v-model:visible="dialogVisible"
:title="isEdit ? '编辑职位' : '创建职位'" :header="isEdit ? '编辑职位' : '创建职位'"
width="600px" width="600px"
:confirm-btn="{ content: '确定', loading: submitting }"
:on-confirm="handleSubmit"
:on-close="() => dialogVisible = false"
> >
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px"> <t-form ref="formRef" :data="form" :rules="rules" :label-width="100">
<el-form-item label="职位标题" prop="title"> <t-form-item label="职位标题" name="title">
<el-input v-model="form.title" placeholder="请输入职位标题" /> <t-input v-model="form.title" placeholder="请输入职位标题" />
</el-form-item> </t-form-item>
<el-form-item label="部门" prop="department"> <t-form-item label="部门" name="department">
<el-input v-model="form.department" placeholder="请输入部门" /> <t-input v-model="form.department" placeholder="请输入部门" />
</el-form-item> </t-form-item>
<el-form-item label="工作地点" prop="location"> <t-form-item label="工作地点" name="location">
<el-input v-model="form.location" placeholder="请输入工作地点" /> <t-input v-model="form.location" placeholder="请输入工作地点" />
</el-form-item> </t-form-item>
<el-form-item label="薪资范围"> <t-form-item label="薪资范围">
<el-row :gutter="10"> <t-input-group>
<el-col :span="11"> <t-input-number v-model="form.salary_min" :min="0" placeholder="最低" style="width: 120px" />
<el-input-number v-model="form.salary_min" :min="0" placeholder="最低" style="width: 100%" /> <span style="padding: 0 8px">-</span>
</el-col> <t-input-number v-model="form.salary_max" :min="0" placeholder="最高" style="width: 120px" />
<el-col :span="2" style="text-align: center;">-</el-col> </t-input-group>
<el-col :span="11"> </t-form-item>
<el-input-number v-model="form.salary_max" :min="0" placeholder="最高" style="width: 100%" /> <t-form-item label="评价方案">
</el-col> <t-select v-model="form.evaluation_schema_id" placeholder="请选择评价方案" clearable style="width: 100%">
</el-row> <t-option
</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" v-for="schema in schemaList"
:key="schema.id" :key="schema.id"
:label="schema.name" :label="schema.name"
:value="schema.id" :value="schema.id"
/> />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item label="职位描述"> <t-form-item label="职位描述">
<el-input <t-textarea
v-model="form.description" v-model="form.description"
type="textarea"
:rows="4" :rows="4"
placeholder="请输入职位描述" placeholder="请输入职位描述"
/> />
</el-form-item> </t-form-item>
</el-form> </t-form>
<template #footer> </t-dialog>
<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"> <t-dialog
<el-form label-width="100px"> v-model:visible="bindDialogVisible"
<el-form-item label="选择方案"> header="关联评价方案"
<el-select v-model="bindSchemaId" placeholder="请选择评价方案" style="width: 100%"> width="500px"
<el-option :confirm-btn="{ content: '确定', loading: binding }"
:on-confirm="confirmBindSchema"
:on-close="() => bindDialogVisible = false"
>
<t-form :label-width="100">
<t-form-item label="选择方案">
<t-select v-model="bindSchemaId" placeholder="请选择评价方案" style="width: 100%">
<t-option
v-for="schema in schemaList" v-for="schema in schemaList"
:key="schema.id" :key="schema.id"
:label="schema.name" :label="schema.name"
:value="schema.id" :value="schema.id"
/> />
</el-select> </t-select>
</el-form-item> </t-form-item>
</el-form> </t-form>
<template #footer> </t-dialog>
<el-button @click="bindDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmBindSchema" :loading="binding">
确定
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { jobApi } from '@/api/api' import { jobApi } from '@/api/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -206,6 +143,79 @@ const loading = ref(false)
const jobList = ref([]) const jobList = ref([])
const schemaList = ref([]) const schemaList = ref([])
// 表格列定义
const columns = [
{ colKey: 'title', title: '职位标题', minWidth: 200, ellipsis: true },
{ colKey: 'department', title: '部门', width: 120 },
{ colKey: 'location', title: '地点', width: 120 },
{
colKey: 'salary',
title: '薪资范围',
width: 150,
cell: (h, { row }) => {
if (row.salary_min && row.salary_max) {
return `${row.salary_min}K - ${row.salary_max}K`
}
return h('span', { class: 'text-gray' }, '面议')
}
},
{
colKey: 'status',
title: '状态',
width: 100,
cell: (h, { row }) => {
const themeMap = { active: 'success', paused: 'warning', closed: 'default', archived: 'danger' }
const labelMap = { active: '进行中', paused: '已暂停', closed: '已关闭', archived: '已归档' }
return h('t-tag', { theme: themeMap[row.status] || 'default' }, labelMap[row.status] || row.status)
}
},
{
colKey: 'candidate_count',
title: '候选人',
width: 120,
cell: (h, { row }) => {
if (row.new_candidate_count > 0) {
return h('t-badge', { count: row.new_candidate_count, dot: false }, `${row.candidate_count || 0}`)
}
return `${row.candidate_count || 0}`
}
},
{
colKey: 'evaluation_schema',
title: '评价方案',
minWidth: 150,
cell: (h, { row }) => {
if (row.evaluation_schema_id) {
return h('div', { class: 'schema-tag' }, [
h('t-tag', { size: 'small', theme: 'success' }, getSchemaName(row.evaluation_schema_id)),
h('t-button', { size: 'small', variant: 'text', theme: 'primary', onClick: () => handleUnbindSchema(row) }, '解除')
])
}
return h('t-button', { size: 'small', variant: 'text', theme: 'primary', onClick: () => handleBindSchema(row) }, '关联方案')
}
},
{
colKey: 'last_sync_at',
title: '最后同步',
width: 160,
cell: (h, { row }) => formatTime(row.last_sync_at)
},
{
colKey: 'operation',
title: '操作',
width: 200,
fixed: 'right',
cell: (h, { row }) => {
return h('t-space', {}, {
default: () => [
h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'),
h('t-button', { size: 'small', theme: 'danger', onClick: () => handleDelete(row) }, '删除')
]
})
}
}
]
// 搜索表单 // 搜索表单
const searchForm = reactive({ const searchForm = reactive({
keyword: '', keyword: '',
@@ -263,7 +273,7 @@ const loadData = async () => {
jobList.value = res.data?.items || [] jobList.value = res.data?.items || []
pagination.total = res.data?.total || 0 pagination.total = res.data?.total || 0
} catch (error) { } catch (error) {
ElMessage.error('加载数据失败: ' + error.message) MessagePlugin.error('加载数据失败: ' + error.message)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -293,13 +303,7 @@ const resetSearch = () => {
} }
// 分页 // 分页
const handleSizeChange = (size) => { const handlePageChange = () => {
pagination.pageSize = size
loadData()
}
const handlePageChange = (page) => {
pagination.page = page
loadData() loadData()
} }
@@ -341,15 +345,15 @@ const handleSubmit = async () => {
try { try {
if (isEdit.value) { if (isEdit.value) {
await jobApi.update(form.id, form) await jobApi.update(form.id, form)
ElMessage.success('更新成功') MessagePlugin.success('更新成功')
} else { } else {
await jobApi.create(form) await jobApi.create(form)
ElMessage.success('创建成功') MessagePlugin.success('创建成功')
} }
dialogVisible.value = false dialogVisible.value = false
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('提交失败: ' + error.message) MessagePlugin.error('提交失败: ' + error.message)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -357,18 +361,22 @@ const handleSubmit = async () => {
// 删除 // 删除
const handleDelete = async (row) => { const handleDelete = async (row) => {
try { const confirmDia = DialogPlugin.confirm({
await ElMessageBox.confirm('确定要删除该职位吗?', '提示', { header: '确认删除',
type: 'warning' body: '确定要删除该职位吗?',
}) confirmBtn: '确定',
await jobApi.delete(row.id) cancelBtn: '取消',
ElMessage.success('删除成功') onConfirm: async () => {
loadData() try {
} catch (error) { await jobApi.delete(row.id)
if (error !== 'cancel') { MessagePlugin.success('删除成功')
ElMessage.error('删除失败: ' + error.message) loadData()
confirmDia.destroy()
} catch (error) {
MessagePlugin.error('删除失败: ' + error.message)
}
} }
} })
} }
// 关联评价方案 // 关联评价方案
@@ -380,17 +388,17 @@ const handleBindSchema = (row) => {
const confirmBindSchema = async () => { const confirmBindSchema = async () => {
if (!bindSchemaId.value) { if (!bindSchemaId.value) {
ElMessage.warning('请选择评价方案') MessagePlugin.warning('请选择评价方案')
return return
} }
binding.value = true binding.value = true
try { try {
await jobApi.bindSchema(currentJob.value.id, bindSchemaId.value) await jobApi.bindSchema(currentJob.value.id, bindSchemaId.value)
ElMessage.success('关联成功') MessagePlugin.success('关联成功')
bindDialogVisible.value = false bindDialogVisible.value = false
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('关联失败: ' + error.message) MessagePlugin.error('关联失败: ' + error.message)
} finally { } finally {
binding.value = false binding.value = false
} }
@@ -398,31 +406,25 @@ const confirmBindSchema = async () => {
// 解除关联 // 解除关联
const handleUnbindSchema = async (row) => { const handleUnbindSchema = async (row) => {
try { const confirmDia = DialogPlugin.confirm({
await ElMessageBox.confirm('确定要解除该职位的评价方案关联吗?', '提示', { header: '确认解除',
type: 'warning' body: '确定要解除该职位的评价方案关联吗?',
}) confirmBtn: '确定',
await jobApi.update(row.id, { evaluation_schema_id: null }) cancelBtn: '取消',
ElMessage.success('已解除关联') onConfirm: async () => {
loadData() try {
} catch (error) { await jobApi.update(row.id, { evaluation_schema_id: null })
if (error !== 'cancel') { MessagePlugin.success('已解除关联')
ElMessage.error('操作失败: ' + error.message) loadData()
confirmDia.destroy()
} catch (error) {
MessagePlugin.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 getSchemaName = (id) => {
const schema = schemaList.value.find(s => s.id === id) const schema = schemaList.value.find(s => s.id === id)
return schema?.name || id return schema?.name || id
@@ -440,24 +442,30 @@ onMounted(() => {
<style scoped> <style scoped>
.jobs-page { .jobs-page {
padding: 20px; max-width: 1400px;
margin: 0 auto;
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 24px;
} }
.page-title { .page-title {
font-size: 20px; font-size: 24px;
font-weight: 600; font-weight: 700;
color: #303133; color: var(--text-primary);
} }
.search-card { .search-card {
margin-bottom: 20px; margin-bottom: 24px;
border-radius: 20px !important;
}
.search-card :deep(.t-card__body) {
padding: 20px 24px;
} }
.schema-tag { .schema-tag {
@@ -467,12 +475,20 @@ onMounted(() => {
} }
.text-gray { .text-gray {
color: #909399; color: var(--text-muted);
} }
.pagination { .pagination {
margin-top: 20px; margin-top: 24px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
:deep(.t-table) {
border-radius: 16px !important;
}
:deep(.t-card__body) {
padding: 24px;
}
</style> </style>

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

@@ -2,168 +2,291 @@
<div class="recruiters-page"> <div class="recruiters-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">招聘者管理</h2> <h2 class="page-title">招聘者管理</h2>
<el-button type="primary" @click="showAddDialog"> <t-button theme="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>添加招聘者 <template #icon><t-icon name="add" /></template>
</el-button> 添加招聘者
</t-button>
</div> </div>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<el-card class="search-card"> <t-card class="search-card" :bordered="false">
<el-form :inline="true" :model="searchForm"> <t-form layout="inline" :data="searchForm">
<el-form-item label="平台来源"> <t-form-item label="平台来源">
<el-select v-model="searchForm.source" placeholder="全部" clearable> <t-select v-model="searchForm.source" placeholder="全部" clearable style="width: 150px">
<el-option label="Boss直聘" value="boss" /> <t-option label="Boss直聘" value="boss" />
<el-option label="猎聘" value="liepin" /> <t-option label="猎聘" value="liepin" />
<el-option label="智联招聘" value="zhilian" /> <t-option label="智联招聘" value="zhilian" />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item> <t-form-item>
<el-button type="primary" @click="handleSearch"> <t-space>
<el-icon><Search /></el-icon>搜索 <t-button theme="primary" @click="handleSearch">
</el-button> <template #icon><t-icon name="search" /></template>
<el-button @click="resetSearch">重置</el-button> 搜索
</el-form-item> </t-button>
</el-form> <t-button theme="default" @click="resetSearch">重置</t-button>
</el-card> </t-space>
</t-form-item>
</t-form>
</t-card>
<!-- 数据表格 --> <!-- 数据表格 -->
<el-card> <t-card :bordered="false">
<el-table :data="recruiterList" v-loading="loading" stripe> <t-table
<el-table-column prop="name" label="账号名称" min-width="150" /> :data="recruiterList"
<el-table-column prop="source" label="平台" width="100"> :columns="columns"
<template #default="{ row }"> :loading="loading"
<el-tag :type="getSourceType(row.source)"> row-key="id"
{{ getSourceLabel(row.source) }} stripe
</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"> <div class="pagination">
<el-pagination <t-pagination
v-model:current-page="pagination.page" v-model="pagination.page"
v-model:page-size="pagination.pageSize" v-model:pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
layout="total, sizes, prev, pager, next" :page-size-options="[10, 20, 50]"
:page-sizes="[10, 20, 50]" @change="handlePageChange"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/> />
</div> </div>
</el-card> </t-card>
<!-- 添加/编辑对话框 --> <!-- 添加/编辑对话框 -->
<el-dialog <t-dialog
v-model="dialogVisible" v-model:visible="dialogVisible"
:title="isEdit ? '编辑招聘者' : '添加招聘者'" :header="isEdit ? '编辑招聘者' : '添加招聘者'"
width="500px" width="500px"
:confirm-btn="{ content: '确定', loading: submitting }"
:on-confirm="handleSubmit"
:on-close="() => dialogVisible = false"
> >
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px"> <t-form ref="formRef" :data="form" :rules="rules" :label-width="100">
<el-form-item label="账号名称" prop="name"> <t-form-item label="账号名称" name="name">
<el-input v-model="form.name" placeholder="请输入账号名称" /> <t-input v-model="form.name" placeholder="请输入账号名称" />
</el-form-item> </t-form-item>
<el-form-item label="平台来源" prop="source"> <t-form-item label="平台来源" name="source">
<el-select v-model="form.source" placeholder="请选择平台" style="width: 100%"> <t-select v-model="form.source" placeholder="请选择平台" style="width: 100%">
<el-option label="Boss直聘" value="boss" /> <t-option label="Boss直聘" value="boss" />
<el-option label="猎聘" value="liepin" /> <t-option label="猎聘" value="liepin" />
<el-option label="智联招聘" value="zhilian" /> <t-option label="智联招聘" value="zhilian" />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item label="WT Token" prop="wt_token"> <t-form-item label="WT Token" name="wt_token">
<el-input <t-textarea
v-model="form.wt_token" v-model="form.wt_token"
type="textarea"
:rows="3" :rows="3"
placeholder="请输入WT Token" placeholder="请输入WT Token"
/> />
</el-form-item> </t-form-item>
</el-form> </t-form>
<template #footer> </t-dialog>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
<!-- 自动注册对话框 --> <!-- 自动注册对话框 -->
<el-dialog <t-dialog
v-model="registerDialogVisible" v-model:visible="registerDialogVisible"
title="自动注册招聘者" header="自动注册招聘者"
width="500px" width="500px"
:confirm-btn="{ content: '自动注册', loading: registering }"
:on-confirm="handleRegister"
:on-close="() => registerDialogVisible = false"
> >
<el-form :model="registerForm" :rules="registerRules" ref="registerFormRef" label-width="100px"> <t-form ref="registerFormRef" :data="registerForm" :rules="registerRules" :label-width="100">
<el-form-item label="平台来源" prop="source"> <t-form-item label="平台来源" name="source">
<el-select v-model="registerForm.source" placeholder="请选择平台" style="width: 100%"> <t-select v-model="registerForm.source" placeholder="请选择平台" style="width: 100%">
<el-option label="Boss直聘" value="boss" /> <t-option label="Boss直聘" value="boss" />
</el-select> </t-select>
</el-form-item> </t-form-item>
<el-form-item label="WT Token" prop="wt_token"> <t-form-item label="WT Token" name="wt_token">
<el-input <t-textarea
v-model="registerForm.wt_token" v-model="registerForm.wt_token"
type="textarea"
:rows="3" :rows="3"
placeholder="请输入WT Token系统将自动获取账号信息" placeholder="请输入WT Token系统将自动获取账号信息"
/> />
</el-form-item> </t-form-item>
</el-form> </t-form>
<template #footer> </t-dialog>
<el-button @click="registerDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleRegister" :loading="registering"> <!-- 通知渠道绑定对话框 -->
自动注册 <t-dialog
</el-button> v-model:visible="channelBindDialogVisible"
</template> header="绑定通知渠道"
</el-dialog> 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 } from 'vue' import { ref, reactive, onMounted, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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)
const recruiterList = ref([]) const recruiterList = ref([])
// 表格列定义
const columns = [
{ colKey: 'name', title: '账号名称', width: 150 },
{
colKey: 'source',
title: '平台',
width: 100,
cell: (h, { row }) => {
const themeMap = { boss: 'danger', liepin: 'primary', zhilian: 'success' }
const labelMap = { boss: 'Boss直聘', liepin: '猎聘', zhilian: '智联招聘' }
return h('t-tag', { theme: themeMap[row.source] || 'default' }, labelMap[row.source] || row.source)
}
},
{
colKey: 'status',
title: '状态',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.status === 'active' ? 'success' : 'default' },
row.status === 'active' ? '启用' : '停用')
}
},
{
colKey: 'privilege',
title: '权益信息',
minWidth: 200,
cell: (h, { row }) => {
if (row.privilege) {
return h('div', { class: 'privilege-info' }, [
h('div', `VIP: ${row.privilege.vip_level || '无'}`),
h('div', `剩余简历: ${row.privilege.resume_view_count || 0}`)
])
}
return h('span', { class: 'text-gray' }, '暂无权益信息')
}
},
{
colKey: 'last_sync_at',
title: '最后同步',
width: 180,
cell: (h, { row }) => formatTime(row.last_sync_at)
},
{
colKey: 'operation',
title: '操作',
width: 340,
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: () => handleSync(row) }, '同步'),
h('t-button', { size: 'small', onClick: () => handleChannelBind(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 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: ''
@@ -208,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
@@ -220,7 +358,7 @@ const loadData = async () => {
recruiterList.value = res.data?.items || [] recruiterList.value = res.data?.items || []
pagination.total = res.data?.total || 0 pagination.total = res.data?.total || 0
} catch (error) { } catch (error) {
ElMessage.error('加载数据失败: ' + error.message) MessagePlugin.error('加载数据失败: ' + error.message)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -238,13 +376,7 @@ const resetSearch = () => {
} }
// 分页 // 分页
const handleSizeChange = (size) => { const handlePageChange = () => {
pagination.pageSize = size
loadData()
}
const handlePageChange = (page) => {
pagination.page = page
loadData() loadData()
} }
@@ -277,15 +409,15 @@ const handleSubmit = async () => {
try { try {
if (isEdit.value) { if (isEdit.value) {
await recruiterApi.update(form.id, form) await recruiterApi.update(form.id, form)
ElMessage.success('更新成功') MessagePlugin.success('更新成功')
} else { } else {
await recruiterApi.create(form) await recruiterApi.create(form)
ElMessage.success('创建成功') MessagePlugin.success('创建成功')
} }
dialogVisible.value = false dialogVisible.value = false
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('提交失败: ' + error.message) MessagePlugin.error('提交失败: ' + error.message)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -293,27 +425,31 @@ const handleSubmit = async () => {
// 删除 // 删除
const handleDelete = async (row) => { const handleDelete = async (row) => {
try { const confirmDia = DialogPlugin.confirm({
await ElMessageBox.confirm('确定要删除该招聘者吗?', '提示', { header: '确认删除',
type: 'warning' body: '确定要删除该招聘者吗?',
}) confirmBtn: '确定',
await recruiterApi.delete(row.id) cancelBtn: '取消',
ElMessage.success('删除成功') onConfirm: async () => {
loadData() try {
} catch (error) { await recruiterApi.delete(row.id)
if (error !== 'cancel') { MessagePlugin.success('删除成功')
ElMessage.error('删除失败: ' + error.message) loadData()
confirmDia.destroy()
} catch (error) {
MessagePlugin.error('删除失败: ' + error.message)
}
} }
} })
} }
// 同步 // 同步
const handleSync = async (row) => { const handleSync = async (row) => {
try { try {
await recruiterApi.sync(row.id) await recruiterApi.sync(row.id)
ElMessage.success('同步任务已触发') MessagePlugin.success('同步任务已触发')
} catch (error) { } catch (error) {
ElMessage.error('同步失败: ' + error.message) MessagePlugin.error('同步失败: ' + error.message)
} }
} }
@@ -322,14 +458,14 @@ const toggleStatus = async (row) => {
try { try {
if (row.status === 'active') { if (row.status === 'active') {
await recruiterApi.deactivate(row.id) await recruiterApi.deactivate(row.id)
ElMessage.success('已停用') MessagePlugin.success('已停用')
} else { } else {
await recruiterApi.activate(row.id) await recruiterApi.activate(row.id)
ElMessage.success('已启用') MessagePlugin.success('已启用')
} }
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('操作失败: ' + error.message) MessagePlugin.error('操作失败: ' + error.message)
} }
} }
@@ -342,34 +478,121 @@ const handleRegister = async () => {
try { try {
const res = await recruiterApi.register(registerForm) const res = await recruiterApi.register(registerForm)
if (res.data?.success) { if (res.data?.success) {
ElMessage.success('注册成功: ' + res.data?.message) MessagePlugin.success('注册成功: ' + res.data?.message)
registerDialogVisible.value = false registerDialogVisible.value = false
loadData() loadData()
} else { } else {
ElMessage.warning(res.data?.message || '注册失败') MessagePlugin.warning(res.data?.message || '注册失败')
} }
} catch (error) { } catch (error) {
ElMessage.error('注册失败: ' + error.message) MessagePlugin.error('注册失败: ' + error.message)
} finally { } finally {
registering.value = false 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) => { 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()
}) })
@@ -377,39 +600,59 @@ onMounted(() => {
<style scoped> <style scoped>
.recruiters-page { .recruiters-page {
padding: 20px; max-width: 1400px;
margin: 0 auto;
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 24px;
} }
.page-title { .page-title {
font-size: 20px; font-size: 24px;
font-weight: 600; font-weight: 700;
color: #303133; color: var(--text-primary);
} }
.search-card { .search-card {
margin-bottom: 20px; margin-bottom: 24px;
border-radius: 20px !important;
}
.search-card :deep(.t-card__body) {
padding: 20px 24px;
} }
.privilege-info { .privilege-info {
font-size: 13px; font-size: 13px;
color: #606266; color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
} }
.text-gray { .text-gray {
color: #909399; color: var(--text-muted);
} }
.pagination { .pagination {
margin-top: 20px; margin-top: 24px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
:deep(.t-table) {
border-radius: 16px !important;
}
:deep(.t-card__body) {
padding: 24px;
}
.score-value {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
}
</style> </style>

View File

@@ -2,158 +2,111 @@
<div class="scheduler-page"> <div class="scheduler-page">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">定时任务管理</h2> <h2 class="page-title">定时任务管理</h2>
<el-button-group> <t-space>
<el-button type="success" @click="handleStart" :disabled="schedulerStatus.running"> <t-button theme="success" @click="handleStart" :disabled="schedulerStatus.running">
<el-icon><VideoPlay /></el-icon>启动调度器 <template #icon><t-icon name="play-circle" /></template>
</el-button> 启动调度器
<el-button type="danger" @click="handleStop" :disabled="!schedulerStatus.running"> </t-button>
<el-icon><VideoPause /></el-icon>停止调度器 <t-button theme="danger" @click="handleStop" :disabled="!schedulerStatus.running">
</el-button> <template #icon><t-icon name="pause-circle" /></template>
</el-button-group> 停止调度器
</t-button>
</t-space>
</div> </div>
<!-- 调度器状态 --> <!-- 调度器状态 -->
<el-row :gutter="20" class="status-row"> <t-row :gutter="16" class="status-row">
<el-col :span="6"> <t-col :span="3">
<el-card class="status-card"> <t-card class="status-card" :bordered="false">
<div class="status-icon" :class="schedulerStatus.running ? 'running' : 'stopped'"> <div class="status-icon" :class="schedulerStatus.running ? 'running' : 'stopped'">
<el-icon size="32" color="#fff"><Timer /></el-icon> <t-icon name="time" size="32px" color="#fff" />
</div> </div>
<div class="status-info"> <div class="status-info">
<div class="status-value">{{ schedulerStatus.running ? '运行中' : '已停止' }}</div> <div class="status-value">{{ schedulerStatus.running ? '运行中' : '已停止' }}</div>
<div class="status-label">调度器状态</div> <div class="status-label">调度器状态</div>
</div> </div>
</el-card> </t-card>
</el-col> </t-col>
<el-col :span="6"> <t-col :span="3">
<el-card class="status-card"> <t-card class="status-card" :bordered="false">
<div class="status-icon" style="background: #409EFF;"> <div class="status-icon" style="background: #0052D9;">
<el-icon size="32" color="#fff"><List /></el-icon> <t-icon name="list" size="32px" color="#fff" />
</div> </div>
<div class="status-info"> <div class="status-info">
<div class="status-value">{{ schedulerStatus.total_jobs || 0 }}</div> <div class="status-value">{{ schedulerStatus.total_jobs || 0 }}</div>
<div class="status-label">任务总数</div> <div class="status-label">任务总数</div>
</div> </div>
</el-card> </t-card>
</el-col> </t-col>
<el-col :span="6"> <t-col :span="3">
<el-card class="status-card"> <t-card class="status-card" :bordered="false">
<div class="status-icon" style="background: #67C23A;"> <div class="status-icon" style="background: #00A870;">
<el-icon size="32" color="#fff"><CircleCheck /></el-icon> <t-icon name="check-circle" size="32px" color="#fff" />
</div> </div>
<div class="status-info"> <div class="status-info">
<div class="status-value">{{ schedulerStatus.job_status_summary?.enabled || 0 }}</div> <div class="status-value">{{ schedulerStatus.job_status_summary?.enabled || 0 }}</div>
<div class="status-label">已启用任务</div> <div class="status-label">已启用任务</div>
</div> </div>
</el-card> </t-card>
</el-col> </t-col>
<el-col :span="6"> <t-col :span="3">
<el-card class="status-card"> <t-card class="status-card" :bordered="false">
<div class="status-icon" style="background: #E6A23C;"> <div class="status-icon" style="background: #EBB105;">
<el-icon size="32" color="#fff"><Loading /></el-icon> <t-icon name="loading" size="32px" color="#fff" />
</div> </div>
<div class="status-info"> <div class="status-info">
<div class="status-value">{{ schedulerStatus.job_status_summary?.running || 0 }}</div> <div class="status-value">{{ schedulerStatus.job_status_summary?.running || 0 }}</div>
<div class="status-label">正在运行</div> <div class="status-label">正在运行</div>
</div> </div>
</el-card> </t-card>
</el-col> </t-col>
</el-row> </t-row>
<!-- 任务列表 --> <!-- 任务列表 -->
<el-card> <t-card title="任务列表" :bordered="false">
<template #header> <template #actions>
<div class="card-header"> <t-button theme="primary" size="small" @click="loadData" :loading="loading">
<span>任务列表</span> <template #icon><t-icon name="refresh" /></template>
<el-button type="primary" size="small" @click="loadData" :loading="loading"> 刷新
<el-icon><Refresh /></el-icon>刷新 </t-button>
</el-button>
</div>
</template> </template>
<el-table :data="jobList" v-loading="loading" stripe> <t-table
<el-table-column prop="job_id" label="任务ID" min-width="150" /> :data="jobList"
<el-table-column prop="name" label="任务名称" min-width="150" /> :columns="columns"
<el-table-column label="状态" width="100"> :loading="loading"
<template #default="{ row }"> row-key="job_id"
<el-tag :type="row.enabled ? 'success' : 'info'" size="small"> stripe
{{ row.enabled ? '启用' : '禁用' }} />
</el-tag> </t-card>
</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"> <t-dialog
<el-form :model="configForm" label-width="120px"> v-model:visible="configDialogVisible"
<el-form-item label="任务ID"> header="任务配置"
<el-input v-model="configForm.job_id" disabled /> width="500px"
</el-form-item> :confirm-btn="{ content: '保存', loading: configuring }"
<el-form-item label="启用状态"> :on-confirm="handleSubmitConfig"
<el-switch v-model="configForm.enabled" /> :on-close="() => configDialogVisible = false"
</el-form-item> >
<el-form-item label="执行间隔(分钟)"> <t-form :data="configForm" :label-width="120">
<el-input-number v-model="configForm.interval_minutes" :min="1" :max="1440" /> <t-form-item label="任务ID">
</el-form-item> <t-input v-model="configForm.job_id" disabled />
</el-form> </t-form-item>
<template #footer> <t-form-item label="启用状态">
<el-button @click="configDialogVisible = false">取消</el-button> <t-switch v-model="configForm.enabled" />
<el-button type="primary" @click="handleSubmitConfig" :loading="configuring"> </t-form-item>
保存 <t-form-item label="执行间隔(分钟)">
</el-button> <t-input-number v-model="configForm.interval_minutes" :min="1" :max="1440" />
</template> </t-form-item>
</el-dialog> </t-form>
</t-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { schedulerApi } from '@/api/api' import { schedulerApi } from '@/api/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -161,6 +114,83 @@ const loading = ref(false)
const jobList = ref([]) const jobList = ref([])
const schedulerStatus = ref({}) const schedulerStatus = ref({})
// 表格列定义
const columns = [
{ colKey: 'job_id', title: '任务ID', minWidth: 150 },
{ colKey: 'name', title: '任务名称', minWidth: 150 },
{
colKey: 'enabled',
title: '状态',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.enabled ? 'success' : 'default', size: 'small' },
row.enabled ? '启用' : '禁用')
}
},
{
colKey: 'is_running',
title: '运行状态',
width: 100,
cell: (h, { row }) => {
return h('t-tag', { theme: row.is_running ? 'warning' : 'default', size: 'small', variant: 'dark' },
row.is_running ? '运行中' : '空闲')
}
},
{
colKey: 'stats',
title: '执行统计',
minWidth: 200,
cell: (h, { row }) => {
return h('t-space', { size: 'small' }, {
default: () => [
h('t-tag', { size: 'small', theme: 'success' }, `成功: ${row.success_count || 0}`),
h('t-tag', { size: 'small', theme: 'danger' }, `失败: ${row.fail_count || 0}`),
h('t-tag', { size: 'small', theme: 'default' }, `总计: ${row.run_count || 0}`)
]
})
}
},
{
colKey: 'last_run_time',
title: '最后执行',
width: 160,
cell: (h, { row }) => formatTime(row.last_run_time)
},
{
colKey: 'next_run_time',
title: '下次执行',
width: 160,
cell: (h, { row }) => formatTime(row.next_run_time)
},
{
colKey: 'operation',
title: '操作',
width: 280,
fixed: 'right',
cell: (h, { row }) => {
return h('t-space', { size: 'small' }, {
default: () => [
h('t-button', {
size: 'small',
theme: 'primary',
onClick: () => handleRun(row),
loading: row.is_running
}, {
default: () => [h('t-icon', { name: 'play-circle' }), ' 执行']
}),
h('t-button', {
size: 'small',
theme: row.enabled ? 'warning' : 'success',
onClick: () => toggleJobStatus(row),
disabled: row.is_running
}, row.enabled ? '暂停' : '恢复'),
h('t-button', { size: 'small', onClick: () => handleConfig(row) }, '配置')
]
})
}
}
]
// 配置对话框 // 配置对话框
const configDialogVisible = ref(false) const configDialogVisible = ref(false)
const configuring = ref(false) const configuring = ref(false)
@@ -181,7 +211,7 @@ const loadData = async () => {
jobList.value = jobsRes.data || [] jobList.value = jobsRes.data || []
schedulerStatus.value = statusRes.data || {} schedulerStatus.value = statusRes.data || {}
} catch (error) { } catch (error) {
ElMessage.error('加载数据失败: ' + error.message) MessagePlugin.error('加载数据失败: ' + error.message)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -191,37 +221,42 @@ const loadData = async () => {
const handleStart = async () => { const handleStart = async () => {
try { try {
await schedulerApi.start() await schedulerApi.start()
ElMessage.success('调度器已启动') MessagePlugin.success('调度器已启动')
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('启动失败: ' + error.message) MessagePlugin.error('启动失败: ' + error.message)
} }
} }
// 停止调度器 // 停止调度器
const handleStop = async () => { const handleStop = async () => {
try { const confirmDia = DialogPlugin.confirm({
await ElMessageBox.confirm('确定要停止调度器吗?正在运行的任务将被中断。', '警告', { header: '警告',
type: 'warning' body: '确定要停止调度器吗?正在运行的任务将被中断。',
}) theme: 'warning',
await schedulerApi.stop() confirmBtn: '确定',
ElMessage.success('调度器已停止') cancelBtn: '取消',
loadData() onConfirm: async () => {
} catch (error) { try {
if (error !== 'cancel') { await schedulerApi.stop()
ElMessage.error('停止失败: ' + error.message) MessagePlugin.success('调度器已停止')
loadData()
confirmDia.destroy()
} catch (error) {
MessagePlugin.error('停止失败: ' + error.message)
}
} }
} })
} }
// 立即执行任务 // 立即执行任务
const handleRun = async (row) => { const handleRun = async (row) => {
try { try {
await schedulerApi.runJob(row.job_id) await schedulerApi.runJob(row.job_id)
ElMessage.success('任务已开始执行') MessagePlugin.success('任务已开始执行')
setTimeout(loadData, 1000) setTimeout(loadData, 1000)
} catch (error) { } catch (error) {
ElMessage.error('执行失败: ' + error.message) MessagePlugin.error('执行失败: ' + error.message)
} }
} }
@@ -230,14 +265,14 @@ const toggleJobStatus = async (row) => {
try { try {
if (row.enabled) { if (row.enabled) {
await schedulerApi.pauseJob(row.job_id) await schedulerApi.pauseJob(row.job_id)
ElMessage.success('任务已暂停') MessagePlugin.success('任务已暂停')
} else { } else {
await schedulerApi.resumeJob(row.job_id) await schedulerApi.resumeJob(row.job_id)
ElMessage.success('任务已恢复') MessagePlugin.success('任务已恢复')
} }
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('操作失败: ' + error.message) MessagePlugin.error('操作失败: ' + error.message)
} }
} }
@@ -257,11 +292,11 @@ const handleSubmitConfig = async () => {
enabled: configForm.enabled, enabled: configForm.enabled,
interval_minutes: configForm.interval_minutes interval_minutes: configForm.interval_minutes
}) })
ElMessage.success('配置已更新') MessagePlugin.success('配置已更新')
configDialogVisible.value = false configDialogVisible.value = false
loadData() loadData()
} catch (error) { } catch (error) {
ElMessage.error('更新失败: ' + error.message) MessagePlugin.error('更新失败: ' + error.message)
} finally { } finally {
configuring.value = false configuring.value = false
} }
@@ -281,20 +316,21 @@ onMounted(() => {
<style scoped> <style scoped>
.scheduler-page { .scheduler-page {
padding: 20px; max-width: 1400px;
margin: 0 auto;
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 24px;
} }
.page-title { .page-title {
font-size: 20px; font-size: 24px;
font-weight: 600; font-weight: 700;
color: #303133; color: var(--text-primary);
} }
.status-row { .status-row {
@@ -302,27 +338,32 @@ onMounted(() => {
} }
.status-card { .status-card {
border-radius: 20px !important;
}
.status-card :deep(.t-card__body) {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 20px; padding: 24px;
} }
.status-icon { .status-icon {
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 8px; border-radius: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 16px; margin-right: 16px;
color: #fff;
} }
.status-icon.running { .status-icon.running {
background: #67C23A; background: linear-gradient(135deg, #10B981 0%, #34D399 100%);
} }
.status-icon.stopped { .status-icon.stopped {
background: #F56C6C; background: linear-gradient(135deg, #EF4444 0%, #F87171 100%);
} }
.status-info { .status-info {
@@ -332,26 +373,21 @@ onMounted(() => {
.status-value { .status-value {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #303133; color: var(--text-primary);
line-height: 1; line-height: 1;
} }
.status-label { .status-label {
font-size: 14px; font-size: 14px;
color: #909399; color: var(--text-muted);
margin-top: 8px; margin-top: 8px;
} }
.card-header { :deep(.t-table) {
display: flex; border-radius: 16px !important;
justify-content: space-between;
align-items: center;
font-weight: 600;
} }
.stats { :deep(.t-card__body) {
display: flex; padding: 24px;
gap: 8px;
flex-wrap: wrap;
} }
</style> </style>