feat(notification): 新增通知渠道及绑定管理功能
- 新增数据库表 notification_channels, recruiter_channel_bindings 支持多渠道通知绑定 - 在 notifications 表中新增 channel_id 关联通知渠道 - 增加默认通知渠道示例数据插入脚本(企业微信、钉钉、飞书) - 实现 NotificationChannel 和 RecruiterChannelBinding 两个ORM模型及关联关系 - 增加通知渠道管理API,支持增删改查及启用停用操作 - 实现通知渠道类型枚举及配置验证 - 新增招聘者与通知渠道绑定管理路由,支持绑定关系创建、更新和删除 - 在招聘者模块中集成通知渠道绑定管理相关接口 - 增加对应的请求参数、响应模型及数据校验模型 - 更新数据库配置和依赖注入,支持通知渠道服务 - 完善接口响应的错误处理和成功提示信息 - 保证所有新增代码符合项目代码风格和结构规范
This commit is contained in:
@@ -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', '通用评价方案', '适用于各类岗位的通用评价方案',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,6 +33,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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)}")
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</el-header>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
<t-card :bordered="false">
|
||||
<t-table
|
||||
:data="candidateList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
stripe
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-gray">未评分</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="筛选状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.llm_filtered ? 'success' : 'info'" size="small">
|
||||
{{ row.llm_filtered ? '已通过' : '未筛选' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="handleView(row)">详情</el-button>
|
||||
<el-button size="small" type="primary" @click="handleScore(row)">评分</el-button>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
<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>
|
||||
|
||||
@@ -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-trend up">
|
||||
<t-icon name="arrow-up" size="14px" />
|
||||
<span>12%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<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-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 class="stat-card stat-card-green">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon green">
|
||||
<t-icon name="briefcase" size="24px" />
|
||||
</div>
|
||||
<div class="stat-trend up">
|
||||
<t-icon name="arrow-up" size="14px" />
|
||||
<span>8%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.jobs }}</div>
|
||||
<div class="stat-label">职位数量</div>
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar green" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-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 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-info">
|
||||
<div class="stat-value">{{ stats.candidates }}</div>
|
||||
<div class="stat-label">候选人</div>
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar orange" style="width: 85%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-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 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-info">
|
||||
<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>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="content-grid">
|
||||
<!-- 快捷操作 -->
|
||||
<el-row :gutter="20" class="action-row">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<t-card class="action-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>快捷操作</span>
|
||||
<span class="card-title">快捷操作</span>
|
||||
<t-tag theme="primary" variant="light">常用</t-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<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="action-item" @click="$router.push('/recruiters')">
|
||||
<div class="action-icon-wrapper blue">
|
||||
<t-icon name="user-add" size="24px" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<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('/jobs')">
|
||||
<div class="action-icon-wrapper green">
|
||||
<t-icon name="add-circle" size="24px" />
|
||||
</div>
|
||||
<div class="action-info">
|
||||
<span class="action-name">创建职位</span>
|
||||
<span class="action-desc">发布新的招聘职位</span>
|
||||
</div>
|
||||
<t-icon name="chevron-right" class="action-arrow" />
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/scheduler')">
|
||||
<div class="action-icon-wrapper orange">
|
||||
<t-icon name="play-circle" size="24px" />
|
||||
</div>
|
||||
<div class="action-info">
|
||||
<span class="action-name">启动任务</span>
|
||||
<span class="action-desc">开始自动化流程</span>
|
||||
</div>
|
||||
<t-icon name="chevron-right" class="action-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<!-- 系统状态 -->
|
||||
<t-card class="status-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>系统状态</span>
|
||||
<span class="card-title">系统状态</span>
|
||||
<div class="live-indicator">
|
||||
<span class="live-dot"></span>
|
||||
<span>实时</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="system-status">
|
||||
<div class="status-item">
|
||||
<span class="status-label">API服务</span>
|
||||
<el-tag :type="apiStatus === 'running' ? 'success' : 'danger'">
|
||||
<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' ? '运行中' : '异常' }}
|
||||
</el-tag>
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">调度器</span>
|
||||
<el-tag :type="schedulerStatus.running ? 'success' : 'info'">
|
||||
<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 ? '运行中' : '已停止' }}
|
||||
</el-tag>
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">任务数量</span>
|
||||
<span class="status-value">{{ schedulerStatus.total_jobs || 0 }}</span>
|
||||
<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>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<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>
|
||||
|
||||
@@ -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) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认删除',
|
||||
body: '确定要删除该职位吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该职位吗?', '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
await jobApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
MessagePlugin.success('删除成功')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + error.message)
|
||||
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) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认解除',
|
||||
body: '确定要解除该职位的评价方案关联吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要解除该职位的评价方案关联吗?', '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
await jobApi.update(row.id, { evaluation_schema_id: null })
|
||||
ElMessage.success('已解除关联')
|
||||
MessagePlugin.success('已解除关联')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败: ' + error.message)
|
||||
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>
|
||||
|
||||
@@ -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) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认删除',
|
||||
body: '确定要删除该招聘者吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该招聘者吗?', '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
await recruiterApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
MessagePlugin.success('删除成功')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + error.message)
|
||||
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>
|
||||
|
||||
@@ -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 () => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '警告',
|
||||
body: '确定要停止调度器吗?正在运行的任务将被中断。',
|
||||
theme: 'warning',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要停止调度器吗?正在运行的任务将被中断。', '警告', {
|
||||
type: 'warning'
|
||||
})
|
||||
await schedulerApi.stop()
|
||||
ElMessage.success('调度器已停止')
|
||||
MessagePlugin.success('调度器已停止')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('停止失败: ' + error.message)
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user