diff --git a/migrations/001_init_schema.sql b/migrations/001_init_schema.sql index 4b2a5b2..b93c9c8 100644 --- a/migrations/001_init_schema.sql +++ b/migrations/001_init_schema.sql @@ -163,13 +163,50 @@ CREATE TABLE IF NOT EXISTS evaluations ( ); -- ============================================ --- 7. 通知记录表 +-- 7. 通知渠道表 +-- ============================================ +CREATE TABLE IF NOT EXISTS notification_channels ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(128) NOT NULL, -- 渠道名称 + channel_type VARCHAR(32) NOT NULL, -- 渠道类型: wechat_work, dingtalk, email, feishu, webhook + config JSON NOT NULL, -- 渠道配置JSON + status VARCHAR(32) DEFAULT 'active', -- active, inactive + description TEXT, -- 描述 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_channel_type (channel_type), + INDEX idx_status (status) +); + +-- ============================================ +-- 8. 招聘者与通知渠道绑定关系表(多对多) +-- ============================================ +CREATE TABLE IF NOT EXISTS recruiter_channel_bindings ( + id VARCHAR(64) PRIMARY KEY, + recruiter_id VARCHAR(64) NOT NULL, + channel_id VARCHAR(64) NOT NULL, + is_enabled TINYINT(1) DEFAULT 1, -- 是否启用该渠道 + notify_on_new_candidate TINYINT(1) DEFAULT 1, -- 新候选人时通知 + notify_on_evaluation TINYINT(1) DEFAULT 1, -- 完成评价时通知 + notify_on_high_score TINYINT(1) DEFAULT 0, -- 高分候选人时通知 + high_score_threshold INT DEFAULT 85, -- 高分阈值 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (recruiter_id) REFERENCES recruiters(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE, + UNIQUE KEY uk_recruiter_channel (recruiter_id, channel_id), + INDEX idx_recruiter_id (recruiter_id), + INDEX idx_channel_id (channel_id) +); + +-- ============================================ +-- 9. 通知记录表 -- ============================================ CREATE TABLE IF NOT EXISTS notifications ( id VARCHAR(64) PRIMARY KEY, candidate_id VARCHAR(64) NOT NULL, evaluation_id VARCHAR(64), - channel VARCHAR(32) NOT NULL, -- WECHAT_WORK, DINGTALK, EMAIL, WEBHOOK + channel_id VARCHAR(64), -- 关联的通知渠道ID + channel VARCHAR(32) NOT NULL, -- 渠道类型: WECHAT_WORK, DINGTALK, EMAIL, FEISHU, WEBHOOK content TEXT, status VARCHAR(32) DEFAULT 'PENDING', -- PENDING, SENT, FAILED error_message TEXT, @@ -177,13 +214,36 @@ CREATE TABLE IF NOT EXISTS notifications ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (candidate_id) REFERENCES candidates(id) ON DELETE CASCADE, FOREIGN KEY (evaluation_id) REFERENCES evaluations(id) ON DELETE SET NULL, + FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE SET NULL, INDEX idx_candidate_id (candidate_id), + INDEX idx_channel_id (channel_id), INDEX idx_status (status), INDEX idx_created_at (created_at) ); -- ============================================ --- 8. 插入默认评价方案 +-- 10. 插入默认通知渠道(可选) +-- ============================================ +INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES +('demo_wechat', '企业微信通知', 'wechat_work', + '{"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "secret": ""}', + '企业微信机器人通知渠道(示例配置)') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES +('demo_dingtalk', '钉钉通知', 'dingtalk', + '{"access_token": "YOUR_ACCESS_TOKEN", "sign_secret": ""}', + '钉钉机器人通知渠道(示例配置)') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO notification_channels (id, name, channel_type, config, description) VALUES +('demo_feishu', '飞书通知', 'feishu', + '{"feishu_webhook": "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", "feishu_secret": ""}', + '飞书机器人通知渠道(示例配置)') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +-- ============================================ +-- 11. 插入默认评价方案 -- ============================================ INSERT INTO evaluation_schemas (id, name, description, dimensions, weights, is_default) VALUES ('general', '通用评价方案', '适用于各类岗位的通用评价方案', diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py index a83ad9a..cd79e68 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py @@ -1,8 +1,8 @@ """Database configuration using SQLAlchemy""" -from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON -from sqlalchemy.orm import declarative_base, sessionmaker, Session +from sqlalchemy import create_engine, Column, String, DateTime, Text, DECIMAL, Integer, ForeignKey, JSON, Boolean +from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship from sqlalchemy.sql import func -from typing import Optional +from typing import Optional, List Base = declarative_base() @@ -32,6 +32,9 @@ class RecruiterModel(Base): created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + channel_bindings = relationship("RecruiterChannelBindingModel", back_populates="recruiter", cascade="all, delete-orphan") class CandidateModel(Base): @@ -139,6 +142,48 @@ class EvaluationModel(Base): created_at = Column(DateTime, server_default=func.now()) +class NotificationChannelModel(Base): + """通知渠道表""" + __tablename__ = 'notification_channels' + + id = Column(String(64), primary_key=True) + name = Column(String(128), nullable=False) # 渠道名称 + channel_type = Column(String(32), nullable=False) # 渠道类型: wechat_work, dingtalk, email, feishu, webhook + config = Column(JSON, nullable=False) # 渠道配置JSON + status = Column(String(32), default='active') # active, inactive + description = Column(Text) # 描述 + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + recruiter_bindings = relationship("RecruiterChannelBindingModel", back_populates="channel", cascade="all, delete-orphan") + + +class RecruiterChannelBindingModel(Base): + """招聘者与通知渠道绑定关系表(多对多)""" + __tablename__ = 'recruiter_channel_bindings' + + id = Column(String(64), primary_key=True) + recruiter_id = Column(String(64), ForeignKey('recruiters.id'), nullable=False) + channel_id = Column(String(64), ForeignKey('notification_channels.id'), nullable=False) + is_enabled = Column(Boolean, default=True) # 是否启用该渠道 + notify_on_new_candidate = Column(Boolean, default=True) # 新候选人时通知 + notify_on_evaluation = Column(Boolean, default=True) # 完成评价时通知 + notify_on_high_score = Column(Boolean, default=False) # 高分候选人时通知 + high_score_threshold = Column(Integer, default=85) # 高分阈值 + created_at = Column(DateTime, server_default=func.now()) + + # 关联关系 + recruiter = relationship("RecruiterModel", back_populates="channel_bindings") + channel = relationship("NotificationChannelModel", back_populates="recruiter_bindings") + + # 唯一约束:一个招聘者不能重复绑定同一个渠道 + __table_args__ = ( + # 使用唯一约束防止重复绑定 + {'sqlite_autoincrement': True}, + ) + + class NotificationModel(Base): """通知记录表""" __tablename__ = 'notifications' @@ -146,7 +191,8 @@ class NotificationModel(Base): id = Column(String(64), primary_key=True) candidate_id = Column(String(64), ForeignKey('candidates.id'), nullable=False) evaluation_id = Column(String(64), ForeignKey('evaluations.id')) - channel = Column(String(32), nullable=False) + channel_id = Column(String(64), ForeignKey('notification_channels.id')) # 关联渠道ID + channel = Column(String(32), nullable=False) # 渠道类型(冗余字段,方便查询) content = Column(Text) status = Column(String(32), default='PENDING') error_message = Column(Text) diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py index de26fcc..32941d3 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py @@ -10,7 +10,7 @@ FastAPI主应用入口 from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routes import recruiter_router, scheduler_router, candidate_router, job_router +from .routes import recruiter_router, scheduler_router, candidate_router, job_router, notification_channel_router from .routes.system import router as system_router @@ -49,6 +49,9 @@ def create_app() -> FastAPI: # 职位管理路由 app.include_router(job_router) + # 通知渠道管理路由 + app.include_router(notification_channel_router) + return app diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py index e79e73e..a6a0ef3 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py @@ -6,6 +6,7 @@ API路由模块 - scheduler: 定时任务管理 - system: 系统接口 - candidate: 候选人管理 +- notification_channel: 通知渠道管理 """ from .recruiter import router as recruiter_router @@ -35,10 +36,17 @@ except ImportError: from fastapi import APIRouter job_router = APIRouter() +try: + from .notification_channel import router as notification_channel_router +except ImportError: + from fastapi import APIRouter + notification_channel_router = APIRouter() + __all__ = [ "recruiter_router", "scheduler_router", "system_router", "candidate_router", - "job_router" + "job_router", + "notification_channel_router" ] diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/notification_channel.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/notification_channel.py new file mode 100644 index 0000000..6c7e6a3 --- /dev/null +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/notification_channel.py @@ -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)}") diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py index e483bc9..d2a59ce 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/recruiter.py @@ -12,13 +12,17 @@ from ..schemas import ( RecruiterCreate, RecruiterRegister, RecruiterUpdate, RecruiterResponse, RecruiterListResponse, RecruiterRegisterResponse, RecruiterPrivilegeInfo, RecruiterSyncInfo, RecruiterSourceInfo, - BaseResponse + RecruiterChannelBindingCreate, RecruiterChannelBindingUpdate, + RecruiterChannelBindingResponse, RecruiterChannelBindingListResponse, + RecruiterWithChannelsResponse, BaseResponse ) from ...domain.candidate import CandidateSource from ...domain.recruiter import Recruiter, RecruiterStatus +from ...domain.notification_channel import RecruiterChannelBinding from ...service.recruiter_service import RecruiterService from ...service.crawler import BossCrawler from ...mapper.recruiter_mapper import RecruiterMapper +from ...mapper.notification_channel_mapper import NotificationChannelMapper from ...config.settings import get_settings @@ -32,6 +36,12 @@ def get_recruiter_service(): return RecruiterService(mapper=mapper) +def get_channel_mapper(): + """依赖注入:获取NotificationChannelMapper""" + settings = get_settings() + return NotificationChannelMapper(db_url=settings.db_url) + + def _build_recruiter_response(recruiter: Recruiter) -> RecruiterResponse: """构建招聘者账号响应""" # 构建权益信息 @@ -357,3 +367,164 @@ async def sync_recruiter( ) except Exception as e: return BaseResponse.error(msg=f"同步账号失败: {str(e)}") + + +# ==================== 通知渠道绑定管理 ==================== + +def _build_binding_response(binding, channel=None) -> RecruiterChannelBindingResponse: + """构建绑定关系响应""" + return RecruiterChannelBindingResponse( + recruiter_id=binding.recruiter_id, + channel_id=binding.channel_id, + channel_name=channel.name if channel else None, + channel_type=channel.channel_type.value if channel else None, + is_enabled=binding.is_enabled, + notify_on_new_candidate=binding.notify_on_new_candidate, + notify_on_evaluation=binding.notify_on_evaluation, + notify_on_high_score=binding.notify_on_high_score, + high_score_threshold=binding.high_score_threshold, + created_at=binding.created_at + ) + + +@router.get("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingListResponse]) +async def get_recruiter_channels( + recruiter_id: str, + service: RecruiterService = Depends(get_recruiter_service), + channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper) +): + """获取招聘者绑定的所有通知渠道""" + try: + recruiter = service.get_recruiter(recruiter_id) + if not recruiter: + return BaseResponse.error(msg="招聘者不存在", code=404) + + bindings = channel_mapper.find_bindings_by_recruiter(recruiter_id) + + items = [] + for binding in bindings: + channel = channel_mapper.find_by_id(binding.channel_id) + items.append(_build_binding_response(binding, channel)) + + response = RecruiterChannelBindingListResponse(total=len(items), items=items) + return BaseResponse.success(data=response, msg="获取招聘者绑定的通知渠道成功") + except Exception as e: + return BaseResponse.error(msg=f"获取招聘者绑定的通知渠道失败: {str(e)}") + + +@router.post("/{recruiter_id}/channels", response_model=BaseResponse[RecruiterChannelBindingResponse]) +async def bind_channel_to_recruiter( + recruiter_id: str, + data: RecruiterChannelBindingCreate, + service: RecruiterService = Depends(get_recruiter_service), + channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper) +): + """为招聘者绑定通知渠道""" + try: + # 验证招聘者存在 + recruiter = service.get_recruiter(recruiter_id) + if not recruiter: + return BaseResponse.error(msg="招聘者不存在", code=404) + + # 验证渠道存在 + channel = channel_mapper.find_by_id(data.channel_id) + if not channel: + return BaseResponse.error(msg="通知渠道不存在", code=404) + + # 创建绑定关系 + binding = RecruiterChannelBinding( + recruiter_id=recruiter_id, + channel_id=data.channel_id, + is_enabled=data.is_enabled, + notify_on_new_candidate=data.notify_on_new_candidate, + notify_on_evaluation=data.notify_on_evaluation, + notify_on_high_score=data.notify_on_high_score, + high_score_threshold=data.high_score_threshold + ) + + saved_binding = channel_mapper.bind_recruiter(binding) + + return BaseResponse.success( + data=_build_binding_response(saved_binding, channel), + msg=f"成功为招聘者 '{recruiter.name}' 绑定通知渠道 '{channel.name}'" + ) + except Exception as e: + return BaseResponse.error(msg=f"绑定通知渠道失败: {str(e)}") + + +@router.put("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[RecruiterChannelBindingResponse]) +async def update_recruiter_channel_binding( + recruiter_id: str, + channel_id: str, + data: RecruiterChannelBindingUpdate, + service: RecruiterService = Depends(get_recruiter_service), + channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper) +): + """更新招聘者的通知渠道绑定配置""" + try: + # 验证招聘者存在 + recruiter = service.get_recruiter(recruiter_id) + if not recruiter: + return BaseResponse.error(msg="招聘者不存在", code=404) + + # 检查绑定是否存在 + existing = channel_mapper.get_binding(recruiter_id, channel_id) + if not existing: + return BaseResponse.error(msg="绑定关系不存在", code=404) + + # 构建更新参数 + update_data = {} + if data.is_enabled is not None: + update_data['is_enabled'] = data.is_enabled + if data.notify_on_new_candidate is not None: + update_data['notify_on_new_candidate'] = data.notify_on_new_candidate + if data.notify_on_evaluation is not None: + update_data['notify_on_evaluation'] = data.notify_on_evaluation + if data.notify_on_high_score is not None: + update_data['notify_on_high_score'] = data.notify_on_high_score + if data.high_score_threshold is not None: + update_data['high_score_threshold'] = data.high_score_threshold + + if not update_data: + return BaseResponse.error(msg="没有提供要更新的字段", code=400) + + success = channel_mapper.update_binding(recruiter_id, channel_id, **update_data) + if not success: + return BaseResponse.error(msg="更新绑定配置失败") + + # 获取更新后的绑定 + updated = channel_mapper.get_binding(recruiter_id, channel_id) + channel = channel_mapper.find_by_id(channel_id) + + return BaseResponse.success( + data=_build_binding_response(updated, channel), + msg="更新绑定配置成功" + ) + except Exception as e: + return BaseResponse.error(msg=f"更新绑定配置失败: {str(e)}") + + +@router.delete("/{recruiter_id}/channels/{channel_id}", response_model=BaseResponse[dict]) +async def unbind_channel_from_recruiter( + recruiter_id: str, + channel_id: str, + service: RecruiterService = Depends(get_recruiter_service), + channel_mapper: NotificationChannelMapper = Depends(get_channel_mapper) +): + """解绑招聘者的通知渠道""" + try: + # 验证招聘者存在 + recruiter = service.get_recruiter(recruiter_id) + if not recruiter: + return BaseResponse.error(msg="招聘者不存在", code=404) + + success = channel_mapper.unbind_recruiter(recruiter_id, channel_id) + if not success: + return BaseResponse.error(msg="绑定关系不存在", code=404) + + return BaseResponse.success( + data={"recruiter_id": recruiter_id, "channel_id": channel_id}, + msg="解绑通知渠道成功" + ) + except Exception as e: + return BaseResponse.error(msg=f"解绑通知渠道失败: {str(e)}") diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py index 1696228..a2056b1 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py @@ -305,3 +305,122 @@ class EvaluationSchemaListResponse(BaseModel): """评价方案列表响应""" total: int items: List[EvaluationSchemaResponse] + + +# ============== 通知渠道 ============== + +class ChannelTypeInfo(BaseModel): + """通知渠道类型信息""" + value: str = Field(..., description="类型值") + label: str = Field(..., description="显示名称") + description: Optional[str] = Field(None, description="描述") + + +class ChannelConfigBase(BaseModel): + """通知渠道配置基础""" + # 企业微信/通用Webhook配置 + webhook_url: Optional[str] = Field(None, description="Webhook地址") + secret: Optional[str] = Field(None, description="密钥") + + # 钉钉配置 + access_token: Optional[str] = Field(None, description="钉钉Access Token") + sign_secret: Optional[str] = Field(None, description="钉钉签名密钥") + + # 邮件配置 + smtp_host: Optional[str] = Field(None, description="SMTP服务器") + smtp_port: Optional[int] = Field(None, description="SMTP端口") + username: Optional[str] = Field(None, description="邮箱用户名") + password: Optional[str] = Field(None, description="邮箱密码") + use_tls: bool = Field(True, description="是否使用TLS") + sender_name: Optional[str] = Field(None, description="发件人名称") + + # 飞书配置 + feishu_webhook: Optional[str] = Field(None, description="飞书Webhook地址") + feishu_secret: Optional[str] = Field(None, description="飞书密钥") + + # 通用配置 + custom_headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头") + custom_payload_template: Optional[str] = Field(None, description="自定义Payload模板") + + +class NotificationChannelCreate(BaseModel): + """创建通知渠道请求""" + name: str = Field(..., description="渠道名称") + channel_type: str = Field(..., description="渠道类型: wechat_work, dingtalk, email, feishu, webhook") + config: ChannelConfigBase = Field(..., description="渠道配置") + description: Optional[str] = Field(None, description="渠道描述") + + +class NotificationChannelUpdate(BaseModel): + """更新通知渠道请求""" + name: Optional[str] = Field(None, description="渠道名称") + config: Optional[ChannelConfigBase] = Field(None, description="渠道配置") + description: Optional[str] = Field(None, description="渠道描述") + status: Optional[str] = Field(None, description="状态: active, inactive") + + +class NotificationChannelResponse(BaseModel): + """通知渠道响应""" + id: str + name: str + channel_type: str + config: Dict[str, Any] + status: str + description: Optional[str] = None + recruiter_ids: List[str] = Field(default_factory=list, description="关联的招聘者ID列表") + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class NotificationChannelListResponse(BaseModel): + """通知渠道列表响应""" + total: int + items: List[NotificationChannelResponse] + + +# ============== 招聘者与通知渠道绑定 ============== + +class RecruiterChannelBindingCreate(BaseModel): + """创建招聘者与通知渠道绑定请求""" + channel_id: str = Field(..., description="通知渠道ID") + is_enabled: bool = Field(True, description="是否启用") + notify_on_new_candidate: bool = Field(True, description="新候选人时通知") + notify_on_evaluation: bool = Field(True, description="完成评价时通知") + notify_on_high_score: bool = Field(False, description="高分候选人时通知") + high_score_threshold: int = Field(85, ge=0, le=100, description="高分阈值") + + +class RecruiterChannelBindingUpdate(BaseModel): + """更新绑定配置请求""" + is_enabled: Optional[bool] = Field(None, description="是否启用") + notify_on_new_candidate: Optional[bool] = Field(None, description="新候选人时通知") + notify_on_evaluation: Optional[bool] = Field(None, description="完成评价时通知") + notify_on_high_score: Optional[bool] = Field(None, description="高分候选人时通知") + high_score_threshold: Optional[int] = Field(None, ge=0, le=100, description="高分阈值") + + +class RecruiterChannelBindingResponse(BaseModel): + """招聘者与通知渠道绑定响应""" + recruiter_id: str + channel_id: str + channel_name: Optional[str] = Field(None, description="渠道名称") + channel_type: Optional[str] = Field(None, description="渠道类型") + is_enabled: bool + notify_on_new_candidate: bool + notify_on_evaluation: bool + notify_on_high_score: bool + high_score_threshold: int + created_at: Optional[datetime] = None + + +class RecruiterChannelBindingListResponse(BaseModel): + """绑定列表响应""" + total: int + items: List[RecruiterChannelBindingResponse] + + +class RecruiterWithChannelsResponse(BaseModel): + """招聘者及其绑定的通知渠道响应""" + recruiter_id: str + recruiter_name: str + channels: List[RecruiterChannelBindingResponse] diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/notification_channel.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/notification_channel.py new file mode 100644 index 0000000..fe9552f --- /dev/null +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/notification_channel.py @@ -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() diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/notification_channel_mapper.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/notification_channel_mapper.py new file mode 100644 index 0000000..abddf81 --- /dev/null +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/notification_channel_mapper.py @@ -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() diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py index 95720ad..1f0816a 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/__init__.py @@ -4,6 +4,7 @@ from .base_channel import NotificationChannel, NotificationMessage, SendResult from .wechat_work_channel import WeChatWorkChannel from .dingtalk_channel import DingTalkChannel from .email_channel import EmailChannel +from .feishu_channel import FeishuChannel, FeishuTextChannel __all__ = [ "NotificationChannel", @@ -12,4 +13,6 @@ __all__ = [ "WeChatWorkChannel", "DingTalkChannel", "EmailChannel", + "FeishuChannel", + "FeishuTextChannel", ] diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/feishu_channel.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/feishu_channel.py new file mode 100644 index 0000000..2498882 --- /dev/null +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/service/notification/channels/feishu_channel.py @@ -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) diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json index 8b1d171..ae6f15e 100644 --- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json +++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/package.json @@ -10,17 +10,19 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.4.0", - "vue-router": "^4.2.0", - "pinia": "^2.1.0", - "axios": "^1.6.0", - "element-plus": "^2.5.0", "@element-plus/icons-vue": "^2.3.0", - "dayjs": "^1.11.0" + "axios": "^1.6.0", + "dayjs": "^1.11.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "tdesign-icons-vue-next": "^0.4.2", + "tdesign-vue-next": "^1.18.6", + "vue": "^3.4.0", + "vue-router": "^4.2.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", - "vite": "^5.0.0", - "sass": "^1.70.0" + "sass": "^1.70.0", + "vite": "^5.0.0" } } diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml index b055f54..a1e15d4 100644 --- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml +++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: pinia: specifier: ^2.1.0 version: 2.3.1(vue@3.5.30) + tdesign-icons-vue-next: + specifier: ^0.4.2 + version: 0.4.2(vue@3.5.30) + tdesign-vue-next: + specifier: ^1.18.6 + version: 1.18.6(vue@3.5.30) vue: specifier: ^3.4.0 version: 3.5.30 @@ -55,6 +61,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -300,6 +310,9 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@rollup/rollup-android-arm-eabi@4.60.0': resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] @@ -437,6 +450,15 @@ packages: '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + + '@types/tinycolor2@1.4.6': + resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -647,6 +669,9 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -695,10 +720,31 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + tdesign-icons-vue-next@0.4.2: + resolution: {integrity: sha512-mTPk1ApcCA9oxDiSs9ttMdd09H8ICBooZIr2bwDEELnYr60sYSUbvWojQ2tp84MUAMuw21HgyVyGkT49db0GFg==} + peerDependencies: + vue: ^3.0.0 + + tdesign-vue-next@1.18.6: + resolution: {integrity: sha512-oc7wOE5awfWd0/mqCVOESv3rg1Nh6HJGr9vAvgjUkRTp6/KS9gexiKU2qR1hBKqh6cmBQkbFMWmX8MLnJJ6zLQ==} + engines: {node: '>= 18'} + peerDependencies: + vue: '>=3.1.0' + + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -767,6 +813,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.2': {} + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -921,6 +969,8 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@popperjs/core@2.11.8': {} + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true @@ -1006,6 +1056,12 @@ snapshots: '@types/lodash@4.17.24': {} + '@types/sortablejs@1.15.9': {} + + '@types/tinycolor2@1.4.6': {} + + '@types/validator@13.15.10': {} + '@types/web-bluetooth@0.0.20': {} '@vitejs/plugin-vue@5.2.4(vite@5.4.21(sass@1.98.0))(vue@3.5.30)': @@ -1271,6 +1327,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mitt@3.0.1: {} + nanoid@3.3.11: {} node-addon-api@7.1.1: @@ -1340,8 +1398,36 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 + sortablejs@1.15.7: {} + source-map-js@1.2.1: {} + tdesign-icons-vue-next@0.4.2(vue@3.5.30): + dependencies: + '@babel/runtime': 7.29.2 + vue: 3.5.30 + + tdesign-vue-next@1.18.6(vue@3.5.30): + dependencies: + '@babel/runtime': 7.29.2 + '@popperjs/core': 2.11.8 + '@types/lodash-es': 4.17.12 + '@types/sortablejs': 1.15.9 + '@types/tinycolor2': 1.4.6 + '@types/validator': 13.15.10 + dayjs: 1.11.20 + lodash-es: 4.17.23 + mitt: 3.0.1 + sortablejs: 1.15.7 + tdesign-icons-vue-next: 0.4.2(vue@3.5.30) + tinycolor2: 1.6.0 + validator: 13.15.26 + vue: 3.5.30 + + tinycolor2@1.6.0: {} + + validator@13.15.26: {} + vite@5.4.21(sass@1.98.0): dependencies: esbuild: 0.21.5 diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue index 02e6d3e..f8f70ff 100644 --- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue +++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/App.vue @@ -3,6 +3,40 @@ diff --git a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue index 2f8cff7..30b8d84 100644 --- a/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue +++ b/src/main/web/cn.yinlihupo/ylhp_hr_2_0_fronted/src/components/Layout.vue @@ -1,63 +1,93 @@