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
|
||||||
**/.idea/**
|
**/.idea/**
|
||||||
|
|
||||||
|
#node_modules
|
||||||
|
**/node_modules/**
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
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 (
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
candidate_id VARCHAR(64) NOT NULL,
|
candidate_id VARCHAR(64) NOT NULL,
|
||||||
evaluation_id VARCHAR(64),
|
evaluation_id VARCHAR(64),
|
||||||
channel VARCHAR(32) NOT NULL, -- WECHAT_WORK, DINGTALK, EMAIL, WEBHOOK
|
channel_id VARCHAR(64), -- 关联的通知渠道ID
|
||||||
|
channel VARCHAR(32) NOT NULL, -- 渠道类型: WECHAT_WORK, DINGTALK, EMAIL, FEISHU, WEBHOOK
|
||||||
content TEXT,
|
content TEXT,
|
||||||
status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED
|
status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
@@ -177,13 +214,36 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE,
|
FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (evaluation_id) REFERENCES evaluations(id) ON DELETE SET NULL,
|
FOREIGN KEY (evaluation_id) REFERENCES evaluations(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE SET NULL,
|
||||||
INDEX idx_candidate_id (candidate_id),
|
INDEX idx_candidate_id (candidate_id),
|
||||||
|
INDEX idx_channel_id (channel_id),
|
||||||
INDEX idx_status (status),
|
INDEX idx_status (status),
|
||||||
INDEX idx_created_at (created_at)
|
INDEX idx_created_at (created_at)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- 8. 插入默认评价方案
|
-- 10. 插入默认通知渠道(可选)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
|
||||||
|
('demo_wechat', '企业微信通知', 'wechat_work',
|
||||||
|
'{"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "secret": ""}',
|
||||||
|
'企业微信机器人通知渠道(示例配置)')
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||||
|
|
||||||
|
INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
|
||||||
|
('demo_dingtalk', '钉钉通知', 'dingtalk',
|
||||||
|
'{"access_token": "YOUR_ACCESS_TOKEN", "sign_secret": ""}',
|
||||||
|
'钉钉机器人通知渠道(示例配置)')
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||||
|
|
||||||
|
INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES
|
||||||
|
('demo_feishu', '飞书通知', 'feishu',
|
||||||
|
'{"feishu_webhook": "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", "feishu_secret": ""}',
|
||||||
|
'飞书机器人通知渠道(示例配置)')
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 11. 插入默认评价方案
|
||||||
-- ============================================
|
-- ============================================
|
||||||
INSERT INTO evaluation_schemas (id, name, description, dimensions, weights, is_default) VALUES
|
INSERT INTO evaluation_schemas (id, name, description, dimensions, weights, is_default) VALUES
|
||||||
('general', '通用评价方案', '适用于各类岗位的通用评价方案',
|
('general', '通用评价方案', '适用于各类岗位的通用评价方案',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if str(src_path) not in sys.path:
|
|||||||
sys.path.insert(0, str(src_path))
|
sys.path.insert(0, str(src_path))
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, BackgroundTasks
|
||||||
|
|
||||||
from cn.yinlihupo.ylhp_hr_2_0.controller.api import create_app
|
from cn.yinlihupo.ylhp_hr_2_0.controller.api import create_app
|
||||||
from cn.yinlihupo.ylhp_hr_2_0.service.scheduler import get_scheduler
|
from cn.yinlihupo.ylhp_hr_2_0.service.scheduler import get_scheduler
|
||||||
@@ -61,10 +61,10 @@ def create_combined_app(enable_scheduler: bool = True) -> FastAPI:
|
|||||||
return {"success": True, "message": f"Job {job_id} resumed"}
|
return {"success": True, "message": f"Job {job_id} resumed"}
|
||||||
|
|
||||||
@app.post("/api/scheduler/trigger/crawl")
|
@app.post("/api/scheduler/trigger/crawl")
|
||||||
async def trigger_crawl():
|
async def trigger_crawl(background_tasks: BackgroundTasks):
|
||||||
"""手动触发爬取任务"""
|
"""手动触发爬取任务"""
|
||||||
scheduler = get_scheduler()
|
scheduler = get_scheduler()
|
||||||
asyncio.create_task(scheduler._crawl_boss())
|
background_tasks.add_task(scheduler._crawl_boss)
|
||||||
return {"success": True, "message": "Crawl task triggered"}
|
return {"success": True, "message": "Crawl task triggered"}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Database configuration using SQLAlchemy"""
|
"""Database configuration using SQLAlchemy"""
|
||||||
from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON
|
from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON, Boolean
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ class RecruiterModel(Base):
|
|||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
channel_bindings = relationship("RecruiterChannelBindingModel", back_populates="recruiter", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class CandidateModel(Base):
|
class CandidateModel(Base):
|
||||||
"""候选人主表"""
|
"""候选人主表"""
|
||||||
@@ -139,6 +142,48 @@ class EvaluationModel(Base):
|
|||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelModel(Base):
|
||||||
|
"""通知渠道表"""
|
||||||
|
__tablename__ = 'notification_channels'
|
||||||
|
|
||||||
|
id = Column(String(64), primary_key=True)
|
||||||
|
name = Column(String(128), nullable=False) # 渠道名称
|
||||||
|
channel_type = Column(String(32), nullable=False) # 渠道类型: wechat_work, dingtalk, email, feishu, webhook
|
||||||
|
config = Column(JSON, nullable=False) # 渠道配置JSON
|
||||||
|
status = Column(String(32), default='active') # active, inactive
|
||||||
|
description = Column(Text) # 描述
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
recruiter_bindings = relationship("RecruiterChannelBindingModel", back_populates="channel", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class RecruiterChannelBindingModel(Base):
|
||||||
|
"""招聘者与通知渠道绑定关系表(多对多)"""
|
||||||
|
__tablename__ = 'recruiter_channel_bindings'
|
||||||
|
|
||||||
|
id = Column(String(64), primary_key=True)
|
||||||
|
recruiter_id = Column(String(64), ForeignKey('recruiters.id'), nullable=False)
|
||||||
|
channel_id = Column(String(64), ForeignKey('notification_channels.id'), nullable=False)
|
||||||
|
is_enabled = Column(Boolean, default=True) # 是否启用该渠道
|
||||||
|
notify_on_new_candidate = Column(Boolean, default=True) # 新候选人时通知
|
||||||
|
notify_on_evaluation = Column(Boolean, default=True) # 完成评价时通知
|
||||||
|
notify_on_high_score = Column(Boolean, default=False) # 高分候选人时通知
|
||||||
|
high_score_threshold = Column(Integer, default=85) # 高分阈值
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
recruiter = relationship("RecruiterModel", back_populates="channel_bindings")
|
||||||
|
channel = relationship("NotificationChannelModel", back_populates="recruiter_bindings")
|
||||||
|
|
||||||
|
# 唯一约束:一个招聘者不能重复绑定同一个渠道
|
||||||
|
__table_args__ = (
|
||||||
|
# 使用唯一约束防止重复绑定
|
||||||
|
{'sqlite_autoincrement': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationModel(Base):
|
class NotificationModel(Base):
|
||||||
"""通知记录表"""
|
"""通知记录表"""
|
||||||
__tablename__ = 'notifications'
|
__tablename__ = 'notifications'
|
||||||
@@ -146,7 +191,8 @@ class NotificationModel(Base):
|
|||||||
id = Column(String(64), primary_key=True)
|
id = Column(String(64), primary_key=True)
|
||||||
candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False)
|
candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False)
|
||||||
evaluation_id = Column(String(64), ForeignKey('evaluations.id'))
|
evaluation_id = Column(String(64), ForeignKey('evaluations.id'))
|
||||||
channel = Column(String(32), nullable=False)
|
channel_id = Column(String(64), ForeignKey('notification_channels.id')) # 关联渠道ID
|
||||||
|
channel = Column(String(32), nullable=False) # 渠道类型(冗余字段,方便查询)
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
status = Column(String(32), default='PENDING')
|
status = Column(String(32), default='PENDING')
|
||||||
error_message = Column(Text)
|
error_message = Column(Text)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ FastAPI主应用入口
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .routes import recruiter_router, scheduler_router, candidate_router, job_router
|
from .routes import recruiter_router, scheduler_router, candidate_router, job_router, notification_channel_router
|
||||||
from .routes.system import router as system_router
|
from .routes.system import router as system_router
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +49,9 @@ def create_app() -> FastAPI:
|
|||||||
# 职位管理路由
|
# 职位管理路由
|
||||||
app.include_router(job_router)
|
app.include_router(job_router)
|
||||||
|
|
||||||
|
# 通知渠道管理路由
|
||||||
|
app.include_router(notification_channel_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ API路由模块
|
|||||||
- scheduler: 定时任务管理
|
- scheduler: 定时任务管理
|
||||||
- system: 系统接口
|
- system: 系统接口
|
||||||
- candidate: 候选人管理
|
- candidate: 候选人管理
|
||||||
|
- notification_channel: 通知渠道管理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .recruiter import router as recruiter_router
|
from .recruiter import router as recruiter_router
|
||||||
@@ -31,14 +32,24 @@ except ImportError:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from .job import router as job_router
|
from .job import router as job_router
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[ERROR] Failed to import job router: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
job_router = APIRouter()
|
job_router = APIRouter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .notification_channel import router as notification_channel_router
|
||||||
|
except ImportError:
|
||||||
|
from fastapi import APIRouter
|
||||||
|
notification_channel_router = APIRouter()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"recruiter_router",
|
"recruiter_router",
|
||||||
"scheduler_router",
|
"scheduler_router",
|
||||||
"system_router",
|
"system_router",
|
||||||
"candidate_router",
|
"candidate_router",
|
||||||
"job_router"
|
"job_router",
|
||||||
|
"notification_channel_router"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from ..schemas import (
|
|||||||
from ...mapper.candidate_mapper import CandidateMapper
|
from ...mapper.candidate_mapper import CandidateMapper
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/candidates", tags=["候选人管理"])
|
router = APIRouter(prefix="/api/candidates", tags=["候选人管理"])
|
||||||
|
|
||||||
|
|
||||||
def _candidate_to_response(candidate) -> CandidateResponse:
|
def _candidate_to_response(candidate) -> CandidateResponse:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from ..schemas import (
|
|||||||
EvaluationSchemaResponse, EvaluationSchemaListResponse
|
EvaluationSchemaResponse, EvaluationSchemaListResponse
|
||||||
)
|
)
|
||||||
from ...domain.job import Job
|
from ...domain.job import Job
|
||||||
from ...domain.enums import CandidateSource
|
from ...domain.candidate import CandidateSource
|
||||||
from ...mapper.job_mapper import JobMapper
|
from ...mapper.job_mapper import JobMapper
|
||||||
from ...mapper.evaluation_mapper import EvaluationMapper
|
from ...mapper.evaluation_mapper import EvaluationMapper
|
||||||
|
|
||||||
@@ -24,6 +24,16 @@ router = APIRouter(prefix="/api/jobs", tags=["职位管理"])
|
|||||||
|
|
||||||
def _job_to_response(job: Job) -> JobPositionResponse:
|
def _job_to_response(job: Job) -> JobPositionResponse:
|
||||||
"""将领域实体转换为响应模型"""
|
"""将领域实体转换为响应模型"""
|
||||||
|
# 将 JobRequirement 对象转换为字典
|
||||||
|
requirements_dict = None
|
||||||
|
if job.requirements is not None:
|
||||||
|
if hasattr(job.requirements, 'to_dict'):
|
||||||
|
requirements_dict = job.requirements.to_dict()
|
||||||
|
elif hasattr(job.requirements, '__dict__'):
|
||||||
|
requirements_dict = job.requirements.__dict__
|
||||||
|
else:
|
||||||
|
requirements_dict = job.requirements
|
||||||
|
|
||||||
return JobPositionResponse(
|
return JobPositionResponse(
|
||||||
id=job.id,
|
id=job.id,
|
||||||
title=job.title,
|
title=job.title,
|
||||||
@@ -35,7 +45,7 @@ def _job_to_response(job: Job) -> JobPositionResponse:
|
|||||||
location=job.location,
|
location=job.location,
|
||||||
salary_min=job.salary_min,
|
salary_min=job.salary_min,
|
||||||
salary_max=job.salary_max,
|
salary_max=job.salary_max,
|
||||||
requirements=job.requirements,
|
requirements=requirements_dict,
|
||||||
description=job.description,
|
description=job.description,
|
||||||
candidate_count=job.candidate_count,
|
candidate_count=job.candidate_count,
|
||||||
new_candidate_count=job.new_candidate_count,
|
new_candidate_count=job.new_candidate_count,
|
||||||
@@ -45,6 +55,45 @@ def _job_to_response(job: Job) -> JobPositionResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schemas/list", response_model=BaseResponse[EvaluationSchemaListResponse])
|
||||||
|
async def list_evaluation_schemas(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取评价方案列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: 页码
|
||||||
|
page_size: 每页数量
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BaseResponse[EvaluationSchemaListResponse]: 统一响应格式的评价方案列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
schema_mapper = EvaluationMapper()
|
||||||
|
schemas, total = schema_mapper.find_all_schemas(page=page, page_size=page_size)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
EvaluationSchemaResponse(
|
||||||
|
id=schema.id,
|
||||||
|
name=schema.name,
|
||||||
|
description=schema.description,
|
||||||
|
dimensions=schema.dimensions,
|
||||||
|
weights=schema.weights,
|
||||||
|
is_default=schema.is_default,
|
||||||
|
created_at=schema.created_at,
|
||||||
|
updated_at=schema.updated_at
|
||||||
|
)
|
||||||
|
for schema in schemas
|
||||||
|
]
|
||||||
|
|
||||||
|
response = EvaluationSchemaListResponse(total=total, items=items)
|
||||||
|
return BaseResponse.success(data=response, msg="获取评价方案列表成功")
|
||||||
|
except Exception as e:
|
||||||
|
return BaseResponse.error(msg=f"获取评价方案列表失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=BaseResponse[JobPositionListResponse])
|
@router.get("", response_model=BaseResponse[JobPositionListResponse])
|
||||||
async def list_jobs(
|
async def list_jobs(
|
||||||
source: Optional[str] = Query(None, description="平台来源"),
|
source: Optional[str] = Query(None, description="平台来源"),
|
||||||
@@ -368,42 +417,3 @@ async def get_job_evaluation_schema(job_id: str):
|
|||||||
return BaseResponse.success(data=response, msg="获取评价方案成功")
|
return BaseResponse.success(data=response, msg="获取评价方案成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return BaseResponse.error(msg=f"获取评价方案失败: {str(e)}")
|
return BaseResponse.error(msg=f"获取评价方案失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schemas/list", response_model=BaseResponse[EvaluationSchemaListResponse])
|
|
||||||
async def list_evaluation_schemas(
|
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
|
||||||
page_size: int = Query(20, ge=1, le=100, description="每页数量")
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
获取评价方案列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page: 页码
|
|
||||||
page_size: 每页数量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BaseResponse[EvaluationSchemaListResponse]: 统一响应格式的评价方案列表
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
schema_mapper = EvaluationMapper()
|
|
||||||
schemas, total = schema_mapper.find_all_schemas(page=page, page_size=page_size)
|
|
||||||
|
|
||||||
items = [
|
|
||||||
EvaluationSchemaResponse(
|
|
||||||
id=schema.id,
|
|
||||||
name=schema.name,
|
|
||||||
description=schema.description,
|
|
||||||
dimensions=schema.dimensions,
|
|
||||||
weights=schema.weights,
|
|
||||||
is_default=schema.is_default,
|
|
||||||
created_at=schema.created_at,
|
|
||||||
updated_at=schema.updated_at
|
|
||||||
)
|
|
||||||
for schema in schemas
|
|
||||||
]
|
|
||||||
|
|
||||||
response = EvaluationSchemaListResponse(total=total, items=items)
|
|
||||||
return BaseResponse.success(data=response, msg="获取评价方案列表成功")
|
|
||||||
except Exception as e:
|
|
||||||
return BaseResponse.error(msg=f"获取评价方案列表失败: {str(e)}")
|
|
||||||
|
|||||||
@@ -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,
|
RecruiterCreate, RecruiterRegister, RecruiterUpdate,
|
||||||
RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse,
|
RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse,
|
||||||
RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo,
|
RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo,
|
||||||
BaseResponse
|
RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate,
|
||||||
|
RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse,
|
||||||
|
RecruiterWithChannelsResponse, BaseResponse
|
||||||
)
|
)
|
||||||
from ...domain.candidate import CandidateSource
|
from ...domain.candidate import CandidateSource
|
||||||
from ...domain.recruiter import Recruiter, RecruiterStatus
|
from ...domain.recruiter import Recruiter, RecruiterStatus
|
||||||
|
from ...domain.notification_channel import RecruiterChannelBinding
|
||||||
from ...service.recruiter_service import RecruiterService
|
from ...service.recruiter_service import RecruiterService
|
||||||
from ...service.crawler import BossCrawler
|
from ...service.crawler import BossCrawler
|
||||||
from ...mapper.recruiter_mapper import RecruiterMapper
|
from ...mapper.recruiter_mapper import RecruiterMapper
|
||||||
|
from ...mapper.notification_channel_mapper import NotificationChannelMapper
|
||||||
from ...config.settings import get_settings
|
from ...config.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +36,12 @@ def get_recruiter_service():
|
|||||||
return RecruiterService(mapper=mapper)
|
return RecruiterService(mapper=mapper)
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_mapper():
|
||||||
|
"""依赖注入:获取NotificationChannelMapper"""
|
||||||
|
settings = get_settings()
|
||||||
|
return NotificationChannelMapper(db_url=settings.db_url)
|
||||||
|
|
||||||
|
|
||||||
def _build_recruiter_response(recruiter: Recruiter) -> RecruiterResponse:
|
def _build_recruiter_response(recruiter: Recruiter) -> RecruiterResponse:
|
||||||
"""构建招聘者账号响应"""
|
"""构建招聘者账号响应"""
|
||||||
# 构建权益信息
|
# 构建权益信息
|
||||||
@@ -357,3 +367,164 @@ async def sync_recruiter(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return BaseResponse.error(msg=f"同步账号失败: {str(e)}")
|
return BaseResponse.error(msg=f"同步账号失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 通知渠道绑定管理 ====================
|
||||||
|
|
||||||
|
def _build_binding_response(binding, channel=None) -> RecruiterChannelBindingResponse:
|
||||||
|
"""构建绑定关系响应"""
|
||||||
|
return RecruiterChannelBindingResponse(
|
||||||
|
recruiter_id=binding.recruiter_id,
|
||||||
|
channel_id=binding.channel_id,
|
||||||
|
channel_name=channel.name if channel else None,
|
||||||
|
channel_type=channel.channel_type.value if channel else None,
|
||||||
|
is_enabled=binding.is_enabled,
|
||||||
|
notify_on_new_candidate=binding.notify_on_new_candidate,
|
||||||
|
notify_on_evaluation=binding.notify_on_evaluation,
|
||||||
|
notify_on_high_score=binding.notify_on_high_score,
|
||||||
|
high_score_threshold=binding.high_score_threshold,
|
||||||
|
created_at=binding.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingListResponse])
|
||||||
|
async def get_recruiter_channels(
|
||||||
|
recruiter_id: str,
|
||||||
|
service: RecruiterService = Depends(get_recruiter_service),
|
||||||
|
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
|
||||||
|
):
|
||||||
|
"""获取招聘者绑定的所有通知渠道"""
|
||||||
|
try:
|
||||||
|
recruiter = service.get_recruiter(recruiter_id)
|
||||||
|
if not recruiter:
|
||||||
|
return BaseResponse.error(msg="招聘者不存在", code=404)
|
||||||
|
|
||||||
|
bindings = channel_mapper.find_bindings_by_recruiter(recruiter_id)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for binding in bindings:
|
||||||
|
channel = channel_mapper.find_by_id(binding.channel_id)
|
||||||
|
items.append(_build_binding_response(binding, channel))
|
||||||
|
|
||||||
|
response = RecruiterChannelBindingListResponse(total=len(items), items=items)
|
||||||
|
return BaseResponse.success(data=response, msg="获取招聘者绑定的通知渠道成功")
|
||||||
|
except Exception as e:
|
||||||
|
return BaseResponse.error(msg=f"获取招聘者绑定的通知渠道失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingResponse])
|
||||||
|
async def bind_channel_to_recruiter(
|
||||||
|
recruiter_id: str,
|
||||||
|
data: RecruiterChannelBindingCreate,
|
||||||
|
service: RecruiterService = Depends(get_recruiter_service),
|
||||||
|
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
|
||||||
|
):
|
||||||
|
"""为招聘者绑定通知渠道"""
|
||||||
|
try:
|
||||||
|
# 验证招聘者存在
|
||||||
|
recruiter = service.get_recruiter(recruiter_id)
|
||||||
|
if not recruiter:
|
||||||
|
return BaseResponse.error(msg="招聘者不存在", code=404)
|
||||||
|
|
||||||
|
# 验证渠道存在
|
||||||
|
channel = channel_mapper.find_by_id(data.channel_id)
|
||||||
|
if not channel:
|
||||||
|
return BaseResponse.error(msg="通知渠道不存在", code=404)
|
||||||
|
|
||||||
|
# 创建绑定关系
|
||||||
|
binding = RecruiterChannelBinding(
|
||||||
|
recruiter_id=recruiter_id,
|
||||||
|
channel_id=data.channel_id,
|
||||||
|
is_enabled=data.is_enabled,
|
||||||
|
notify_on_new_candidate=data.notify_on_new_candidate,
|
||||||
|
notify_on_evaluation=data.notify_on_evaluation,
|
||||||
|
notify_on_high_score=data.notify_on_high_score,
|
||||||
|
high_score_threshold=data.high_score_threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_binding = channel_mapper.bind_recruiter(binding)
|
||||||
|
|
||||||
|
return BaseResponse.success(
|
||||||
|
data=_build_binding_response(saved_binding, channel),
|
||||||
|
msg=f"成功为招聘者 '{recruiter.name}' 绑定通知渠道 '{channel.name}'"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return BaseResponse.error(msg=f"绑定通知渠道失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[RecruiterChannelBindingResponse])
|
||||||
|
async def update_recruiter_channel_binding(
|
||||||
|
recruiter_id: str,
|
||||||
|
channel_id: str,
|
||||||
|
data: RecruiterChannelBindingUpdate,
|
||||||
|
service: RecruiterService = Depends(get_recruiter_service),
|
||||||
|
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
|
||||||
|
):
|
||||||
|
"""更新招聘者的通知渠道绑定配置"""
|
||||||
|
try:
|
||||||
|
# 验证招聘者存在
|
||||||
|
recruiter = service.get_recruiter(recruiter_id)
|
||||||
|
if not recruiter:
|
||||||
|
return BaseResponse.error(msg="招聘者不存在", code=404)
|
||||||
|
|
||||||
|
# 检查绑定是否存在
|
||||||
|
existing = channel_mapper.get_binding(recruiter_id, channel_id)
|
||||||
|
if not existing:
|
||||||
|
return BaseResponse.error(msg="绑定关系不存在", code=404)
|
||||||
|
|
||||||
|
# 构建更新参数
|
||||||
|
update_data = {}
|
||||||
|
if data.is_enabled is not None:
|
||||||
|
update_data['is_enabled'] = data.is_enabled
|
||||||
|
if data.notify_on_new_candidate is not None:
|
||||||
|
update_data['notify_on_new_candidate'] = data.notify_on_new_candidate
|
||||||
|
if data.notify_on_evaluation is not None:
|
||||||
|
update_data['notify_on_evaluation'] = data.notify_on_evaluation
|
||||||
|
if data.notify_on_high_score is not None:
|
||||||
|
update_data['notify_on_high_score'] = data.notify_on_high_score
|
||||||
|
if data.high_score_threshold is not None:
|
||||||
|
update_data['high_score_threshold'] = data.high_score_threshold
|
||||||
|
|
||||||
|
if not update_data:
|
||||||
|
return BaseResponse.error(msg="没有提供要更新的字段", code=400)
|
||||||
|
|
||||||
|
success = channel_mapper.update_binding(recruiter_id, channel_id, **update_data)
|
||||||
|
if not success:
|
||||||
|
return BaseResponse.error(msg="更新绑定配置失败")
|
||||||
|
|
||||||
|
# 获取更新后的绑定
|
||||||
|
updated = channel_mapper.get_binding(recruiter_id, channel_id)
|
||||||
|
channel = channel_mapper.find_by_id(channel_id)
|
||||||
|
|
||||||
|
return BaseResponse.success(
|
||||||
|
data=_build_binding_response(updated, channel),
|
||||||
|
msg="更新绑定配置成功"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return BaseResponse.error(msg=f"更新绑定配置失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[dict])
|
||||||
|
async def unbind_channel_from_recruiter(
|
||||||
|
recruiter_id: str,
|
||||||
|
channel_id: str,
|
||||||
|
service: RecruiterService = Depends(get_recruiter_service),
|
||||||
|
channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper)
|
||||||
|
):
|
||||||
|
"""解绑招聘者的通知渠道"""
|
||||||
|
try:
|
||||||
|
# 验证招聘者存在
|
||||||
|
recruiter = service.get_recruiter(recruiter_id)
|
||||||
|
if not recruiter:
|
||||||
|
return BaseResponse.error(msg="招聘者不存在", code=404)
|
||||||
|
|
||||||
|
success = channel_mapper.unbind_recruiter(recruiter_id, channel_id)
|
||||||
|
if not success:
|
||||||
|
return BaseResponse.error(msg="绑定关系不存在", code=404)
|
||||||
|
|
||||||
|
return BaseResponse.success(
|
||||||
|
data={"recruiter_id": recruiter_id, "channel_id": channel_id},
|
||||||
|
msg="解绑通知渠道成功"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return BaseResponse.error(msg=f"解绑通知渠道失败: {str(e)}")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def root():
|
|||||||
"endpoints": {
|
"endpoints": {
|
||||||
"recruiters": "/api/recruiters",
|
"recruiters": "/api/recruiters",
|
||||||
"jobs": "/api/jobs",
|
"jobs": "/api/jobs",
|
||||||
"candidates": "/candidates",
|
"candidates": "/api/candidates",
|
||||||
"scheduler": "/api/scheduler",
|
"scheduler": "/api/scheduler",
|
||||||
"health": "/health"
|
"health": "/health"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ API共享Schema定义
|
|||||||
|
|
||||||
集中定义所有API请求和响应的数据模型
|
集中定义所有API请求和响应的数据模型
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional, Any, TypeVar, Generic
|
from typing import List, Optional, Any, TypeVar, Generic, Dict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -45,14 +45,7 @@ class PaginationData(BaseModel, Generic[T]):
|
|||||||
items: List[T] = Field(..., description="数据列表")
|
items: List[T] = Field(..., description="数据列表")
|
||||||
|
|
||||||
|
|
||||||
# ============== 通用响应 (兼容旧代码) ==============
|
# ============== 分页参数 ==============
|
||||||
|
|
||||||
class APIResponse(BaseModel):
|
|
||||||
"""通用API响应"""
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
data: Optional[dict] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PaginationParams(BaseModel):
|
class PaginationParams(BaseModel):
|
||||||
"""分页参数"""
|
"""分页参数"""
|
||||||
@@ -314,3 +307,139 @@ class EvaluationSchemaListResponse(BaseModel):
|
|||||||
"""评价方案列表响应"""
|
"""评价方案列表响应"""
|
||||||
total: int
|
total: int
|
||||||
items: List[EvaluationSchemaResponse]
|
items: List[EvaluationSchemaResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 通知渠道 ==============
|
||||||
|
|
||||||
|
class ChannelTypeInfo(BaseModel):
|
||||||
|
"""通知渠道类型信息"""
|
||||||
|
value: str = Field(..., description="类型值")
|
||||||
|
label: str = Field(..., description="显示名称")
|
||||||
|
description: Optional[str] = Field(None, description="描述")
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConfigBase(BaseModel):
|
||||||
|
"""通知渠道配置基础"""
|
||||||
|
# 企业微信/通用Webhook配置
|
||||||
|
webhook_url: Optional[str] = Field(None, description="Webhook地址")
|
||||||
|
secret: Optional[str] = Field(None, description="密钥")
|
||||||
|
|
||||||
|
# 钉钉配置
|
||||||
|
access_token: Optional[str] = Field(None, description="钉钉Access Token")
|
||||||
|
sign_secret: Optional[str] = Field(None, description="钉钉签名密钥")
|
||||||
|
|
||||||
|
# 邮件配置
|
||||||
|
smtp_host: Optional[str] = Field(None, description="SMTP服务器")
|
||||||
|
smtp_port: Optional[int] = Field(None, description="SMTP端口")
|
||||||
|
username: Optional[str] = Field(None, description="邮箱用户名")
|
||||||
|
password: Optional[str] = Field(None, description="邮箱密码")
|
||||||
|
use_tls: bool = Field(True, description="是否使用TLS")
|
||||||
|
sender_name: Optional[str] = Field(None, description="发件人名称")
|
||||||
|
|
||||||
|
# 飞书配置
|
||||||
|
feishu_webhook: Optional[str] = Field(None, description="飞书Webhook地址")
|
||||||
|
feishu_secret: Optional[str] = Field(None, description="飞书密钥")
|
||||||
|
|
||||||
|
# 通用配置
|
||||||
|
custom_headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
|
||||||
|
custom_payload_template: Optional[str] = Field(None, description="自定义Payload模板")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelCreate(BaseModel):
|
||||||
|
"""创建通知渠道请求"""
|
||||||
|
name: str = Field(..., description="渠道名称")
|
||||||
|
channel_type: str = Field(..., description="渠道类型: wechat_work, dingtalk, email, feishu, webhook")
|
||||||
|
config: ChannelConfigBase = Field(..., description="渠道配置")
|
||||||
|
description: Optional[str] = Field(None, description="渠道描述")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelUpdate(BaseModel):
|
||||||
|
"""更新通知渠道请求"""
|
||||||
|
name: Optional[str] = Field(None, description="渠道名称")
|
||||||
|
config: Optional[ChannelConfigBase] = Field(None, description="渠道配置")
|
||||||
|
description: Optional[str] = Field(None, description="渠道描述")
|
||||||
|
status: Optional[str] = Field(None, description="状态: active, inactive")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelResponse(BaseModel):
|
||||||
|
"""通知渠道响应"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
channel_type: str
|
||||||
|
config: Dict[str, Any]
|
||||||
|
status: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
recruiter_ids: List[str] = Field(default_factory=list, description="关联的招聘者ID列表")
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelListResponse(BaseModel):
|
||||||
|
"""通知渠道列表响应"""
|
||||||
|
total: int
|
||||||
|
items: List[NotificationChannelResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 招聘者与通知渠道绑定 ==============
|
||||||
|
|
||||||
|
class RecruiterChannelBindingCreate(BaseModel):
|
||||||
|
"""创建招聘者与通知渠道绑定请求"""
|
||||||
|
channel_id: str = Field(..., description="通知渠道ID")
|
||||||
|
is_enabled: bool = Field(True, description="是否启用")
|
||||||
|
notify_on_new_candidate: bool = Field(True, description="新候选人时通知")
|
||||||
|
notify_on_evaluation: bool = Field(True, description="完成评价时通知")
|
||||||
|
notify_on_high_score: bool = Field(False, description="高分候选人时通知")
|
||||||
|
high_score_threshold: int = Field(85, ge=0, le=100, description="高分阈值")
|
||||||
|
|
||||||
|
|
||||||
|
class RecruiterChannelBindingUpdate(BaseModel):
|
||||||
|
"""更新绑定配置请求"""
|
||||||
|
is_enabled: Optional[bool] = Field(None, description="是否启用")
|
||||||
|
notify_on_new_candidate: Optional[bool] = Field(None, description="新候选人时通知")
|
||||||
|
notify_on_evaluation: Optional[bool] = Field(None, description="完成评价时通知")
|
||||||
|
notify_on_high_score: Optional[bool] = Field(None, description="高分候选人时通知")
|
||||||
|
high_score_threshold: Optional[int] = Field(None, ge=0, le=100, description="高分阈值")
|
||||||
|
|
||||||
|
|
||||||
|
class RecruiterChannelBindingResponse(BaseModel):
|
||||||
|
"""招聘者与通知渠道绑定响应"""
|
||||||
|
recruiter_id: str
|
||||||
|
channel_id: str
|
||||||
|
channel_name: Optional[str] = Field(None, description="渠道名称")
|
||||||
|
channel_type: Optional[str] = Field(None, description="渠道类型")
|
||||||
|
is_enabled: bool
|
||||||
|
notify_on_new_candidate: bool
|
||||||
|
notify_on_evaluation: bool
|
||||||
|
notify_on_high_score: bool
|
||||||
|
high_score_threshold: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecruiterChannelBindingListResponse(BaseModel):
|
||||||
|
"""绑定列表响应"""
|
||||||
|
total: int
|
||||||
|
items: List[RecruiterChannelBindingResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class RecruiterWithChannelsResponse(BaseModel):
|
||||||
|
"""招聘者及其绑定的通知渠道响应"""
|
||||||
|
recruiter_id: str
|
||||||
|
recruiter_name: str
|
||||||
|
channels: List[RecruiterChannelBindingResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# 重建所有模型以解决前向引用问题
|
||||||
|
BaseResponse.model_rebuild()
|
||||||
|
PaginationData.model_rebuild()
|
||||||
|
CandidateResponse.model_rebuild()
|
||||||
|
CandidateListResponse.model_rebuild()
|
||||||
|
JobPositionResponse.model_rebuild()
|
||||||
|
JobPositionListResponse.model_rebuild()
|
||||||
|
RecruiterResponse.model_rebuild()
|
||||||
|
RecruiterListResponse.model_rebuild()
|
||||||
|
EvaluationSchemaResponse.model_rebuild()
|
||||||
|
EvaluationSchemaListResponse.model_rebuild()
|
||||||
|
NotificationChannelResponse.model_rebuild()
|
||||||
|
NotificationChannelListResponse.model_rebuild()
|
||||||
|
RecruiterChannelBindingResponse.model_rebuild()
|
||||||
|
RecruiterChannelBindingListResponse.model_rebuild()
|
||||||
|
|||||||
@@ -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}, "
|
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id}, "
|
||||||
f"评价方案: {job.evaluation_schema_id or 'general'})")
|
f"评价方案: {job.evaluation_schema_id or 'general'})")
|
||||||
|
|
||||||
# 获取候选人列表
|
# 获取候选人列表(在线程池中执行,避免阻塞事件循环)
|
||||||
candidates = crawler.get_candidates(job.source_id, page=1)
|
candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
result.message = "该职位下没有候选人"
|
result.message = "该职位下没有候选人"
|
||||||
@@ -359,8 +359,8 @@ class ResumeProcessJob:
|
|||||||
|
|
||||||
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id})")
|
print(f"[{datetime.now()}] 处理职位: {job.title} (ID: {job.source_id})")
|
||||||
|
|
||||||
# 获取候选人列表
|
# 获取候选人列表(在线程池中执行,避免阻塞事件循环)
|
||||||
candidates = crawler.get_candidates(job.source_id, page=1)
|
candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
result.message = "该职位下没有候选人"
|
result.message = "该职位下没有候选人"
|
||||||
@@ -425,8 +425,8 @@ class ResumeProcessJob:
|
|||||||
print(f"[{datetime.now()}] 处理候选人: {candidate.name}")
|
print(f"[{datetime.now()}] 处理候选人: {candidate.name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 获取简历详情
|
# 获取简历详情(在线程池中执行,避免阻塞事件循环)
|
||||||
resume = crawler.get_resume_detail(candidate)
|
resume = await asyncio.to_thread(crawler.get_resume_detail, candidate)
|
||||||
|
|
||||||
if not resume:
|
if not resume:
|
||||||
print(f"[{datetime.now()}] 候选人 {candidate.name} 无法获取简历详情")
|
print(f"[{datetime.now()}] 候选人 {candidate.name} 无法获取简历详情")
|
||||||
@@ -435,10 +435,11 @@ class ResumeProcessJob:
|
|||||||
# 构建原始数据
|
# 构建原始数据
|
||||||
raw_data = self._build_raw_data(candidate, resume, job)
|
raw_data = self._build_raw_data(candidate, resume, job)
|
||||||
|
|
||||||
# 统一入库
|
# 统一入库(在线程池中执行,避免阻塞事件循环)
|
||||||
ingestion_result = self.ingestion_service.ingest(
|
ingestion_result = await asyncio.to_thread(
|
||||||
source=candidate.source,
|
self.ingestion_service.ingest,
|
||||||
raw_data=raw_data
|
candidate.source,
|
||||||
|
raw_data
|
||||||
)
|
)
|
||||||
|
|
||||||
if not ingestion_result.success:
|
if not ingestion_result.success:
|
||||||
|
|||||||
@@ -262,9 +262,11 @@ class CandidateMapper:
|
|||||||
count_stmt = count_stmt.where(*conditions)
|
count_stmt = count_stmt.where(*conditions)
|
||||||
total = session.execute(count_stmt).scalar()
|
total = session.execute(count_stmt).scalar()
|
||||||
|
|
||||||
# 分页查询,按LLM评分降序,再按创建时间倒序
|
# 分页查询,按LLM评分降序(NULL值在后),再按创建时间倒序
|
||||||
|
# 使用MySQL兼容的语法:先按是否为NULL排序,再按值排序
|
||||||
stmt = stmt.order_by(
|
stmt = stmt.order_by(
|
||||||
CandidateModel.llm_score.desc().nullslast(),
|
CandidateModel.llm_score.is_(None).asc(), # NULL值在后
|
||||||
|
CandidateModel.llm_score.desc(),
|
||||||
CandidateModel.created_at.desc()
|
CandidateModel.created_at.desc()
|
||||||
)
|
)
|
||||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|||||||
@@ -330,8 +330,12 @@ class JobMapper:
|
|||||||
# 获取总数
|
# 获取总数
|
||||||
total = session.execute(count_stmt).scalar()
|
total = session.execute(count_stmt).scalar()
|
||||||
|
|
||||||
# 分页查询,按最后同步时间降序
|
# 分页查询,按最后同步时间降序(NULL值在后)
|
||||||
stmt = stmt.order_by(JobModel.last_sync_at.desc().nullslast())
|
# 使用MySQL兼容的语法:先按是否为NULL排序,再按值排序
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
JobModel.last_sync_at.is_(None).asc(), # NULL值在后
|
||||||
|
JobModel.last_sync_at.desc()
|
||||||
|
)
|
||||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|
||||||
results = session.execute(stmt).scalars().all()
|
results = session.execute(stmt).scalars().all()
|
||||||
|
|||||||
@@ -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序列化的格式"""
|
"""递归转换对象为可JSON序列化的格式"""
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return None
|
return None
|
||||||
|
# 处理基本类型
|
||||||
|
if isinstance(obj, (str, int, float, bool)):
|
||||||
|
return obj
|
||||||
# 处理枚举类型
|
# 处理枚举类型
|
||||||
if hasattr(obj, 'value'):
|
if hasattr(obj, 'value'):
|
||||||
return obj.value
|
return obj.value
|
||||||
@@ -65,7 +68,17 @@ class ResumeMapper:
|
|||||||
value = getattr(obj, field_name)
|
value = getattr(obj, field_name)
|
||||||
result[field_name] = self._convert_to_serializable(value)
|
result[field_name] = self._convert_to_serializable(value)
|
||||||
return result
|
return result
|
||||||
return obj
|
# 处理一般对象(如SDK返回的Model对象)
|
||||||
|
if hasattr(obj, '__dict__'):
|
||||||
|
result = {}
|
||||||
|
for key, value in obj.__dict__.items():
|
||||||
|
# 跳过私有属性和方法
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
result[key] = self._convert_to_serializable(value)
|
||||||
|
return result
|
||||||
|
# 最后尝试转换为字符串
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
def _entity_to_model(self, entity: Resume) -> ResumeModel:
|
def _entity_to_model(self, entity: Resume) -> ResumeModel:
|
||||||
"""将实体转换为模型"""
|
"""将实体转换为模型"""
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ class IngestionResult:
|
|||||||
errors: list = None
|
errors: list = None
|
||||||
is_duplicate: bool = False
|
is_duplicate: bool = False
|
||||||
existing_candidate_id: Optional[str] = None
|
existing_candidate_id: Optional[str] = None
|
||||||
|
is_new: bool = True # 是否为新增候选人(False表示更新)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def success_result(cls, candidate_id: str, message: str = "") -> "IngestionResult":
|
def success_result(cls, candidate_id: str, message: str = "", is_new: bool = True) -> "IngestionResult":
|
||||||
"""创建成功结果"""
|
"""创建成功结果"""
|
||||||
return cls(
|
return cls(
|
||||||
success=True,
|
success=True,
|
||||||
candidate_id=candidate_id,
|
candidate_id=candidate_id,
|
||||||
message=message or "入库成功"
|
message=message or "入库成功",
|
||||||
|
is_new=is_new
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -50,7 +52,8 @@ class IngestionResult:
|
|||||||
success=True, # 重复不算失败
|
success=True, # 重复不算失败
|
||||||
is_duplicate=True,
|
is_duplicate=True,
|
||||||
existing_candidate_id=existing_id,
|
existing_candidate_id=existing_id,
|
||||||
message=message or "候选人已存在"
|
message=message or "候选人已存在",
|
||||||
|
is_new=False # 重复候选人不算新增
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .base_channel import NotificationChannel, NotificationMessage, SendResult
|
|||||||
from .wechat_work_channel import WeChatWorkChannel
|
from .wechat_work_channel import WeChatWorkChannel
|
||||||
from .dingtalk_channel import DingTalkChannel
|
from .dingtalk_channel import DingTalkChannel
|
||||||
from .email_channel import EmailChannel
|
from .email_channel import EmailChannel
|
||||||
|
from .feishu_channel import FeishuChannel, FeishuTextChannel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NotificationChannel",
|
"NotificationChannel",
|
||||||
@@ -12,4 +13,6 @@ __all__ = [
|
|||||||
"WeChatWorkChannel",
|
"WeChatWorkChannel",
|
||||||
"DingTalkChannel",
|
"DingTalkChannel",
|
||||||
"EmailChannel",
|
"EmailChannel",
|
||||||
|
"FeishuChannel",
|
||||||
|
"FeishuTextChannel",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}")
|
print(f"[ResumeEvaluationService] 开始处理候选人: {candidate.name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. 获取简历详情 - 同步操作
|
# 1. 获取简历详情 - 在线程池中异步执行
|
||||||
resume = self._get_resume(crawler, candidate)
|
resume = await self._get_resume(crawler, candidate)
|
||||||
if not resume:
|
if not resume:
|
||||||
result.success = False
|
result.success = False
|
||||||
result.status = "failed"
|
result.status = "failed"
|
||||||
@@ -192,9 +192,9 @@ class ResumeEvaluationService:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_resume(self, crawler: BaseCrawler, candidate: Candidate) -> Optional[Resume]:
|
async def _get_resume(self, crawler: BaseCrawler, candidate: Candidate) -> Optional[Resume]:
|
||||||
"""
|
"""
|
||||||
获取简历详情
|
获取简历详情(在线程池中执行,避免阻塞事件循环)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
crawler: 爬虫实例
|
crawler: 爬虫实例
|
||||||
@@ -203,8 +203,9 @@ class ResumeEvaluationService:
|
|||||||
Returns:
|
Returns:
|
||||||
Resume: 简历对象
|
Resume: 简历对象
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
try:
|
try:
|
||||||
return crawler.get_resume_detail(candidate)
|
return await asyncio.to_thread(crawler.get_resume_detail, candidate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ResumeEvaluationService] 获取简历失败: {e}")
|
print(f"[ResumeEvaluationService] 获取简历失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -110,22 +110,22 @@ class CrawlScheduler:
|
|||||||
if not crawler:
|
if not crawler:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 获取职位列表
|
# 获取职位列表(在线程池中执行,避免阻塞事件循环)
|
||||||
jobs = crawler.get_jobs()
|
jobs = await asyncio.to_thread(crawler.get_jobs)
|
||||||
print(f"[{datetime.now()}] 找到 {len(jobs)} 个职位")
|
print(f"[{datetime.now()}] 找到 {len(jobs)} 个职位")
|
||||||
|
|
||||||
# 遍历职位爬取候选人
|
# 遍历职位爬取候选人
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
print(f"[{datetime.now()}] 爬取职位: {job.title}")
|
print(f"[{datetime.now()}] 爬取职位: {job.title}")
|
||||||
|
|
||||||
# 爬取候选人
|
# 爬取候选人(在线程池中执行,避免阻塞事件循环)
|
||||||
candidates = crawler.get_candidates(job.source_id, page=1)
|
candidates = await asyncio.to_thread(crawler.get_candidates, job.source_id, page=1)
|
||||||
print(f"[{datetime.now()}] 职位 '{job.title}' 找到 {len(candidates)} 个候选人")
|
print(f"[{datetime.now()}] 职位 '{job.title}' 找到 {len(candidates)} 个候选人")
|
||||||
|
|
||||||
for candidate in candidates[:10]: # 每职位限制10个候选人
|
for candidate in candidates[:10]: # 每职位限制10个候选人
|
||||||
try:
|
try:
|
||||||
# 获取简历详情
|
# 获取简历详情(在线程池中执行,避免阻塞事件循环)
|
||||||
resume = crawler.get_resume_detail(candidate)
|
resume = await asyncio.to_thread(crawler.get_resume_detail, candidate)
|
||||||
if not resume:
|
if not resume:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -145,8 +145,12 @@ class CrawlScheduler:
|
|||||||
"resumeText": resume.raw_content,
|
"resumeText": resume.raw_content,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 入库
|
# 入库(在线程池中执行,避免阻塞事件循环)
|
||||||
result = self.app.ingestion_service.ingest(CandidateSource.BOSS, raw_data)
|
result = await asyncio.to_thread(
|
||||||
|
self.app.ingestion_service.ingest,
|
||||||
|
CandidateSource.BOSS,
|
||||||
|
raw_data
|
||||||
|
)
|
||||||
print(f"[{datetime.now()}] 候选人 {candidate.name} 入库: {result.message}")
|
print(f"[{datetime.now()}] 候选人 {candidate.name} 入库: {result.message}")
|
||||||
|
|
||||||
# 触发分析
|
# 触发分析
|
||||||
|
|||||||
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