提交 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
......@@ -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:
......
此差异已折叠。
......@@ -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">
......
此差异已折叠。
<!--
页面名称: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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论