feat(notification): 新增通知渠道及绑定管理功能

- 新增数据库表 notification_channels, recruiter_channel_bindings 支持多渠道通知绑定
- 在 notifications 表中新增 channel_id 关联通知渠道
- 增加默认通知渠道示例数据插入脚本(企业微信、钉钉、飞书)
- 实现 NotificationChannel 和 RecruiterChannelBinding 两个ORM模型及关联关系
- 增加通知渠道管理API,支持增删改查及启用停用操作
- 实现通知渠道类型枚举及配置验证
- 新增招聘者与通知渠道绑定管理路由,支持绑定关系创建、更新和删除
- 在招聘者模块中集成通知渠道绑定管理相关接口
- 增加对应的请求参数、响应模型及数据校验模型
- 更新数据库配置和依赖注入,支持通知渠道服务
- 完善接口响应的错误处理和成功提示信息
- 保证所有新增代码符合项目代码风格和结构规范
This commit is contained in:
2026-03-25 10:39:33 +08:00
parent 91b6808d45
commit eedaac69b0
22 changed files with 3499 additions and 911 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 (
id VARCHAR(64) PRIMARY KEY,
candidate_id VARCHAR(64) NOT NULL,
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,
status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED
error_message TEXT,
@@ -177,13 +214,36 @@ CREATE TABLE IF NOT EXISTS notifications (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE,
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_channel_id (channel_id),
INDEX idx_status (status),
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
('general', '通用评价方案', '适用于各类岗位的通用评价方案',

View File

@@ -1,8 +1,8 @@
"""Database configuration using SQLAlchemy"""
from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON, Boolean
from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship
from sqlalchemy.sql import func
from typing import Optional
from typing import Optional, List
Base = declarative_base()
@@ -32,6 +32,9 @@ class RecruiterModel(Base):
created_at = Column(DateTime, server_default=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):
@@ -139,6 +142,48 @@ class EvaluationModel(Base):
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):
"""通知记录表"""
__tablename__ = 'notifications'
@@ -146,7 +191,8 @@ class NotificationModel(Base):
id = Column(String(64), primary_key=True)
candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False)
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)
status = Column(String(32), default='PENDING')
error_message = Column(Text)

View File

@@ -10,7 +10,7 @@ FastAPI主应用入口
from fastapi import FastAPI
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
@@ -49,6 +49,9 @@ def create_app() -> FastAPI:
# 职位管理路由
app.include_router(job_router)
# 通知渠道管理路由
app.include_router(notification_channel_router)
return app

View File

@@ -6,6 +6,7 @@ API路由模块
- scheduler: 定时任务管理
- system: 系统接口
- candidate: 候选人管理
- notification_channel: 通知渠道管理
"""
from .recruiter import router as recruiter_router
@@ -35,10 +36,17 @@ except ImportError:
from fastapi import 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__ = [
"recruiter_router",
"scheduler_router",
"system_router",
"candidate_router",
"job_router"
"job_router",
"notification_channel_router"
]

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,
RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse,
RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo,
BaseResponse
RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate,
RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse,
RecruiterWithChannelsResponse, BaseResponse
)
from ...domain.candidate import CandidateSource
from ...domain.recruiter import Recruiter, RecruiterStatus
from ...domain.notification_channel import RecruiterChannelBinding
from ...service.recruiter_service import RecruiterService
from ...service.crawler import BossCrawler
from ...mapper.recruiter_mapper import RecruiterMapper
from ...mapper.notification_channel_mapper import NotificationChannelMapper
from ...config.settings import get_settings
@@ -32,6 +36,12 @@ def get_recruiter_service():
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:
"""构建招聘者账号响应"""
# 构建权益信息
@@ -357,3 +367,164 @@ async def sync_recruiter(
)
except Exception as 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

@@ -305,3 +305,122 @@ class EvaluationSchemaListResponse(BaseModel):
"""评价方案列表响应"""
total: int
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]

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

@@ -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

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

@@ -10,17 +10,19 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"dayjs": "^1.11.0"
"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": {
"@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:
specifier: ^2.1.0
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:
specifier: ^3.4.0
version: 3.5.30
@@ -55,6 +61,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
@@ -300,6 +310,9 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@rollup/rollup-android-arm-eabi@4.60.0':
resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
cpu: [arm]
@@ -437,6 +450,15 @@ packages:
'@types/lodash@4.17.24':
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':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -647,6 +669,9 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -695,10 +720,31 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
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:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -767,6 +813,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@babel/runtime@7.29.2': {}
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
@@ -921,6 +969,8 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@popperjs/core@2.11.8': {}
'@rollup/rollup-android-arm-eabi@4.60.0':
optional: true
@@ -1006,6 +1056,12 @@ snapshots:
'@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': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(sass@1.98.0))(vue@3.5.30)':
@@ -1271,6 +1327,8 @@ snapshots:
dependencies:
mime-db: 1.52.0
mitt@3.0.1: {}
nanoid@3.3.11: {}
node-addon-api@7.1.1:
@@ -1340,8 +1398,36 @@ snapshots:
optionalDependencies:
'@parcel/watcher': 2.5.6
sortablejs@1.15.7: {}
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):
dependencies:
esbuild: 0.21.5

View File

@@ -3,6 +3,40 @@
</template>
<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;
padding: 0;
@@ -10,9 +44,123 @@
}
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;
-webkit-font-smoothing: antialiased;
-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>

View File

@@ -1,63 +1,93 @@
<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">
<el-icon size="28" color="#409EFF"><Briefcase /></el-icon>
<div class="logo-icon">
<t-icon name="briefcase" size="24px" />
</div>
<span class="logo-text">简历智能体</span>
</div>
<el-menu
:default-active="$route.path"
router
<t-menu
:value="$route.path"
class="sidebar-menu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
@change="handleMenuChange"
>
<el-menu-item v-for="route in menuRoutes" :key="route.path" :index="route.path">
<el-icon>
<component :is="route.meta.icon" />
</el-icon>
<span>{{ route.meta.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<t-menu-item v-for="route in menuRoutes" :key="route.path" :value="route.path">
<template #icon>
<div class="menu-icon-wrapper" :class="{ active: $route.path === route.path }">
<t-icon :name="route.meta.icon" />
</div>
</template>
<span class="menu-text">{{ route.meta.title }}</span>
</t-menu-item>
</t-menu>
<!-- 升级卡片 -->
<div class="upgrade-card">
<div class="upgrade-icon">
<t-icon name="secured" size="48px" />
</div>
<p class="upgrade-text">升级到 <span class="highlight">PRO</span> 版本</p>
<p class="upgrade-desc">解锁更多高级功能</p>
<t-button theme="primary" block class="upgrade-btn">立即升级</t-button>
</div>
</t-aside>
<!-- 主内容区 -->
<el-container>
<t-layout class="main-layout">
<!-- 顶部导航 -->
<el-header class="header">
<t-header class="header">
<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 class="header-right">
<el-tooltip content="系统状态">
<el-icon size="20" :class="systemStatus"><CircleCheck /></el-icon>
</el-tooltip>
<span class="version">v0.1.0</span>
<div class="header-action">
<t-badge :count="3" :offset="[-2, 2]">
<div class="action-icon">
<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>
</el-header>
</t-header>
<!-- 内容区 -->
<el-main class="main-content">
<t-content class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</t-content>
</t-layout>
</t-layout>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import router from '@/router'
import { systemApi } from '@/api/api'
const $route = useRoute()
const $router = useRouter()
const systemStatus = ref('status-healthy')
// 菜单路由
@@ -67,6 +97,11 @@ const menuRoutes = computed(() => {
?.children.filter(r => r.meta) || []
})
// 菜单切换
const handleMenuChange = (value) => {
$router.push(value)
}
// 检查系统状态
const checkStatus = async () => {
try {
@@ -86,41 +121,170 @@ onMounted(() => {
<style scoped>
.layout-container {
height: 100vh;
background: var(--bg-color);
}
.sidebar {
background-color: #304156;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
padding: 20px 16px;
box-shadow: var(--shadow);
border-radius: 0 24px 24px 0;
z-index: 10;
}
.logo {
height: 60px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
margin-bottom: 24px;
}
.logo-icon {
width: 42px;
height: 42px;
background: var(--primary-gradient);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border-bottom: 1px solid #1f2d3d;
color: #fff;
}
.logo-text {
color: #fff;
font-size: 18px;
font-weight: 600;
font-weight: 700;
color: var(--text-primary);
}
.sidebar-menu {
border-right: none;
flex: 1;
background: transparent !important;
border: none !important;
}
.sidebar-menu :deep(.t-menu__item) {
height: 48px;
padding: 0 16px !important;
margin: 4px 0 !important;
border-radius: 12px !important;
color: var(--text-secondary);
transition: all 0.3s ease;
}
.sidebar-menu :deep(.t-menu__item:hover) {
background: #F3F4F6 !important;
color: var(--primary-color);
}
.sidebar-menu :deep(.t-menu__item.t-is-active) {
background: var(--primary-gradient) !important;
color: #fff !important;
}
.sidebar-menu :deep(.t-menu__item.t-is-active .t-icon) {
color: #fff !important;
}
.menu-icon-wrapper {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: #F3F4F6;
transition: all 0.3s ease;
}
.menu-icon-wrapper.active {
background: rgba(255, 255, 255, 0.2);
}
.menu-text {
font-weight: 500;
}
.upgrade-card {
background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%);
border-radius: 20px;
padding: 24px 16px;
text-align: center;
margin-top: auto;
}
.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 {
background: var(--bg-color);
}
.header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
background: transparent;
display: flex;
align-items: center;
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 {
@@ -129,32 +293,82 @@ onMounted(() => {
gap: 16px;
}
.status-healthy {
color: #67c23a;
.header-action {
cursor: pointer;
}
.status-error {
color: #f56c6c;
.action-icon {
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 {
color: #909399;
font-size: 14px;
.action-icon:hover {
color: var(--primary-color);
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 {
background-color: #f0f2f5;
padding: 20px;
padding: 0 32px 32px;
overflow-y: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
/* 页面切换动画 */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
.fade-slide-enter-from {
opacity: 0;
transform: translateY(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>

View File

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

View File

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

View File

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

View File

@@ -1,115 +1,194 @@
<template>
<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">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #409EFF;">
<el-icon size="32" color="#fff"><UserFilled /></el-icon>
<div class="stat-grid">
<div class="stat-card stat-card-blue">
<div class="stat-header">
<div class="stat-icon blue">
<t-icon name="user" size="24px" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recruiters }}</div>
<div class="stat-label">招聘者账号</div>
<div class="stat-trend up">
<t-icon name="arrow-up" size="14px" />
<span>12%</span>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #67C23A;">
<el-icon size="32" color="#fff"><Briefcase /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.jobs }}</div>
<div class="stat-label">职位数量</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #E6A23C;">
<el-icon size="32" color="#fff"><Avatar /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.candidates }}</div>
<div class="stat-label">候选人</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #F56C6C;">
<el-icon size="32" color="#fff"><Star /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.evaluations }}</div>
<div class="stat-label">评价记录</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<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-row :gutter="20" class="action-row">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>快捷操作</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="$router.push('/recruiters')">
<el-icon><Plus /></el-icon>添加招聘者
</el-button>
<el-button type="success" @click="$router.push('/jobs')">
<el-icon><Plus /></el-icon>创建职位
</el-button>
<el-button type="warning" @click="$router.push('/scheduler')">
<el-icon><VideoPlay /></el-icon>启动任务
</el-button>
<div class="stat-card stat-card-green">
<div class="stat-header">
<div class="stat-icon green">
<t-icon name="briefcase" size="24px" />
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>系统状态</span>
<div class="stat-trend up">
<t-icon name="arrow-up" size="14px" />
<span>8%</span>
</div>
</div>
<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>
<div class="stat-card stat-card-orange">
<div class="stat-header">
<div class="stat-icon orange">
<t-icon name="user-circle" size="24px" />
</div>
<div class="stat-trend up">
<t-icon name="arrow-up" size="14px" />
<span>24%</span>
</div>
</div>
<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>
<div class="stat-card stat-card-pink">
<div class="stat-header">
<div class="stat-icon pink">
<t-icon name="star" size="24px" />
</div>
<div class="stat-trend down">
<t-icon name="arrow-down" size="14px" />
<span>3%</span>
</div>
</div>
<div class="stat-value">{{ stats.evaluations }}</div>
<div class="stat-label">评价记录</div>
<div class="stat-progress">
<div class="progress-bar pink" style="width: 45%"></div>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="content-grid">
<!-- 快捷操作 -->
<t-card class="action-card">
<template #header>
<div class="card-header">
<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>
</template>
<div class="system-status">
<div class="status-item">
<span class="status-label">API服务</span>
<el-tag :type="apiStatus === 'running' ? 'success' : 'danger'">
{{ apiStatus === 'running' ? '运行中' : '异常' }}
</el-tag>
<div class="action-info">
<span class="action-name">添加招聘者</span>
<span class="action-desc">创建新的招聘账号</span>
</div>
<div class="status-item">
<span class="status-label">调度器</span>
<el-tag :type="schedulerStatus.running ? 'success' : 'info'">
{{ schedulerStatus.running ? '运行中' : '已停止' }}
</el-tag>
<t-icon name="chevron-right" class="action-arrow" />
</div>
<div class="action-item" @click="$router.push('/jobs')">
<div class="action-icon-wrapper green">
<t-icon name="add-circle" size="24px" />
</div>
<div class="status-item">
<span class="status-label">任务数量</span>
<span class="status-value">{{ schedulerStatus.total_jobs || 0 }}</span>
<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>
<!-- 系统状态 -->
<t-card class="status-card">
<template #header>
<div class="card-header">
<span class="card-title">系统状态</span>
<div class="live-indicator">
<span class="live-dot"></span>
<span>实时</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</template>
<div class="system-status">
<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>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { MessagePlugin } from 'tdesign-vue-next'
import { recruiterApi, schedulerApi, systemApi } from '@/api/api'
const stats = ref({
@@ -124,11 +203,9 @@ const schedulerStatus = ref({})
const loadStats = async () => {
try {
// 获取招聘者数量
const recruiterRes = await recruiterApi.getList()
stats.value.recruiters = recruiterRes.data?.total || 0
// 获取调度器状态
const schedulerRes = await schedulerApi.getStatus()
schedulerStatus.value = schedulerRes.data || {}
} catch (error) {
@@ -153,66 +230,246 @@ onMounted(() => {
<style scoped>
.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 {
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
color: #303133;
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.stat-row {
margin-bottom: 24px;
.page-subtitle {
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 {
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;
justify-content: space-between;
align-items: center;
padding: 20px;
margin-bottom: 16px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 8px;
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
color: #fff;
}
.stat-info {
flex: 1;
.stat-icon.blue { background: linear-gradient(135deg, #5B6CFF 0%, #8B5CF6 100%); }
.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 {
font-size: 28px;
font-size: 36px;
font-weight: 700;
color: #303133;
line-height: 1;
color: var(--text-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
color: var(--text-muted);
margin-bottom: 16px;
}
.action-row {
margin-bottom: 24px;
.stat-progress {
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 {
font-weight: 600;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
/* 快捷操作 */
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
flex-direction: column;
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 {
@@ -225,20 +482,65 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #EBEEF5;
padding: 16px;
background: #F9FAFB;
border-radius: 16px;
}
.status-item:last-child {
border-bottom: none;
.status-left {
display: flex;
align-items: center;
gap: 12px;
}
.status-label {
color: #606266;
.status-icon-wrapper {
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;
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>

View File

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

View File

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

View File

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