提交 96d702f8 authored 作者: 陈泽健's avatar 陈泽健

feat(dingtalk): 定时任务钉钉报告通知(三形态消息 + 加签发送 + 配置管理)

- 新增 dingtalk_notify_service:build_sign(HMAC-SHA256 加签) / 三形态消息构建( 报告 / ️ 告警 / 🚨 异常)/ 失败明细折叠与超长保护 / send_markdown 发送
- 定时任务新增 dingtalk_notify 开关:执行完成后自动推送钉钉报告通知
- 系统设置提供报告钉钉通知配置管理(report_webhook_url / report_secret / report_at_mobiles,secret 脱敏)
- 新增 PLATFORM_BASE_URL 配置(消息中查看完整报告链接用)
- database._ensure_columns 旧库升级补列(scheduled_tasks.dingtalk_notify / dingtalk_configs.report_*)
- PRD + 执行计划文档归档 Docs/PRD/需求文档/执行中心/
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 ea2a90cb
# 执行计划 - 定时任务钉钉报告通知
> **文档类型**: 计划执行文档
> **创建日期**: 2026-09-07
> **作者**: czj
> **关联 PRD**: `_PRD_需求文档_定时任务钉钉报告通知.md`
> **状态**: 待执行
---
## Phase 1:数据模型与配置(P0)
**目标**:配置有地方存,链接有地址可拼。
### 1.1 `dingtalk_configs` 表加列(`models/dingtalk_config.py` + `database.py` 迁移)
- [ ] `report_webhook_url` String(512) nullable
- [ ] `report_secret` String(255) nullable
- [ ] `report_at_mobiles` JSON nullable(默认 list)
- [ ] `database.py` 启动迁移补列(SQLite/MySQL 双兼容,沿用既有模式)
### 1.2 `scheduled_tasks` 表加列
- [ ] `dingtalk_notify` Boolean default False
- [ ] 模型 `to_dict()` / docstring 同步
- [ ] `database.py` 迁移补列
### 1.3 `config.py` + `schemas/`
- [ ] `Settings.PLATFORM_BASE_URL: str = ""`(env 可覆盖)
- [ ] `ReportNotifyConfigResponse`(webhook_url / secret 脱敏 / at_mobiles / configured 标志)
- [ ] `ReportNotifyConfigUpdateRequest`(secret 为 `******` 表示保持原值)
- [ ] 定时任务 create/update schema 加 `dingtalk_notify: bool = False`
**验收**:应用启动后两张表新列存在,旧数据不丢。
---
## Phase 2:钉钉通知服务(P0 核心)
**目标**:新建 `backend/app/services/dingtalk_notify_service.py`
### 2.1 签名与发送
- [ ] `build_sign(secret, timestamp)`: `f"{ts}\n{secret}"` → HMAC-SHA256 → base64 → `quote_plus`
- [ ] `send_markdown(config, title, text, at_mobiles)`: 拼接 `&timestamp=&sign=` → POST JSON(msgtype=markdown + at)→ errcode==0 判成功
- [ ] `requests.post(timeout=10)``loop.run_in_executor` 线程池,不阻塞事件循环
### 2.2 消息构建
- [ ] `build_execution_message(execution, failed_results, task_name)``(title, text, at_mobiles)`
- [ ] 三形态选择:✅ 全通过 / ⚠️ 有失败 / 🚨 0 条用例结果
- [ ] 失败明细 ≤5 条 + 「还有 N 项,详见报告」折叠
- [ ] 错误摘要:error_message 优先 → 首个失败步骤 error 兜底;压平换行、截 80 字符
- [ ] 超长保护:UTF-8 > 2000 字节逐条丢明细,追加「(明细过长已折叠,详见报告)」
- [ ] 链接:`{PLATFORM_BASE_URL}/api/reports/generate/{id}`,base 为空省略链接行
### 2.3 业务入口
- [ ] `notify_execution_result(execution_id, task)`:查执行 + 失败用例 → 构建发送;异常只 log 不抛
- [ ] `send_test_message()`:读配置发测试 markdown,透出钉钉 errmsg
**验收**:单测覆盖签名对拍、三形态消息、5 条折叠、超长折叠。
---
## Phase 3:API 路由(P1)
### 3.1 `routers/system.py`
- [ ] `GET /api/system/report-notify-config`(secret 恒返 `******`,未设置返空)
- [ ] `PUT /api/system/report-notify-config``******` 不覆盖原值)
- [ ] `POST /api/system/report-notify-config/test`(返回 `{success, message}`
### 3.2 `routers/scheduled_tasks.py`
- [ ] create/update 透传 `dingtalk_notify`
- [ ] 任务列表/详情返回 `dingtalk_notify`
**验收**:curl 走通 GET/PUT/test 三接口;任务带开关保存后读回一致。
---
## Phase 4:调度挂钩(P0)
- [ ] `scheduler_service.py` UI 路径报告生成块之后:`if task.dingtalk_notify:``notify_execution_result`,独立 try/except
- [ ] webhook 未配置 → warning 日志跳过
- [ ] security 路径不挂钩(非目标)
**验收**:单测 mock 发送函数,断言开关开/关两种路径的调用与否。
---
## Phase 5:前端(P1)
### 5.1 `Settings.vue`
- [ ] 新增「报告钉钉通知(机器人)」卡片:webhook 地址(文本框)/ 加签密钥(密码框,`******` 占位)/ @手机号(标签输入)/ 保存 + 发送测试按钮
- [ ] `api/system.ts` 补三个接口封装
### 5.2 `ScheduledTasks.vue`
- [ ] 任务编辑表单加「执行完成后发送钉钉通知」开关
- [ ] 任务列表列标识(可选,若列宽允许可省)
**验收**:配置→测试发送→保存→任务开启开关,全流程页面操作走通;`npm run build` 通过。
---
## Phase 6:测试、部署与真实验证(P1)
### 6.1 单元测试 `tests/test_dingtalk_notify.py`
- [ ] 签名对拍(已知 secret+timestamp → 期望 sign)
- [ ] 三形态消息构建
- [ ] 5 条折叠 / 超长折叠 / 链接省略
- [ ] 调度挂钩开关行为
- [ ] 配置 API 脱敏与 `******` 保留语义
### 6.2 回归与部署
- [ ] `pytest tests/ -v` 全量回归
- [ ] `deploy_44_202.py --host 192.168.5.44 --no-frontend` 同步后端(前端改动视情况带上)
- [ ] 5.44 设置 `PLATFORM_BASE_URL`(确认实际访问地址,如 `http://192.168.5.44`)并重启容器
- [ ] 页面配置真实机器人 webhook → 测试发送 → 群内收到消息
- [ ] 触发一次小任务执行(含失败用例)→ 收到 ⚠️ 告警 + 链接手机可打开
### 6.3 收尾
- [ ] 更新 `HANDOFF_UI自动化.md`
- [ ] PRD/计划文档状态更新
---
## 风险与回滚
| 风险 | 缓解 |
|------|------|
| 钉钉机器人安全设置(关键字/加签/IP白名单)导致发送失败 | 测试发送透出 errmsg;消息标题含「UI自动化」可配合关键字设置 |
| 消息超 2048 字节被钉钉拒绝 | 超长保护逐条折叠明细(PRD 2.4.4-5),单测覆盖 |
| requests 阻塞事件循环 | run_in_executor 线程池 + timeout 10s |
| 误推大量失败消息刷屏 | 失败明细限 5 条折叠;一个任务一次执行只推一条 |
| 平台未配 PLATFORM_BASE_URL | 省略链接行仍发汇总,不阻断 |
---
*本文档由 Claude Code 生成,遵循项目计划执行文档规范。*
...@@ -189,6 +189,14 @@ async def _ensure_columns(conn) -> None: ...@@ -189,6 +189,14 @@ async def _ensure_columns(conn) -> None:
("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"), ("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 定时任务:安全测试配置ID(旧库升级) # 定时任务:安全测试配置ID(旧库升级)
("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL"), ("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL"),
# 定时任务:执行完成后发送钉钉报告通知开关(旧库升级)
("scheduled_tasks", "dingtalk_notify", "BOOLEAN DEFAULT 0"),
# 钉钉配置:报告通知机器人 Webhook(旧库升级)
("dingtalk_configs", "report_webhook_url", "VARCHAR(512) DEFAULT ''"),
# 钉钉配置:报告通知机器人加签密钥(旧库升级)
("dingtalk_configs", "report_secret", "VARCHAR(255) DEFAULT ''"),
# 钉钉配置:报告通知被@手机号列表(旧库升级)
("dingtalk_configs", "report_at_mobiles", "JSON"),
# 性能测试:增强指标体系(P95/标准差/延迟/连接时间/带宽/峰值TPS/Apdex/错误分类,旧库升级) # 性能测试:增强指标体系(P95/标准差/延迟/连接时间/带宽/峰值TPS/Apdex/错误分类,旧库升级)
("performance_tasks", "p95_response_time", "FLOAT DEFAULT 0.0"), ("performance_tasks", "p95_response_time", "FLOAT DEFAULT 0.0"),
("performance_tasks", "std_dev", "FLOAT DEFAULT 0.0"), ("performance_tasks", "std_dev", "FLOAT DEFAULT 0.0"),
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import String, DateTime, Integer from sqlalchemy import String, DateTime, Integer, JSON
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
...@@ -45,6 +45,16 @@ class DingTalkConfig(Base): ...@@ -45,6 +45,16 @@ class DingTalkConfig(Base):
captcha_path: Mapped[str] = mapped_column( captcha_path: Mapped[str] = mapped_column(
String(200), default="/platform/api/code", comment="验证码接口路径" String(200), default="/platform/api/code", comment="验证码接口路径"
) )
# 报告钉钉通知(群机器人 webhook 模式,与企业内部应用配置相互独立)
report_webhook_url: Mapped[Optional[str]] = mapped_column(
String(512), default="", comment="报告通知机器人 Webhook 地址"
)
report_secret: Mapped[Optional[str]] = mapped_column(
String(255), default="", comment="报告通知机器人加签密钥"
)
report_at_mobiles: Mapped[Optional[list]] = mapped_column(
JSON, default=list, comment="报告通知被@手机号列表(仅失败/异常时@)"
)
updated_at: Mapped[Optional[datetime]] = mapped_column( updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间" DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
) )
...@@ -60,4 +70,7 @@ class DingTalkConfig(Base): ...@@ -60,4 +70,7 @@ class DingTalkConfig(Base):
"target_url": self.target_url or "", "target_url": self.target_url or "",
"login_path": self.login_path or "/platform/api/auth/login", "login_path": self.login_path or "/platform/api/auth/login",
"captcha_path": self.captcha_path or "/platform/api/code", "captcha_path": self.captcha_path or "/platform/api/code",
"report_webhook_url": self.report_webhook_url or "",
"report_secret": self.report_secret or "",
"report_at_mobiles": self.report_at_mobiles or [],
} }
\ No newline at end of file
...@@ -38,7 +38,8 @@ class ScheduledTask(Base): ...@@ -38,7 +38,8 @@ class ScheduledTask(Base):
interval_unit (str): 间隔单位 (minutes/hours/days) interval_unit (str): 间隔单位 (minutes/hours/days)
run_time (str): 执行时间 HH:MM(schedule_type=daily/weekly) run_time (str): 执行时间 HH:MM(schedule_type=daily/weekly)
weekdays (list): 星期 [0-6](schedule_type=weekly) weekdays (list): 星期 [0-6](schedule_type=weekly)
auto_report (bool): 执行完成后是否自动生成报告 auto_report (bool): 执行完成后是否自动生成报告
dingtalk_notify (bool): 执行完成后是否发送钉钉报告通知
last_run_at (datetime): 最近一次触发时间 last_run_at (datetime): 最近一次触发时间
next_run_at (datetime): 下次触发时间 next_run_at (datetime): 下次触发时间
run_count (int): 累计触发次数 run_count (int): 累计触发次数
...@@ -95,6 +96,9 @@ class ScheduledTask(Base): ...@@ -95,6 +96,9 @@ class ScheduledTask(Base):
auto_report: Mapped[bool] = mapped_column( auto_report: Mapped[bool] = mapped_column(
Boolean, default=True, comment="执行完成后自动生成报告" Boolean, default=True, comment="执行完成后自动生成报告"
) )
dingtalk_notify: Mapped[bool] = mapped_column(
Boolean, default=False, comment="执行完成后发送钉钉报告通知"
)
last_run_at: Mapped[Optional[datetime]] = mapped_column( last_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, default=None, comment="最近一次触发时间" DateTime, nullable=True, default=None, comment="最近一次触发时间"
) )
...@@ -137,6 +141,7 @@ class ScheduledTask(Base): ...@@ -137,6 +141,7 @@ class ScheduledTask(Base):
"run_time": self.run_time or "", "run_time": self.run_time or "",
"weekdays": self.weekdays or [], "weekdays": self.weekdays or [],
"auto_report": self.auto_report, "auto_report": self.auto_report,
"dingtalk_notify": self.dingtalk_notify,
"last_run_at": self.last_run_at.isoformat() if self.last_run_at else None, "last_run_at": self.last_run_at.isoformat() if self.last_run_at else None,
"next_run_at": self.next_run_at.isoformat() if self.next_run_at else None, "next_run_at": self.next_run_at.isoformat() if self.next_run_at else None,
"run_count": self.run_count, "run_count": self.run_count,
......
...@@ -115,6 +115,7 @@ async def create_task( ...@@ -115,6 +115,7 @@ async def create_task(
run_time=data.run_time or "", run_time=data.run_time or "",
weekdays=data.weekdays or [], weekdays=data.weekdays or [],
auto_report=data.auto_report, auto_report=data.auto_report,
dingtalk_notify=data.dingtalk_notify,
) )
task.next_run_at = compute_next_run(task) task.next_run_at = compute_next_run(task)
db.add(task) db.add(task)
......
...@@ -30,6 +30,8 @@ from app.models.dingtalk_config import DingTalkConfig ...@@ -30,6 +30,8 @@ from app.models.dingtalk_config import DingTalkConfig
from app.schemas.dingtalk_config import ( from app.schemas.dingtalk_config import (
DingTalkConfigResponse, DingTalkConfigResponse,
DingTalkConfigUpdateRequest, DingTalkConfigUpdateRequest,
ReportNotifyConfigResponse,
ReportNotifyConfigUpdateRequest,
SutConfigResponse, SutConfigResponse,
SutConfigUpdateRequest, SutConfigUpdateRequest,
) )
...@@ -256,6 +258,80 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async ...@@ -256,6 +258,80 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async
) )
# ==================== 报告钉钉通知配置(群机器人 Webhook 模式) ====================
@router.get("/report-notify-config", response_model=ReportNotifyConfigResponse, summary="获取报告钉钉通知配置")
async def get_report_notify_config(db: AsyncSession = Depends(get_db)):
"""
获取报告钉钉通知配置(群机器人)
secret 已设置时脱敏返回 ******,前端密码框展示。
"""
from app.services.dingtalk_notify_service import get_notify_config, get_config_masked
config = get_config_masked(await get_notify_config(db))
return ReportNotifyConfigResponse(
webhook_url=config.get("webhook_url", ""),
secret=config.get("secret", ""),
at_mobiles=config.get("at_mobiles", []),
configured=bool(config.get("webhook_url")),
)
@router.put("/report-notify-config", response_model=ReportNotifyConfigResponse, summary="更新报告钉钉通知配置")
async def update_report_notify_config(
request: ReportNotifyConfigUpdateRequest,
db: AsyncSession = Depends(get_db),
):
"""
更新报告钉钉通知配置(持久化到数据库)
secret 传 ****** 表示保持原值不变(前端回显脱敏值后原样提交的场景)。
"""
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = DingTalkConfig(id=1)
db.add(config)
if request.webhook_url is not None:
config.report_webhook_url = request.webhook_url.strip()
if request.secret is not None and request.secret != "******":
config.report_secret = request.secret.strip()
if request.at_mobiles is not None:
# 去空白、去重、保留顺序
seen, mobiles = set(), []
for m in request.at_mobiles:
m = (m or "").strip()
if m and m not in seen:
seen.add(m)
mobiles.append(m)
config.report_at_mobiles = mobiles
await db.commit()
await db.refresh(config)
logger.info("报告钉钉通知配置已更新")
return ReportNotifyConfigResponse(
webhook_url=config.report_webhook_url or "",
secret="******" if config.report_secret else "",
at_mobiles=config.report_at_mobiles or [],
configured=bool(config.report_webhook_url),
)
@router.post("/report-notify-config/test", response_model=dict, summary="发送钉钉测试消息")
async def test_report_notify():
"""
用当前已保存的报告通知配置发送一条测试消息,验证机器人连通性。
"""
from app.services.dingtalk_notify_service import send_test_message
ok, message = await send_test_message()
return {"success": ok, "message": message}
# ==================== 被测系统配置 ==================== # ==================== 被测系统配置 ====================
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
创建日期:2026-08-13 创建日期:2026-08-13
""" """
from typing import Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
...@@ -44,4 +44,19 @@ class SutConfigUpdateRequest(BaseModel): ...@@ -44,4 +44,19 @@ class SutConfigUpdateRequest(BaseModel):
) )
captcha_path: Optional[str] = Field( captcha_path: Optional[str] = Field(
None, description="验证码接口路径,默认 /platform/api/code" None, description="验证码接口路径,默认 /platform/api/code"
) )
\ No newline at end of file
class ReportNotifyConfigResponse(BaseModel):
"""报告钉钉通知配置响应(secret 脱敏)"""
webhook_url: str = Field("", description="机器人 Webhook 地址")
secret: str = Field("", description="加签密钥(已设置时返回 ******)")
at_mobiles: List[str] = Field([], description="被@手机号列表")
configured: bool = Field(False, description="webhook 是否已配置")
class ReportNotifyConfigUpdateRequest(BaseModel):
"""报告钉钉通知配置更新请求(secret 传 ****** 表示保持原值)"""
webhook_url: Optional[str] = Field(None, description="机器人 Webhook 地址")
secret: Optional[str] = Field(None, description="加签密钥,****** 表示保持原值")
at_mobiles: Optional[List[str]] = Field(None, description="被@手机号列表")
\ No newline at end of file
...@@ -22,6 +22,7 @@ class ScheduledTaskCreate(BaseModel): ...@@ -22,6 +22,7 @@ class ScheduledTaskCreate(BaseModel):
run_time: str = Field("", description="执行时间 HH:MM(daily/weekly)") run_time: str = Field("", description="执行时间 HH:MM(daily/weekly)")
weekdays: List[int] = Field([], description="星期 [0-6](weekly)") weekdays: List[int] = Field([], description="星期 [0-6](weekly)")
auto_report: bool = Field(True, description="执行完成后自动生成报告") auto_report: bool = Field(True, description="执行完成后自动生成报告")
dingtalk_notify: bool = Field(False, description="执行完成后发送钉钉报告通知")
enabled: bool = Field(True, description="是否启用") enabled: bool = Field(True, description="是否启用")
case_type: str = Field("ui", description="用例类型: ui/security") case_type: str = Field("ui", description="用例类型: ui/security")
config_id: Optional[str] = Field(None, description="安全测试配置ID(security时必填)") config_id: Optional[str] = Field(None, description="安全测试配置ID(security时必填)")
...@@ -37,6 +38,7 @@ class ScheduledTaskUpdate(BaseModel): ...@@ -37,6 +38,7 @@ class ScheduledTaskUpdate(BaseModel):
run_time: Optional[str] = Field(None, description="执行时间 HH:MM") run_time: Optional[str] = Field(None, description="执行时间 HH:MM")
weekdays: Optional[List[int]] = Field(None, description="星期 [0-6]") weekdays: Optional[List[int]] = Field(None, description="星期 [0-6]")
auto_report: Optional[bool] = Field(None, description="自动生成报告") auto_report: Optional[bool] = Field(None, description="自动生成报告")
dingtalk_notify: Optional[bool] = Field(None, description="发送钉钉报告通知")
enabled: Optional[bool] = Field(None, description="是否启用") enabled: Optional[bool] = Field(None, description="是否启用")
case_type: Optional[str] = Field(None, description="用例类型: ui/security") case_type: Optional[str] = Field(None, description="用例类型: ui/security")
config_id: Optional[str] = Field(None, description="安全测试配置ID") config_id: Optional[str] = Field(None, description="安全测试配置ID")
......
此差异已折叠。
...@@ -390,6 +390,23 @@ async def run_task_once(task_id: str, manual: bool = False) -> Optional[str]: ...@@ -390,6 +390,23 @@ async def run_task_once(task_id: str, manual: bool = False) -> Optional[str]:
f"[定时任务] 任务「{task.name}」生成报告失败: {e}" f"[定时任务] 任务「{task.name}」生成报告失败: {e}"
) )
# 5b. 钉钉报告通知(任务级开关;发送失败不影响调度推进)
if task.dingtalk_notify:
try:
from app.services.dingtalk_notify_service import notify_execution_result
ok, message = await notify_execution_result(execution.id, task.name, db=db)
if ok:
logger.info(f"[定时任务] 任务「{task.name}」钉钉通知已发送")
else:
logger.warning(
f"[定时任务] 任务「{task.name}」钉钉通知未发送: {message}"
)
except Exception as e:
logger.error(
f"[定时任务] 任务「{task.name}」钉钉通知异常: {e}"
)
# 6. 推进调度状态 # 6. 推进调度状态
task.last_run_at = datetime.now() task.last_run_at = datetime.now()
task.run_count = (task.run_count or 0) + 1 task.run_count = (task.run_count or 0) + 1
......
此差异已折叠。
...@@ -21,6 +21,7 @@ export interface ScheduledTask { ...@@ -21,6 +21,7 @@ export interface ScheduledTask {
run_time: string run_time: string
weekdays: number[] weekdays: number[]
auto_report: boolean auto_report: boolean
dingtalk_notify: boolean
schedule_description: string schedule_description: string
last_run_at: string | null last_run_at: string | null
next_run_at: string | null next_run_at: string | null
...@@ -39,6 +40,7 @@ export interface ScheduledTaskPayload { ...@@ -39,6 +40,7 @@ export interface ScheduledTaskPayload {
run_time?: string run_time?: string
weekdays?: number[] weekdays?: number[]
auto_report?: boolean auto_report?: boolean
dingtalk_notify?: boolean
enabled?: boolean enabled?: boolean
} }
......
...@@ -83,4 +83,37 @@ export const systemApi = { ...@@ -83,4 +83,37 @@ export const systemApi = {
const response = await request.put('/api/system/sut-config', data) const response = await request.put('/api/system/sut-config', data)
return response as any return response as any
}, },
/**
* 获取报告钉钉通知配置(群机器人)
*/
async getReportNotifyConfig(): Promise<{
webhook_url: string
secret: string
at_mobiles: string[]
configured: boolean
}> {
const response = await request.get('/api/system/report-notify-config')
return response as any
},
/**
* 更新报告钉钉通知配置(secret 传 ****** 表示保持原值)
*/
async updateReportNotifyConfig(data: {
webhook_url?: string
secret?: string
at_mobiles?: string[]
}): Promise<any> {
const response = await request.put('/api/system/report-notify-config', data)
return response as any
},
/**
* 发送钉钉测试消息
*/
async testReportNotify(): Promise<{ success: boolean; message: string }> {
const response = await request.post('/api/system/report-notify-config/test')
return response as any
},
} }
<!-- <!--
页面名称:ScheduledTasks.vue 页面名称:ScheduledTasks.vue
页面描述:UI 自动化定时任务页面(任务列表 / 创建编辑 / 立即执行 / 运行历史) 页面描述:UI 自动化定时任务页面(任务列表 / 创建编辑 / 立即执行 / 运行历史 / 钉钉通知开关
路径:/scheduled-tasks 路径:/scheduled-tasks
@author czj @author czj
@date 2026-08-19 @date 2026-08-19
@last-modified 2026-09-07
--> -->
<template> <template>
<div class="scheduled-tasks-page"> <div class="scheduled-tasks-page">
...@@ -65,6 +66,13 @@ ...@@ -65,6 +66,13 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="钉钉通知" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.dingtalk_notify ? 'success' : 'info'" size="small">
{{ row.dingtalk_notify ? '开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center"> <el-table-column label="状态" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-switch <el-switch
...@@ -233,6 +241,11 @@ ...@@ -233,6 +241,11 @@
<span class="form-tip">执行完成后自动生成 HTML 报告到报告中心</span> <span class="form-tip">执行完成后自动生成 HTML 报告到报告中心</span>
</el-form-item> </el-form-item>
<el-form-item label="钉钉通知">
<el-switch v-model="form.dingtalk_notify" />
<span class="form-tip">执行完成后推送钉钉报告消息(机器人在「系统设置」配置)</span>
</el-form-item>
<el-form-item label="启用"> <el-form-item label="启用">
<el-switch v-model="form.enabled" /> <el-switch v-model="form.enabled" />
<span class="form-tip">停用后不会按周期触发</span> <span class="form-tip">停用后不会按周期触发</span>
...@@ -345,6 +358,7 @@ interface TaskForm { ...@@ -345,6 +358,7 @@ interface TaskForm {
run_time: string run_time: string
weekdays: number[] weekdays: number[]
auto_report: boolean auto_report: boolean
dingtalk_notify: boolean
enabled: boolean enabled: boolean
} }
...@@ -357,6 +371,7 @@ const createEmptyForm = (): TaskForm => ({ ...@@ -357,6 +371,7 @@ const createEmptyForm = (): TaskForm => ({
run_time: '', run_time: '',
weekdays: [1, 2, 3, 4, 5], weekdays: [1, 2, 3, 4, 5],
auto_report: true, auto_report: true,
dingtalk_notify: false,
enabled: true, enabled: true,
}) })
...@@ -495,6 +510,7 @@ const openEdit = async (task: ScheduledTask) => { ...@@ -495,6 +510,7 @@ const openEdit = async (task: ScheduledTask) => {
run_time: task.run_time || '', run_time: task.run_time || '',
weekdays: [...(task.weekdays || [])], weekdays: [...(task.weekdays || [])],
auto_report: task.auto_report, auto_report: task.auto_report,
dingtalk_notify: task.dingtalk_notify,
enabled: task.enabled, enabled: task.enabled,
}) })
if (modules.value.length === 0) await loadModules() if (modules.value.length === 0) await loadModules()
...@@ -517,6 +533,7 @@ const handleSave = async () => { ...@@ -517,6 +533,7 @@ const handleSave = async () => {
run_time: form.schedule_type === 'interval' ? '' : form.run_time, run_time: form.schedule_type === 'interval' ? '' : form.run_time,
weekdays: form.schedule_type === 'weekly' ? form.weekdays : [], weekdays: form.schedule_type === 'weekly' ? form.weekdays : [],
auto_report: form.auto_report, auto_report: form.auto_report,
dingtalk_notify: form.dingtalk_notify,
enabled: form.enabled, enabled: form.enabled,
} }
if (isEdit.value) { if (isEdit.value) {
......
<!-- <!--
页面名称:Settings.vue 页面名称:Settings.vue
页面描述:系统配置页面(被测系统配置 + 钉钉对接配置) 页面描述:系统配置页面(被测系统配置 + 钉钉对接配置 + 报告钉钉通知配置
路径:/system/settings 路径:/system/settings
@author czj @author czj
@date 2026-08-13 @date 2026-08-13
@last-modified 2026-08-19 @last-modified 2026-09-07
--> -->
<template> <template>
<div class="settings-page"> <div class="settings-page">
...@@ -123,6 +123,67 @@ ...@@ -123,6 +123,67 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<!-- 报告钉钉通知配置(群机器人) -->
<el-card shadow="never" style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>报告钉钉通知(群机器人)</span>
<el-tag v-if="reportNotifyForm.configured" type="success" size="small">已配置</el-tag>
</div>
</template>
<el-form
ref="reportNotifyFormRef"
:model="reportNotifyForm"
:rules="reportNotifyRules"
label-width="140px"
label-position="right"
style="max-width: 600px"
>
<el-form-item label="Webhook 地址" prop="webhook_url">
<el-input
v-model="reportNotifyForm.webhook_url"
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx"
clearable
/>
</el-form-item>
<el-form-item label="加签密钥" prop="secret">
<el-input
v-model="reportNotifyForm.secret"
type="password"
show-password
placeholder="机器人开启加签时填 SEC 开头密钥;未开启可留空"
clearable
/>
</el-form-item>
<el-form-item label="被@手机号" prop="at_mobiles_text">
<el-input
v-model="reportNotifyForm.at_mobiles_text"
placeholder="仅失败/异常消息时@,多个用逗号分隔,如 13800000001,13800000002"
clearable
/>
</el-form-item>
<el-alert
title="定时任务开启「执行完成后发送钉钉通知」后,按结果推送 ✅ 通过 / ⚠️ 失败(@手机号)/ 🚨 异常 三种消息,并附报告链接。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form-item>
<el-button type="primary" :loading="reportNotifySaving" @click="handleReportNotifySave">
{{ reportNotifySaving ? '保存中...' : '保存配置' }}
</el-button>
<el-button :loading="reportNotifyTesting" @click="handleReportNotifyTest">发送测试消息</el-button>
<el-button @click="handleReportNotifyReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div> </div>
</template> </template>
...@@ -306,11 +367,121 @@ function handleReset() { ...@@ -306,11 +367,121 @@ function handleReset() {
formRef.value?.clearValidate() formRef.value?.clearValidate()
} }
// ==================== 报告钉钉通知配置(群机器人) ====================
interface ReportNotifyForm {
webhook_url: string
secret: string
at_mobiles_text: string
configured: boolean
}
const reportNotifyFormRef = ref()
const reportNotifySaving = ref(false)
const reportNotifyTesting = ref(false)
const reportNotifyForm = reactive<ReportNotifyForm>({
webhook_url: '',
secret: '',
at_mobiles_text: '',
configured: false,
})
/** 原始快照,用于重置 */
let reportNotifySnapshot = ''
const reportNotifyRules = {
webhook_url: [{ required: true, message: '请输入机器人 Webhook 地址', trigger: 'blur' }],
}
/** 手机号文本 → 数组(去空白、去重、保留顺序) */
function parseMobiles(text: string): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const m of (text || '').split(/[,,;;\s]+/)) {
const trimmed = m.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
result.push(trimmed)
}
}
return result
}
/** 加载报告通知配置 */
async function loadReportNotifyConfig() {
try {
const res = await systemApi.getReportNotifyConfig()
reportNotifyForm.webhook_url = res.webhook_url || ''
reportNotifyForm.secret = res.secret || ''
reportNotifyForm.at_mobiles_text = (res.at_mobiles || []).join(',')
reportNotifyForm.configured = !!res.configured
reportNotifySnapshot = JSON.stringify({
webhook_url: reportNotifyForm.webhook_url,
secret: reportNotifyForm.secret,
at_mobiles_text: reportNotifyForm.at_mobiles_text,
})
} catch (err: any) {
ElMessage.error('加载报告通知配置失败:' + (err?.message || '未知错误'))
}
}
/** 保存报告通知配置 */
async function handleReportNotifySave() {
const valid = await reportNotifyFormRef.value?.validate().catch(() => false)
if (!valid) return
reportNotifySaving.value = true
try {
await systemApi.updateReportNotifyConfig({
webhook_url: reportNotifyForm.webhook_url.trim(),
secret: reportNotifyForm.secret,
at_mobiles: parseMobiles(reportNotifyForm.at_mobiles_text),
})
ElMessage.success('报告通知配置已保存')
reportNotifyForm.configured = true
reportNotifySnapshot = JSON.stringify({
webhook_url: reportNotifyForm.webhook_url,
secret: reportNotifyForm.secret,
at_mobiles_text: reportNotifyForm.at_mobiles_text,
})
} catch (err: any) {
ElMessage.error('保存报告通知配置失败:' + (err?.message || '未知错误'))
} finally {
reportNotifySaving.value = false
}
}
/** 发送测试消息 */
async function handleReportNotifyTest() {
reportNotifyTesting.value = true
try {
const res = await systemApi.testReportNotify()
if (res.success) {
ElMessage.success('测试消息已发送成功')
} else {
ElMessage.error('测试消息发送失败:' + (res.message || '未知原因'))
}
} catch (err: any) {
ElMessage.error('发送测试消息异常:' + (err?.message || '未知错误'))
} finally {
reportNotifyTesting.value = false
}
}
/** 重置表单到加载时的值 */
function handleReportNotifyReset() {
const snapshot = JSON.parse(reportNotifySnapshot)
Object.assign(reportNotifyForm, snapshot)
reportNotifyFormRef.value?.clearValidate()
}
// ==================== 生命周期 ==================== // ==================== 生命周期 ====================
onMounted(() => { onMounted(() => {
loadConfig() loadConfig()
loadSutConfig() loadSutConfig()
loadReportNotifyConfig()
}) })
</script> </script>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论