提交 9d3c24b9 authored 作者: 陈泽健's avatar 陈泽健

feat(ui): UI自动化定时任务模块 + 系统配置被测系统URL

- 定时任务模块:30s 轮询调度引擎 + 到期异步触发 + 执行冲突跳过 + 任务防重入
- 定时任务 CRUD/启停/立即执行/运行记录,复用执行引擎生成 trigger_type=scheduled 执行
- 报告按模块分组渲染 + 头部展示被测系统 URL;报告列表新增触发方式列
- 系统配置新增被测系统 URL 读写(保存持久化 + 重启从数据库加载,修复重启回退)
- 前端新增定时任务页 + 系统配置被测系统卡片
- 后端 179 测试全过、前端 build 通过、真实端到端验证通过
- PRD 与计划执行文档:Docs/PRD/需求文档/定时任务/
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 1bb8756a
# PRD:UI 自动化定时任务模块 + 系统配置-被测系统 URL
> **版本**:v1.0
> **创建日期**:2026-08-19
> **状态**:待实现
> **关联项目**:平台自动化测试可视化系统 (platform-auto-test)
> **关联文档**:`_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL_计划执行.md`
---
## 一、需求背景
当前 UI 自动化用例只能**手动**发起执行(执行中心选择用例 → 执行)。当需要周期性回归测试(如每晚跑一遍登录/会议/审批等核心模块)时,必须人工到点点击,无法自动化、无人值守。同时,被测系统地址目前隐藏在「钉钉对接配置」卡片里,且仅在**保存时**写入内存(`settings.TARGET_URL`),**服务重启后回退到环境变量默认值**,配置不能真正持久生效。
本 PRD 提出两个目标:
1. **UI 自动化定时任务模块**:设置定时任务自动执行指定模块的用例,执行完成后自动生成 UI 自动化测试报告到报告中心,报告明确展示执行了哪些模块、哪些用例、耗时等。
2. **系统配置-被测系统设置**:在系统管理的系统配置界面提供独立的「被测系统」设置项,填写系统访问 URL,保存后 UI 自动化执行即使用该地址,且服务重启后依然生效。
---
## 二、现状分析与复用基础
| 现状 | 结论 |
|------|------|
| `Execution` 模型已含 `trigger_type`(manual/scheduled)+ `trigger_by`(触发人/任务名) | 定时任务产生的执行记录可直接打标 `trigger_type=scheduled``trigger_by=任务名`,无需改模型 |
| `ExecutionService.create_execution()` 已接受 `trigger_type` / `trigger_by` 参数 | 定时任务复用现成接口创建执行记录 |
| `ExecutionService.run_execution()` 已实现串行执行锁 + Playwright 线程池 + WebSocket 广播 | 定时任务直接复用执行引擎,天然串行、不冲突 |
| 报告中心已存在(`/reports/ui` + `/api/reports/generate|export|download|list`),HTML 报告已显示触发方式/耗时/通过率 | 定时任务完成后的报告自动进入报告中心,无需新建报告页面 |
| 报告 HTML **只列用例、不分组显示模块** | 需增强:报告按模块分组展示,明确"执行了哪些模块及用例" |
| 系统配置页(`Settings.vue`)钉钉卡片里已有 `target_url` 字段,保存时写入 `settings.TARGET_URL` | 需独立成「被测系统」卡片 + **启动时从 DB 加载**,保证重启后仍生效 |
| **无任何调度器**`backend/app/tasks/` 为空,requirements 无 APScheduler) | 需自建后台调度循环(沿用 `message_consumer_loop` 的 asyncio 后台任务模式,避免引入新依赖 + 容器重装) |
---
## 三、需求描述
### 3.1 功能一:UI 自动化定时任务模块
#### 3.1.1 菜单入口
侧边栏「自动化测试」父菜单下新增子菜单 **「定时任务」**(位于「执行中心」与「报告中心」之间):
| 菜单项 | 路由 |
|--------|------|
| 定时任务 | `/scheduled-tasks` |
#### 3.1.2 定时任务列表页
展示所有定时任务,表格列:
| 列 | 说明 |
|----|------|
| 任务名称 | 用户自定义 |
| 执行模块 | 该任务选择的模块名称列表(逗号分隔) |
| 周期 | 人类可读周期描述,如「每 30 分钟」「每天 22:00」「每周一 22:00」 |
| 状态 | 启用/停用 开关 |
| 上次执行 | 最近一次运行开始时间 |
| 下次执行 | 计算出的下次触发时间 |
| 累计执行 | 已成功触发的次数 |
| 操作 | 立即执行 / 编辑 / 删除 |
支持:刷新列表、启用/停用切换(Switch)、立即执行(手动触发一次)。
#### 3.1.3 创建 / 编辑定时任务(对话框)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 任务名称 | 文本 | 是 | 唯一标识,执行记录的 `trigger_by` 取该值 |
| 执行模块 | 多选(Checkbox 组/多选下拉) | 是 | 数据源 `/api/modules?case_type=ui`(UI 测试模块),每项展示模块名 + 用例数 |
| 周期模式 | 单选 | 是 | `interval`(按间隔)/ `daily`(每天定时)/ `weekly`(每周定时) |
| 间隔值 | 数字 | 模式=interval | 如 30 |
| 间隔单位 | 单选 | 模式=interval | `minutes` / `hours` / `days` |
| 执行时间 | 时间选择器 HH:MM | 模式=daily | 每天该时刻执行 |
| 星期 + 执行时间 | 多选星期 + 时间 | 模式=weekly | 每周选中日的该时刻执行 |
| 自动生成报告 | 开关 | 否 | 默认开启;执行完成后自动生成 HTML 报告到报告中心 |
**保存即计算 `next_run_at`**,创建/编辑后写入数据库。
#### 3.1.4 立即执行
点击「立即执行」→ 立即收集该任务选中模块的用例 → 创建执行记录并运行 → 完成后生成报告。与手动执行中心效果一致,执行记录标记 `trigger_type=scheduled``trigger_by=任务名`
#### 3.1.5 后台调度引擎
- 应用启动(`lifespan`)时启动一个 asyncio 后台循环任务(沿用 `message_consumer_loop` 模式,`backend/app/services/scheduler_service.py`);
-**30 秒** 轮询一次数据库:找出 `enabled=True``next_run_at <= now`(或从未运行过)的任务;
- 对每个到期任务执行触发流程(见 3.1.6),触发后重算 `next_run_at` 并更新 `last_run_at` / `run_count`
- 调度循环异常捕获并记日志,不中断整个循环。
#### 3.1.6 触发流程
```
到期任务 → 收集模块用例
→ 查询 TestCase where module_id ∈ 任务.module_ids AND status='active' AND case_type='ui',按 module_id+order 排序
→ 若用例数为 0 → 记日志,重算下次执行,跳过本次
→ 若 is_execution_running() == True(已有手动/其他任务在执行)→ 记日志,重算下次执行,跳过本次(不创建失败记录)
→ create_execution(case_ids, name="{任务名}-{yyyyMMddHHmmss}", trigger_type="scheduled", trigger_by=任务名, case_type="ui")
→ run_execution(exec_id) # 复用执行引擎,串行锁保证不并发
→ 执行完成后:若 auto_report=True → ReportService.generate_html() + save_report()(报告进入报告中心)
→ 更新任务 last_run_at / run_count / next_run_at
```
**执行冲突策略**:因 UI 用例必须串行执行(全局执行锁),到期时若已有执行在运行,**跳过本次**并在日志中记录「任务 X 已到点但存在运行中的执行,跳过」;下次周期自动再触发。不在前台创建一条注定失败的执行记录。
#### 3.1.7 运行历史
每个定时任务运行都会在报告中心产生一条执行记录(`trigger_type=scheduled``trigger_by=任务名`,可查看/下载报告)。定时任务页提供「运行记录」查看入口(抽屉/弹窗),列出该任务的历史执行:执行时间、状态、通过率、耗时。
#### 3.1.8 删除任务
删除定时任务仅删除任务定义,**不影响**已产生的执行记录与报告。
### 3.2 功能二:系统配置-被测系统设置
#### 3.2.1 配置界面
系统管理 → 系统配置(`/system/settings`)页面新增独立 **「被测系统」** 卡片:
- 字段:被测系统访问 URL(如 `https://192.168.5.44`
- 校验:必填、须以 `http://``https://` 开头
- 保存按钮 + 重置按钮
- 原「钉钉对接配置」卡片移除 `target_url` 字段(避免两处维护同一概念),其余钉钉字段保持不变
#### 3.2.2 生效机制
| 时机 | 行为 |
|------|------|
| 保存时 | 持久化到数据库,并立即更新 `settings.TARGET_URL`(运行时全局生效) |
| 应用启动时 | `lifespan` 内从数据库读取被测系统 URL → 写入 `settings.TARGET_URL`**修复重启回退问题**) |
| 执行用例时 | `ExecutionService.run_execution()``executor_config.setdefault("base_url", settings.TARGET_URL)`,执行器自动使用该地址(无需改执行引擎) |
#### 3.2.3 持久化方案
复用现有单行配置表 `dingtalk_configs`(id=1)的 `target_url` 字段作为唯一持久化存储(该字段已存在,**无需数据库迁移**)。新增独立 API 面 `GET/PUT /api/system/sut-config` 读写该字段,语义清晰、与钉钉配置解耦。
### 3.3 报告增强:明确"执行了哪些模块及用例,耗时"
为满足"报告要明确当前执行了哪些模块及用例,耗时等",对 HTML 报告做以下增强(**同时惠及手动与定时执行**):
1. **报告按模块分组**`ReportService.generate_html` 关联 `TestCase.module_id → Module.name`,将用例结果按模块分组渲染,每个模块显示模块名 + 该模块通过/失败统计,组内列出各用例及其状态、耗时、错误;每个用例下保留步骤明细。
2. **报告头部已含**:执行时间、总耗时、触发方式(manual/scheduled)、触发人(任务名)——现有模板已支持,无需改动。
3. **报告中心列表**新增「触发方式」列:手动 / 定时,定时执行一眼可辨(`/api/reports/list` 响应补充 `trigger_type` / `trigger_by`)。
---
## 四、数据模型设计
### 4.1 新增表 `scheduled_tasks`
| 字段 | 类型 | 说明 |
|------|------|------|
| id | String(64) PK | `scheduled_` 前缀 UUID |
| name | String(100) | 任务名称(= 执行记录 `trigger_by`) |
| enabled | Boolean | 是否启用(默认 True) |
| module_ids | JSON(list) | 选中的模块 ID 列表 |
| schedule_type | String(20) | `interval` / `daily` / `weekly` |
| interval_value | Integer | 间隔值(模式=interval,默认 0) |
| interval_unit | String(10) | `minutes` / `hours` / `days` |
| run_time | String(5) | `HH:MM`(模式=daily/weekly) |
| weekdays | JSON(list) | [0-6](模式=weekly) |
| auto_report | Boolean | 执行完成后自动生成报告(默认 True) |
| last_run_at | DateTime nullable | 最近一次触发时间 |
| next_run_at | DateTime nullable | 下次触发时间 |
| run_count | Integer | 累计触发次数(默认 0) |
| created_at | DateTime | 创建时间 |
| updated_at | DateTime | 更新时间 |
模型新增 `backend/app/models/scheduled_task.py`,并在 `database.py``_ensure_columns` 依赖导入中注册(保证 `create_all` 建表)。
### 4.2 不新增/不修改
- `executions``case_results``test_cases``modules``dingtalk_configs` 均无结构变更(`dingtalk_configs.target_url` 复用)。
---
## 五、接口设计(后端新增)
### 5.1 定时任务管理 `routers/scheduled_tasks.py`,前缀 `/api/scheduled-tasks`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/scheduled-tasks` | 任务列表(含下次执行时间) |
| POST | `/api/scheduled-tasks` | 创建任务(服务端计算 `next_run_at`) |
| PUT | `/api/scheduled-tasks/{id}` | 更新任务 |
| DELETE | `/api/scheduled-tasks/{id}` | 删除任务(不影响已产生执行记录) |
| POST | `/api/scheduled-tasks/{id}/run` | 立即执行(后台异步创建执行 + 运行 + 生成报告) |
| POST | `/api/scheduled-tasks/{id}/toggle` | 启用/停用(body: `{enabled: bool}`) |
| GET | `/api/scheduled-tasks/{id}/runs` | 运行历史(`executions` where trigger_type=scheduled AND trigger_by=任务名) |
### 5.2 被测系统配置(新增到 `routers/system.py`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/system/sut-config` | 读取被测系统 URL(DB 优先,回退环境变量) |
| PUT | `/api/system/sut-config` | 保存被测系统 URL(持久化 + 更新 `settings.TARGET_URL`) |
### 5.3 小改(兼容扩展)
| 文件 | 改动 |
|------|------|
| `routers/executions.py` 列表接口 | 增加可选 `trigger_type` / `trigger_by` 查询参数(供定时任务运行历史使用,向后兼容) |
| `routers/reports.py` 列表接口 | 响应补充 `trigger_type` / `trigger_by` 字段(供报告中心显示触发方式) |
| `services/report_service.py` | 报告按模块分组渲染(见 3.3) |
---
## 六、变更范围
| 层面 | 变更内容 | 涉及文件 |
|------|---------|---------|
| **后端模型** | 新增 `ScheduledTask` | `backend/app/models/scheduled_task.py`(新建)、`backend/app/database.py` |
| **后端服务** | 调度引擎 + 触发流程 | `backend/app/services/scheduler_service.py`(新建) |
| **后端路由** | 定时任务 CRUD + 立即执行 + 运行历史 | `backend/app/routers/scheduled_tasks.py`(新建)、`backend/app/main.py`(注册) |
| **后端路由** | 被测系统配置接口 | `backend/app/routers/system.py` |
| **后端小改** | 执行列表/报告列表参数补充、报告按模块分组 | `backend/app/routers/executions.py``backend/app/routers/reports.py``backend/app/services/report_service.py` |
| **后端启动** | lifespan 加载被测系统 URL + 启动调度循环 | `backend/app/main.py` |
| **前端 API** | 定时任务 + 被测系统 API 封装 | `frontend/src/api/scheduledTasks.ts`(新建)、`frontend/src/api/system.ts` |
| **前端页面** | 定时任务列表页 | `frontend/src/views/ScheduledTasks.vue`(新建) |
| **前端页面** | 系统配置新增「被测系统」卡片 | `frontend/src/views/system/Settings.vue` |
| **前端路由/菜单** | 新增 `/scheduled-tasks` 路由 + 侧边栏菜单项 | `frontend/src/router/index.ts``frontend/src/App.vue` |
| **前端报告中心** | 增加「触发方式」列 | `frontend/src/views/Reports.vue` |
---
## 七、验收标准
### 7.1 定时任务模块
1. ✅ 侧边栏「自动化测试」下显示「定时任务」子菜单,可进入列表页
2. ✅ 可创建定时任务:选择多个 UI 模块、设置间隔/定点周期、开启自动报告
3. ✅ 列表正确显示任务名、执行模块、周期、状态、上次/下次执行时间
4. ✅ 启用任务后到点自动触发执行(无需人工操作),执行记录 `trigger_type=scheduled``trigger_by=任务名`
5. ✅ 执行完成后自动生成 HTML 报告,出现在报告中心,报告按模块分组展示用例、状态、耗时
6. ✅ 立即执行按钮可手动触发一次,产生执行记录 + 报告
7. ✅ 执行进行中到点触发的任务自动跳过本次(不产生失败记录),下次周期恢复
8. ✅ 运行历史列出该任务的历史执行(时间/状态/通过率/耗时)
9. ✅ 停用任务后不再触发;删除任务不影响已产生的执行记录与报告
10. ✅ 服务重启后,启用的定时任务自动恢复调度(读取 DB 中的 `next_run_at`
### 7.2 被测系统配置
1. ✅ 系统配置页有独立「被测系统」卡片,显示当前 URL
2. ✅ 保存后立即生效:随后发起的 UI 执行使用新 URL
3. ✅ 服务重启后仍使用保存的 URL(启动时从 DB 加载,不回退到环境变量默认值)
4. ✅ 钉钉配置卡片不再包含 target_url 字段(单一来源)
### 7.3 兼容性
- ✅ 手动执行功能不受影响(执行锁共用,串行互斥)
- ✅ 现有报告查看/下载功能正常
- ✅ 后端测试套件通过(`pytest`),前端构建通过(`npm run build`
---
## 八、注意事项与风险
| 风险/注意 | 说明 | 缓解 |
|-----------|------|------|
| UI 用例必须串行 | 定时任务到点时若已有执行在跑,只能跳过 | 跳过策略 + 30s 轮询粒度,业务可接受;后续可支持"顺延执行" |
| 会议室占用 | 定时跑会议类模块,若某次执行异常未结束会议,房间可能被占用,影响下次执行 | 依赖执行器结束会议步骤 + 会议模块用例本身已具备零残留验收;定时任务建议与手动执行错峰 |
| 调度循环与 Playwright 线程 | 调度器只负责"创建 + 等待",真正跑 Playwright 的仍是执行器专用线程 | 复用 `run_execution`,不新增并发路径 |
| 无用户权限系统 | 定时任务创建/启停无鉴权 | 与现有平台一致,仅内网使用 |
| 时区 | `next_run_at` 用服务器本地时间计算与比较 | 与现有 `datetime.now()` 用法一致,部署服务器为国内时区 |
---
## 九、后续规划(本次不实现)
- 定时任务失败重试/告警(钉钉通知)
- cron 表达式高级周期
- 任务级超时控制与强制终止
---
*本文档由 Claude Code 生成,遵循项目 PRD 文档规范。*
# 执行计划 - UI 自动化定时任务模块 + 系统配置-被测系统 URL
> **文档类型**: 计划执行文档
> **创建日期**: 2026-08-19
> **作者**: czj
> **状态**: 待实现
> **关联文档**: `_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL.md`
---
## 一、执行概述
实现两大功能:
1. **UI 自动化定时任务模块**:新增 `scheduled_tasks` 表 + 后台 asyncio 调度循环 + 定时任务管理 API + 前端列表/编辑页 + 菜单/路由;执行完成后自动生成报告到报告中心,报告按模块分组展示。
2. **系统配置-被测系统 URL**:新增独立「被测系统」配置卡片 + `sut-config` API,保存即时生效,**启动时从 DB 加载**(修复重启回退问题)。
**设计原则**
- 复用现有 `ExecutionService.create_execution()`(已支持 `trigger_type=scheduled` / `trigger_by`)与 `run_execution()`(串行锁),**不新增执行路径**
- 调度器用 asyncio 后台任务(沿用 `message_consumer_loop` 模式),**不引入 APScheduler 依赖**(避免容器重装 + Windows 事件循环风险);
- 被测系统 URL 复用 `dingtalk_configs.target_url`(单行表,字段已存在,**无数据库迁移**)。
---
## 二、任务分解与实施步骤
### Step 1:新增 `ScheduledTask` 模型
**文件**`backend/app/models/scheduled_task.py`(新建)
```python
class ScheduledTask(Base):
__tablename__ = "scheduled_tasks"
id: str = String(64) PK # scheduled_ 前缀
name: str = String(100) # 任务名(= trigger_by)
enabled: bool = Boolean default True
module_ids: list = JSON # 选中模块 ID 列表
schedule_type: str = String(20) # interval / daily / weekly
interval_value: int = Integer default 0
interval_unit: str = String(10) # minutes / hours / days
run_time: str = String(5) # HH:MM
weekdays: list = JSON # [0-6]
auto_report: bool = Boolean default True
last_run_at: datetime nullable
next_run_at: datetime nullable
run_count: int = Integer default 0
created_at / updated_at
```
**同时**`backend/app/database.py``_ensure_columns` 顶部依赖导入加入 `from app.models import scheduled_task # noqa: F401`,确保 `init_db` 建表。
**验证**:本地起服务,确认表创建成功(SQLite),`SHOW CREATE` 无异常。
### Step 2:`SchedulerService` 调度引擎
**文件**`backend/app/services/scheduler_service.py`(新建)
核心内容:
- `compute_next_run(task, now) -> datetime`:按 `schedule_type` 计算下次触发时间
- `interval``now + interval_value × 单位`(分钟/小时/天)
- `daily`:今天 `run_time`;若已过则明天
- `weekly`:在选中 `weekdays` 中找下一个 ≥ now 的 `run_time`
- `async def run_task_once(task_id)`:单次触发流程(供后台循环与"立即执行"共用)
1. 读任务 + 收集模块 active UI 用例(`module_id in module_ids AND status='active' AND case_type='ui'`,按 `module_id, order` 排序)
2. 用例数=0 → 记日志,返回 `None`(跳过)
3. `is_execution_running()` 为 True → 记日志「已有执行运行,跳过本次」,返回 `None`
4. `create_execution(case_ids, name=f"{task.name}-{ts}", trigger_type="scheduled", trigger_by=task.name, case_type="ui")`
5. `run_execution(exec_id)`
6. 完成后:若 `auto_report``ReportService().generate_html()` + `save_report()`
7. 更新 `last_run_at` / `run_count` / `next_run_at`,commit
- `async def scheduler_loop()``while True: 每 30s 轮询 enabled 且 (next_run_at<=now 或从未运行) 的任务 → asyncio.create_task(run_task_once(...))`
**注意**:触发用 `asyncio.create_task` 异步执行,调度循环不被单个长执行阻塞;`run_task_once` 内部已由 `run_execution` 的全局锁保证串行,且触发前先查 `is_execution_running()` 防重。
**验证**:单测/脚本创建一条 interval=1min 任务,观察调度触发产生 execution + 报告。
### Step 3:定时任务管理 API
**文件**`backend/app/routers/scheduled_tasks.py`(新建)+ `backend/app/main.py`(注册,prefix `/api/scheduled-tasks`
| 方法 | 路径 | 行为 |
|------|------|------|
| GET | `""` | 任务列表(排序 enabled desc, next_run_at asc) |
| POST | `""` | 校验 name/module_ids/周期 → 创建 → 计算 next_run_at |
| PUT | `/{id}` | 更新 → 重算 next_run_at |
| DELETE | `/{id}` | 删除任务定义 |
| POST | `/{id}/run` | 立即执行:`asyncio.create_task(run_task_once(id))`,返回「已触发」 |
| POST | `/{id}/toggle` | body `{enabled: bool}` → 更新 enabled + 重算 next_run_at |
| GET | `/{id}/runs` | 查 executions where trigger_type='scheduled' AND trigger_by=任务名,返回列表 |
**schemas**`backend/app/schemas/scheduled_task.py`(新建)定义 Create/Update 请求模型。
### Step 4:执行列表/报告列表小改
- `backend/app/routers/executions.py``list_executions` 增加可选 `trigger_type` / `trigger_by` 查询参数(透传给 `ExecutionService.list_executions`,追加 where 条件)。
- `backend/app/routers/reports.py``/api/reports/list` 响应 items 补充 `trigger_type` / `trigger_by` 字段。
- `backend/app/services/execution_service.py``list_executions` 方法签名加 `trigger_type` / `trigger_by` 可选参数。
### Step 5:报告按模块分组
**文件**`backend/app/services/report_service.py`
- `generate_html`:关联 `TestCase.module_id``Module.name`,将 `case_results` 按模块分组;
- 新模板 `MODULE_SECTION_TEMPLATE`:模块名 + 该模块 passed/failed 统计 + 组内用例结果(复用现有 `CASE_RESULT_TEMPLATE`);
- 报告头部已有执行时间/耗时/触发方式/触发人,保持。
- 增加可选的 `SUT_URL` 展示(报告页脚或头部 meta 显示被测系统地址,取自 `settings.TARGET_URL`)。
### Step 6:被测系统配置(后端)
**文件**`backend/app/routers/system.py`
- `GET /api/system/sut-config`:读 `dingtalk_configs` id=1 的 `target_url`(DB 优先,回退 `settings.TARGET_URL`);
- `PUT /api/system/sut-config`:body `{url}` → 持久化 `target_url` + `settings.TARGET_URL = url`
- `backend/app/main.py` lifespan:`init_db` 后加载 `dingtalk_configs.target_url``settings.TARGET_URL`(修复重启回退)。
- `backend/app/schemas/dingtalk_config.py`:新增 `SutConfigUpdateRequest {url}` 模型(或复用)。
### Step 7:前端 API 封装
- `frontend/src/api/scheduledTasks.ts`(新建):list/create/update/delete/run/toggle/runs;
- `frontend/src/api/system.ts`:新增 `getSutConfig()` / `updateSutConfig(url)`
- `frontend/src/api/reports.ts``list` 参数/类型无需改(返回 items 含新字段,TS any)。
### Step 8:定时任务前端页面
**文件**`frontend/src/views/ScheduledTasks.vue`(新建)
- 列表表格(见 PRD 3.1.2);
- 创建/编辑对话框(模块多选:调 `/api/modules?case_type=ui`;周期表单联动:interval/daily/weekly 显示对应控件;auto_report 开关);
- 「立即执行」按钮 + 执行中 loading;
- 启用/停用 Switch → toggle API;
- 「运行记录」抽屉:调 `/api/scheduled-tasks/{id}/runs` 展示历史执行(时间/状态/通过率/耗时),行内"查看报告"链接到 `/reports/ui`
- 删除确认。
### Step 9:菜单 / 路由 / 系统配置卡片
- `frontend/src/router/index.ts`:新增 `/scheduled-tasks` → ScheduledTasks.vue(title 定时任务);
- `frontend/src/App.vue`:自动化测试父菜单中,「执行中心」与「报告中心」之间加 `<el-menu-item index="/scheduled-tasks">定时任务</el-menu-item>``currentRoute` 回退逻辑无需改(一级 path 直达);
- `frontend/src/views/system/Settings.vue`:新增「被测系统」卡片(独立 form,URL 校验),调 sut-config API;「钉钉对接配置」卡片移除 target_url 字段。
### Step 10:验证
1. 后端:`cd backend && python -m pytest tests/ -x -q`(新增 scheduled_task 相关最小单测:compute_next_run 三种模式);确认原有 171 passed 不回归;
2. 手动功能验证(本地起 8001 + 前端 3000):
- 建一条「每 1 分钟」任务选 1-2 个模块 → 等待调度触发 → 执行记录 trigger_type=scheduled、trigger_by=任务名 → 报告出现在报告中心且按模块分组;
- 立即执行按钮 → 立即产生执行 + 报告;
- 执行中到点 → 任务跳过(日志可见)→ 下次恢复;
- 停用 → 不再触发;重启服务 → 任务自动恢复调度、被测系统 URL 保持保存值;
- 被测系统 URL 改为无效地址 → 执行失败(验证 URL 真正被使用),改回后恢复。
3. 前端:`cd frontend && npm run build` 通过。
4. 部署 5.60:scp 后端文件 + 前端 dist 到容器,`docker restart plat-auto-test-app`,验证调度 + 报告 + URL 配置。
---
## 三、涉及文件清单
### 新建
| 文件 | 说明 |
|------|------|
| `backend/app/models/scheduled_task.py` | ScheduledTask 模型 |
| `backend/app/schemas/scheduled_task.py` | 定时任务请求/响应 schema |
| `backend/app/services/scheduler_service.py` | 调度引擎 + 触发流程 |
| `backend/app/routers/scheduled_tasks.py` | 定时任务 API |
| `frontend/src/api/scheduledTasks.ts` | 前端 API |
| `frontend/src/views/ScheduledTasks.vue` | 定时任务页面 |
### 修改
| 文件 | 改动 |
|------|------|
| `backend/app/database.py` | 注册 scheduled_task 模型导入 |
| `backend/app/main.py` | 注册路由 + lifespan 加载被测系统 URL + 启动调度循环 |
| `backend/app/routers/executions.py` | 列表增加 trigger_type/trigger_by 过滤 |
| `backend/app/services/execution_service.py` | list_executions 支持新过滤 |
| `backend/app/routers/reports.py` | 列表响应补 trigger_type/trigger_by |
| `backend/app/services/report_service.py` | 报告按模块分组 + 被测系统地址 |
| `backend/app/routers/system.py` | sut-config 接口 |
| `backend/app/schemas/dingtalk_config.py` | SutConfig 请求模型 |
| `frontend/src/router/index.ts` | 定时任务路由 |
| `frontend/src/App.vue` | 侧边栏菜单项 |
| `frontend/src/views/system/Settings.vue` | 被测系统卡片 |
| `frontend/src/views/Reports.vue` | 触发方式列 |
| `frontend/src/api/system.ts` | sut-config API |
---
## 四、验收标准(对照 PRD 第七节)
| 验收项 | 预期 | 检查方式 |
|--------|------|---------|
| 定时任务自动触发 | 到点自动产生 scheduled 执行记录 | 本地观察日志 + 执行列表 |
| 报告自动生成且按模块分组 | 报告中心出现新报告,含模块分组统计 | 查看报告 |
| 立即执行 | 手动触发产生执行 + 报告 | 页面操作 |
| 执行冲突 | 到点时有执行在跑 → 跳过,无失败记录 | 日志 |
| 启停/删除 | 行为符合预期 | 页面操作 |
| 被测系统 URL | 保存即时生效 + 重启持久 | 改 URL → 执行;重启 → 仍生效 |
| 兼容 | 手动执行不回归、pytest 通过、前端构建通过 | 回归 |
---
## 五、风险与依赖
| 风险 | 缓解 |
|------|------|
| 调度循环在 Windows/容器内的事件循环兼容 | 仅使用 `asyncio` + `asyncio.create_task`,与 `message_consumer_loop` 同模式,已由该模式验证可行 |
| `create_task` 触发多任务并发 | 触发前 `is_execution_running()` 预检 + 执行器全局锁双保险 |
| `scheduled_tasks` 表在 MySQL 生产库自动建表 | `init_db``create_all` 会自动创建新表(MySQL 兼容),无需手动 DDL |
| 定时任务长时间执行时调度循环阻塞 | 触发用 `asyncio.create_task` 异步,循环 30s 粒度继续轮询 |
| 前端多窗口并行开发 | 本功能涉及 backend + frontend 多处文件,避免与他窗口同时改 `main.py` / `App.vue` / `router/index.ts` |
---
*本文档由 Claude Code 生成,遵循项目计划执行文档规范。*
......@@ -2,8 +2,8 @@
> **生成时间**: 2026-08-19
> **当前分支**: `platform-auto-test`
> **最近提交**: `c0dedbdf` fix(smart-locate): 新建会议用例 step 11/17 执行失败修复 + 生产验收通过
> **状态**: 🟢 **会话26:新建会议用例 step 11/17 执行失败已修复并部署 5.60 生产——执行器策略5 增强(表格懒加载轮询等待 150s + checkbox force 勾选 + is-checked 校验防假通过)+ 用例 step 12 改「保持立即开始」(选择器修正零匹配坑);本地 + 生产各连跑 3 次 18/18 全绿、零残留,验收通过**
> **最近提交**: `1bb8756a` chore: .gitignore 遮蔽历史会话遗留的一次性脚本与临时输出
> **状态**: 🟢 **会话27:UI自动化定时任务模块 + 系统配置被测系统URL 已实现并本地全链路验证通过——调度引擎(30s 轮询 + 到点异步触发 + 冲突跳过)复用现有执行引擎生成 `trigger_type=scheduled` 执行记录,报告按模块分组并展示被测系统 URL;SUT URL 保存后重启持久化。后端 179 测试全过、前端 build 通过、真实端到端验收通过**
---
......@@ -149,12 +149,46 @@
- 回归:`test_smart_locate_semantic.py` 47/47 通过(`test_api.py` 8 个 fixture 报错为既有环境问题,与本次修复无关)
- **会议生命周期结论(长期有效)**:立即开始→进行中→详情有「结束会议」;预定会议→未开始→详情只有「修改/取消/提前开始」,无「结束会议」
### ✅ UI自动化定时任务模块 + 系统配置被测系统URL(会话 27,2026-08-19)
**按 PRD 工作流交付**:需求文档 + 计划执行文档 → 实现 → 本地全链路验证通过。PRD:`Docs/PRD/需求文档/定时任务/_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL.md``_计划执行.md`
**需求**:① 定时任务模块——设置周期自动执行所选模块的 UI 用例,执行完成自动生成 HTML 报告到报告中心(报告明确执行了哪些模块/用例、耗时);② 系统管理→系统配置增加「被测系统设置」——填写系统访问 URL,保存后 UI 自动化使用该地址。
**后端实现**
| 文件 | 说明 |
|------|------|
| `models/scheduled_task.py` | 定时任务表:`module_ids`(JSON) / `schedule_type`(interval/daily/weekly) / `interval_value` / `run_time` / `weekdays` / `auto_report` / `enabled` / `last_run_at` / `next_run_at` / `run_count` |
| `services/scheduler_service.py` | **调度引擎**:lifespan 启动 asyncio 后台循环,30s 轮询到期任务 → `asyncio.create_task` 异步触发;`compute_next_run` 支持间隔/每天/每周;**冲突跳过**(已有执行在跑则跳过本次不产生失败记录)+ `_running_tasks` 防重入;复用 `ExecutionService.create_execution(trigger_type="scheduled", trigger_by=任务名)` + `run_execution`**不新增执行路径**;执行成功且 `auto_report` 时自动生成报告 |
| `routers/scheduled_tasks.py` | `/api/scheduled-tasks` CRUD + 启停 + 立即执行 + 运行记录(查 `trigger_type=scheduled` 执行) |
| `services/report_service.py` | 报告**按模块分组**渲染 + 头部展示被测系统 URL + 模块汇总卡片(模块名/用例数/通过率) |
| `routers/system.py` | `GET/PUT /api/system/sut-config` 被测系统 URL 读取/保存(校验 http/https 前缀;持久化到 `dingtalk_configs.target_url` 单行表,**无需 DB 迁移**) |
| `main.py` | lifespan:**启动时从 DB 加载 SUT URL 到 `settings.TARGET_URL`**(修复重启回退 bug)+ 启动/关闭调度引擎 |
**前端实现**`views/ScheduledTasks.vue`(新建弹窗支持按间隔/每天/每周 + 模块多选显示用例数 + 运行记录抽屉 + 立即执行/启停/删除);`api/scheduledTasks.ts``App.vue` 菜单新增「定时任务」+ `router` 新路由;`views/system/Settings.vue` 新增「被测系统配置」卡片(URL 校验/保存/重置 + db/env 来源标记);`views/Reports.vue` 报告列表新增「触发方式」列(定时/手动)。
**验证结果(全部通过)**
- 后端 `pytest` **179 passed**(补装缺失的 `pytest-asyncio`——`pytest.ini``asyncio_mode=auto` 但 requirements 未含该插件);前端 `npm run build`(vue-tsc + vite)通过
- **真实端到端**:创建「每 1 分钟」任务 → 调度引擎到点自动触发 → 真实 Playwright 执行登录模块 5 条用例(3 过 2 败,495s)→ 任务 `run_count` 自增、`next_run_at` 推进 → 自动生成 **按模块分组、含被测系统 URL 与每步耗时** 的 HTML 报告到报告中心(`trigger_type=scheduled`
- **SUT URL**:非法地址被拒、保存生效、重启后从 DB 加载(`source: db`
- **浏览器冒烟**:定时任务页/新建弹窗/模块用例数/创建/启停/删除、系统配置卡片、报告「触发方式」列均正常
**关键经验/踩坑**
- `ModuleResponse` 经 Pydantic camelCase 序列化 → 前端字段是 `caseCount`(不是 `case_count`),`vue-tsc` 类型报错也指向 `caseCount`
- 前端 vite 代理指向 **8003**(多窗口并行开发端口),改后端后需同时刷新对应端口进程
- git-bash 的 `/tmp` 与 Windows Python 的 `/tmp` 指向不同路径(`C:\tmp` vs Git 安装目录)——跨工具传 JSON 文件务必用显式绝对路径
- 多窗口并行时注意与另一后端共用 SQLite,避免并发写锁
**遗留**:验证用定时执行记录 + 报告留在开发库(`verify-interval-1min-20260819-140054`,可作演示数据);**尚未部署 5.60 生产**(按用户要求待后续)。
---
## 📊 会话历史时间线
| 会话 | 日期 | 主题 | 结果 |
|------|------|------|------|
| 27 | 08-19 | **UI自动化定时任务模块 + 系统配置被测系统URL**:调度引擎(30s 轮询 + 到期异步触发 + 冲突跳过)+ 定时任务 CRUD/启停/立即执行/运行记录 + 报告按模块分组并展示被测系统 URL + SUT URL 保存/重启持久化 + 前端定时任务页与系统配置卡片 | ✅ 按 PRD 工作流实现,后端 179 测试全过、前端 build 通过、真实端到端验收通过(未部署生产) |
| 26 | 08-19 | **新建会议用例 step 11/17 执行失败修复**:保留页面直达 + 执行器策略5 增强(轮询等待/force勾选/校验防假通过)+ 用例 step 12 改「保持立即开始」(选择器修正零匹配坑);部署 5.60 | ✅ 本地 + 生产各连跑 3 次全绿、零残留,提交 `c0dedbdf` |
| 25 | 08-19 | **新建会议用例执行失败根因定位**:智能定位通过但执行 step 11 失败 → 页面直达跳过导航 + 直达后表单懒加载 + checkbox `is-hidden` 隐藏列 | ✅ 根因已定位(会话26 已修复闭环) |
| 24 | 08-18 | **finally api_call 端点真实化 + 完整 17 步验收**:真实端点 + 浏览器 token 自动注入 + `body_not_contains` 断言 + 修复 finally 双跑 | ✅ 完整 17 步 5.60 全绿、零残留,阶段 6 完成 |
......@@ -246,6 +280,8 @@ cd frontend && npm run build
| 🔴 **语义化用例执行机制执行计划** | `Docs/PRD/需求文档/用例管理/_执行计划_复杂用例通用执行机制_语义化用例执行.md` |
| 智能定位 PRD/执行计划 | `Docs/PRD/需求文档/用例管理/_PRD_自然语言用例智能定位功能.md` 等 |
| 元素映射表定位方案 PRD/执行计划 | `Docs/PRD/需求文档/用例管理/_PRD_元素映射表定位方案_前端key-value键值_提升定位稳定性.md` |
| 🔴 **定时任务模块 + 系统配置被测系统URL PRD** | `Docs/PRD/需求文档/定时任务/_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL.md` |
| 🔴 **定时任务模块执行计划** | `Docs/PRD/需求文档/定时任务/_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL_计划执行.md` |
| 🔴 新建会议执行失败问题处理 | `Docs/PRD/问题处理/会议管理/_问题处理_新建会议用例执行失败_页面直达后会议室表格checkbox不可见.md` |
| 🔴 新建会议执行失败执行计划 | `Docs/PRD/问题处理/会议管理/_执行计划_修复新建会议用例执行失败.md` |
| HANDOFF 总交接 | `HANDOFF.md` |
......@@ -258,6 +294,11 @@ cd frontend && npm run build
### P0(高优先级)
- [x] **UI自动化定时任务模块 + 系统配置被测系统URL**(会话27 实现 + 本地验收)
- ✅ 调度引擎(30s 轮询/到期异步触发/冲突跳过/防重入)+ 定时任务 CRUD/启停/立即执行/运行记录
- ✅ 报告按模块分组 + 被测系统 URL + 触发方式列;SUT URL 保存/重启持久化
- ✅ 后端 179 测试全过、前端 build 通过、真实端到端验收通过
- [ ] **待部署 5.60 生产**(用户后续要求时执行;开发库留有验证用执行记录与报告)
- [x] **阶段6 最终验收:完整 17 步示范用例(含 finally 自清理)**(会话24 完成)
-**16 步主流程真实验证通过**(本地会话22 + 5.60 生产会话23,各连跑两次全绿、零残留)——提交 `345572f2`
-**已部署 5.60 生产**(37 后端文件 + 前端 dist,含 semantic_resolver / ui_context / 映射表 v2 / 前端语义编辑)——部署脚本 `backend/_deploy_560_semantic.py`
......@@ -288,4 +329,4 @@ cd frontend && npm run build
---
*本文档由 Claude Code 于 2026-08-19 更新(会话 26:新建会议用例 step 11/17 执行失败修复 + 生产验收通过——执行器策略5 增强 + 用例 step 12 保持立即开始,本地与生产各连跑 3 次全绿零残留)。*
*本文档由 Claude Code 于 2026-08-19 更新(会话 27:UI自动化定时任务模块 + 系统配置被测系统URL——调度引擎 + 定时任务管理 + 报告按模块分组 + SUT URL 持久化,后端 179 测试全过、前端 build 通过、真实端到端验收通过,未部署生产)。*
......@@ -125,6 +125,7 @@ async def _ensure_columns(conn) -> None:
from app.models import performance # noqa: F401
from app.models import performance_output # noqa: F401
from app.models import api_preset # noqa: F401
from app.models import scheduled_task # noqa: F401
# 需要补齐的字段:[(表名, 列名, 列定义SQL)]
columns_to_add = [
......
......@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings
from app.database import init_db
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, security_erp, security_erp_upload, security_erp_task, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template, api_test, debug
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, security_erp, security_erp_upload, security_erp_task, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template, api_test, debug, scheduled_tasks
# 配置日志
logging.basicConfig(
......@@ -70,6 +70,21 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"默认登录模板初始化失败(非致命): {e}")
# 加载被测系统 URL(从数据库持久化值,修复服务重启后回退到环境变量默认值的问题)
try:
from app.models.dingtalk_config import DingTalkConfig
from sqlalchemy import select
async with async_session_maker() as session:
result = await session.execute(
select(DingTalkConfig).where(DingTalkConfig.id == 1)
)
cfg = result.scalar_one_or_none()
if cfg and cfg.target_url:
settings.TARGET_URL = cfg.target_url
logger.info(f"被测系统 URL 已从数据库加载: {settings.TARGET_URL}")
except Exception as e:
logger.warning(f"加载被测系统 URL 失败(使用环境变量默认值): {e}")
# 启动消息消费循环
consumer_task = None
try:
......@@ -79,6 +94,15 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"消息消费循环启动失败: {e}")
# 启动定时任务调度引擎
scheduler_task = None
try:
from app.services.scheduler_service import start_scheduler
scheduler_task = start_scheduler()
logger.info("定时任务调度引擎已启动")
except Exception as e:
logger.warning(f"定时任务调度引擎启动失败: {e}")
yield
# 关闭时
......@@ -93,6 +117,15 @@ async def lifespan(app: FastAPI):
pass
logger.info("消息消费循环已停止")
# 停止定时任务调度引擎
if scheduler_task:
scheduler_task.cancel()
try:
await scheduler_task
except asyncio.CancelledError:
pass
logger.info("定时任务调度引擎已停止")
# 创建 FastAPI 应用实例
app = FastAPI(
......@@ -284,6 +317,13 @@ app.include_router(
tags=["调试"]
)
# UI 自动化定时任务管理
app.include_router(
scheduled_tasks.router,
prefix="/api/scheduled-tasks",
tags=["定时任务"]
)
# ==================== 根路径 ====================
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:dingtalk_config.py
模块描述:系统管理 钉钉对接配置数据库模型定义
作者:czj
创建日期:2026-08-13
最后修改:2026-08-13
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DingTalkConfig(Base):
"""
钉钉对接配置数据库模型
存储钉钉开放平台企业内部应用对接凭据,支持持久化到数据库。
使用固定 ID 为 1 的单行记录。
Attributes:
id (int): 主键,固定为 1
app_key (str): 钉钉应用 AppKey
app_secret (str): 钉钉应用 AppSecret
agent_id (int): 钉钉应用 AgentId
updated_at (datetime): 更新时间
"""
__tablename__ = "dingtalk_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False, default=1, comment="配置ID(固定为1)")
app_key: Mapped[str] = mapped_column(String(255), default="", comment="钉钉AppKey")
app_secret: Mapped[str] = mapped_column(String(255), default="", comment="钉钉AppSecret")
agent_id: Mapped[int] = mapped_column(Integer, default=0, comment="钉钉AgentId")
target_url: Mapped[str] = mapped_column(String(500), default="", comment="被测系统URL")
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
def __repr__(self) -> str:
return f"<DingTalkConfig(id={self.id}, app_key={self.app_key})>"
def to_dict(self) -> dict:
return {
"app_key": self.app_key or "",
"app_secret": self.app_secret or "",
"agent_id": self.agent_id or 0,
"target_url": self.target_url or "",
}
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:scheduled_task.py
模块描述:UI 自动化定时任务数据库模型定义
作者:czj
创建日期:2026-08-19
最后修改:2026-08-19
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Boolean, Integer, JSON, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ScheduledTask(Base):
"""
UI 自动化定时任务数据库模型
存储定时任务的定义:选择哪些模块执行、周期设置、启用状态等。
每次到期触发会创建一条 Execution 记录(trigger_type=scheduled,
trigger_by=任务名),执行完成后自动生成报告到报告中心。
Attributes:
id (str): 任务唯一标识
name (str): 任务名称(作为执行记录的 trigger_by)
enabled (bool): 是否启用
module_ids (list): 选中的模块 ID 列表
schedule_type (str): 周期模式 (interval/daily/weekly)
interval_value (int): 间隔值(schedule_type=interval)
interval_unit (str): 间隔单位 (minutes/hours/days)
run_time (str): 执行时间 HH:MM(schedule_type=daily/weekly)
weekdays (list): 星期 [0-6](schedule_type=weekly)
auto_report (bool): 执行完成后是否自动生成报告
last_run_at (datetime): 最近一次触发时间
next_run_at (datetime): 下次触发时间
run_count (int): 累计触发次数
created_at (datetime): 创建时间
updated_at (datetime): 更新时间
Example:
>>> task = ScheduledTask(
... id="scheduled_abc123",
... name="每日回归",
... module_ids=["module_001", "module_002"],
... schedule_type="daily",
... run_time="22:00",
... )
"""
__tablename__ = "scheduled_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="任务ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="任务名称")
enabled: Mapped[bool] = mapped_column(
Boolean, default=True, comment="是否启用"
)
module_ids: Mapped[list] = mapped_column(
JSON, default=list, comment="选中的模块ID列表"
)
schedule_type: Mapped[str] = mapped_column(
String(20),
default="interval",
comment="周期模式: interval(按间隔)/daily(每天定时)/weekly(每周定时)"
)
interval_value: Mapped[int] = mapped_column(
Integer, default=0, comment="间隔值(schedule_type=interval)"
)
interval_unit: Mapped[str] = mapped_column(
String(10),
default="minutes",
comment="间隔单位: minutes/hours/days"
)
run_time: Mapped[str] = mapped_column(
String(5), default="", comment="执行时间 HH:MM(daily/weekly)"
)
weekdays: Mapped[list] = mapped_column(
JSON, default=list, comment="星期 [0-6](weekly)"
)
auto_report: Mapped[bool] = mapped_column(
Boolean, default=True, comment="执行完成后自动生成报告"
)
last_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, default=None, comment="最近一次触发时间"
)
next_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, default=None, comment="下次触发时间"
)
run_count: Mapped[int] = mapped_column(
Integer, default=0, comment="累计触发次数"
)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间"
)
def __repr__(self) -> str:
return f"<ScheduledTask(id={self.id}, name={self.name}, enabled={self.enabled})>"
def to_dict(self) -> dict:
"""
转换为字典
Returns:
dict: 定时任务数据字典
"""
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"module_ids": self.module_ids or [],
"schedule_type": self.schedule_type,
"interval_value": self.interval_value,
"interval_unit": self.interval_unit,
"run_time": self.run_time or "",
"weekdays": self.weekdays or [],
"auto_report": self.auto_report,
"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,
"run_count": self.run_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def schedule_description(self) -> str:
"""
生成人类可读的周期描述
Returns:
str: 周期描述,如「每 30 分钟」「每天 22:00」
"""
unit_map = {"minutes": "分钟", "hours": "小时", "days": "天"}
if self.schedule_type == "interval":
unit = unit_map.get(self.interval_unit, self.interval_unit)
return f"每 {self.interval_value} {unit}"
if self.schedule_type == "daily":
return f"每天 {self.run_time or '--:--'}"
if self.schedule_type == "weekly":
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
names = [weekday_names[w] for w in (self.weekdays or []) if 0 <= w <= 6]
if not names:
return f"每周 {self.run_time or '--:--'}"
return f"每周{','.join(names)} {self.run_time or '--:--'}"
return self.schedule_type
......@@ -86,6 +86,8 @@ async def list_executions(
limit: int = Query(50, ge=1, le=200, description="返回记录数上限"),
status: Optional[str] = Query(None, description="状态筛选"),
case_type: Optional[str] = Query(None, description="用例类型筛选: ui/api/security/deploy/performance/functional"),
trigger_type: Optional[str] = Query(None, description="触发类型筛选: manual/scheduled"),
trigger_by: Optional[str] = Query(None, description="触发人/任务名筛选"),
service: ExecutionService = Depends(get_execution_service),
):
"""
......@@ -96,6 +98,8 @@ async def list_executions(
limit (int): 返回记录数上限
status (Optional[str]): 状态筛选
case_type (Optional[str]): 用例类型筛选
trigger_type (Optional[str]): 触发类型筛选
trigger_by (Optional[str]): 触发人/任务名筛选
service (ExecutionService): 执行服务实例
Returns:
......@@ -103,7 +107,8 @@ async def list_executions(
"""
try:
executions, total = await service.list_executions(
skip=skip, limit=limit, status=status, case_type=case_type
skip=skip, limit=limit, status=status, case_type=case_type,
trigger_type=trigger_type, trigger_by=trigger_by,
)
return {
"total": total,
......
......@@ -178,6 +178,8 @@ async def list_reports(
"passed": e.passed,
"failed": e.failed,
"duration": e.duration,
"trigger_type": e.trigger_type,
"trigger_by": e.trigger_by,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in items
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:scheduled_tasks.py
模块描述:UI 自动化定时任务管理 API 路由
作者:czj
创建日期:2026-08-19
最后修改:2026-08-19
"""
import asyncio
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, desc, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.scheduled_task import ScheduledTask
from app.models.module import Module
from app.models.execution import Execution
from app.schemas.scheduled_task import (
ScheduledTaskCreate,
ScheduledTaskUpdate,
ToggleRequest,
)
from app.services.scheduler_service import compute_next_run, run_task_once
from app.utils.id_generator import generate_id
logger = logging.getLogger(__name__)
router = APIRouter()
async def _module_name_map(db: AsyncSession, module_ids: List[str]) -> dict:
"""查询模块 ID → 名称映射"""
if not module_ids:
return {}
result = await db.execute(select(Module).where(Module.id.in_(module_ids)))
return {m.id: m.name for m in result.scalars().all()}
async def _task_to_dict(db: AsyncSession, task: ScheduledTask) -> dict:
"""任务字典 + 模块名称列表 + 周期描述"""
data = task.to_dict()
name_map = await _module_name_map(db, task.module_ids or [])
data["module_names"] = [
name_map.get(mid, mid) for mid in (task.module_ids or [])
]
data["schedule_description"] = task.schedule_description()
return data
@router.get("", response_model=dict)
async def list_tasks(
enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
):
"""
获取定时任务列表
Args:
enabled (Optional[bool]): 按启用状态筛选
db (AsyncSession): 数据库会话
Returns:
dict: 任务列表
"""
try:
query = select(ScheduledTask).order_by(
desc(ScheduledTask.enabled),
ScheduledTask.created_at.desc(),
)
if enabled is not None:
query = query.where(ScheduledTask.enabled == enabled)
result = await db.execute(query)
tasks = list(result.scalars().all())
items = []
for task in tasks:
items.append(await _task_to_dict(db, task))
return {"total": len(items), "items": items}
except Exception as e:
logger.error(f"获取定时任务列表失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.post("", response_model=dict, status_code=201)
async def create_task(
data: ScheduledTaskCreate,
db: AsyncSession = Depends(get_db),
):
"""
创建定时任务
Args:
data (ScheduledTaskCreate): 创建数据
db (AsyncSession): 数据库会话
Returns:
dict: 任务数据
"""
try:
task = ScheduledTask(
id=generate_id("scheduled"),
name=data.name.strip(),
enabled=data.enabled,
module_ids=data.module_ids or [],
schedule_type=data.schedule_type,
interval_value=data.interval_value,
interval_unit=data.interval_unit,
run_time=data.run_time or "",
weekdays=data.weekdays or [],
auto_report=data.auto_report,
)
task.next_run_at = compute_next_run(task)
db.add(task)
await db.flush()
await db.refresh(task)
return await _task_to_dict(db, task)
except Exception as e:
logger.error(f"创建定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.put("/{task_id}", response_model=dict)
async def update_task(
task_id: str,
data: ScheduledTaskUpdate,
db: AsyncSession = Depends(get_db),
):
"""
更新定时任务
只更新传入字段,更新后重算下次触发时间。
Args:
task_id (str): 任务ID
data (ScheduledTaskUpdate): 更新数据
db (AsyncSession): 数据库会话
Returns:
dict: 任务数据
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(task, field, value)
task.name = (task.name or "").strip()
task.next_run_at = compute_next_run(task)
await db.flush()
await db.refresh(task)
return await _task_to_dict(db, task)
except HTTPException:
raise
except Exception as e:
logger.error(f"更新定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.delete("/{task_id}", status_code=204)
async def delete_task(
task_id: str,
db: AsyncSession = Depends(get_db),
):
"""
删除定时任务(不影响已产生的执行记录与报告)
Args:
task_id (str): 任务ID
db (AsyncSession): 数据库会话
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
await db.delete(task)
await db.flush()
logger.info(f"定时任务已删除: {task_id}")
except HTTPException:
raise
except Exception as e:
logger.error(f"删除定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.post("/{task_id}/toggle", response_model=dict)
async def toggle_task(
task_id: str,
data: ToggleRequest,
db: AsyncSession = Depends(get_db),
):
"""
启用 / 停用定时任务
启用时计算下次触发时间;停用时保留下次触发时间(再次启用后重算)。
Args:
task_id (str): 任务ID
data (ToggleRequest): {enabled: bool}
db (AsyncSession): 数据库会话
Returns:
dict: 任务数据
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
task.enabled = data.enabled
if data.enabled:
task.next_run_at = compute_next_run(task)
await db.flush()
await db.refresh(task)
return await _task_to_dict(db, task)
except HTTPException:
raise
except Exception as e:
logger.error(f"切换定时任务状态失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.post("/{task_id}/run", response_model=dict)
async def run_task_now(
task_id: str,
db: AsyncSession = Depends(get_db),
):
"""
立即执行定时任务(手动触发一次)
后台异步创建执行记录并运行,执行完成后自动生成报告。
Args:
task_id (str): 任务ID
db (AsyncSession): 数据库会话
Returns:
dict: 触发结果
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
# 后台异步执行(不阻塞请求)
asyncio.create_task(run_task_once(task_id))
return {
"message": f"任务「{task.name}」已触发执行",
"task_id": task_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"立即执行定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.get("/{task_id}/runs", response_model=dict)
async def list_task_runs(
task_id: str,
skip: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
"""
获取定时任务运行历史
查询该任务触发的执行记录(trigger_type=scheduled 且 trigger_by=任务名)。
Args:
task_id (str): 任务ID
skip (int): 跳过记录数
limit (int): 返回记录数上限
db (AsyncSession): 数据库会话
Returns:
dict: 运行历史列表
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
query = (
select(Execution)
.where(
Execution.trigger_type == "scheduled",
Execution.trigger_by == task.name,
)
.order_by(desc(Execution.created_at))
.offset(skip)
.limit(limit)
)
result = await db.execute(query)
executions = list(result.scalars().all())
return {
"total": len(executions),
"items": [e.to_dict() for e in executions],
}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取定时任务运行历史失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
......@@ -16,7 +16,7 @@ import time
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
......@@ -25,7 +25,12 @@ from app.database import get_db
from app.models.module import Module
from app.models.test_case import TestCase
from app.models.dingtalk_config import DingTalkConfig
from app.schemas.dingtalk_config import DingTalkConfigResponse, DingTalkConfigUpdateRequest
from app.schemas.dingtalk_config import (
DingTalkConfigResponse,
DingTalkConfigUpdateRequest,
SutConfigResponse,
SutConfigUpdateRequest,
)
logger = logging.getLogger(__name__)
......@@ -189,3 +194,60 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async
agent_id=config.agent_id or settings.DINGTALK_AGENT_ID or 0,
target_url=config.target_url or settings.TARGET_URL or "",
)
# ==================== 被测系统配置 ====================
@router.get("/sut-config", response_model=SutConfigResponse, summary="获取被测系统配置")
async def get_sut_config(db: AsyncSession = Depends(get_db)):
"""
获取被测系统访问 URL
优先读取数据库持久化的值,无记录时回退到环境变量默认值。
"""
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if config and config.target_url:
return SutConfigResponse(url=config.target_url, source="db")
return SutConfigResponse(url=settings.TARGET_URL or "", source="env")
@router.put("/sut-config", response_model=SutConfigResponse, summary="更新被测系统配置")
async def update_sut_config(
request: SutConfigUpdateRequest,
db: AsyncSession = Depends(get_db),
):
"""
更新被测系统访问 URL
持久化到数据库(dingtalk_configs.target_url)并立即更新运行时
settings.TARGET_URL,UI 自动化执行即使用该地址;服务重启后仍生效
(lifespan 启动时从数据库加载)。
"""
url = (request.url or "").strip().rstrip("/")
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="URL 须以 http:// 或 https:// 开头")
# 从数据库读取或创建单行记录(id=1)
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = DingTalkConfig(
id=1,
app_key=settings.DINGTALK_APP_KEY or "",
app_secret=settings.DINGTALK_APP_SECRET or "",
agent_id=settings.DINGTALK_AGENT_ID or 0,
)
db.add(config)
config.target_url = url
settings.TARGET_URL = url
await db.commit()
await db.refresh(config)
logger.info(f"被测系统 URL 已更新并持久化: {url}")
return SutConfigResponse(url=config.target_url, source="db")
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:dingtalk_config.py
模块描述:系统管理 钉钉对接配置 Pydantic 数据模型
作者:czj
创建日期:2026-08-13
"""
from typing import Optional
from pydantic import BaseModel, Field
class DingTalkConfigResponse(BaseModel):
"""钉钉配置响应"""
app_key: str = Field("", description="钉钉AppKey")
app_secret: str = Field("", description="钉钉AppSecret")
agent_id: int = Field(0, description="钉钉AgentId")
target_url: str = Field("", description="被测系统URL")
class DingTalkConfigUpdateRequest(BaseModel):
"""钉钉配置更新请求"""
app_key: Optional[str] = Field(None, description="钉钉AppKey")
app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
agent_id: Optional[int] = Field(None, description="钉钉AgentId")
target_url: Optional[str] = Field(None, description="被测系统URL")
class SutConfigResponse(BaseModel):
"""被测系统配置响应"""
url: str = Field("", description="被测系统访问 URL")
source: str = Field("env", description="来源: db(数据库)/env(环境变量默认)")
class SutConfigUpdateRequest(BaseModel):
"""被测系统配置更新请求"""
url: str = Field(..., description="被测系统访问 URL,须以 http:// 或 https:// 开头")
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:scheduled_task.py
模块描述:UI 自动化定时任务 Pydantic 数据模型
作者:czj
创建日期:2026-08-19
"""
from typing import Optional, List
from pydantic import BaseModel, Field
class ScheduledTaskCreate(BaseModel):
"""定时任务创建请求"""
name: str = Field(..., description="任务名称", min_length=1, max_length=100)
module_ids: List[str] = Field(..., description="选中的模块ID列表")
schedule_type: str = Field("interval", description="周期模式: interval/daily/weekly")
interval_value: int = Field(0, description="间隔值(interval)")
interval_unit: str = Field("minutes", description="间隔单位: minutes/hours/days")
run_time: str = Field("", description="执行时间 HH:MM(daily/weekly)")
weekdays: List[int] = Field([], description="星期 [0-6](weekly)")
auto_report: bool = Field(True, description="执行完成后自动生成报告")
enabled: bool = Field(True, description="是否启用")
class ScheduledTaskUpdate(BaseModel):
"""定时任务更新请求(全部可选,只更新传入字段)"""
name: Optional[str] = Field(None, description="任务名称")
module_ids: Optional[List[str]] = Field(None, description="选中的模块ID列表")
schedule_type: Optional[str] = Field(None, description="周期模式")
interval_value: Optional[int] = Field(None, description="间隔值")
interval_unit: Optional[str] = Field(None, description="间隔单位")
run_time: Optional[str] = Field(None, description="执行时间 HH:MM")
weekdays: Optional[List[int]] = Field(None, description="星期 [0-6]")
auto_report: Optional[bool] = Field(None, description="自动生成报告")
enabled: Optional[bool] = Field(None, description="是否启用")
class ToggleRequest(BaseModel):
"""启停请求"""
enabled: bool = Field(..., description="目标启用状态")
......@@ -105,6 +105,8 @@ class ExecutionService:
limit: int = 50,
status: Optional[str] = None,
case_type: Optional[str] = None,
trigger_type: Optional[str] = None,
trigger_by: Optional[str] = None,
) -> Tuple[List[Execution], int]:
"""
获取执行记录列表
......@@ -114,6 +116,8 @@ class ExecutionService:
limit (int): 返回记录数上限
status (Optional[str]): 状态筛选
case_type (Optional[str]): 用例类型筛选 (ui/api/security/deploy/performance/functional)
trigger_type (Optional[str]): 触发类型筛选 (manual/scheduled)
trigger_by (Optional[str]): 触发人/任务名筛选
Returns:
Tuple[List[Execution], int]: (执行记录列表, 总数)
......@@ -123,6 +127,10 @@ class ExecutionService:
conditions.append(Execution.status == status)
if case_type:
conditions.append(Execution.case_type == case_type)
if trigger_type:
conditions.append(Execution.trigger_type == trigger_type)
if trigger_by:
conditions.append(Execution.trigger_by == trigger_by)
count_query = select(func.count(Execution.id))
if conditions:
......
......@@ -89,6 +89,46 @@ REPORT_TEMPLATE = """<!DOCTYPE html>
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}}
.progress-card h3 {{ margin-bottom: 16px; }}
/* 执行模块汇总 */
.module-summary-card {{
background: white;
border-radius: 10px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}}
.module-summary-card h3 {{ margin-bottom: 16px; }}
.module-summary-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}}
.module-summary-item {{
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px 16px;
background: #fafafa;
}}
.module-summary-item .module-name {{ font-weight: 600; font-size: 14px; }}
.module-summary-item .module-stats {{ font-size: 13px; color: #909399; margin-top: 4px; }}
/* 模块分组标题 */
.module-section-title {{
font-size: 15px;
font-weight: 600;
color: #303133;
padding: 12px 16px;
background: #ecf5ff;
border-left: 4px solid #409eff;
border-radius: 4px 4px 0 0;
}}
.module-section-title .module-section-stats {{
font-size: 13px;
font-weight: 400;
color: #909399;
margin-left: 12px;
}}
.progress-bar {{
height: 24px;
background: #ebeef5;
......@@ -197,7 +237,8 @@ REPORT_TEMPLATE = """<!DOCTYPE html>
<h1>{execution_name}</h1>
<div class="meta">
执行时间: {start_time} - {end_time} | 总耗时: {duration}s |
触发方式: {trigger_type} | 触发人: {trigger_by}
触发方式: {trigger_type} | 触发人: {trigger_by} |
被测系统: {sut_url}
</div>
</div>
......@@ -231,6 +272,12 @@ REPORT_TEMPLATE = """<!DOCTYPE html>
</div>
</div>
<!-- 执行模块汇总 -->
<div class="module-summary-card">
<h3>执行模块</h3>
{module_summary_html}
</div>
<!-- 用例结果 -->
<div class="results-card">
<h3>用例执行结果</h3>
......@@ -283,6 +330,27 @@ STEP_TEMPLATE = """
<td class="step-error">{error}</td>
</tr>"""
# 执行模块汇总项
MODULE_SUMMARY_ITEM_TEMPLATE = """
<div class="module-summary-item">
<div class="module-name">{module_name}</div>
<div class="module-stats">
通过 <span style="color:#67c23a;font-weight:600;">{passed}</span>
/ 失败 <span style="color:#f56c6c;font-weight:600;">{failed}</span>
/ 共 {total}
</div>
</div>"""
# 模块分组标题(每个模块一组)
MODULE_SECTION_TEMPLATE = """
<div class="module-section-title">
{module_name}
<span class="module-section-stats">
通过 {passed} / 失败 {failed} / 跳过 {skipped} / 共 {total}
</span>
</div>
{cases_html}"""
class ReportService:
"""
......@@ -341,8 +409,29 @@ class ReportService:
results_result = await db.execute(results_query)
case_results = list(results_result.scalars().all())
# 生成用例结果 HTML
case_results_html = self._render_case_results(case_results)
# 构建 用例ID → 模块名 映射(用于按模块分组展示)
module_map: dict = {}
case_ids = [cr.case_id for cr in case_results if cr.case_id]
if case_ids:
cases_q = await db.execute(
select(TestCase).where(TestCase.id.in_(case_ids))
)
cases_data = list(cases_q.scalars().all())
module_ids = [c.module_id for c in cases_data if c.module_id]
name_map: dict = {}
if module_ids:
mods_q = await db.execute(
select(Module).where(Module.id.in_(module_ids))
)
name_map = {m.id: m.name for m in mods_q.scalars().all()}
module_map = {
c.id: name_map.get(c.module_id, c.module_id or "未分组")
for c in cases_data
}
# 生成用例结果 HTML(按模块分组)与执行模块汇总
case_results_html = self._render_case_results_grouped(case_results, module_map)
module_summary_html = self._render_module_summary(case_results, module_map)
# 计算统计数据
total = len(case_results)
......@@ -369,6 +458,7 @@ class ReportService:
duration=f"{execution.duration:.2f}" if execution.duration else "N/A",
trigger_type=execution.trigger_type,
trigger_by=execution.trigger_by,
sut_url=settings.TARGET_URL or "N/A",
passed=passed,
failed=failed,
skipped=skipped,
......@@ -376,6 +466,7 @@ class ReportService:
passed_percent=passed_percent,
failed_percent=failed_percent,
skipped_percent=skipped_percent,
module_summary_html=module_summary_html,
case_results_html=case_results_html,
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
......@@ -401,6 +492,108 @@ class ReportService:
return "\n".join(items)
def _group_by_module(
self,
case_results: List[CaseResult],
module_map: dict,
) -> List[tuple]:
"""
将用例结果按模块分组(保持首次出现顺序)
Args:
case_results (List[CaseResult]): 用例结果列表
module_map (dict): 用例ID → 模块名映射
Returns:
List[tuple]: [(module_name, [case_results])]
"""
grouped: dict = {}
for cr in case_results:
module_name = module_map.get(cr.case_id, "未分组")
grouped.setdefault(module_name, []).append(cr)
return list(grouped.items())
def _render_module_summary(
self,
case_results: List[CaseResult],
module_map: dict,
) -> str:
"""
渲染「执行模块」汇总卡片
Args:
case_results (List[CaseResult]): 用例结果列表
module_map (dict): 用例ID → 模块名映射
Returns:
str: 模块汇总 HTML
"""
groups = self._group_by_module(case_results, module_map)
if not groups:
return '<div style="padding: 12px; text-align: center; color: #909399;">暂无模块数据</div>'
items = []
for module_name, results in groups:
passed = sum(1 for r in results if r.status == "passed")
failed = sum(1 for r in results if r.status == "failed")
items.append(MODULE_SUMMARY_ITEM_TEMPLATE.format(
module_name=module_name,
passed=passed,
failed=failed,
total=len(results),
))
return f'<div class="module-summary-grid">{"".join(items)}</div>'
def _render_case_results_grouped(
self,
case_results: List[CaseResult],
module_map: dict,
) -> str:
"""
按模块分组渲染用例结果
Args:
case_results (List[CaseResult]): 用例结果列表
module_map (dict): 用例ID → 模块名映射
Returns:
str: 用例结果 HTML
"""
if not case_results:
return '<div style="padding: 24px; text-align: center; color: #909399;">暂无执行结果</div>'
groups = self._group_by_module(case_results, module_map)
sections = []
for module_name, results in groups:
passed = sum(1 for r in results if r.status == "passed")
failed = sum(1 for r in results if r.status == "failed")
skipped = sum(1 for r in results if r.status == "skipped")
cases_html = self._render_case_items(results)
sections.append(MODULE_SECTION_TEMPLATE.format(
module_name=module_name,
passed=passed,
failed=failed,
skipped=skipped,
total=len(results),
cases_html=cases_html,
))
return "\n".join(sections)
def _render_case_items(self, case_results: List[CaseResult]) -> str:
"""渲染单个模块下的用例结果项"""
items = []
for cr in case_results:
steps_html = self._render_steps(cr.steps_result or [])
items.append(CASE_RESULT_TEMPLATE.format(
case_id=cr.case_id,
case_name=cr.case_name,
duration=f"{cr.duration:.2f}" if cr.duration else "0",
status_css=cr.status,
status_text=self._get_status_text(cr.status),
steps_html=steps_html,
))
return "\n".join(items)
def _render_steps(self, steps: list) -> str:
"""渲染步骤 HTML"""
if not steps:
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:scheduler_service.py
模块描述:UI 自动化定时任务调度引擎
作者:czj
创建日期:2026-08-19
最后修改:2026-08-19
调度策略:
- 应用启动时(lifespan)调用 start_scheduler() 启动一个 asyncio 后台循环,
每 30 秒轮询数据库中的启用定时任务,到期则触发执行;
- 触发通过 asyncio.create_task 异步执行,不阻塞调度循环;
- 复用 ExecutionService.create_execution()(trigger_type=scheduled,
trigger_by=任务名)与 run_execution()(全局串行锁),不新增执行路径;
- 到期时若已有 UI 执行在运行,跳过本次(不产生失败记录),下次周期再触发;
- 执行完成后若任务开启 auto_report,自动生成 HTML 报告到报告中心。
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select
from app.database import async_session_maker
from app.models.scheduled_task import ScheduledTask
from app.models.test_case import TestCase
from app.services.execution_service import ExecutionService, is_execution_running
from app.services.report_service import ReportService
from app.utils.id_generator import generate_id
logger = logging.getLogger(__name__)
# 调度轮询间隔(秒)
POLL_INTERVAL_SECONDS = 30
# 正在触发/执行中的任务ID集合,防止同一任务重复触发
_running_tasks: set = set()
# ==================== 周期计算 ====================
def compute_next_run(
task: ScheduledTask,
now: Optional[datetime] = None,
) -> Optional[datetime]:
"""
计算任务的下次触发时间
Args:
task (ScheduledTask): 定时任务
now (Optional[datetime]): 参考时间,默认当前时间
Returns:
Optional[datetime]: 下次触发时间;周期配置非法时返回 None
"""
now = now or datetime.now()
if task.schedule_type == "interval":
unit_seconds = {
"minutes": 60,
"hours": 3600,
"days": 86400,
}.get(task.interval_unit, 60)
value = int(task.interval_value or 0)
if value <= 0:
value = 1 # 兜底:非法间隔按 1 处理
return now + timedelta(seconds=value * unit_seconds)
if task.schedule_type == "daily":
try:
hour, minute = map(int, (task.run_time or "00:00").split(":"))
except ValueError:
return None
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= now:
candidate += timedelta(days=1)
return candidate
if task.schedule_type == "weekly":
try:
hour, minute = map(int, (task.run_time or "00:00").split(":"))
except ValueError:
return None
weekdays = [int(w) for w in (task.weekdays or []) if isinstance(w, int) or str(w).isdigit()]
weekdays = [w for w in weekdays if 0 <= w <= 6]
if not weekdays:
return None
today = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
for offset in range(8): # 未来 7 天内必然包含全部 7 个 weekday
candidate = today + timedelta(days=offset)
if candidate.weekday() in weekdays and candidate > now:
return candidate
return None
return None
# ==================== 触发流程 ====================
async def collect_module_cases(db, module_ids: List[str]) -> List[TestCase]:
"""
收集选中模块下所有启用的 UI 测试用例(按模块分组 + order 排序)
Args:
db: 数据库会话
module_ids (List[str]): 模块 ID 列表
Returns:
List[TestCase]: 用例列表
"""
if not module_ids:
return []
query = (
select(TestCase)
.where(
TestCase.module_id.in_(module_ids),
TestCase.status == "active",
TestCase.case_type == "ui",
)
.order_by(TestCase.module_id, TestCase.order, TestCase.created_at)
)
result = await db.execute(query)
return list(result.scalars().all())
async def run_task_once(task_id: str) -> Optional[str]:
"""
执行一次定时任务(创建执行记录 → 运行 → 自动生成报告 → 推进下次调度)
供后台调度循环与「立即执行」接口共用。
Args:
task_id (str): 定时任务 ID
Returns:
Optional[str]: 本次创建的执行记录 ID;跳过/失败时返回 None
"""
if task_id in _running_tasks:
logger.info(f"[定时任务] 任务 {task_id} 正在执行中,忽略重复触发")
return None
_running_tasks.add(task_id)
try:
async with async_session_maker() as db:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
logger.warning(f"[定时任务] 任务不存在: {task_id}")
return None
# 1. 收集模块用例
cases = await collect_module_cases(db, task.module_ids or [])
if not cases:
logger.warning(
f"[定时任务] 任务「{task.name}」选中模块下无启用 UI 用例,跳过本次"
)
task.next_run_at = compute_next_run(task)
await db.flush()
return None
# 2. 执行锁预检:已有 UI 执行在运行 → 跳过(不创建注定失败的记录)
is_running, running_id = is_execution_running()
if is_running:
logger.info(
f"[定时任务] 任务「{task.name}」到点但已有执行 {running_id} 在运行,"
f"跳过本次,下次周期再触发"
)
task.next_run_at = compute_next_run(task)
await db.flush()
return None
# 3. 创建执行记录(trigger_type=scheduled, trigger_by=任务名)
case_ids = [c.id for c in cases]
exec_service = ExecutionService(db)
run_name = f"{task.name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
execution = await exec_service.create_execution(
case_ids=case_ids,
name=run_name,
trigger_type="scheduled",
trigger_by=task.name,
environment="default",
config={"auto_report": task.auto_report},
)
await db.commit()
logger.info(
f"[定时任务] 任务「{task.name}」触发执行: {execution.id}, "
f"用例 {len(case_ids)} 个"
)
# 4. 运行执行(阻塞等待完成;内部全局锁保证串行)
run_ok = True
try:
await exec_service.run_execution(execution.id)
except Exception as e:
run_ok = False
logger.error(f"[定时任务] 任务「{task.name}」执行异常: {e}")
# 5. 自动生成报告(执行成功后才生成,避免冲突失败记录产出空报告)
if task.auto_report and run_ok:
try:
report_service = ReportService()
html = await report_service.generate_html(execution.id, db)
await report_service.save_report(execution.id, html)
logger.info(
f"[定时任务] 任务「{task.name}」报告已生成: {execution.id}"
)
except Exception as e:
logger.error(
f"[定时任务] 任务「{task.name}」生成报告失败: {e}"
)
# 6. 推进调度状态
task.last_run_at = datetime.now()
task.run_count = (task.run_count or 0) + 1
task.next_run_at = compute_next_run(task)
await db.flush()
await db.commit()
return execution.id
except Exception as e:
logger.error(f"[定时任务] 触发任务 {task_id} 失败: {e}")
return None
finally:
_running_tasks.discard(task_id)
# ==================== 后台调度循环 ====================
async def check_and_trigger() -> None:
"""
轮询一轮:找出到期任务并异步触发
每轮独立会话,异常不影响调度循环。
"""
try:
due_ids: List[str] = []
async with async_session_maker() as db:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.enabled == True) # noqa: E712
)
tasks = list(result.scalars().all())
now = datetime.now()
for task in tasks:
# 首次启用但未设置下次执行时间 → 立即计算(避免立刻触发)
if task.next_run_at is None:
task.next_run_at = compute_next_run(task)
elif task.next_run_at <= now:
due_ids.append(task.id)
await db.commit()
# 到期任务异步触发(不阻塞轮询循环)
for task_id in due_ids:
asyncio.create_task(run_task_once(task_id))
except Exception as e:
logger.error(f"[定时任务] 调度轮询异常: {e}")
async def scheduler_loop() -> None:
"""
后台调度主循环(应用启动时由 lifespan 创建)
每 POLL_INTERVAL_SECONDS 秒轮询一次到期任务。
"""
logger.info(
f"[定时任务] 调度引擎已启动,轮询间隔 {POLL_INTERVAL_SECONDS}s"
)
while True:
try:
await check_and_trigger()
except asyncio.CancelledError:
logger.info("[定时任务] 调度引擎已停止")
raise
except Exception as e:
logger.error(f"[定时任务] 调度循环异常: {e}")
await asyncio.sleep(POLL_INTERVAL_SECONDS)
def start_scheduler() -> asyncio.Task:
"""
启动调度引擎(供 lifespan 调用)
Returns:
asyncio.Task: 调度任务句柄(用于关闭时取消)
"""
return asyncio.create_task(scheduler_loop())
......@@ -52,6 +52,12 @@
<span>执行中心</span>
</el-menu-item>
<!-- 定时任务 -->
<el-menu-item index="/scheduled-tasks">
<el-icon><Timer /></el-icon>
<span>定时任务</span>
</el-menu-item>
<!-- 报告中心 -->
<el-menu-item index="/reports/ui">
<el-icon><DataLine /></el-icon>
......@@ -215,7 +221,8 @@ import {
Lock,
Setting,
EditPen,
Connection
Connection,
Timer
} from '@element-plus/icons-vue'
// ==================== 响应式数据 ====================
......@@ -320,6 +327,7 @@ const pageTitle = computed(() => {
'/recorder': '用例录制',
'/execution': '执行中心',
'/reports': '报告中心',
'/scheduled-tasks': '定时任务',
'/performance': '性能测试',
'/security': '安全测试',
'/deploy': '自动化部署测试',
......
/**
* 定时任务管理 API 接口
*
* @module api/scheduledTasks
* @author czj
* @date 2026-08-19
*/
import request from '@/utils/request'
/** 定时任务数据结构 */
export interface ScheduledTask {
id: string
name: string
enabled: boolean
module_ids: string[]
module_names: string[]
schedule_type: string
interval_value: number
interval_unit: string
run_time: string
weekdays: number[]
auto_report: boolean
schedule_description: string
last_run_at: string | null
next_run_at: string | null
run_count: number
created_at: string | null
updated_at: string | null
}
/** 创建/编辑定时任务请求体 */
export interface ScheduledTaskPayload {
name: string
module_ids: string[]
schedule_type: 'interval' | 'daily' | 'weekly'
interval_value?: number
interval_unit?: 'minutes' | 'hours' | 'days'
run_time?: string
weekdays?: number[]
auto_report?: boolean
enabled?: boolean
}
/**
* 定时任务管理 API 服务
*/
export const scheduledTaskApi = {
/** 获取定时任务列表 */
async list(): Promise<{ total: number; items: ScheduledTask[] }> {
const response = await request.get('/api/scheduled-tasks')
return response as any
},
/** 创建定时任务 */
async create(data: ScheduledTaskPayload): Promise<ScheduledTask> {
const response = await request.post('/api/scheduled-tasks', data)
return response as any
},
/** 更新定时任务 */
async update(id: string, data: Partial<ScheduledTaskPayload>): Promise<ScheduledTask> {
const response = await request.put(`/api/scheduled-tasks/${id}`, data)
return response as any
},
/** 删除定时任务(不影响已产生的执行记录与报告) */
async remove(id: string): Promise<void> {
await request.delete(`/api/scheduled-tasks/${id}`)
},
/** 立即执行 */
async run(id: string): Promise<{ message: string; task_id: string }> {
const response = await request.post(`/api/scheduled-tasks/${id}/run`)
return response as any
},
/** 启用 / 停用 */
async toggle(id: string, enabled: boolean): Promise<ScheduledTask> {
const response = await request.post(`/api/scheduled-tasks/${id}/toggle`, { enabled })
return response as any
},
/** 运行历史(该任务触发的执行记录) */
async getRuns(id: string, params?: { skip?: number; limit?: number }): Promise<{ total: number; items: any[] }> {
const response = await request.get(`/api/scheduled-tasks/${id}/runs`, { params })
return response as any
},
}
......@@ -63,4 +63,20 @@ export const systemApi = {
const response = await request.put('/api/system/dingtalk-config', config)
return response as any
},
/**
* 获取被测系统配置
*/
async getSutConfig(): Promise<{ url: string; source: string }> {
const response = await request.get('/api/system/sut-config')
return response as any
},
/**
* 更新被测系统配置
*/
async updateSutConfig(url: string): Promise<{ url: string; source: string }> {
const response = await request.put('/api/system/sut-config', { url })
return response as any
},
}
......@@ -47,6 +47,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/Reports.vue'),
meta: { title: '报告中心' }
},
{
path: '/scheduled-tasks',
name: 'ScheduledTasks',
component: () => import('@/views/ScheduledTasks.vue'),
meta: { title: '定时任务' }
},
// 功能测试报告(父菜单,含生成报告和ERP配置两个子页面)
{
path: '/reports/functional',
......
......@@ -35,6 +35,16 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="触发方式" width="90">
<template #default="{ row }">
<el-tag
:type="row.trigger_type === 'scheduled' ? 'warning' : 'info'"
size="small"
>
{{ row.trigger_type === 'scheduled' ? '定时' : '手动' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="通过率" width="120">
<template #default="{ row }">
<div class="pass-rate-cell">
......
<!--
页面名称:ScheduledTasks.vue
页面描述:UI 自动化定时任务页面(任务列表 / 创建编辑 / 立即执行 / 运行历史)
路径:/scheduled-tasks
@author czj
@date 2026-08-19
-->
<template>
<div class="scheduled-tasks-page">
<!-- 页面头部 -->
<div class="page-header">
<h2>定时任务</h2>
<div class="header-actions">
<el-button @click="loadTasks">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
</div>
<el-alert
title="定时任务将按周期自动执行所选模块的 UI 用例,执行完成后自动生成报告到报告中心;若到点时有执行正在进行将自动跳过本次。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<!-- 任务列表 -->
<el-card shadow="never">
<el-table
:data="tasks"
stripe
style="width: 100%"
v-loading="loading"
empty-text="暂无定时任务"
>
<el-table-column prop="name" label="任务名称" min-width="160" />
<el-table-column label="执行模块" min-width="220">
<template #default="{ row }">
<template v-if="row.module_names?.length">
<el-tag
v-for="name in row.module_names"
:key="name"
size="small"
type="info"
style="margin-right: 4px"
>
{{ name }}
</el-tag>
</template>
<span v-else style="color: #909399">未选择</span>
</template>
</el-table-column>
<el-table-column prop="schedule_description" label="周期" min-width="140" />
<el-table-column label="自动报告" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.auto_report ? 'success' : 'info'" size="small">
{{ row.auto_report ? '开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
:disabled="togglingId === row.id"
@change="(val: boolean) => handleToggle(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="上次执行" width="170">
<template #default="{ row }">
{{ formatTime(row.last_run_at) }}
</template>
</el-table-column>
<el-table-column label="下次执行" width="170">
<template #default="{ row }">
<span :style="{ color: row.enabled ? '#409eff' : '#909399' }">
{{ formatTime(row.next_run_at) }}
</span>
</template>
</el-table-column>
<el-table-column prop="run_count" label="执行次数" width="90" align="center" />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
link
:loading="runningId === row.id"
@click="handleRun(row)"
>
<el-icon><VideoPlay /></el-icon>
立即执行
</el-button>
<el-button size="small" type="primary" link @click="openRuns(row)">
<el-icon><List /></el-icon>
运行记录
</el-button>
<el-button size="small" type="primary" link @click="openEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建 / 编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑定时任务' : '新建定时任务'"
width="620px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
label-position="right"
>
<el-form-item label="任务名称" prop="name">
<el-input
v-model="form.name"
placeholder="如:每日核心模块回归"
maxlength="100"
clearable
/>
</el-form-item>
<el-form-item label="执行模块" prop="module_ids">
<el-select
v-model="form.module_ids"
multiple
filterable
collapse-tags
collapse-tags-tooltip
style="width: 100%"
placeholder="选择要执行的 UI 模块"
:loading="modulesLoading"
>
<el-option
v-for="m in modules"
:key="m.id"
:label="`${m.name}(${m.caseCount ?? 0} 用例)`"
:value="m.id"
/>
</el-select>
</el-form-item>
<el-form-item label="周期模式" prop="schedule_type">
<el-radio-group v-model="form.schedule_type">
<el-radio-button value="interval">按间隔</el-radio-button>
<el-radio-button value="daily">每天定时</el-radio-button>
<el-radio-button value="weekly">每周定时</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 按间隔 -->
<el-form-item v-if="form.schedule_type === 'interval'" label="间隔" prop="interval_value">
<div style="display: flex; gap: 8px; width: 100%">
<el-input-number
v-model="form.interval_value"
:min="1"
:max="99999"
style="width: 160px"
/>
<el-select v-model="form.interval_unit" style="width: 140px">
<el-option label="分钟" value="minutes" />
<el-option label="小时" value="hours" />
<el-option label="天" value="days" />
</el-select>
</div>
</el-form-item>
<!-- 每天定时 -->
<el-form-item v-if="form.schedule_type === 'daily'" label="执行时间" prop="run_time">
<el-time-select
v-model="form.run_time"
start="00:00"
step="00:30"
end="23:30"
placeholder="选择执行时间"
style="width: 160px"
/>
</el-form-item>
<!-- 每周定时 -->
<el-form-item
v-if="form.schedule_type === 'weekly'"
label="执行时间"
prop="run_time"
>
<el-time-select
v-model="form.run_time"
start="00:00"
step="00:30"
end="23:30"
placeholder="选择执行时间"
style="width: 160px"
/>
</el-form-item>
<el-form-item
v-if="form.schedule_type === 'weekly'"
label="星期"
prop="weekdays"
>
<el-checkbox-group v-model="form.weekdays">
<el-checkbox-button
v-for="(label, idx) in WEEKDAY_LABELS"
:key="idx"
:value="idx"
>
{{ label }}
</el-checkbox-button>
</el-checkbox-group>
</el-form-item>
<el-form-item label="自动报告">
<el-switch v-model="form.auto_report" />
<span class="form-tip">执行完成后自动生成 HTML 报告到报告中心</span>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
<span class="form-tip">停用后不会按周期触发</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存' }}
</el-button>
</template>
</el-dialog>
<!-- 运行历史抽屉 -->
<el-drawer
v-model="runsDrawerVisible"
:title="`运行记录 - ${currentTaskName}`"
size="60%"
destroy-on-close
>
<el-table :data="runs" v-loading="runsLoading" empty-text="暂无运行记录" stripe>
<el-table-column prop="name" label="执行名称" min-width="180" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="通过率" width="100">
<template #default="{ row }">{{ row.pass_rate }}%</template>
</el-table-column>
<el-table-column label="通过/失败/总数" width="150">
<template #default="{ row }">
{{ row.passed }}/{{ row.failed }}/{{ row.total_cases }}
</template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">{{ row.duration?.toFixed(1) || 0 }}s</template>
</el-table-column>
<el-table-column label="执行时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default>
<el-button size="small" type="primary" link @click="goReports">
查看报告
</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</div>
</template>
<script setup lang="ts">
/**
* 定时任务页面
*
* 功能:
* 1. 定时任务列表(状态/周期/上下次执行时间)
* 2. 创建/编辑任务(选择模块 + 周期设置 + 自动报告)
* 3. 立即执行 / 启停 / 删除
* 4. 运行历史查看(跳转报告中心查看报告)
*/
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Plus, VideoPlay, List, Edit, Delete } from '@element-plus/icons-vue'
import { scheduledTaskApi, type ScheduledTask } from '@/api/scheduledTasks'
import { moduleApi } from '@/api/modules'
import type { Module } from '@/types/module'
const router = useRouter()
const WEEKDAY_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
// ==================== 响应式数据 ====================
const tasks = ref<ScheduledTask[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const formRef = ref()
const editId = ref('')
const modules = ref<Module[]>([])
const modulesLoading = ref(false)
const togglingId = ref('')
const runningId = ref('')
const runsDrawerVisible = ref(false)
const runsLoading = ref(false)
const runs = ref<any[]>([])
const currentTaskName = ref('')
// ==================== 表单 ====================
interface TaskForm {
name: string
module_ids: string[]
schedule_type: 'interval' | 'daily' | 'weekly'
interval_value: number
interval_unit: 'minutes' | 'hours' | 'days'
run_time: string
weekdays: number[]
auto_report: boolean
enabled: boolean
}
const createEmptyForm = (): TaskForm => ({
name: '',
module_ids: [],
schedule_type: 'interval',
interval_value: 30,
interval_unit: 'minutes',
run_time: '',
weekdays: [1, 2, 3, 4, 5],
auto_report: true,
enabled: true,
})
const form = reactive<TaskForm>(createEmptyForm())
const formRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
module_ids: [
{
validator: (_rule: any, value: string[], callback: (e?: Error) => void) => {
if (!value || value.length === 0) {
callback(new Error('请至少选择一个执行模块'))
} else {
callback()
}
},
trigger: 'change',
},
],
schedule_type: [{ required: true, message: '请选择周期模式', trigger: 'change' }],
interval_value: [
{
validator: (_rule: any, value: number, callback: (e?: Error) => void) => {
if (form.schedule_type === 'interval' && (!value || value <= 0)) {
callback(new Error('请输入有效的间隔值'))
} else {
callback()
}
},
trigger: 'blur',
},
],
run_time: [
{
validator: (_rule: any, value: string, callback: (e?: Error) => void) => {
if (form.schedule_type !== 'interval' && !value) {
callback(new Error('请选择执行时间'))
} else {
callback()
}
},
trigger: 'change',
},
],
weekdays: [
{
validator: (_rule: any, value: number[], callback: (e?: Error) => void) => {
if (form.schedule_type === 'weekly' && (!value || value.length === 0)) {
callback(new Error('请至少选择一个星期'))
} else {
callback()
}
},
trigger: 'change',
},
],
}
// ==================== 方法 ====================
const formatTime = (time: string | null) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
pending: '待执行',
running: '执行中',
completed: '已完成',
cancelled: '已取消',
failed: '失败',
}
return map[status] || status
}
const statusTag = (status: string) => {
const map: Record<string, string> = {
pending: 'info',
running: 'warning',
completed: 'success',
cancelled: 'info',
failed: 'danger',
}
return map[status] || 'info'
}
const loadTasks = async () => {
loading.value = true
try {
const res = await scheduledTaskApi.list()
tasks.value = res.items
} catch (err: any) {
ElMessage.error('加载定时任务失败:' + (err?.message || '未知错误'))
} finally {
loading.value = false
}
}
const loadModules = async () => {
modulesLoading.value = true
try {
const res = await moduleApi.list(0, 200, undefined, 'ui')
modules.value = res.items
} catch (err: any) {
ElMessage.error('加载模块列表失败:' + (err?.message || '未知错误'))
} finally {
modulesLoading.value = false
}
}
const openCreate = async () => {
isEdit.value = false
editId.value = ''
Object.assign(form, createEmptyForm())
if (modules.value.length === 0) await loadModules()
dialogVisible.value = true
formRef.value?.clearValidate()
}
const openEdit = async (task: ScheduledTask) => {
isEdit.value = true
editId.value = task.id
Object.assign(form, {
name: task.name,
module_ids: [...(task.module_ids || [])],
schedule_type: task.schedule_type,
interval_value: task.interval_value,
interval_unit: task.interval_unit,
run_time: task.run_time || '',
weekdays: [...(task.weekdays || [])],
auto_report: task.auto_report,
enabled: task.enabled,
})
if (modules.value.length === 0) await loadModules()
dialogVisible.value = true
formRef.value?.clearValidate()
}
const handleSave = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = {
name: form.name.trim(),
module_ids: form.module_ids,
schedule_type: form.schedule_type,
interval_value: form.interval_value,
interval_unit: form.interval_unit,
run_time: form.schedule_type === 'interval' ? '' : form.run_time,
weekdays: form.schedule_type === 'weekly' ? form.weekdays : [],
auto_report: form.auto_report,
enabled: form.enabled,
}
if (isEdit.value) {
await scheduledTaskApi.update(editId.value, payload)
ElMessage.success('定时任务已更新')
} else {
await scheduledTaskApi.create(payload)
ElMessage.success('定时任务已创建')
}
dialogVisible.value = false
await loadTasks()
} catch (err: any) {
ElMessage.error('保存定时任务失败:' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
const handleToggle = async (task: ScheduledTask, enabled: boolean) => {
togglingId.value = task.id
try {
await scheduledTaskApi.toggle(task.id, enabled)
ElMessage.success(enabled ? '任务已启用' : '任务已停用')
await loadTasks()
} catch (err: any) {
ElMessage.error('切换任务状态失败:' + (err?.message || '未知错误'))
} finally {
togglingId.value = ''
}
}
const handleRun = async (task: ScheduledTask) => {
try {
await ElMessageBox.confirm(
`确定立即执行任务「${task.name}」吗?将执行其选中的所有模块用例。`,
'立即执行',
{ type: 'warning', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch {
return
}
runningId.value = task.id
try {
const res = await scheduledTaskApi.run(task.id)
ElMessage.success(res.message || '任务已触发执行')
// 延迟刷新,等待执行状态写入
setTimeout(loadTasks, 3000)
} catch (err: any) {
ElMessage.error('触发执行失败:' + (err?.message || '未知错误'))
} finally {
runningId.value = ''
}
}
const handleDelete = async (task: ScheduledTask) => {
try {
await ElMessageBox.confirm(
`确定删除定时任务「${task.name}」吗?已产生的执行记录与报告不受影响。`,
'删除定时任务',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await scheduledTaskApi.remove(task.id)
ElMessage.success('定时任务已删除')
await loadTasks()
} catch (err: any) {
ElMessage.error('删除定时任务失败:' + (err?.message || '未知错误'))
}
}
const openRuns = async (task: ScheduledTask) => {
currentTaskName.value = task.name
runs.value = []
runsDrawerVisible.value = true
runsLoading.value = true
try {
const res = await scheduledTaskApi.getRuns(task.id, { skip: 0, limit: 50 })
runs.value = res.items
} catch (err: any) {
ElMessage.error('加载运行记录失败:' + (err?.message || '未知错误'))
} finally {
runsLoading.value = false
}
}
const goReports = () => {
runsDrawerVisible.value = false
router.push('/reports/ui')
}
// ==================== 生命周期 ====================
onMounted(() => {
loadTasks()
})
</script>
<style lang="scss" scoped>
.scheduled-tasks-page {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #909399;
}
}
</style>
<!--
页面名称:Settings.vue
页面描述:系统配置页面(钉钉对接配置)
页面描述:系统配置页面(被测系统配置 + 钉钉对接配置)
路径:/system/settings
@author czj
@date 2026-08-13
@last-modified 2026-08-19
-->
<template>
<div class="settings-page">
<!-- 被测系统配置 -->
<el-card shadow="never" style="margin-bottom: 20px">
<template #header>
<div class="card-header">
<span>被测系统配置</span>
<el-tag v-if="sutSource" :type="sutSource === 'db' ? 'success' : 'info'" size="small">
{{ sutSource === 'db' ? '数据库已保存' : '环境变量默认' }}
</el-tag>
</div>
</template>
<el-form
ref="sutFormRef"
:model="sutForm"
:rules="sutRules"
label-width="140px"
label-position="right"
style="max-width: 600px"
>
<el-form-item label="被测系统URL" prop="url">
<el-input
v-model="sutForm.url"
placeholder="请输入被测系统访问 URL,如 https://192.168.5.44"
clearable
/>
</el-form-item>
<el-alert
title="保存后,UI 自动化执行即使用该地址作为被测系统;服务重启后依然生效。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form-item>
<el-button type="primary" :loading="sutSaving" @click="handleSutSave">
{{ sutSaving ? '保存中...' : '保存配置' }}
</el-button>
<el-button @click="handleSutReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 钉钉对接配置 -->
<el-card shadow="never">
<template #header>
<div class="card-header">
......@@ -53,14 +99,6 @@
/>
</el-form-item>
<el-form-item label="被测系统URL" prop="target_url">
<el-input
v-model="form.target_url"
placeholder="请输入被测系统基础 URL,如 https://192.168.5.44"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存配置' }}
......@@ -77,25 +115,79 @@
* 系统配置页面
*
* 功能:
* 1. 加载时自动获取当前钉钉对接配置(从数据库 / 环境变量回退
* 2. 修改后保存到数据库,重启后持久化
* 1. 被测系统配置:填写被测系统访问 URL,保存后 UI 自动化执行即使用该地址(重启持久化
* 2. 钉钉对接配置:钉钉应用凭据持久化
*/
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { systemApi } from '@/api/system'
// ==================== 类型定义 ====================
// ==================== 被测系统配置 ====================
const sutFormRef = ref()
const sutSaving = ref(false)
const sutSource = ref('')
const sutForm = reactive({
url: '',
})
const sutRules = {
url: [
{ required: true, message: '请输入被测系统 URL', trigger: 'blur' },
{
pattern: /^https?:\/\/.+/,
message: 'URL 需以 http:// 或 https:// 开头',
trigger: 'blur',
},
],
}
/** 原始快照,用于重置 */
let sutSnapshot = ''
async function loadSutConfig() {
try {
const res = await systemApi.getSutConfig()
sutForm.url = res.url || ''
sutSource.value = res.source || ''
sutSnapshot = res.url || ''
} catch (err: any) {
ElMessage.error('加载被测系统配置失败:' + (err?.message || '未知错误'))
}
}
async function handleSutSave() {
const valid = await sutFormRef.value?.validate().catch(() => false)
if (!valid) return
sutSaving.value = true
try {
const res = await systemApi.updateSutConfig(sutForm.url.trim())
ElMessage.success('被测系统配置已保存')
sutSnapshot = res.url || sutForm.url
sutSource.value = 'db'
} catch (err: any) {
ElMessage.error('保存被测系统配置失败:' + (err?.message || '未知错误'))
} finally {
sutSaving.value = false
}
}
function handleSutReset() {
sutForm.url = sutSnapshot
sutFormRef.value?.clearValidate()
}
// ==================== 钉钉对接配置 ====================
interface DingTalkConfigForm {
app_key: string
app_secret: string
agent_id: number
target_url: string
}
// ==================== 响应式数据 ====================
const formRef = ref()
const saving = ref(false)
......@@ -103,42 +195,33 @@ const form = reactive<DingTalkConfigForm>({
app_key: '',
app_secret: '',
agent_id: 0,
target_url: '',
})
/** 原始数据快照,用于重置 */
let originalSnapshot = ''
// ==================== 表单校验规则 ====================
const rules = {
app_key: [{ required: true, message: '请输入钉钉 AppKey', trigger: 'blur' }],
app_secret: [{ required: true, message: '请输入钉钉 AppSecret', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入钉钉 AgentId', trigger: 'blur' }],
target_url: [
{ required: true, message: '请输入被测系统 URL', trigger: 'blur' },
{
pattern: /^https?:\/\/.+/,
message: 'URL 需以 http:// 或 https:// 开头',
trigger: 'blur',
},
],
}
// ==================== 方法 ====================
/** 加载配置 */
/** 加载钉钉配置 */
async function loadConfig() {
try {
const res = await systemApi.getDingTalkConfig()
Object.assign(form, res)
originalSnapshot = JSON.stringify(res)
Object.assign(form, {
app_key: res.app_key,
app_secret: res.app_secret,
agent_id: res.agent_id,
})
originalSnapshot = JSON.stringify({ ...form })
} catch (err: any) {
ElMessage.error('加载钉钉配置失败:' + (err?.message || '未知错误'))
}
}
/** 保存配置 */
/** 保存钉钉配置 */
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
......@@ -149,7 +232,6 @@ async function handleSave() {
app_key: form.app_key,
app_secret: form.app_secret,
agent_id: form.agent_id,
target_url: form.target_url,
})
ElMessage.success('钉钉配置已保存')
originalSnapshot = JSON.stringify({ ...form })
......@@ -171,6 +253,7 @@ function handleReset() {
onMounted(() => {
loadConfig()
loadSutConfig()
})
</script>
......@@ -180,6 +263,9 @@ onMounted(() => {
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
font-weight: 600;
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论