Compare commits
8 Commits
6f1f438159
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| af11f8ad48 | |||
| fc24e3a37b | |||
| 148f2cc4f6 | |||
| 6f3487a09a | |||
| eedaac69b0 | |||
| 91b6808d45 | |||
| 6dc57076b6 | |||
| 498fd7a5e8 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,9 @@ __pycache__/
|
||||
#idea
|
||||
**/.idea/**
|
||||
|
||||
#node_modules
|
||||
**/node_modules/**
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
|
||||
@@ -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', '通用评价方案', '适用于各类岗位的通用评价方案',
|
||||
|
||||
@@ -16,7 +16,7 @@ if str(src_path) not in sys.path:
|
||||
sys.path.insert(0, str(src_path))
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, BackgroundTasks
|
||||
|
||||
from cn.yinlihupo.ylhp_hr_2_0.controller.api import create_app
|
||||
from cn.yinlihupo.ylhp_hr_2_0.service.scheduler import get_scheduler
|
||||
@@ -61,10 +61,10 @@ def create_combined_app(enable_scheduler: bool = True) -> FastAPI:
|
||||
return {"success": True, "message": f"Job {job_id} resumed"}
|
||||
|
||||
@app.post("/api/scheduler/trigger/crawl")
|
||||
async def trigger_crawl():
|
||||
async def trigger_crawl(background_tasks: BackgroundTasks):
|
||||
"""手动触发爬取任务"""
|
||||
scheduler = get_scheduler()
|
||||
asyncio.create_task(scheduler._crawl_boss())
|
||||
background_tasks.add_task(scheduler._crawl_boss)
|
||||
return {"success": True, "message": "Crawl task triggered"}
|
||||
|
||||
return app
|
||||
|
||||
@@ -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
|
||||
@@ -31,14 +32,24 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from .job import router as job_router
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
import traceback
|
||||
print(f"[ERROR] Failed to import job router: {e}")
|
||||
traceback.print_exc()
|
||||
from fastapi import APIRouter
|
||||
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"
|
||||
]
|
||||
|
||||
@@ -15,7 +15,7 @@ from ..schemas import (
|
||||
from ...mapper.candidate_mapper import CandidateMapper
|
||||
|
||||
|
||||
router = APIRouter(prefix="/candidates", tags=["候选人管理"])
|
||||
router = APIRouter(prefix="/api/candidates", tags=["候选人管理"])
|
||||
|
||||
|
||||
def _candidate_to_response(candidate) -> CandidateResponse:
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..schemas import (
|
||||
EvaluationSchemaResponse, EvaluationSchemaListResponse
|
||||
)
|
||||
from ...domain.job import Job
|
||||
from ...domain.enums import CandidateSource
|
||||
from ...domain.candidate import CandidateSource
|
||||
from ...mapper.job_mapper import JobMapper
|
||||
from ...mapper.evaluation_mapper import EvaluationMapper
|
||||
|
||||
@@ -24,6 +24,16 @@ router = APIRouter(prefix="/api/jobs", tags=["职位管理"])
|
||||
|
||||
def _job_to_response(job: Job) -> JobPositionResponse:
|
||||
"""将领域实体转换为响应模型"""
|
||||
# 将 JobRequirement 对象转换为字典
|
||||
requirements_dict = None
|
||||
if job.requirements is not None:
|
||||
if hasattr(job.requirements, 'to_dict'):
|
||||
requirements_dict = job.requirements.to_dict()
|
||||
elif hasattr(job.requirements, '__dict__'):
|
||||
requirements_dict = job.requirements.__dict__
|
||||
else:
|
||||
requirements_dict = job.requirements
|
||||
|
||||
return JobPositionResponse(
|
||||
id=job.id,
|
||||
title=job.title,
|
||||
@@ -35,7 +45,7 @@ def _job_to_response(job: Job) -> JobPositionResponse:
|
||||
location=job.location,
|
||||
salary_min=job.salary_min,
|
||||
salary_max=job.salary_max,
|
||||
requirements=job.requirements,
|
||||
requirements=requirements_dict,
|
||||
description=job.description,
|
||||
candidate_count=job.candidate_count,
|
||||
new_candidate_count=job.new_candidate_count,
|
||||
@@ -45,6 +55,45 @@ def _job_to_response(job: Job) -> JobPositionResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/schemas/list", response_model=BaseResponse[EvaluationSchemaListResponse])
|
||||
async def list_evaluation_schemas(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量")
|
||||
):
|
||||
"""
|
||||
获取评价方案列表
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
BaseResponse[EvaluationSchemaListResponse]: 统一响应格式的评价方案列表
|
||||
"""
|
||||
try:
|
||||
schema_mapper = EvaluationMapper()
|
||||
schemas, total = schema_mapper.find_all_schemas(page=page, page_size=page_size)
|
||||
|
||||
items = [
|
||||
EvaluationSchemaResponse(
|
||||
id=schema.id,
|
||||
name=schema.name,
|
||||
description=schema.description,
|
||||
dimensions=schema.dimensions,
|
||||
weights=schema.weights,
|
||||
is_default=schema.is_default,
|
||||
created_at=schema.created_at,
|
||||
updated_at=schema.updated_at
|
||||
)
|
||||
for schema in schemas
|
||||
]
|
||||
|
||||
response = EvaluationSchemaListResponse(total=total, items=items)
|
||||
return BaseResponse.success(data=response, msg="获取评价方案列表成功")
|
||||
except Exception as e:
|
||||
return BaseResponse.error(msg=f"获取评价方案列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("", response_model=BaseResponse[JobPositionListResponse])
|
||||
async def list_jobs(
|
||||
source: Optional[str] = Query(None, description="平台来源"),
|
||||
@@ -368,42 +417,3 @@ async def get_job_evaluation_schema(job_id: str):
|
||||
return BaseResponse.success(data=response, msg="获取评价方案成功")
|
||||
except Exception as e:
|
||||
return BaseResponse.error(msg=f"获取评价方案失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/schemas/list", response_model=BaseResponse[EvaluationSchemaListResponse])
|
||||
async def list_evaluation_schemas(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量")
|
||||
):
|
||||
"""
|
||||
获取评价方案列表
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
BaseResponse[EvaluationSchemaListResponse]: 统一响应格式的评价方案列表
|
||||
"""
|
||||
try:
|
||||
schema_mapper = EvaluationMapper()
|
||||
schemas, total = schema_mapper.find_all_schemas(page=page, page_size=page_size)
|
||||
|
||||
items = [
|
||||
EvaluationSchemaResponse(
|
||||
id=schema.id,
|
||||
name=schema.name,
|
||||
description=schema.description,
|
||||
dimensions=schema.dimensions,
|
||||
weights=schema.weights,
|
||||
is_default=schema.is_default,
|
||||
created_at=schema.created_at,
|
||||
updated_at=schema.updated_at
|
||||
)
|
||||
for schema in schemas
|
||||
]
|
||||
|
||||
response = EvaluationSchemaListResponse(total=total, items=items)
|
||||
return BaseResponse.success(data=response, msg="获取评价方案列表成功")
|
||||
except Exception as e:
|
||||
return BaseResponse.error(msg=f"获取评价方案列表失败: {str(e)}")
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -21,7 +21,7 @@ async def root():
|
||||
"endpoints": {
|
||||
"recruiters": "/api/recruiters",
|
||||
"jobs": "/api/jobs",
|
||||
"candidates": "/candidates",
|
||||
"candidates": "/api/candidates",
|
||||
"scheduler": "/api/scheduler",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ API共享Schema定义
|
||||
|
||||
集中定义所有API请求和响应的数据模型
|
||||
"""
|
||||
from typing import List, Optional, Any, TypeVar, Generic
|
||||
from typing import List, Optional, Any, TypeVar, Generic, Dict
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -45,14 +45,7 @@ class PaginationData(BaseModel, Generic[T]):
|
||||
items: List[T] = Field(..., description="数据列表")
|
||||
|
||||
|
||||
# ============== 通用响应 (兼容旧代码) ==============
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
"""通用API响应"""
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[dict] = None
|
||||
|
||||
# ============== 分页参数 ==============
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""分页参数"""
|
||||
@@ -314,3 +307,139 @@ 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]
|
||||
|
||||
|
||||
# 重建所有模型以解决前向引用问题
|
||||
BaseResponse.model_rebuild()
|
||||
PaginationData.model_rebuild()
|
||||
CandidateResponse.model_rebuild()
|
||||
CandidateListResponse.model_rebuild()
|
||||
JobPositionResponse.model_rebuild()
|
||||
JobPositionListResponse.model_rebuild()
|
||||
RecruiterResponse.model_rebuild()
|
||||
RecruiterListResponse.model_rebuild()
|
||||
EvaluationSchemaResponse.model_rebuild()
|
||||
EvaluationSchemaListResponse.model_rebuild()
|
||||
NotificationChannelResponse.model_rebuild()
|
||||
NotificationChannelListResponse.model_rebuild()
|
||||
RecruiterChannelBindingResponse.model_rebuild()
|
||||
RecruiterChannelBindingListResponse.model_rebuild()
|
||||
|
||||
@@ -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()
|
||||
@@ -253,8 +253,8 @@ class ResumeProcessJob:
|
||||
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id}, "
|
||||
f"评价方案: {job.evaluation_schema_id or 'general'})")
|
||||
|
||||
# 获取候选人列表
|
||||
candidates = crawler.get_candidates(job.source_id, page=1)
|
||||
# 获取候选人列表(在线程池中执行,避免阻塞事件循环)
|
||||
candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
|
||||
|
||||
if not candidates:
|
||||
result.message = "该职位下没有候选人"
|
||||
@@ -359,8 +359,8 @@ class ResumeProcessJob:
|
||||
|
||||
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id})")
|
||||
|
||||
# 获取候选人列表
|
||||
candidates = crawler.get_candidates(job.source_id, page=1)
|
||||
# 获取候选人列表(在线程池中执行,避免阻塞事件循环)
|
||||
candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
|
||||
|
||||
if not candidates:
|
||||
result.message = "该职位下没有候选人"
|
||||
@@ -425,8 +425,8 @@ class ResumeProcessJob:
|
||||
print(f"[{datetime.now()}] 处理候选人: {candidate.name}")
|
||||
|
||||
try:
|
||||
# 获取简历详情
|
||||
resume = crawler.get_resume_detail(candidate)
|
||||
# 获取简历详情(在线程池中执行,避免阻塞事件循环)
|
||||
resume = await asyncio.to_thread(crawler.get_resume_detail, candidate)
|
||||
|
||||
if not resume:
|
||||
print(f"[{datetime.now()}] 候选人 {candidate.name} 无法获取简历详情")
|
||||
@@ -435,10 +435,11 @@ class ResumeProcessJob:
|
||||
# 构建原始数据
|
||||
raw_data = self._build_raw_data(candidate, resume, job)
|
||||
|
||||
# 统一入库
|
||||
ingestion_result = self.ingestion_service.ingest(
|
||||
source=candidate.source,
|
||||
raw_data=raw_data
|
||||
# 统一入库(在线程池中执行,避免阻塞事件循环)
|
||||
ingestion_result = await asyncio.to_thread(
|
||||
self.ingestion_service.ingest,
|
||||
candidate.source,
|
||||
raw_data
|
||||
)
|
||||
|
||||
if not ingestion_result.success:
|
||||
|
||||
@@ -262,9 +262,11 @@ class CandidateMapper:
|
||||
count_stmt = count_stmt.where(*conditions)
|
||||
total = session.execute(count_stmt).scalar()
|
||||
|
||||
# 分页查询,按LLM评分降序,再按创建时间倒序
|
||||
# 分页查询,按LLM评分降序(NULL值在后),再按创建时间倒序
|
||||
# 使用MySQL兼容的语法:先按是否为NULL排序,再按值排序
|
||||
stmt = stmt.order_by(
|
||||
CandidateModel.llm_score.desc().nullslast(),
|
||||
CandidateModel.llm_score.is_(None).asc(), # NULL值在后
|
||||
CandidateModel.llm_score.desc(),
|
||||
CandidateModel.created_at.desc()
|
||||
)
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
@@ -330,8 +330,12 @@ class JobMapper:
|
||||
# 获取总数
|
||||
total = session.execute(count_stmt).scalar()
|
||||
|
||||
# 分页查询,按最后同步时间降序
|
||||
stmt = stmt.order_by(JobModel.last_sync_at.desc().nullslast())
|
||||
# 分页查询,按最后同步时间降序(NULL值在后)
|
||||
# 使用MySQL兼容的语法:先按是否为NULL排序,再按值排序
|
||||
stmt = stmt.order_by(
|
||||
JobModel.last_sync_at.is_(None).asc(), # NULL值在后
|
||||
JobModel.last_sync_at.desc()
|
||||
)
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
results = session.execute(stmt).scalars().all()
|
||||
|
||||
@@ -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()
|
||||
@@ -41,6 +41,9 @@ class ResumeMapper:
|
||||
"""递归转换对象为可JSON序列化的格式"""
|
||||
if obj is None:
|
||||
return None
|
||||
# 处理基本类型
|
||||
if isinstance(obj, (str, int, float, bool)):
|
||||
return obj
|
||||
# 处理枚举类型
|
||||
if hasattr(obj, 'value'):
|
||||
return obj.value
|
||||
@@ -65,7 +68,17 @@ class ResumeMapper:
|
||||
value = getattr(obj, field_name)
|
||||
result[field_name] = self._convert_to_serializable(value)
|
||||
return result
|
||||
return obj
|
||||
# 处理一般对象(如SDK返回的Model对象)
|
||||
if hasattr(obj, '__dict__'):
|
||||
result = {}
|
||||
for key, value in obj.__dict__.items():
|
||||
# 跳过私有属性和方法
|
||||
if key.startswith('_'):
|
||||
continue
|
||||
result[key] = self._convert_to_serializable(value)
|
||||
return result
|
||||
# 最后尝试转换为字符串
|
||||
return str(obj)
|
||||
|
||||
def _entity_to_model(self, entity: Resume) -> ResumeModel:
|
||||
"""将实体转换为模型"""
|
||||
|
||||
@@ -20,14 +20,16 @@ class IngestionResult:
|
||||
errors: list = None
|
||||
is_duplicate: bool = False
|
||||
existing_candidate_id: Optional[str] = None
|
||||
is_new: bool = True # 是否为新增候选人(False表示更新)
|
||||
|
||||
@classmethod
|
||||
def success_result(cls, candidate_id: str, message: str = "") -> "IngestionResult":
|
||||
def success_result(cls, candidate_id: str, message: str = "", is_new: bool = True) -> "IngestionResult":
|
||||
"""创建成功结果"""
|
||||
return cls(
|
||||
success=True,
|
||||
candidate_id=candidate_id,
|
||||
message=message or "入库成功"
|
||||
message=message or "入库成功",
|
||||
is_new=is_new
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -50,7 +52,8 @@ class IngestionResult:
|
||||
success=True, # 重复不算失败
|
||||
is_duplicate=True,
|
||||
existing_candidate_id=existing_id,
|
||||
message=message or "候选人已存在"
|
||||
message=message or "候选人已存在",
|
||||
is_new=False # 重复候选人不算新增
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -132,8 +132,8 @@ class ResumeEvaluationService:
|
||||
print(f"[ResumeEvaluationService] 开始处理候选人: {candidate.name}")
|
||||
|
||||
try:
|
||||
# 1. 获取简历详情 - 同步操作
|
||||
resume = self._get_resume(crawler, candidate)
|
||||
# 1. 获取简历详情 - 在线程池中异步执行
|
||||
resume = await self._get_resume(crawler, candidate)
|
||||
if not resume:
|
||||
result.success = False
|
||||
result.status = "failed"
|
||||
@@ -192,9 +192,9 @@ class ResumeEvaluationService:
|
||||
|
||||
return result
|
||||
|
||||
def _get_resume(self, crawler: BaseCrawler, candidate: Candidate) -> Optional[Resume]:
|
||||
async def _get_resume(self, crawler: BaseCrawler, candidate: Candidate) -> Optional[Resume]:
|
||||
"""
|
||||
获取简历详情
|
||||
获取简历详情(在线程池中执行,避免阻塞事件循环)
|
||||
|
||||
Args:
|
||||
crawler: 爬虫实例
|
||||
@@ -203,8 +203,9 @@ class ResumeEvaluationService:
|
||||
Returns:
|
||||
Resume: 简历对象
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
return crawler.get_resume_detail(candidate)
|
||||
return await asyncio.to_thread(crawler.get_resume_detail, candidate)
|
||||
except Exception as e:
|
||||
print(f"[ResumeEvaluationService] 获取简历失败: {e}")
|
||||
return None
|
||||
|
||||
@@ -110,22 +110,22 @@ class CrawlScheduler:
|
||||
if not crawler:
|
||||
continue
|
||||
|
||||
# 获取职位列表
|
||||
jobs = crawler.get_jobs()
|
||||
# 获取职位列表(在线程池中执行,避免阻塞事件循环)
|
||||
jobs = await asyncio.to_thread(crawler.get_jobs)
|
||||
print(f"[{datetime.now()}] 找到 {len(jobs)} 个职位")
|
||||
|
||||
# 遍历职位爬取候选人
|
||||
for job in jobs:
|
||||
print(f"[{datetime.now()}] 爬取职位: {job.title}")
|
||||
|
||||
# 爬取候选人
|
||||
candidates = crawler.get_candidates(job.source_id, page=1)
|
||||
# 爬取候选人(在线程池中执行,避免阻塞事件循环)
|
||||
candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
|
||||
print(f"[{datetime.now()}] 职位 '{job.title}' 找到 {len(candidates)} 个候选人")
|
||||
|
||||
for candidate in candidates[:10]: # 每职位限制10个候选人
|
||||
try:
|
||||
# 获取简历详情
|
||||
resume = crawler.get_resume_detail(candidate)
|
||||
# 获取简历详情(在线程池中执行,避免阻塞事件循环)
|
||||
resume = await asyncio.to_thread(crawler.get_resume_detail, candidate)
|
||||
if not resume:
|
||||
continue
|
||||
|
||||
@@ -145,8 +145,12 @@ class CrawlScheduler:
|
||||
"resumeText": resume.raw_content,
|
||||
}
|
||||
|
||||
# 入库
|
||||
result = self.app.ingestion_service.ingest(CandidateSource.BOSS, raw_data)
|
||||
# 入库(在线程池中执行,避免阻塞事件循环)
|
||||
result = await asyncio.to_thread(
|
||||
self.app.ingestion_service.ingest,
|
||||
CandidateSource.BOSS,
|
||||
raw_data
|
||||
)
|
||||
print(f"[{datetime.now()}] 候选人 {candidate.name} 入库: {result.message}")
|
||||
|
||||
# 触发分析
|
||||
|
||||
4
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/.npmrc
Normal file
4
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/.npmrc
Normal file
@@ -0,0 +1,4 @@
|
||||
# pnpm 配置
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
13
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/index.html
Normal file
13
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>简历智能体 - HR管理系统</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
Normal file
28
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "ylhp-hr-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.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",
|
||||
"sass": "^1.70.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
1457
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml
generated
Normal file
1457
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
166
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
Normal file
166
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
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>
|
||||
193
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/api/api.js
Normal file
193
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/api/api.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 响应拦截器 - 统一处理
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data
|
||||
if (data.code !== 200) {
|
||||
return Promise.reject(new Error(data.msg || '请求失败'))
|
||||
}
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 招聘者管理 API
|
||||
export const recruiterApi = {
|
||||
// 获取平台来源列表
|
||||
getSources: () => api.get('/api/recruiters/sources'),
|
||||
|
||||
// 获取招聘者列表
|
||||
getList: (params = {}) => api.get('/api/recruiters', { params }),
|
||||
|
||||
// 获取招聘者详情
|
||||
getDetail: (id) => api.get(`/api/recruiters/${id}`),
|
||||
|
||||
// 创建招聘者
|
||||
create: (data) => api.post('/api/recruiters', data),
|
||||
|
||||
// 自动注册
|
||||
register: (data) => api.post('/api/recruiters/register', data),
|
||||
|
||||
// 更新招聘者
|
||||
update: (id, data) => api.put(`/api/recruiters/${id}`, data),
|
||||
|
||||
// 删除招聘者
|
||||
delete: (id) => api.delete(`/api/recruiters/${id}`),
|
||||
|
||||
// 启用招聘者
|
||||
activate: (id) => api.post(`/api/recruiters/${id}/activate`),
|
||||
|
||||
// 停用招聘者
|
||||
deactivate: (id) => api.post(`/api/recruiters/${id}/deactivate`),
|
||||
|
||||
// 同步招聘者
|
||||
sync: (id) => api.post(`/api/recruiters/${id}/sync`)
|
||||
}
|
||||
|
||||
// 职位管理 API
|
||||
export const jobApi = {
|
||||
// 获取职位列表
|
||||
getList: (params = {}) => api.get('/api/jobs', { params }),
|
||||
|
||||
// 筛选职位
|
||||
filter: (data) => api.post('/api/jobs/filter', data),
|
||||
|
||||
// 获取职位详情
|
||||
getDetail: (id) => api.get(`/api/jobs/${id}`),
|
||||
|
||||
// 创建职位
|
||||
create: (data) => api.post('/api/jobs', data),
|
||||
|
||||
// 更新职位
|
||||
update: (id, data) => api.put(`/api/jobs/${id}`, data),
|
||||
|
||||
// 删除职位
|
||||
delete: (id) => api.delete(`/api/jobs/${id}`),
|
||||
|
||||
// 关联评价方案
|
||||
bindSchema: (id, schemaId) => api.post(`/api/jobs/${id}/bind-schema`, { evaluation_schema_id: schemaId }),
|
||||
|
||||
// 获取职位关联的评价方案
|
||||
getSchema: (id) => api.get(`/api/jobs/${id}/schema`),
|
||||
|
||||
// 获取评价方案列表
|
||||
getSchemaList: (params = {}) => api.get('/api/jobs/schemas/list', { params })
|
||||
}
|
||||
|
||||
// 候选人管理 API
|
||||
export const candidateApi = {
|
||||
// 获取筛选通过的候选人
|
||||
getFiltered: (params = {}) => api.get('/api/candidates/filtered', { params }),
|
||||
|
||||
// 筛选候选人
|
||||
filter: (data) => api.post('/api/candidates/filter', data),
|
||||
|
||||
// 获取候选人详情
|
||||
getDetail: (id) => api.get(`/api/candidates/${id}`),
|
||||
|
||||
// 标记候选人筛选状态
|
||||
markFiltered: (data) => api.post('/api/candidates/mark-filtered', data),
|
||||
|
||||
// 更新候选人评分
|
||||
updateScore: (data) => api.post('/api/candidates/update-score', data),
|
||||
|
||||
// 根据评分范围查询
|
||||
getByScoreRange: (params) => api.get('/api/candidates/by-score-range', { params })
|
||||
}
|
||||
|
||||
// 定时任务管理 API
|
||||
export const schedulerApi = {
|
||||
// 获取任务列表
|
||||
getJobs: () => api.get('/api/scheduler/jobs'),
|
||||
|
||||
// 获取任务状态列表
|
||||
getJobsStatus: () => api.get('/api/scheduler/jobs/status'),
|
||||
|
||||
// 获取单个任务状态
|
||||
getJobStatus: (id) => api.get(`/api/scheduler/jobs/${id}/status`),
|
||||
|
||||
// 立即执行任务
|
||||
runJob: (id) => api.post(`/api/scheduler/jobs/${id}/run`),
|
||||
|
||||
// 暂停任务
|
||||
pauseJob: (id) => api.post(`/api/scheduler/jobs/${id}/pause`),
|
||||
|
||||
// 恢复任务
|
||||
resumeJob: (id) => api.post(`/api/scheduler/jobs/${id}/resume`),
|
||||
|
||||
// 更新任务配置
|
||||
updateConfig: (id, data) => api.put(`/api/scheduler/jobs/${id}/config`, data),
|
||||
|
||||
// 获取调度器状态
|
||||
getStatus: () => api.get('/api/scheduler/status'),
|
||||
|
||||
// 启动调度器
|
||||
start: () => api.post('/api/scheduler/start'),
|
||||
|
||||
// 停止调度器
|
||||
stop: () => api.post('/api/scheduler/stop')
|
||||
}
|
||||
|
||||
// 通知渠道管理 API
|
||||
export const notificationChannelApi = {
|
||||
// 获取渠道类型列表
|
||||
getTypes: () => api.get('/api/notification-channels/types'),
|
||||
|
||||
// 获取通知渠道列表
|
||||
getList: (params = {}) => api.get('/api/notification-channels', { params }),
|
||||
|
||||
// 获取通知渠道详情
|
||||
getDetail: (id) => api.get(`/api/notification-channels/${id}`),
|
||||
|
||||
// 创建通知渠道
|
||||
create: (data) => api.post('/api/notification-channels', data),
|
||||
|
||||
// 更新通知渠道
|
||||
update: (id, data) => api.put(`/api/notification-channels/${id}`, data),
|
||||
|
||||
// 删除通知渠道
|
||||
delete: (id) => api.delete(`/api/notification-channels/${id}`),
|
||||
|
||||
// 启用通知渠道
|
||||
activate: (id) => api.post(`/api/notification-channels/${id}/activate`),
|
||||
|
||||
// 停用通知渠道
|
||||
deactivate: (id) => api.post(`/api/notification-channels/${id}/deactivate`),
|
||||
|
||||
// 获取渠道绑定的招聘者列表
|
||||
getChannelRecruiters: (id) => api.get(`/api/notification-channels/${id}/recruiters`),
|
||||
|
||||
// 绑定招聘者到渠道
|
||||
bindRecruiter: (channelId, recruiterId, data) => api.post(`/api/notification-channels/${channelId}/recruiters/${recruiterId}`, data),
|
||||
|
||||
// 更新绑定配置
|
||||
updateBinding: (channelId, recruiterId, data) => api.put(`/api/notification-channels/${channelId}/recruiters/${recruiterId}`, data),
|
||||
|
||||
// 解绑招聘者
|
||||
unbindRecruiter: (channelId, recruiterId) => api.delete(`/api/notification-channels/${channelId}/recruiters/${recruiterId}`)
|
||||
}
|
||||
|
||||
// 系统 API
|
||||
export const systemApi = {
|
||||
// 获取首页信息
|
||||
getHome: () => api.get('/'),
|
||||
|
||||
// 健康检查
|
||||
health: () => api.get('/health'),
|
||||
|
||||
// 获取API状态
|
||||
getStatus: () => api.get('/api/status')
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<t-layout class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<t-aside width="240px" class="sidebar">
|
||||
<div class="logo">
|
||||
<t-icon name="briefcase" size="28px" color="#5B6CFF" />
|
||||
<span class="logo-text">简历智能体</span>
|
||||
</div>
|
||||
|
||||
<div class="menu-container">
|
||||
<div
|
||||
v-for="route in menuRoutes"
|
||||
:key="route.path"
|
||||
class="menu-item"
|
||||
:class="{ active: $route.path === route.path }"
|
||||
@click="handleMenuChange(route.path)"
|
||||
>
|
||||
<t-icon :name="route.meta.icon" size="22px" />
|
||||
<span class="menu-text">{{ route.meta.title }}</span>
|
||||
<div class="active-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</t-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<t-layout class="main-layout">
|
||||
<!-- 顶部导航 -->
|
||||
<t-header class="header">
|
||||
<div class="header-left">
|
||||
<div class="search-box">
|
||||
<t-icon name="search" class="search-icon" />
|
||||
<input type="text" placeholder="搜索..." class="search-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-action">
|
||||
<t-badge :count="3" :offset="[-2, 2]">
|
||||
<div class="action-icon">
|
||||
<t-icon name="notification" size="20px" />
|
||||
</div>
|
||||
</t-badge>
|
||||
</div>
|
||||
<div class="header-action">
|
||||
<div class="action-icon">
|
||||
<t-icon name="setting" size="20px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-indicator" :class="systemStatus">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ systemStatus === 'status-healthy' ? '系统正常' : '系统异常' }}</span>
|
||||
</div>
|
||||
<div class="user-avatar">
|
||||
<t-avatar size="36px" style="background: var(--primary-gradient);">HR</t-avatar>
|
||||
</div>
|
||||
</div>
|
||||
</t-header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<t-content class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</t-content>
|
||||
</t-layout>
|
||||
</t-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
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')
|
||||
|
||||
// 菜单路由
|
||||
const menuRoutes = computed(() => {
|
||||
return router.getRoutes()
|
||||
.find(r => r.path === '/')
|
||||
?.children.filter(r => r.meta) || []
|
||||
})
|
||||
|
||||
// 菜单切换
|
||||
const handleMenuChange = (value) => {
|
||||
$router.push(value)
|
||||
}
|
||||
|
||||
// 检查系统状态
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
await systemApi.health()
|
||||
systemStatus.value = 'status-healthy'
|
||||
} catch {
|
||||
systemStatus.value = 'status-error'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkStatus()
|
||||
setInterval(checkStatus, 30000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
background: var(--bg-color);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px !important;
|
||||
min-width: 240px !important;
|
||||
max-width: 240px !important;
|
||||
flex-shrink: 0 !important;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 0 24px 24px 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 28px;
|
||||
margin-bottom: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 菜单容器 */
|
||||
.menu-container {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 菜单项 */
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
color: var(--primary-color);
|
||||
background: rgba(91, 108, 255, 0.06);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
color: var(--primary-color);
|
||||
background: rgba(91, 108, 255, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 右侧蓝色指示器 */
|
||||
.active-indicator {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 0;
|
||||
background: var(--primary-color);
|
||||
border-radius: 4px 0 0 4px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-item.active .active-indicator {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 升级卡片 */
|
||||
.upgrade-card {
|
||||
background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%);
|
||||
border-radius: 20px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.upgrade-icon {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.upgrade-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upgrade-text .highlight {
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upgrade-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upgrade-btn {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* 主布局 */
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 0 32px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 页面切换动画 */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
</style>
|
||||
15
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/main.js
Normal file
15
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/main.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
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)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(TDesign)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Layout from '@/components/Layout.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '首页', icon: 'home' }
|
||||
},
|
||||
{
|
||||
path: 'recruiters',
|
||||
name: 'Recruiters',
|
||||
component: () => import('@/views/Recruiters.vue'),
|
||||
meta: { title: '招聘者管理', icon: 'user' }
|
||||
},
|
||||
{
|
||||
path: 'jobs',
|
||||
name: 'Jobs',
|
||||
component: () => import('@/views/Jobs.vue'),
|
||||
meta: { title: '职位管理', icon: 'briefcase' }
|
||||
},
|
||||
{
|
||||
path: 'candidates',
|
||||
name: 'Candidates',
|
||||
component: () => import('@/views/Candidates.vue'),
|
||||
meta: { title: '候选人管理', icon: 'user-circle' }
|
||||
},
|
||||
{
|
||||
path: 'scheduler',
|
||||
name: 'Scheduler',
|
||||
component: () => import('@/views/Scheduler.vue'),
|
||||
meta: { title: '定时任务', icon: 'time' }
|
||||
},
|
||||
{
|
||||
path: 'notification-channels',
|
||||
name: 'NotificationChannels',
|
||||
component: () => import('@/views/NotificationChannels.vue'),
|
||||
meta: { title: '通知渠道', icon: 'notification' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="candidates-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">候选人管理</h2>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<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>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<t-card :bordered="false">
|
||||
<t-table
|
||||
:data="candidateList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
stripe
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<t-pagination
|
||||
v-model="pagination.page"
|
||||
v-model:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 候选人详情对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="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>
|
||||
<t-tag :theme="currentCandidate.llm_filtered ? 'success' : 'default'" size="small">
|
||||
{{ currentCandidate.llm_filtered ? '已通过筛选' : '未通过筛选' }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<span v-else>未评分</span>
|
||||
</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>
|
||||
</t-descriptions-item>
|
||||
</t-descriptions>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 评分对话框 -->
|
||||
<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>
|
||||
</t-form-item>
|
||||
<t-form-item label="评分详情">
|
||||
<t-textarea
|
||||
v-model="scoreForm.llm_score_details"
|
||||
:rows="4"
|
||||
placeholder='{"专业能力": 85, "经验匹配": 90}'
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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: '',
|
||||
llm_filtered: undefined,
|
||||
min_score: undefined,
|
||||
max_score: undefined
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 详情
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentCandidate = ref(null)
|
||||
|
||||
// 评分
|
||||
const scoreDialogVisible = ref(false)
|
||||
const scoreFormRef = ref()
|
||||
const scoreForm = reactive({
|
||||
candidate_id: '',
|
||||
llm_score: 70,
|
||||
llm_score_details: ''
|
||||
})
|
||||
const scoreRules = {
|
||||
llm_score: [{ required: true, message: '请输入评分', trigger: 'blur' }]
|
||||
}
|
||||
const submitting = ref(false)
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
let res
|
||||
if (searchForm.min_score !== undefined && searchForm.max_score !== undefined) {
|
||||
// 使用评分范围查询
|
||||
res = await candidateApi.getByScoreRange({
|
||||
min_score: searchForm.min_score,
|
||||
max_score: searchForm.max_score,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
} else {
|
||||
// 使用筛选查询
|
||||
res = await candidateApi.filter({
|
||||
keyword: searchForm.keyword,
|
||||
llm_filtered: searchForm.llm_filtered,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
}
|
||||
candidateList.value = res.data?.items || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} catch (error) {
|
||||
MessagePlugin.error('加载数据失败: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.llm_filtered = undefined
|
||||
searchForm.min_score = undefined
|
||||
searchForm.max_score = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleView = async (row) => {
|
||||
try {
|
||||
const res = await candidateApi.getDetail(row.id)
|
||||
currentCandidate.value = res.data
|
||||
detailDialogVisible.value = true
|
||||
} catch (error) {
|
||||
MessagePlugin.error('获取详情失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 评分
|
||||
const handleScore = (row) => {
|
||||
scoreForm.candidate_id = row.id
|
||||
scoreForm.llm_score = row.llm_score || 70
|
||||
scoreForm.llm_score_details = row.llm_score_details
|
||||
? JSON.stringify(row.llm_score_details, null, 2)
|
||||
: ''
|
||||
scoreDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交评分
|
||||
const handleSubmitScore = async () => {
|
||||
const valid = await scoreFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = {
|
||||
candidate_id: scoreForm.candidate_id,
|
||||
llm_score: scoreForm.llm_score,
|
||||
llm_score_details: scoreForm.llm_score_details
|
||||
? JSON.parse(scoreForm.llm_score_details)
|
||||
: undefined
|
||||
}
|
||||
await candidateApi.updateScore(data)
|
||||
MessagePlugin.success('评分更新成功')
|
||||
scoreDialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('提交失败: ' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const getScoreColor = (score) => {
|
||||
if (score >= 80) return '#00A870'
|
||||
if (score >= 60) return '#EBB105'
|
||||
return '#E34D59'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.candidates-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.search-card :deep(.t-card__body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.score-display {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.score-details {
|
||||
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>
|
||||
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<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>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<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-value">{{ stats.recruiters }}</div>
|
||||
<div class="stat-label">招聘者账号</div>
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar blue" style="width: 75%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-value">{{ stats.jobs }}</div>
|
||||
<div class="stat-label">职位数量</div>
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar green" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card stat-card-orange">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon orange">
|
||||
<t-icon name="user-circle" size="24px" />
|
||||
</div>
|
||||
<div class="stat-trend up">
|
||||
<t-icon name="arrow-up" size="14px" />
|
||||
<span>24%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ stats.candidates }}</div>
|
||||
<div class="stat-label">候选人</div>
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar orange" style="width: 85%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card stat-card-pink">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon pink">
|
||||
<t-icon name="star" size="24px" />
|
||||
</div>
|
||||
<div class="stat-trend down">
|
||||
<t-icon name="arrow-down" size="14px" />
|
||||
<span>3%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ stats.evaluations }}</div>
|
||||
<div class="stat-label">评价记录</div>
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar pink" style="width: 45%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="content-grid">
|
||||
<!-- 快捷操作 -->
|
||||
<t-card class="action-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">快捷操作</span>
|
||||
<t-tag theme="primary" variant="light">常用</t-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="$router.push('/recruiters')">
|
||||
<div class="action-icon-wrapper blue">
|
||||
<t-icon name="user-add" size="24px" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- 系统状态 -->
|
||||
<t-card class="status-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">系统状态</span>
|
||||
<div class="live-indicator">
|
||||
<span class="live-dot"></span>
|
||||
<span>实时</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="system-status">
|
||||
<div class="status-item">
|
||||
<div class="status-left">
|
||||
<div class="status-icon-wrapper" :class="apiStatus === 'running' ? 'online' : 'offline'">
|
||||
<t-icon name="server" size="20px" />
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<span class="status-name">API 服务</span>
|
||||
<span class="status-desc">后端接口服务</span>
|
||||
</div>
|
||||
</div>
|
||||
<t-tag :theme="apiStatus === 'running' ? 'success' : 'danger'" variant="light" shape="round">
|
||||
{{ apiStatus === 'running' ? '运行中' : '异常' }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-left">
|
||||
<div class="status-icon-wrapper" :class="schedulerStatus.running ? 'online' : 'offline'">
|
||||
<t-icon name="time" size="20px" />
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<span class="status-name">调度器</span>
|
||||
<span class="status-desc">定时任务服务</span>
|
||||
</div>
|
||||
</div>
|
||||
<t-tag :theme="schedulerStatus.running ? 'success' : 'default'" variant="light" shape="round">
|
||||
{{ schedulerStatus.running ? '运行中' : '已停止' }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-left">
|
||||
<div class="status-icon-wrapper online">
|
||||
<t-icon name="task" size="20px" />
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<span class="status-name">任务数量</span>
|
||||
<span class="status-desc">当前任务总数</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-count">{{ schedulerStatus.total_jobs || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { recruiterApi, schedulerApi, systemApi } from '@/api/api'
|
||||
|
||||
const stats = ref({
|
||||
recruiters: 0,
|
||||
jobs: 0,
|
||||
candidates: 0,
|
||||
evaluations: 0
|
||||
})
|
||||
|
||||
const apiStatus = ref('running')
|
||||
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) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const checkApiStatus = async () => {
|
||||
try {
|
||||
await systemApi.health()
|
||||
apiStatus.value = 'running'
|
||||
} catch {
|
||||
apiStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
checkApiStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #F9FAFB;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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: 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>
|
||||
494
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/views/Jobs.vue
Normal file
494
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/views/Jobs.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<div class="jobs-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">职位管理</h2>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<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"
|
||||
/>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-space>
|
||||
<t-button theme="primary" @click="handleSearch">
|
||||
<template #icon><t-icon name="search" /></template>
|
||||
搜索
|
||||
</t-button>
|
||||
<t-button theme="default" @click="resetSearch">重置</t-button>
|
||||
</t-space>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<t-card :bordered="false">
|
||||
<t-table
|
||||
:data="jobList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
stripe
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<t-pagination
|
||||
v-model="pagination.page"
|
||||
v-model:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="isEdit ? '编辑职位' : '创建职位'"
|
||||
width="600px"
|
||||
:confirm-btn="{ content: '确定', loading: submitting }"
|
||||
:on-confirm="handleSubmit"
|
||||
:on-close="() => dialogVisible = false"
|
||||
>
|
||||
<t-form ref="formRef" :data="form" :rules="rules" :label-width="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"
|
||||
/>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form-item label="职位描述">
|
||||
<t-textarea
|
||||
v-model="form.description"
|
||||
:rows="4"
|
||||
placeholder="请输入职位描述"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 关联评价方案对话框 -->
|
||||
<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"
|
||||
/>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { jobApi } from '@/api/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
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: '',
|
||||
status: '',
|
||||
evaluation_schema_id: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
location: '',
|
||||
salary_min: undefined,
|
||||
salary_max: undefined,
|
||||
evaluation_schema_id: '',
|
||||
description: '',
|
||||
source: 'boss',
|
||||
source_id: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: '请输入职位标题', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 关联方案
|
||||
const bindDialogVisible = ref(false)
|
||||
const bindSchemaId = ref('')
|
||||
const currentJob = ref(null)
|
||||
const binding = ref(false)
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// 加载职位数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await jobApi.filter({
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status,
|
||||
evaluation_schema_id: searchForm.evaluation_schema_id,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
jobList.value = res.data?.items || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} catch (error) {
|
||||
MessagePlugin.error('加载数据失败: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载评价方案
|
||||
const loadSchemas = async () => {
|
||||
try {
|
||||
const res = await jobApi.getSchemaList({ page_size: 100 })
|
||||
schemaList.value = res.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('加载评价方案失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
searchForm.evaluation_schema_id = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 添加
|
||||
const showAddDialog = () => {
|
||||
isEdit.value = false
|
||||
form.id = ''
|
||||
form.title = ''
|
||||
form.department = ''
|
||||
form.location = ''
|
||||
form.salary_min = undefined
|
||||
form.salary_max = undefined
|
||||
form.evaluation_schema_id = ''
|
||||
form.description = ''
|
||||
form.source_id = `manual_${Date.now()}`
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
form.id = row.id
|
||||
form.title = row.title
|
||||
form.department = row.department
|
||||
form.location = row.location
|
||||
form.salary_min = row.salary_min
|
||||
form.salary_max = row.salary_max
|
||||
form.evaluation_schema_id = row.evaluation_schema_id
|
||||
form.description = row.description
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await jobApi.update(form.id, form)
|
||||
MessagePlugin.success('更新成功')
|
||||
} else {
|
||||
await jobApi.create(form)
|
||||
MessagePlugin.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('提交失败: ' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认删除',
|
||||
body: '确定要删除该职位吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await jobApi.delete(row.id)
|
||||
MessagePlugin.success('删除成功')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('删除失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 关联评价方案
|
||||
const handleBindSchema = (row) => {
|
||||
currentJob.value = row
|
||||
bindSchemaId.value = row.evaluation_schema_id || ''
|
||||
bindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmBindSchema = async () => {
|
||||
if (!bindSchemaId.value) {
|
||||
MessagePlugin.warning('请选择评价方案')
|
||||
return
|
||||
}
|
||||
binding.value = true
|
||||
try {
|
||||
await jobApi.bindSchema(currentJob.value.id, bindSchemaId.value)
|
||||
MessagePlugin.success('关联成功')
|
||||
bindDialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('关联失败: ' + error.message)
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 解除关联
|
||||
const handleUnbindSchema = async (row) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认解除',
|
||||
body: '确定要解除该职位的评价方案关联吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await jobApi.update(row.id, { evaluation_schema_id: null })
|
||||
MessagePlugin.success('已解除关联')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('操作失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const getSchemaName = (id) => {
|
||||
const schema = schemaList.value.find(s => s.id === id)
|
||||
return schema?.name || id
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadSchemas()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jobs-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.search-card :deep(.t-card__body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.schema-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.t-table) {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
:deep(.t-card__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,679 @@
|
||||
<template>
|
||||
<div class="channels-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">通知渠道管理</h2>
|
||||
<t-button theme="primary" @click="showAddDialog">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
添加渠道
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<t-card class="search-card" :bordered="false">
|
||||
<t-form layout="inline" :data="searchForm">
|
||||
<t-form-item label="渠道类型">
|
||||
<t-select v-model="searchForm.channel_type" placeholder="全部" clearable style="width: 150px">
|
||||
<t-option label="企业微信" value="wechat_work" />
|
||||
<t-option label="钉钉" value="dingtalk" />
|
||||
<t-option label="飞书" value="feishu" />
|
||||
<t-option label="邮件" value="email" />
|
||||
<t-option label="通用Webhook" value="webhook" />
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-space>
|
||||
<t-button theme="primary" @click="handleSearch">
|
||||
<template #icon><t-icon name="search" /></template>
|
||||
搜索
|
||||
</t-button>
|
||||
<t-button theme="default" @click="resetSearch">重置</t-button>
|
||||
</t-space>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<t-card :bordered="false">
|
||||
<t-table
|
||||
:data="channelList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
stripe
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<t-pagination
|
||||
v-model="pagination.page"
|
||||
v-model:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="isEdit ? '编辑通知渠道' : '添加通知渠道'"
|
||||
width="600px"
|
||||
:confirm-btn="{ content: '确定', loading: submitting }"
|
||||
:on-confirm="handleSubmit"
|
||||
:on-close="() => dialogVisible = false"
|
||||
>
|
||||
<t-form ref="formRef" :data="form" :rules="rules" :label-width="120">
|
||||
<t-form-item label="渠道名称" name="name">
|
||||
<t-input v-model="form.name" placeholder="请输入渠道名称" />
|
||||
</t-form-item>
|
||||
<t-form-item label="渠道类型" name="channel_type">
|
||||
<t-select v-model="form.channel_type" placeholder="请选择渠道类型" style="width: 100%" :disabled="isEdit">
|
||||
<t-option label="企业微信" value="wechat_work" />
|
||||
<t-option label="钉钉" value="dingtalk" />
|
||||
<t-option label="飞书" value="feishu" />
|
||||
<t-option label="邮件" value="email" />
|
||||
<t-option label="通用Webhook" value="webhook" />
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form-item label="描述">
|
||||
<t-textarea v-model="form.description" :rows="2" placeholder="请输入描述" />
|
||||
</t-form-item>
|
||||
|
||||
<!-- 飞书配置 -->
|
||||
<template v-if="form.channel_type === 'feishu'">
|
||||
<t-form-item label="Webhook地址" name="config.feishu_webhook">
|
||||
<t-input v-model="form.config.feishu_webhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." />
|
||||
</t-form-item>
|
||||
<t-form-item label="签名密钥">
|
||||
<t-input v-model="form.config.feishu_secret" placeholder="可选,用于验证请求" />
|
||||
</t-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 企业微信配置 -->
|
||||
<template v-if="form.channel_type === 'wechat_work'">
|
||||
<t-form-item label="Webhook地址" name="config.webhook_url">
|
||||
<t-input v-model="form.config.webhook_url" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||
</t-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 钉钉配置 -->
|
||||
<template v-if="form.channel_type === 'dingtalk'">
|
||||
<t-form-item label="Webhook地址" name="config.webhook_url">
|
||||
<t-input v-model="form.config.webhook_url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." />
|
||||
</t-form-item>
|
||||
<t-form-item label="签名密钥">
|
||||
<t-input v-model="form.config.sign_secret" placeholder="可选,用于钉钉安全设置" />
|
||||
</t-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 邮件配置 -->
|
||||
<template v-if="form.channel_type === 'email'">
|
||||
<t-form-item label="SMTP服务器" name="config.smtp_host">
|
||||
<t-input v-model="form.config.smtp_host" placeholder="smtp.example.com" />
|
||||
</t-form-item>
|
||||
<t-form-item label="SMTP端口" name="config.smtp_port">
|
||||
<t-input-number v-model="form.config.smtp_port" :min="1" :max="65535" placeholder="587" />
|
||||
</t-form-item>
|
||||
<t-form-item label="用户名" name="config.username">
|
||||
<t-input v-model="form.config.username" placeholder="邮箱地址" />
|
||||
</t-form-item>
|
||||
<t-form-item label="密码" name="config.password">
|
||||
<t-input v-model="form.config.password" type="password" placeholder="邮箱密码或授权码" />
|
||||
</t-form-item>
|
||||
<t-form-item label="发件人名称">
|
||||
<t-input v-model="form.config.sender_name" placeholder="显示的发件人名称" />
|
||||
</t-form-item>
|
||||
<t-form-item label="使用TLS">
|
||||
<t-switch v-model="form.config.use_tls" />
|
||||
</t-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 通用Webhook配置 -->
|
||||
<template v-if="form.channel_type === 'webhook'">
|
||||
<t-form-item label="Webhook地址" name="config.webhook_url">
|
||||
<t-input v-model="form.config.webhook_url" placeholder="https://..." />
|
||||
</t-form-item>
|
||||
</template>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 绑定招聘者对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="bindDialogVisible"
|
||||
header="绑定招聘者"
|
||||
width="600px"
|
||||
:confirm-btn="{ content: '确定', loading: binding }"
|
||||
:on-confirm="confirmBind"
|
||||
:on-close="() => bindDialogVisible = false"
|
||||
>
|
||||
<t-form :data="bindForm" :label-width="120">
|
||||
<t-form-item label="选择招聘者" name="recruiter_id">
|
||||
<t-select v-model="bindForm.recruiter_id" placeholder="请选择招聘者" style="width: 100%">
|
||||
<t-option
|
||||
v-for="recruiter in recruiterList"
|
||||
:key="recruiter.id"
|
||||
:label="recruiter.name"
|
||||
:value="recruiter.id"
|
||||
/>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form-item label="启用通知">
|
||||
<t-switch v-model="bindForm.is_enabled" />
|
||||
</t-form-item>
|
||||
<t-form-item label="新候选人通知">
|
||||
<t-switch v-model="bindForm.notify_on_new_candidate" />
|
||||
</t-form-item>
|
||||
<t-form-item label="评价完成通知">
|
||||
<t-switch v-model="bindForm.notify_on_evaluation" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高分候选人通知">
|
||||
<t-switch v-model="bindForm.notify_on_high_score" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高分阈值" v-if="bindForm.notify_on_high_score">
|
||||
<t-slider v-model="bindForm.high_score_threshold" :max="100" :step="1" />
|
||||
<div class="score-value">{{ bindForm.high_score_threshold }} 分</div>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 管理绑定对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="manageBindDialogVisible"
|
||||
header="管理绑定"
|
||||
width="700px"
|
||||
:footer="false"
|
||||
>
|
||||
<t-table
|
||||
:data="currentBindings"
|
||||
:columns="bindingColumns"
|
||||
row-key="recruiter_id"
|
||||
stripe
|
||||
/>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { notificationChannelApi, recruiterApi } from '@/api/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const channelList = ref([])
|
||||
const recruiterList = ref([])
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ colKey: 'name', title: '渠道名称', minWidth: 150 },
|
||||
{
|
||||
colKey: 'channel_type',
|
||||
title: '类型',
|
||||
width: 120,
|
||||
cell: (h, { row }) => {
|
||||
const typeMap = {
|
||||
wechat_work: '企业微信',
|
||||
dingtalk: '钉钉',
|
||||
feishu: '飞书',
|
||||
email: '邮件',
|
||||
webhook: 'Webhook'
|
||||
}
|
||||
const themeMap = {
|
||||
wechat_work: 'success',
|
||||
dingtalk: 'primary',
|
||||
feishu: 'warning',
|
||||
email: 'default',
|
||||
webhook: 'danger'
|
||||
}
|
||||
return h('t-tag', { theme: themeMap[row.channel_type] || 'default' }, typeMap[row.channel_type] || row.channel_type)
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-tag', { theme: row.status === 'active' ? 'success' : 'default', size: 'small' },
|
||||
row.status === 'active' ? '启用' : '停用')
|
||||
}
|
||||
},
|
||||
{ colKey: 'description', title: '描述', minWidth: 200, ellipsis: true },
|
||||
{
|
||||
colKey: 'recruiter_count',
|
||||
title: '绑定招聘者',
|
||||
width: 120,
|
||||
cell: (h, { row }) => {
|
||||
const count = row.recruiter_ids?.length || 0
|
||||
return h('t-badge', { count: count, showZero: true }, `${count}个`)
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'operation',
|
||||
title: '操作',
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
cell: (h, { row }) => {
|
||||
return h('t-space', { size: 'small' }, {
|
||||
default: () => [
|
||||
h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'),
|
||||
h('t-button', { size: 'small', theme: 'primary', onClick: () => handleBind(row) }, '绑定'),
|
||||
h('t-button', { size: 'small', onClick: () => handleManageBind(row) }, '管理'),
|
||||
h('t-button', {
|
||||
size: 'small',
|
||||
theme: row.status === 'active' ? 'warning' : 'success',
|
||||
onClick: () => toggleStatus(row)
|
||||
}, row.status === 'active' ? '停用' : '启用'),
|
||||
h('t-button', { size: 'small', theme: 'danger', onClick: () => handleDelete(row) }, '删除')
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 绑定表格列
|
||||
const bindingColumns = [
|
||||
{ colKey: 'recruiter_id', title: '招聘者ID', minWidth: 200 },
|
||||
{
|
||||
colKey: 'is_enabled',
|
||||
title: '启用',
|
||||
width: 80,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-tag', { theme: row.is_enabled ? 'success' : 'default', size: 'small' },
|
||||
row.is_enabled ? '是' : '否')
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'notify_on_new_candidate',
|
||||
title: '新候选人',
|
||||
width: 100,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-tag', { theme: row.notify_on_new_candidate ? 'success' : 'default', size: 'small' },
|
||||
row.notify_on_new_candidate ? '通知' : '关闭')
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'notify_on_evaluation',
|
||||
title: '评价完成',
|
||||
width: 100,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-tag', { theme: row.notify_on_evaluation ? 'success' : 'default', size: 'small' },
|
||||
row.notify_on_evaluation ? '通知' : '关闭')
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'operation',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-button', {
|
||||
size: 'small',
|
||||
theme: 'danger',
|
||||
onClick: () => handleUnbind(row)
|
||||
}, '解绑')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
channel_type: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
channel_type: '',
|
||||
description: '',
|
||||
config: {
|
||||
webhook_url: '',
|
||||
secret: '',
|
||||
access_token: '',
|
||||
sign_secret: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
use_tls: true,
|
||||
sender_name: '',
|
||||
feishu_webhook: '',
|
||||
feishu_secret: ''
|
||||
}
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
|
||||
channel_type: [{ required: true, message: '请选择渠道类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// 绑定对话框
|
||||
const bindDialogVisible = ref(false)
|
||||
const binding = ref(false)
|
||||
const currentChannel = ref(null)
|
||||
const bindForm = reactive({
|
||||
recruiter_id: '',
|
||||
is_enabled: true,
|
||||
notify_on_new_candidate: true,
|
||||
notify_on_evaluation: true,
|
||||
notify_on_high_score: false,
|
||||
high_score_threshold: 80
|
||||
})
|
||||
|
||||
// 管理绑定对话框
|
||||
const manageBindDialogVisible = ref(false)
|
||||
const currentBindings = ref([])
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await notificationChannelApi.getList({
|
||||
channel_type: searchForm.channel_type,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
channelList.value = res.data?.items || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} catch (error) {
|
||||
MessagePlugin.error('加载数据失败: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载招聘者列表
|
||||
const loadRecruiters = async () => {
|
||||
try {
|
||||
const res = await recruiterApi.getList({ page_size: 100 })
|
||||
recruiterList.value = res.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('加载招聘者失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.channel_type = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 添加
|
||||
const showAddDialog = () => {
|
||||
isEdit.value = false
|
||||
form.id = ''
|
||||
form.name = ''
|
||||
form.channel_type = ''
|
||||
form.description = ''
|
||||
resetConfig()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetConfig = () => {
|
||||
form.config = {
|
||||
webhook_url: '',
|
||||
secret: '',
|
||||
access_token: '',
|
||||
sign_secret: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
use_tls: true,
|
||||
sender_name: '',
|
||||
feishu_webhook: '',
|
||||
feishu_secret: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
form.id = row.id
|
||||
form.name = row.name
|
||||
form.channel_type = row.channel_type
|
||||
form.description = row.description
|
||||
// 合并配置
|
||||
form.config = { ...form.config, ...row.config }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 构建提交数据
|
||||
const submitData = {
|
||||
name: form.name,
|
||||
channel_type: form.channel_type,
|
||||
description: form.description,
|
||||
config: {}
|
||||
}
|
||||
|
||||
// 根据类型添加配置
|
||||
if (form.channel_type === 'feishu') {
|
||||
submitData.config = {
|
||||
feishu_webhook: form.config.feishu_webhook,
|
||||
feishu_secret: form.config.feishu_secret
|
||||
}
|
||||
} else if (form.channel_type === 'wechat_work' || form.channel_type === 'dingtalk' || form.channel_type === 'webhook') {
|
||||
submitData.config = {
|
||||
webhook_url: form.config.webhook_url,
|
||||
sign_secret: form.config.sign_secret
|
||||
}
|
||||
} else if (form.channel_type === 'email') {
|
||||
submitData.config = {
|
||||
smtp_host: form.config.smtp_host,
|
||||
smtp_port: form.config.smtp_port,
|
||||
username: form.config.username,
|
||||
password: form.config.password,
|
||||
use_tls: form.config.use_tls,
|
||||
sender_name: form.config.sender_name
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await notificationChannelApi.update(form.id, submitData)
|
||||
MessagePlugin.success('更新成功')
|
||||
} else {
|
||||
await notificationChannelApi.create(submitData)
|
||||
MessagePlugin.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('提交失败: ' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认删除',
|
||||
body: '确定要删除该通知渠道吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await notificationChannelApi.delete(row.id)
|
||||
MessagePlugin.success('删除成功')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('删除失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const toggleStatus = async (row) => {
|
||||
try {
|
||||
if (row.status === 'active') {
|
||||
await notificationChannelApi.deactivate(row.id)
|
||||
MessagePlugin.success('已停用')
|
||||
} else {
|
||||
await notificationChannelApi.activate(row.id)
|
||||
MessagePlugin.success('已启用')
|
||||
}
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('操作失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定招聘者
|
||||
const handleBind = (row) => {
|
||||
currentChannel.value = row
|
||||
bindForm.recruiter_id = ''
|
||||
bindForm.is_enabled = true
|
||||
bindForm.notify_on_new_candidate = true
|
||||
bindForm.notify_on_evaluation = true
|
||||
bindForm.notify_on_high_score = false
|
||||
bindForm.high_score_threshold = 80
|
||||
bindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmBind = async () => {
|
||||
if (!bindForm.recruiter_id) {
|
||||
MessagePlugin.warning('请选择招聘者')
|
||||
return
|
||||
}
|
||||
binding.value = true
|
||||
try {
|
||||
await notificationChannelApi.bindRecruiter(
|
||||
currentChannel.value.id,
|
||||
bindForm.recruiter_id,
|
||||
{
|
||||
is_enabled: bindForm.is_enabled,
|
||||
notify_on_new_candidate: bindForm.notify_on_new_candidate,
|
||||
notify_on_evaluation: bindForm.notify_on_evaluation,
|
||||
notify_on_high_score: bindForm.notify_on_high_score,
|
||||
high_score_threshold: bindForm.high_score_threshold
|
||||
}
|
||||
)
|
||||
MessagePlugin.success('绑定成功')
|
||||
bindDialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('绑定失败: ' + error.message)
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 管理绑定
|
||||
const handleManageBind = async (row) => {
|
||||
currentChannel.value = row
|
||||
try {
|
||||
const res = await notificationChannelApi.getChannelRecruiters(row.id)
|
||||
currentBindings.value = res.data?.items || []
|
||||
manageBindDialogVisible.value = true
|
||||
} catch (error) {
|
||||
MessagePlugin.error('获取绑定列表失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 解绑
|
||||
const handleUnbind = async (row) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认解绑',
|
||||
body: '确定要解绑该招聘者吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await notificationChannelApi.unbindRecruiter(currentChannel.value.id, row.recruiter_id)
|
||||
MessagePlugin.success('解绑成功')
|
||||
// 刷新绑定列表
|
||||
const res = await notificationChannelApi.getChannelRecruiters(currentChannel.value.id)
|
||||
currentBindings.value = res.data?.items || []
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('解绑失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadRecruiters()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.channels-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.search-card :deep(.t-card__body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.t-table) {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
:deep(.t-card__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,658 @@
|
||||
<template>
|
||||
<div class="recruiters-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">招聘者管理</h2>
|
||||
<t-button theme="primary" @click="showAddDialog">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
添加招聘者
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<t-card class="search-card" :bordered="false">
|
||||
<t-form layout="inline" :data="searchForm">
|
||||
<t-form-item label="平台来源">
|
||||
<t-select v-model="searchForm.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>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<t-card :bordered="false">
|
||||
<t-table
|
||||
:data="recruiterList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
stripe
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<t-pagination
|
||||
v-model="pagination.page"
|
||||
v-model:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="isEdit ? '编辑招聘者' : '添加招聘者'"
|
||||
width="500px"
|
||||
:confirm-btn="{ content: '确定', loading: submitting }"
|
||||
:on-confirm="handleSubmit"
|
||||
:on-close="() => dialogVisible = false"
|
||||
>
|
||||
<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"
|
||||
:rows="3"
|
||||
placeholder="请输入WT Token"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 自动注册对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="registerDialogVisible"
|
||||
header="自动注册招聘者"
|
||||
width="500px"
|
||||
:confirm-btn="{ content: '自动注册', loading: registering }"
|
||||
:on-confirm="handleRegister"
|
||||
:on-close="() => registerDialogVisible = false"
|
||||
>
|
||||
<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"
|
||||
:rows="3"
|
||||
placeholder="请输入WT Token,系统将自动获取账号信息"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
<!-- 通知渠道绑定对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="channelBindDialogVisible"
|
||||
header="绑定通知渠道"
|
||||
width="700px"
|
||||
:footer="false"
|
||||
>
|
||||
<t-space direction="vertical" style="width: 100%">
|
||||
<t-card title="已绑定渠道" :bordered="false" size="small">
|
||||
<t-table
|
||||
:data="currentRecruiterChannels"
|
||||
:columns="boundChannelColumns"
|
||||
row-key="channel_id"
|
||||
stripe
|
||||
size="small"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<t-card title="添加绑定" :bordered="false" size="small">
|
||||
<t-form :data="channelBindForm" :label-width="100">
|
||||
<t-form-item label="选择渠道">
|
||||
<t-select v-model="channelBindForm.channel_id" placeholder="请选择通知渠道" style="width: 100%">
|
||||
<t-option
|
||||
v-for="channel in availableChannels"
|
||||
:key="channel.id"
|
||||
:label="channel.name"
|
||||
:value="channel.id"
|
||||
/>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form-item label="启用通知">
|
||||
<t-switch v-model="channelBindForm.is_enabled" />
|
||||
</t-form-item>
|
||||
<t-form-item label="新候选人通知">
|
||||
<t-switch v-model="channelBindForm.notify_on_new_candidate" />
|
||||
</t-form-item>
|
||||
<t-form-item label="评价完成通知">
|
||||
<t-switch v-model="channelBindForm.notify_on_evaluation" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高分候选人通知">
|
||||
<t-switch v-model="channelBindForm.notify_on_high_score" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高分阈值" v-if="channelBindForm.notify_on_high_score">
|
||||
<t-slider v-model="channelBindForm.high_score_threshold" :max="100" :step="1" />
|
||||
<div class="score-value">{{ channelBindForm.high_score_threshold }} 分</div>
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-button theme="primary" @click="confirmChannelBind" :loading="channelBinding">添加绑定</t-button>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-card>
|
||||
</t-space>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { recruiterApi, notificationChannelApi } 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: 340,
|
||||
fixed: 'right',
|
||||
cell: (h, { row }) => {
|
||||
return h('t-space', { size: 'small' }, {
|
||||
default: () => [
|
||||
h('t-button', { size: 'small', onClick: () => handleEdit(row) }, '编辑'),
|
||||
h('t-button', { size: 'small', theme: 'primary', onClick: () => handleSync(row) }, '同步'),
|
||||
h('t-button', { size: 'small', onClick: () => handleChannelBind(row) }, '通知渠道'),
|
||||
h('t-button', {
|
||||
size: 'small',
|
||||
theme: row.status === 'active' ? 'warning' : 'success',
|
||||
onClick: () => toggleStatus(row)
|
||||
}, row.status === 'active' ? '停用' : '启用'),
|
||||
h('t-button', { size: 'small', theme: 'danger', onClick: () => handleDelete(row) }, '删除')
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 已绑定渠道表格列
|
||||
const boundChannelColumns = [
|
||||
{ colKey: 'channel_name', title: '渠道名称', minWidth: 120 },
|
||||
{
|
||||
colKey: 'channel_type',
|
||||
title: '类型',
|
||||
width: 100,
|
||||
cell: (h, { row }) => {
|
||||
const typeMap = {
|
||||
wechat_work: '企业微信',
|
||||
dingtalk: '钉钉',
|
||||
feishu: '飞书',
|
||||
email: '邮件',
|
||||
webhook: 'Webhook'
|
||||
}
|
||||
return typeMap[row.channel_type] || row.channel_type
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'is_enabled',
|
||||
title: '启用',
|
||||
width: 80,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-tag', { theme: row.is_enabled ? 'success' : 'default', size: 'small' },
|
||||
row.is_enabled ? '是' : '否')
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'notify_on_new_candidate',
|
||||
title: '新候选人',
|
||||
width: 90,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-tag', { theme: row.notify_on_new_candidate ? 'success' : 'default', size: 'small' },
|
||||
row.notify_on_new_candidate ? '通知' : '关闭')
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'operation',
|
||||
title: '操作',
|
||||
width: 80,
|
||||
cell: (h, { row }) => {
|
||||
return h('t-button', {
|
||||
size: 'small',
|
||||
theme: 'danger',
|
||||
onClick: () => handleUnbindChannel(row)
|
||||
}, '解绑')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
source: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
source: 'boss',
|
||||
wt_token: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入账号名称', trigger: 'blur' }],
|
||||
source: [{ required: true, message: '请选择平台来源', trigger: 'change' }],
|
||||
wt_token: [{ required: true, message: '请输入WT Token', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 自动注册
|
||||
const registerDialogVisible = ref(false)
|
||||
const registerFormRef = ref()
|
||||
const registerForm = reactive({
|
||||
source: 'boss',
|
||||
wt_token: ''
|
||||
})
|
||||
const registerRules = {
|
||||
source: [{ required: true, message: '请选择平台来源', trigger: 'change' }],
|
||||
wt_token: [{ required: true, message: '请输入WT Token', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const submitting = ref(false)
|
||||
const registering = ref(false)
|
||||
|
||||
// 通知渠道绑定
|
||||
const channelBindDialogVisible = ref(false)
|
||||
const channelBinding = ref(false)
|
||||
const currentRecruiter = ref(null)
|
||||
const currentRecruiterChannels = ref([])
|
||||
const availableChannels = ref([])
|
||||
const channelBindForm = reactive({
|
||||
channel_id: '',
|
||||
is_enabled: true,
|
||||
notify_on_new_candidate: true,
|
||||
notify_on_evaluation: true,
|
||||
notify_on_high_score: false,
|
||||
high_score_threshold: 80
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await recruiterApi.getList({
|
||||
source: searchForm.source,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
recruiterList.value = res.data?.items || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} catch (error) {
|
||||
MessagePlugin.error('加载数据失败: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.source = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 添加
|
||||
const showAddDialog = () => {
|
||||
isEdit.value = false
|
||||
form.id = ''
|
||||
form.name = ''
|
||||
form.source = 'boss'
|
||||
form.wt_token = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
form.id = row.id
|
||||
form.name = row.name
|
||||
form.source = row.source
|
||||
form.wt_token = row.wt_token
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await recruiterApi.update(form.id, form)
|
||||
MessagePlugin.success('更新成功')
|
||||
} else {
|
||||
await recruiterApi.create(form)
|
||||
MessagePlugin.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('提交失败: ' + error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认删除',
|
||||
body: '确定要删除该招聘者吗?',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await recruiterApi.delete(row.id)
|
||||
MessagePlugin.success('删除成功')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('删除失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 同步
|
||||
const handleSync = async (row) => {
|
||||
try {
|
||||
await recruiterApi.sync(row.id)
|
||||
MessagePlugin.success('同步任务已触发')
|
||||
} catch (error) {
|
||||
MessagePlugin.error('同步失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const toggleStatus = async (row) => {
|
||||
try {
|
||||
if (row.status === 'active') {
|
||||
await recruiterApi.deactivate(row.id)
|
||||
MessagePlugin.success('已停用')
|
||||
} else {
|
||||
await recruiterApi.activate(row.id)
|
||||
MessagePlugin.success('已启用')
|
||||
}
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('操作失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册
|
||||
const handleRegister = async () => {
|
||||
const valid = await registerFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
registering.value = true
|
||||
try {
|
||||
const res = await recruiterApi.register(registerForm)
|
||||
if (res.data?.success) {
|
||||
MessagePlugin.success('注册成功: ' + res.data?.message)
|
||||
registerDialogVisible.value = false
|
||||
loadData()
|
||||
} else {
|
||||
MessagePlugin.warning(res.data?.message || '注册失败')
|
||||
}
|
||||
} catch (error) {
|
||||
MessagePlugin.error('注册失败: ' + error.message)
|
||||
} finally {
|
||||
registering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const formatTime = (time) => {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
// 通知渠道绑定相关方法
|
||||
const handleChannelBind = async (row) => {
|
||||
currentRecruiter.value = row
|
||||
channelBindForm.channel_id = ''
|
||||
channelBindForm.is_enabled = true
|
||||
channelBindForm.notify_on_new_candidate = true
|
||||
channelBindForm.notify_on_evaluation = true
|
||||
channelBindForm.notify_on_high_score = false
|
||||
channelBindForm.high_score_threshold = 80
|
||||
|
||||
try {
|
||||
// 加载招聘者已绑定的渠道
|
||||
const bindRes = await notificationChannelApi.getChannelRecruiters(row.id)
|
||||
currentRecruiterChannels.value = bindRes.data?.items || []
|
||||
|
||||
// 加载所有可用渠道
|
||||
const channelRes = await notificationChannelApi.getList({ page_size: 100 })
|
||||
const allChannels = channelRes.data?.items || []
|
||||
|
||||
// 过滤掉已绑定的渠道
|
||||
const boundIds = currentRecruiterChannels.value.map(b => b.channel_id)
|
||||
availableChannels.value = allChannels.filter(c => !boundIds.includes(c.id))
|
||||
|
||||
channelBindDialogVisible.value = true
|
||||
} catch (error) {
|
||||
MessagePlugin.error('加载渠道信息失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmChannelBind = async () => {
|
||||
if (!channelBindForm.channel_id) {
|
||||
MessagePlugin.warning('请选择通知渠道')
|
||||
return
|
||||
}
|
||||
channelBinding.value = true
|
||||
try {
|
||||
await notificationChannelApi.bindRecruiter(
|
||||
channelBindForm.channel_id,
|
||||
currentRecruiter.value.id,
|
||||
{
|
||||
is_enabled: channelBindForm.is_enabled,
|
||||
notify_on_new_candidate: channelBindForm.notify_on_new_candidate,
|
||||
notify_on_evaluation: channelBindForm.notify_on_evaluation,
|
||||
notify_on_high_score: channelBindForm.notify_on_high_score,
|
||||
high_score_threshold: channelBindForm.high_score_threshold
|
||||
}
|
||||
)
|
||||
MessagePlugin.success('绑定成功')
|
||||
|
||||
// 刷新已绑定列表
|
||||
const bindRes = await notificationChannelApi.getChannelRecruiters(currentRecruiter.value.id)
|
||||
currentRecruiterChannels.value = bindRes.data?.items || []
|
||||
|
||||
// 刷新可用渠道列表
|
||||
const channelRes = await notificationChannelApi.getList({ page_size: 100 })
|
||||
const allChannels = channelRes.data?.items || []
|
||||
const boundIds = currentRecruiterChannels.value.map(b => b.channel_id)
|
||||
availableChannels.value = allChannels.filter(c => !boundIds.includes(c.id))
|
||||
|
||||
// 重置表单
|
||||
channelBindForm.channel_id = ''
|
||||
} catch (error) {
|
||||
MessagePlugin.error('绑定失败: ' + error.message)
|
||||
} finally {
|
||||
channelBinding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnbindChannel = async (row) => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '确认解绑',
|
||||
body: `确定要解绑渠道 "${row.channel_name}" 吗?`,
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await notificationChannelApi.unbindRecruiter(row.channel_id, currentRecruiter.value.id)
|
||||
MessagePlugin.success('解绑成功')
|
||||
|
||||
// 刷新已绑定列表
|
||||
const bindRes = await notificationChannelApi.getChannelRecruiters(currentRecruiter.value.id)
|
||||
currentRecruiterChannels.value = bindRes.data?.items || []
|
||||
|
||||
// 刷新可用渠道列表
|
||||
const channelRes = await notificationChannelApi.getList({ page_size: 100 })
|
||||
const allChannels = channelRes.data?.items || []
|
||||
const boundIds = currentRecruiterChannels.value.map(b => b.channel_id)
|
||||
availableChannels.value = allChannels.filter(c => !boundIds.includes(c.id))
|
||||
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('解绑失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recruiters-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.search-card :deep(.t-card__body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.privilege-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.t-table) {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
:deep(.t-card__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="scheduler-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">定时任务管理</h2>
|
||||
<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>
|
||||
|
||||
<!-- 调度器状态 -->
|
||||
<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'">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</t-card>
|
||||
</t-col>
|
||||
</t-row>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<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>
|
||||
|
||||
<t-table
|
||||
:data="jobList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
row-key="job_id"
|
||||
stripe
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<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, h } from 'vue'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { schedulerApi } from '@/api/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
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)
|
||||
const configForm = reactive({
|
||||
job_id: '',
|
||||
enabled: true,
|
||||
interval_minutes: 30
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [jobsRes, statusRes] = await Promise.all([
|
||||
schedulerApi.getJobsStatus(),
|
||||
schedulerApi.getStatus()
|
||||
])
|
||||
jobList.value = jobsRes.data || []
|
||||
schedulerStatus.value = statusRes.data || {}
|
||||
} catch (error) {
|
||||
MessagePlugin.error('加载数据失败: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 启动调度器
|
||||
const handleStart = async () => {
|
||||
try {
|
||||
await schedulerApi.start()
|
||||
MessagePlugin.success('调度器已启动')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('启动失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止调度器
|
||||
const handleStop = async () => {
|
||||
const confirmDia = DialogPlugin.confirm({
|
||||
header: '警告',
|
||||
body: '确定要停止调度器吗?正在运行的任务将被中断。',
|
||||
theme: 'warning',
|
||||
confirmBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await schedulerApi.stop()
|
||||
MessagePlugin.success('调度器已停止')
|
||||
loadData()
|
||||
confirmDia.destroy()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('停止失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 立即执行任务
|
||||
const handleRun = async (row) => {
|
||||
try {
|
||||
await schedulerApi.runJob(row.job_id)
|
||||
MessagePlugin.success('任务已开始执行')
|
||||
setTimeout(loadData, 1000)
|
||||
} catch (error) {
|
||||
MessagePlugin.error('执行失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
const toggleJobStatus = async (row) => {
|
||||
try {
|
||||
if (row.enabled) {
|
||||
await schedulerApi.pauseJob(row.job_id)
|
||||
MessagePlugin.success('任务已暂停')
|
||||
} else {
|
||||
await schedulerApi.resumeJob(row.job_id)
|
||||
MessagePlugin.success('任务已恢复')
|
||||
}
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('操作失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开配置对话框
|
||||
const handleConfig = (row) => {
|
||||
configForm.job_id = row.job_id
|
||||
configForm.enabled = row.enabled
|
||||
configForm.interval_minutes = 30
|
||||
configDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交配置
|
||||
const handleSubmitConfig = async () => {
|
||||
configuring.value = true
|
||||
try {
|
||||
await schedulerApi.updateConfig(configForm.job_id, {
|
||||
enabled: configForm.enabled,
|
||||
interval_minutes: configForm.interval_minutes
|
||||
})
|
||||
MessagePlugin.success('配置已更新')
|
||||
configDialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
MessagePlugin.error('更新失败: ' + error.message)
|
||||
} finally {
|
||||
configuring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const formatTime = (time) => {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
// 自动刷新
|
||||
setInterval(loadData, 10000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scheduler-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.status-card :deep(.t-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-icon.running {
|
||||
background: linear-gradient(135deg, #10B981 0%, #34D399 100%);
|
||||
}
|
||||
|
||||
.status-icon.stopped {
|
||||
background: linear-gradient(135deg, #EF4444 0%, #F87171 100%);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.t-table) {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
:deep(.t-card__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
25
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/vite.config.js
Normal file
25
src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/vite.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/candidates': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user