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

feat(ui): 被测系统URL配置统一 + 会议用例元素映射修复

【会话18】被测系统URL配置功能
- config.py 新增 TARGET_URL,Settings.vue 可配置,持久化到 dingtalk_configs.target_url
- 22 个消费方(routers/services/executors/utils/frontend)统一读取,selector_mapper/page_url_service 运行时替换 base URL

【会话14/17/20】元素映射修复(21步会议用例)
- 步骤11 会议室选择:映射表补"创建会议-会议室选择"条目
- 步骤12 预定会议:新增 .time_btn>div:last-child .btns 映射 + 时间切换规范化
- 步骤14/16 编辑/完成按钮:.room .delimg img:nth-of-type(2) + .room .user_list button:has-text("完成")
- 步骤15 参会人勾选:行级 .el-table__row:has-text("admin@xty12") .el-checkbox
- element_mapping_service 编辑/完成按钮加分逻辑

【执行引擎】
- playwright_executor:checkbox 注入顺序修复(原选择器优先)+ strategy5 表格行勾选回退
- 单元测试扩充(test_element_mapping_service.py)

【文档】HANDOFF_UI自动化 会话20 记录 + HANDOFF/V2部署/性能测试同步
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 e7c07c60
# 执行计划:V2 部署架构升级 — 前后端合一容器 + MySQL
> **文档版本**: v1.0
> **文档版本**: v2.0
> **编写日期**: 2026-07-28
> **最后更新**: 2026-08-17
> **当前分支**: `platform-auto-test`
> **目标服务器**: 192.168.5.60 / Ubuntu 26.04 LTS / 用户 ubains
> **目标服务器**: 192.168.5.60 / Ubuntu 26.04 LTS / 用户 ubains(V2 已完成);192.168.5.69 / KylinOS V10(离线部署包验证已完成)
> **部署目录**: `/data/third_party/plat-auto-test/`
> **关联 PRD**: `_PRD_需求文档_V2部署架构升级_合一容器MySQL.md` v1.0
> **预计工时**: 1.5 天
> **状态**: ✅ V2 部署(5.60)+ 离线部署包(5.69)全部完成并验证通过
---
## 〇、执行状态总览
| 阶段 | 状态 | 说明 |
|------|------|------|
| Phase 1: 本地代码改造 | ✅ 完成 | Dockerfile / docker-compose / main.py / requirements / models |
| Phase 2: 数据迁移脚本 | ✅ 完成 | `migrate_sqlite_to_mysql.py`(含断点续传 + 外键处理) |
| Phase 3: 服务器部署实施 | ✅ 完成 | 5.60 双容器 running/healthy |
| Phase 4: 数据迁移与验证 | ✅ 完成 | 283 用例 + 28 模块 + 636 执行记录全部迁移 |
| **扩展:离线部署包(5.69)** | ✅ 完成 | v4 加固,快照恢复后端到端一次通过 |
> 详细踩坑(MySQL 密码 `@`、ID 字段超长、外键约束、aiomysql 兼容)与运维命令见 `HANDOFF_V2部署升级.md`。
---
......@@ -616,6 +632,10 @@ curl -s http://192.168.5.60/health
| 日期 | 操作 | 负责人 | 备注 |
|------|------|--------|------|
| 2026-07-28 | 编写需求文档和计划执行文档 | czj | 待实施 |
| 2026-07-28 | 完成 Phase 1 代码改造 + Phase 2 迁移脚本 | czj | ✅ 本地完成 |
| 2026-07-28 | 完成 Phase 3 服务器部署 + Phase 4 数据迁移验证(5.60) | czj | ✅ 283 用例/28 模块/636 执行记录全部迁移 |
| 2026-08-17 | 制作离线部署包并部署验证(5.69,KylinOS V10) | czj | ✅ v3/v4 加固,快照恢复后端到端一次通过 |
| 2026-08-17 | 修复问题 11(基础数据导入误判) | czj | ✅ `2>/dev/null``2>&1` + `grep ERROR` |
---
......@@ -623,11 +643,15 @@ curl -s http://192.168.5.60/health
| 优先级 | 待办 | 说明 |
|--------|------|------|
| P1 | 实施 Phase 1-4 | 按计划执行代码改造和部署 |
| P2 | 全量用例执行验证 | 迁移后执行 241 个 UI 用例验证 |
| P3 | 清理旧部署目录 | 确认新部署稳定后清理 `~/ubains-module-test/` 下的旧部署 |
| P1 | 全量用例执行验证(5.60 MySQL) | 批量执行 UI 用例,确认无 `database is locked` |
| P2 | 收敛部署包临时工具 | `upload_pkg.py` / `upload_backend.py` / `run_deploy.py` 移出交付目录或标注非交付组件 |
| P3 | 下次快照验证时实证 compose 插件自动安装 | 快照恢复后先 `ls /usr/libexec/docker/cli-plugins/` 确认缺失再跑 deploy.sh |
| P4 | HTTPS 配置 | 为前端配置 Let's Encrypt 证书 |
| P5 | 编写自动化更新脚本 | 简化后续更新操作 |
| P5 | 编写自动化更新脚本 | 简化后续前端/后端更新操作 |
> 端到端验证细节、问题 1~11 台账与运维命令见:
> - `HANDOFF_V2部署升级.md`(V2 部署 + 离线部署包验证)
> - `Docs/PRD/部署运维/部署包问题记录与修复计划.md`(问题台账)
---
......
......@@ -22,6 +22,7 @@
- `HANDOFF_V2部署升级.md` — V2 部署详细交接
- `HANDOFF_UI自动化.md` — UI 自动化测试模块
- `HANDOFF_安全测试.md` — 安全测试模块
- `HANDOFF_接口测试.md` — 接口测试模块(最新,2026-08-18)
---
......
此差异已折叠。
# HANDOFF — V2 部署架构升级(合一容器 + MySQL)
> **生成时间**: 2026-07-28
> **最后更新**: 2026-08-17
> **当前分支**: `platform-auto-test`
> **状态**: ✅ V2 部署完成并验证通过(app + mysql 双容器已上线)
> **状态**: ✅ V2 部署完成并验证通过(app + mysql 双容器已上线);离线部署包 v5 已修复全部 14 个问题,快照恢复后端到端验证通过
> **关联文档**: `Docs/PRD/需求文档/部署运维/_PRD_需求文档_V2部署架构升级_合一容器MySQL.md`
---
......@@ -258,7 +259,7 @@ npm run dev # http://localhost:3000
```
临时目录\部署新服务器\deploy\
├── deploy.sh # 一键部署脚本(v4,LF 行尾)
├── deploy.sh # 一键部署脚本(v5,LF 行尾)
├── docker-compose.yml # app + mysql 双服务编排(端口变量插值)
├── docker-compose-plugin # Compose v5.3.1 二进制(CLI 插件自动安装用,32MB)
├── .env # MYSQL_ROOT_PASSWORD=Ubains@13579 / MYSQL_USER=platapp / MYSQL_PASSWORD=PlatApp2026
......@@ -281,6 +282,7 @@ npm run dev # http://localhost:3000
|------|--------|
| v3 | MySQL 半初始化自愈(空密码 root 检测 → ALTER USER 修复 → 幂等建库/建用户/授权);端口冲突自动降级(8081/3307 被占时 ±10 内递增);基础数据幂等导入(modules>0 则跳过,保护现有数据) |
| v4 | 文件统一 LF 行尾;镜像加载后 `docker image inspect` 存在性校验(缺失立即报错,不再被离线 pull 超时误导);复制前校验 `backend/app/main.py` 与 `frontend/dist/index.html`(防 app 因缺代码无限重启);健康检查码空值兜底(修 `000000`) |
| v5 | **问题 11 修复**:基础数据导入 `2>/dev/null` → `2>&1` + `grep -q "ERROR"` 精准判断;**启动顺序重构**:拆分为先 `up -d mysql` 等初始化完成再 `up -d app`(降低内存峰值与竞态);**就绪信号改为 root 密码可登录**(不再用 mysqladmin ping——它在 entrypoint 临时服务器阶段就返回 alive,造成与初始化竞态/误报自愈/导入被中断 137);**去掉所有 `2>/dev/null`**,失败时打印真实错误;**建库/建用户改为先探测**(存在则跳过,覆盖 MYSQL_* 失效场景);新增可用内存检查(<1GB 警告) |
### 11.3 本次终验结果(快照恢复后一次通过)
......@@ -434,3 +436,71 @@ fi
|--------|------|------|
| P1 | 收敛部署包临时工具 | 将 `upload_pkg.py` / `upload_backend.py` / `run_deploy.py` 移出交付目录 |
| P2 | 下次快照验证前确认快照范围 | 要求快照恢复至裸机 Docker 状态(无残留镜像/插件),避免"问题 D"疑点反复出现 |
---
## 十三、实施记录(2026-08-17 v5 修复 · 快照恢复后第三次验证)
### 13.1 本轮修复内容
v5 修复了上一轮(v4 终验+问题 11 修复后)实际部署中暴露的 3 个问题:
| 问题 | 现象 | 修复 |
|------|------|------|
| **MySQL 与 app 同时启动 → OOM / 137** | 首次部署时 `EXIT_CODE=137`(SIGKILL),基础数据未导入。14G 内存已有 14 个旁路容器,MySQL 初始化 + app 启动 + 解压管道同时跑撞内存 | 拆分为 `docker compose up -d mysql` → 等就绪 → 再 `up -d app`,降低内存峰值 |
| **mysqladmin ping 提前通过 → 自愈误判** | MySQL entrypoint 的临时服务器阶段 mysqladmin ping 就返回 alive,脚本误判为"半初始化"触发自愈路径,实际 entrypoint 还在跑初始化 | 就绪信号改为"root 密码可登录"(entrypoint 最后一步是设置 root 密码),消除竞态 |
| **`2>/dev/null` 吞错误 → 建库建用户失败时无诊断信息** | 自愈后 `CREATE USER` 因 SQL 语法问题(远程 heredoc 转义)失败,但 `2>/dev/null` 隐藏了真实错误,脚本只输出"建库/建用户失败" | 全部改为 `2>&1`,失败时打印完整错误输出 |
### 13.2 v5 验证环境
| 检查项 | 结果 |
|--------|------|
| Docker 版本 | 29.1.3 ✅ |
| docker compose 插件 | v5.3.1 已就绪(快照残留) |
| `/data/third_party/plat-auto-test/` | 已清理(`docker compose down -v` + `rm -rf`) |
| deploy-app:latest / mysql:8.0 镜像 | 均存在(快照残留,无需重新加载) |
| 端口 8081/3307 | 空闲 ✅ |
| 磁盘空间 | 30G 可用 ✅ |
### 13.3 部署执行结果
**`bash deploy.sh`(v5)执行结果:`EXIT_CODE=0`**,一次通过,无任何错误:
| 阶段 | 耗时 | 结果 |
|------|------|------|
| 前置环境检查 | 即时 | ✅ |
| 镜像加载与校验 | 即时(镜像已存在) | ✅ |
| 复制后端/前端代码 | 即时 | ✅ |
| **MySQL 先行启动并等待初始化(v5 新流程)** | ~30 秒 | ✅ root 密码可登录即视为就绪 |
| 数据库/用户自检(未走自愈路径,v5 先探测再创建) | 即时 | ✅ 数据库/用户已存在,应用用户登录正常 |
| **基础数据导入(问题 11 修复确认)** | 即时 | ✅ `✅ 基础数据导入完成` 正常打印,无 `⚠️ 导入失败` |
| 应用容器启动 | 即时 | ✅ |
| 健康检查 | 5 秒内通过 | ✅ HTTP 200 |
| 前端 | 即时 | ✅ HTTP 200 |
| 最终验证(modules=40 / cases=359 / health / frontend) | 即时 | ✅ 全部通过 |
### 13.4 踩坑记录(新增条目,续 11.4 节)
| # | 现象 | 根因 | 正确做法 |
|---|------|------|---------|
| 11 | 基础数据导入报告"失败"但实际成功 | `gzip` 管道 `2>/dev/null` 吞掉 stderr 非致命警告,`\|\|` 误判管道退出码为失败 | 改为 `2>&1` + `grep -q "ERROR"` 精准判断(v5 已修复) |
| 12 | 首次部署 EXIT_CODE=137(SIGKILL),数据未导入 | MySQL 初始化 + app 同时启动 + 解压管道撞内存峰值(14G 内存已有 14 个旁路容器,可用仅 3.9G) | 拆分启动顺序,先 mysql 等就绪再 app(v5 已修复) |
| 13 | 建库/建用户失败但无诊断信息,只报"失败" | `docker exec ... mysql -e "..." 2>/dev/null` 吞掉真实错误(SQL 语法/转义问题) | 去掉 `2>/dev/null`,失败时打印完整输出(v5 已修复) |
| 14 | 健康检查码 `HTTP 000000`(旧问题 6 修复不完全) | `curl -w %{http_code}` 连接失败输出 000,`|| echo "000"` 追加成 000000 | `|| true` + 空值兜底 `[ -z "$HEALTH" ] && HEALTH="000"`(v4 已修复,v5 保留) |
### 13.5 问题台账总览
| 轮次 | 发现缺陷 | 已修复 | 待修复 |
|------|----------|--------|--------|
| v2 首轮 | 问题 1~7 | 7/7 | 0 |
| v3 修复(端口降级/自愈/幂等) | — | — | — |
| v4 第二轮验证 | 问题 8~11 + A/B/C/D | 11/11 | 0 |
| **v5 本轮修复与验证** | **问题 12~14(启动 OOM/自愈竞态/吞错误)** | **3/3** | **0** |
### 13.6 结论
**v5 部署脚本在干净环境一次通过,EXIT_CODE=0。** 所有已知问题已清零:
- 问题 1~14 全部修复,当前待修复 = 0
- v5 三项核心改进(拆分启动顺序、root 密码就绪信号、去掉所有 `2>/dev/null`)经实测验证有效
- 部署包可交付,后续维护主要是收敛临时工具(P1)和快照范围确认(P2)
# HANDOFF_性能测试
> 生成时间:2026-08-12 15:00
> 最后更新:2026-08-17 13:45
> 最后更新:2026-08-18 00:30
> 当前分支:`platform-auto-test`
> 最近提交:`ce2c75de feat: 功能测试报告新增模板配置功能(多模板管理)`
> 最近提交:`f3035587 feat: 点击策略优化/性能测试A0027修复/报告模板与设备模拟增强`
> 主分支:`master`
---
......
......@@ -79,6 +79,9 @@ class Settings:
DINGTALK_APP_SECRET: str = os.getenv("DINGTALK_APP_SECRET", "")
DINGTALK_AGENT_ID: int = int(os.getenv("DINGTALK_AGENT_ID", "0"))
# 被测系统配置
TARGET_URL: str = os.getenv("TARGET_URL", "https://192.168.5.44")
# 文档管理配置
DOCUMENT_UPLOAD_DIR: str = os.getenv("DOCUMENT_UPLOAD_DIR", "./data/documents/upload")
DOCUMENT_OUTPUT_DIR: str = os.getenv("DOCUMENT_OUTPUT_DIR", "./data/documents/output")
......
......@@ -883,6 +883,12 @@
"tier": "会议预约-创建会议",
"description": "创建会议 - 右侧会议室表格"
},
"创建会议-议题输入框": {
"selector": ".topic_item input[placeholder='会议议题'], .topic_item .el-input__inner",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 议题输入框(议题1 编辑框,placeholder 会议议题)"
},
"创建会议-分页": {
"selector": ".pagination .el-pagination",
"type": "css",
......@@ -913,6 +919,12 @@
"tier": "会议预约-创建会议",
"description": "创建会议 - 右侧参会人表格"
},
"创建会议-参会人勾选": {
"selector": ".el-table:has-text(\"姓名\") .el-table__row:has-text(\"admin@xty12\") .el-checkbox",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 右侧参会人表格复选框(表头含'姓名',区别于会议室表格)"
},
"创建会议-会议室选择": {
"selector": ".el-checkbox",
"type": "css",
......@@ -1400,7 +1412,7 @@
"description": "会议列表[历史] - 评价弹窗星级评分"
},
"创建会议-编辑会议室": {
"selector": ".room .delimg",
"selector": ".room .delimg img:nth-of-type(2)",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 左侧会议室框中的编辑按钮(点击切换右侧到参会人配置模式)"
......@@ -1410,6 +1422,30 @@
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 底部\"确定创建\"按钮"
},
"会议创建成功-查看详情": {
"selector": ".el-message-box button:has-text('查看详情')",
"type": "css",
"tier": "会议预约-创建会议",
"description": "会议创建成功弹窗 - 查看详情按钮(进入会议详情页)"
},
"会议详情-取消会议": {
"selector": ".buttom_btn .el-button--info.is-plain",
"type": "css",
"tier": "会议预约-会议详情",
"description": "会议详情页 - 底部取消会议按钮(修改/取消操作区)"
},
"会议详情-取消确认确定": {
"selector": ".el-dialog:visible .el-dialog__footer .el-button--primary",
"type": "css",
"tier": "会议预约-会议详情",
"description": "取消会议二次确认弹窗 - 确定按钮"
},
"创建会议-编辑完成按钮": {
"selector": ".room .user_list button:has-text(\"完成\")",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 左侧会议室框编辑模式下的完成按钮(保存参会人配置)"
}
}
}
\ No newline at end of file
......@@ -142,6 +142,8 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "preset_id", "VARCHAR(64) DEFAULT NULL"),
# 功能测试报告模板:描述字段(旧库升级)
("report_templates", "description", "VARCHAR(200) DEFAULT ''"),
# 钉钉配置:被测系统URL(旧库升级)
("dingtalk_configs", "target_url", "VARCHAR(500) DEFAULT ''"),
]
def _do_ensure(sync_conn) -> None:
......
......@@ -125,7 +125,7 @@ class PlaywrightExecutor:
# 登录凭据(从环境变量或配置获取)
LOGIN_CONFIG = {
"base_url": "https://192.168.5.44",
"base_url": "https://192.168.5.44", # 兼容默认值,实际使用 self.base_url(配置可覆盖)
"username": "admin@xty",
"password": "Ubains@13579",
"captcha": "csba",
......@@ -146,6 +146,13 @@ class PlaywrightExecutor:
- auto_login (bool): 是否自动登录,默认False
"""
self.config = config or {}
# 被测系统基础 URL:优先取执行配置(execution_service 传入 settings.TARGET_URL),
# 其次回退到全局配置,保证切换目标系统后无需改代码。
self.base_url: str = (
self.config.get("base_url")
or getattr(settings, "TARGET_URL", "")
or self.LOGIN_CONFIG["base_url"]
).rstrip("/") or self.LOGIN_CONFIG["base_url"]
self.timeout: int = self.config.get("timeout", 30000)
self.screenshot_enabled: bool = self.config.get("screenshot", True)
# headless 默认值取自全局配置 settings.PLAYWRIGHT_HEADLESS(由环境变量 PLAYWRIGHT_HEADLESS 控制)。
......@@ -274,7 +281,7 @@ class PlaywrightExecutor:
logger.info("开始自动登录...")
# 访问登录页 - 使用 domcontentloaded 避免 SPA 页面 networkidle 超时
self._page.goto(self.LOGIN_CONFIG["base_url"], wait_until="domcontentloaded", timeout=30000)
self._page.goto(self.base_url, wait_until="domcontentloaded", timeout=30000)
self._page.wait_for_timeout(3000) # 等待页面渲染
# 等待登录表单加载
......@@ -461,7 +468,7 @@ class PlaywrightExecutor:
pass
# 重新访问登录页(用 domcontentloaded 避免 networkidle 超时)
try:
self._page.goto(self.LOGIN_CONFIG["base_url"], wait_until="domcontentloaded", timeout=15000)
self._page.goto(self.base_url, wait_until="domcontentloaded", timeout=15000)
except PlaywrightTimeoutError:
logger.debug("重置登录态 goto 超时(忽略)")
self._page.wait_for_timeout(2000)
......@@ -497,7 +504,7 @@ class PlaywrightExecutor:
logger.info(f"导航到主页... (当前URL: {current_url})")
# 直接导航到主页 URL(最可靠的方式)
self._page.goto(
self.LOGIN_CONFIG["base_url"] + "/#/platform?platform=%2Fplatform%2F%23%2FHome",
self.base_url + "/#/platform?platform=%2Fplatform%2F%23%2FHome",
wait_until="domcontentloaded"
)
self._page.wait_for_timeout(2000)
......@@ -1080,7 +1087,17 @@ class PlaywrightExecutor:
# 保留原始 selector 作为回退(避免映射表选择器不精确时无备选)
original = params.get("selector")
if original:
params["selectors"] = [mapping_selector, str(original)]
original = str(original)
# 注入顺序优化(P1):当映射表命中"通用复选框选择器"(裸 .el-checkbox /
# .el-checkbox__inner / input[type=checkbox],page.click 会命中页面第一个
# 复选框,如"短信"通知勾选框,点击后假通过但目标未选中),而原始选择器
# 自带目标文本(:has-text("X"),可走策略5 表格行点击回退精确命中目标行)时,
# 优先用原始选择器。注意:仅对复选框类通用映射生效,避免把
# div:visible:has-text("会议室列表") 这类宽泛文本选择器也提前。
if mapping_selector in (".el-checkbox", ".el-checkbox__inner", "input[type=checkbox]") and ':has-text("' in original:
params["selectors"] = [original, mapping_selector]
else:
params["selectors"] = [mapping_selector, original]
else:
params["selectors"] = [mapping_selector]
if not params.get("selector"):
......@@ -1105,8 +1122,8 @@ class PlaywrightExecutor:
and not params.get("logout", False)
and "退出" not in step_result.name):
url = params.get("url", "")
if (self.LOGIN_CONFIG["base_url"] in url and
("login" in url.lower() or url == self.LOGIN_CONFIG["base_url"] or url.endswith("/"))):
if (self.base_url in url and
("login" in url.lower() or url == self.base_url or url.endswith("/"))):
step_result.status = "passed"
step_result.log = f"✓ 已登录,跳过导航: {url}"
logger.info(f"✓ 已登录,跳过步骤 {step_result.order}: {step_result.name}")
......
......@@ -5,14 +5,16 @@
模块描述:性能测试请求体模板变量替换器
支持占位符:
- {__NOW__} → 当前时间 "YYYY-MM-DD HH:mm:ss"
- {__NOW_+N_H__} → 当前时间 + N 小时
- {__NOW_+N_M__} → 当前时间 + N 分钟
- {__NOW_+N_D__} → 当前时间 + N 天
- {__RANDOM_NAME_N__} → N 位随机字母数字
- {__UUID__} → 随机 UUID(无横线)
- {__RANDOM_INT_N_M__}→ N~M 随机整数
- {__INDEX__} → 请求序号
- {__NOW__} → 当前时间 "YYYY-MM-DD HH:mm:ss"
- {__NOW_+N_H__} → 当前时间 + N 小时
- {__NOW_+N_M__} → 当前时间 + N 分钟
- {__NOW_+N_D__} → 当前时间 + N 天
- {__NOW_+N_H_INDEX__} → 当前时间 + N 小时 + 请求序号分钟(并发错峰)
- {__NOW_INDEX__} → 当前时间 + 请求序号分钟(并发错峰)
- {__RANDOM_NAME_N__} → N 位随机字母数字
- {__UUID__} → 随机 UUID(无横线)
- {__RANDOM_INT_N_M__} → N~M 随机整数
- {__INDEX__} → 请求序号
- {__REF:task_id:key__} → 引用其他任务输出
作者:czj
......@@ -90,6 +92,7 @@ class TemplateResolver:
支持格式:
- NOW / NOW_+2_H / NOW_+30_M / NOW_+1_D(_ 分隔)
- NOW_+2_H_INDEX / NOW_INDEX(_INDEX 后缀,追加 request_index 分钟)
- RANDOM_NAME_6(_ 分隔)
- RANDOM_INT_1_100(_ 分隔)
- UUID / INDEX
......@@ -121,10 +124,16 @@ class TemplateResolver:
kind = parts[0]
if kind == "NOW":
# NOW / NOW_+2_H / NOW_+30_M / NOW_+1_D
# NOW / NOW_+2_H / NOW_+30_M / NOW_+1_D / 各变体 + _INDEX 后缀
if len(parts) > 1:
if parts[1] == "INDEX":
# {__NOW_INDEX__} → 当前时间 + request_index 分钟
return self._now_with_index_offset("0", "M", request_index)
delta = parts[1]
unit = parts[2] if len(parts) > 2 else "M"
unit = parts[2] if len(parts) > 2 and parts[2] in ("H", "M", "D") else "M"
if len(parts) > 3 and parts[3] == "INDEX":
# {__NOW_+N_H_INDEX__} → 当前时间 + N + request_index 分钟
return self._now_with_index_offset(delta, unit, request_index)
return self._now_with_offset(delta, unit)
return self._now_str()
......@@ -181,6 +190,25 @@ class TemplateResolver:
base += timedelta(minutes=n)
return base.strftime("%Y-%m-%d %H:%M:%S")
def _now_with_index_offset(self, delta: str, unit: str, request_index: int) -> str:
"""
当前时间 + 偏移 + request_index 分钟(INDEX 错峰)
复用 _now_with_offset 保证基础偏移逻辑单一来源,
再追加 request_index 分钟使每个请求时间槽错开。
Args:
delta: 偏移量(如 "+2")
unit: 单位(H=小时, M=分钟, D=天)
request_index: 请求序号(追加的分钟数)
Returns:
str: 偏移后的时间字符串
"""
base = self._now_with_offset(delta, unit)
base_dt = datetime.strptime(base, "%Y-%m-%d %H:%M:%S") + timedelta(minutes=request_index)
return base_dt.strftime("%Y-%m-%d %H:%M:%S")
# ---------- 随机值相关 ----------
@staticmethod
......
......@@ -26,6 +26,7 @@ from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset
from app.models.erp_config import ErpConfig
from app.models.report_template import ReportTemplate
from app.models.dingtalk_config import DingTalkConfig
__all__ = [
"Module",
......@@ -49,4 +50,5 @@ __all__ = [
"ApiPreset",
"ErpConfig",
"ReportTemplate",
"DingTalkConfig",
]
\ No newline at end of file
......@@ -65,8 +65,8 @@ class LoginTemplate(Base):
)
base_url: Mapped[str] = mapped_column(
String(500),
default="https://192.168.5.44",
comment="目标系统基础 URL"
default="",
comment="目标系统基础 URL(运行时以系统配置 TARGET_URL 为准)"
)
steps: Mapped[list] = mapped_column(JSON, default=list, comment="登录/导航步骤")
is_default: Mapped[bool] = mapped_column(default=False, comment="是否为默认模板")
......
......@@ -56,7 +56,7 @@ class LocateResponse(BaseModel):
class BatchLocateRequest(BaseModel):
"""批量元素定位请求"""
case_id: str
page_url: str = "https://192.168.5.44"
page_url: str = settings.TARGET_URL
auto_login: bool = True
# === 新增参数(Phase 1: API 增强)===
page_load_timeout: int = 10000 # 页面加载超时时间(毫秒)
......@@ -615,7 +615,7 @@ def _navigate_to_menu(page, menu_name: str) -> bool:
# 方式0: 通过 URL 路由直接导航(最可靠)
if menu_name in MENU_ROUTES:
try:
base_url = 'https://192.168.5.44'
base_url = settings.TARGET_URL
target_url = base_url + MENU_ROUTES[menu_name]
logger.info(f"通过 URL 路由导航: {target_url}")
page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
......@@ -829,8 +829,8 @@ def _do_locate_with_executor(executor, page_url: str, auto_login: bool,
# 访问目标页面
logger.info(f"访问页面: {page_url}")
current_url = executor._page.url
is_login_page = current_url.rstrip('/') == 'https://192.168.5.44'
is_target_login = page_url.rstrip('/') == 'https://192.168.5.44' or 'login' in page_url.lower()
is_login_page = current_url.rstrip('/') == settings.TARGET_URL.rstrip('/')
is_target_login = page_url.rstrip('/') == settings.TARGET_URL.rstrip('/') or 'login' in page_url.lower()
if executor._is_logged_in and is_target_login and not is_login_page:
logger.info(f"已在登录后页面,跳过导航。当前 URL: {current_url}")
......
......@@ -350,8 +350,8 @@ async def locate_element(request: LocateRequest):
target_url = request.page_url
# 智能跳转:如果已登录且目标 URL 是首页/登录页,不重复导航
is_login_page = 'login' in current_url.lower() or current_url.rstrip('/') == 'https://192.168.5.44'
is_target_login = target_url.rstrip('/') == 'https://192.168.5.44' or 'login' in target_url.lower()
is_login_page = 'login' in current_url.lower() or current_url.rstrip('/') == settings.TARGET_URL.rstrip('/')
is_target_login = target_url.rstrip('/') == settings.TARGET_URL.rstrip('/') or 'login' in target_url.lower()
if executor._is_logged_in and is_target_login and not is_login_page:
# 已登录且目标是登录页,直接在当前页面提取元素
......
......@@ -40,7 +40,7 @@ class SmartLocateRequest(BaseModel):
steps: List[SmartLocateStep] = Field(..., description="步骤列表")
auto_login: bool = Field(default=True, description="是否自动登录")
navigate_menu: str = Field(default="", description="目标菜单名称")
page_url: str = Field(default="https://192.168.5.44", description="被测系统基础 URL")
page_url: str = Field(default_factory=lambda: settings.TARGET_URL, description="被测系统基础 URL")
use_claude: bool = Field(default=True, description="是否启用 Claude 语义增强")
......@@ -74,7 +74,7 @@ class VerifyStepRequest(BaseModel):
selector: str = Field(..., description="选择器")
action: str = Field(..., description="动作类型")
value: Optional[str] = Field(None, description="操作值(fill 时必填)")
page_url: str = Field(default="https://192.168.5.44", description="页面 URL")
page_url: str = Field(default_factory=lambda: settings.TARGET_URL, description="页面 URL")
auto_login: bool = Field(default=True, description="是否自动登录")
......
......@@ -131,6 +131,7 @@ async def get_dingtalk_config(db: AsyncSession = Depends(get_db)):
app_key=config.app_key or settings.DINGTALK_APP_KEY or "",
app_secret=config.app_secret or settings.DINGTALK_APP_SECRET or "",
agent_id=config.agent_id or settings.DINGTALK_AGENT_ID or 0,
target_url=config.target_url or settings.TARGET_URL or "",
)
# 数据库无记录,回退到环境变量
......@@ -138,6 +139,7 @@ async def get_dingtalk_config(db: AsyncSession = Depends(get_db)):
app_key=settings.DINGTALK_APP_KEY or "",
app_secret=settings.DINGTALK_APP_SECRET or "",
agent_id=settings.DINGTALK_AGENT_ID or 0,
target_url=settings.TARGET_URL or "",
)
......@@ -172,6 +174,9 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async
if request.agent_id is not None:
config.agent_id = request.agent_id
settings.DINGTALK_AGENT_ID = request.agent_id
if request.target_url is not None:
config.target_url = request.target_url
settings.TARGET_URL = request.target_url
await db.commit()
await db.refresh(config)
......@@ -182,4 +187,5 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async
app_key=config.app_key or settings.DINGTALK_APP_KEY or "",
app_secret=config.app_secret or settings.DINGTALK_APP_SECRET or "",
agent_id=config.agent_id or settings.DINGTALK_AGENT_ID or 0,
target_url=config.target_url or settings.TARGET_URL or "",
)
......@@ -52,7 +52,7 @@ class LoginTemplateBase(BaseModel):
description: str = Field(default="", max_length=1000, description="模板描述")
template_type: str = Field(default="login", description="模板类型: login/navigation/custom")
target_system: str = Field(default="统一管理平台", description="目标系统名称")
base_url: str = Field(default="https://192.168.5.44", description="目标系统基础 URL")
base_url: str = Field(default="https://192.168.5.44", description="目标系统基础 URL(历史默认,实际以系统配置 TARGET_URL 为准)")
steps: List[TemplateStepDefinition] = Field(default_factory=list, description="登录/导航步骤")
is_default: bool = Field(default=False, description="是否为默认模板")
......
......@@ -273,7 +273,7 @@ class ApiTestService:
# 默认配置(与安全测试共用账号体系)
config = {
"target": {
"base_url": target_url or "https://192.168.5.44",
"base_url": target_url or settings.TARGET_URL,
"verify_ssl": False,
"timeout": 30,
},
......
......@@ -49,6 +49,12 @@ SYNONYM_MAP = {
# 同时避免误配同义词"会议预约-会议室"层级的"会议室-区域树"。
"会议室选择": "勾选会议室",
"勾选会议室": "会议室选择",
# P1 修复:创建会议页"勾选右侧的用户:admin@xty12"步骤。
# 参会人表格列名含"参会人",步骤名用"用户",语义等价(参会人=用户)。
# 不加此同义词则"用户"单 token 无候选,导致映射返回 None、回退到裸 .el-checkbox
# 误点页面上第一个复选框(短信/邮件开关)。
"用户": "参会人",
"参会人": "用户",
}
# 映射表优先级(最高)
......@@ -285,6 +291,33 @@ class ElementMappingService:
weak_count += 1
score += 1.0
# P1 修复:编辑/完成按钮 → "创建会议-编辑会议室"(.room .delimg)
# "点击左侧会议室框的【完成】按钮"只有"会议室"1个弱匹配
# ("会议室" in "编辑会议室",反向"编辑会议室" in "会议室"不成立),
# 且"完成"无法强/弱匹配"编辑会议室",在此补足 weak_count 才可通过阈值。
# 编辑与完成共用 .room .delimg(切换参会人配置模式)。
if ("编辑" in step_name or "完成" in step_name) and "编辑" in key:
weak_count += 1
score += 1.0
# P1 修复(完成按钮独立):步骤含"完成"时,为含"完成"的映射键补足弱匹配,
# 使"创建会议-编辑完成按钮"(.room .user_list 完成按钮)可通过阈值,
# 不再误配"创建会议-编辑会议室"。
if "完成" in step_name and "完成" in key:
weak_count += 1
score += 1.0
# P1 修复:勾选用户 → "创建会议-参会人勾选"(参会人表格复选框)
# "勾选右侧的用户:admin@xty12"中"用户"经 SYNONYM_MAP 展开为"参会人",
# 但弱匹配循环用原始 sk 无法命中"参会人勾选"("用户" not in "参会人勾选"),
# 在此补足阈值;配合后置"用户→参会人"加分规则选中"参会人勾选"。
# 注意不能依赖"参会人表格"——它无"勾选"关键词,弱匹配数不足会被阈值跳过。
if "用户" in step_name and "参会人" in key:
weak_count += 1
score += 1.0
if "勾选" in step_name and "勾选" in key:
weak_count += 1
score += 1.0
# 名称输入:步骤含"名称"且为输入动作时,优先带"名称"的输入框
# 解决"会议名称输入:自动化新建会议"误配"创建会议-新建会议"(值里含"新建会议")
if "名称" in step_clean and ("输入" in step_name or "填写" in step_name) and "名称" in key:
......@@ -351,6 +384,56 @@ class ElementMappingService:
"预定会议" in key or "立即开始" in key or "选择时间" in key):
score += 10.0
# ============ P1 修复:创建会议流程步骤 13-21 映射 ============
# 会议室列表 tie-break:步骤含"会议室列表"且条目属于"创建会议"tier 时优先
# 解决"点击会议室列表"三候选平局(首页-置顶功能/全部功能-预定2.0/创建会议),
# dict 顺序导致命中"首页-置顶功能-会议室列表"([data-key=meetingRoomList] 导航入口)
# 期望命中"创建会议-会议室列表"(.el-table 创建会议页右侧会议室表格)
if "会议室列表" in step_clean and key.startswith("创建会议"):
score += 3.0
# 议题输入:步骤含"议题"且为输入动作时,优先含"议题"的输入框条目
# 解决"会议议题输入:自动化新建会议的议题"误配"创建会议-新建会议"(值含"新建会议")
# 期望命中"创建会议-议题输入框"(.topic_item input)
if "议题" in step_clean and ("输入" in step_name or "填写" in step_name) and "议题" in key:
score += 8.0
# 编辑按钮:步骤含"编辑"时,优先"创建会议-编辑会议室"(.room .delimg img 编辑图标)
# 解决"点击左侧会议室框的编辑按钮"误配"会议室-区域树"(仅"会议室"强匹配)
# 注意排除含"完成"的键,避免与"创建会议-编辑完成按钮"竞争
if "编辑" in step_clean and "编辑" in key and "完成" not in key:
score += 12.0
# 完成按钮(P1 独立):步骤含"完成"时,优先"创建会议-编辑完成按钮"
# 解决"点击左侧会议室框的【完成】按钮"误配"创建会议-编辑会议室"(.room .delimg)
# 完成按钮 = .room .user_list button:has-text("完成"),保存参会人配置
if "完成" in step_clean and "完成" in key:
score += 16.0
# 勾选用户:步骤含"用户"(同义词含"参会人")且条目含"参会人"时优先
# 解决"勾选右侧的用户:admin@xty12"无候选(SYNONYM_MAP 提供 用户↔参会人)
# 期望命中"创建会议-参会人勾选"(.el-table 参会人表格复选框)
if "用户" in step_clean and "参会人" in key:
score += 10.0
# 查看详情:步骤含"查看详情"时,优先"会议创建成功-查看详情"
# 解决"点击弹窗的【查看详情】按钮"误配"会议详情-操作按钮"(.buttom_btn 泛匹配)
# 期望命中创建成功消息框中的查看详情按钮(.el-message-box)
if "查看详情" in step_clean and "查看详情" in key:
score += 10.0
# 取消会议:步骤含"取消会议"时,优先"会议详情-取消会议"
# 解决"点击底部的【取消会议】按钮"误配"创建会议-人员配置按钮"(.personnelConfigure 泛匹配)
# 期望命中详情页底部取消会议按钮(.buttom_btn 内 el-button--info)
if "取消会议" in step_clean and "取消会议" in key:
score += 10.0
# 二次确认:步骤含"二次确认"时,优先含"确认"的确认弹窗条目
# 解决"点击二次确认弹窗的【确定】按钮"误配"筛选-确定"(.RoomFilter 弹窗确定按钮)
# 期望命中取消会议二次确认弹窗的确定按钮(.el-dialog footer 主按钮)
if "二次确认" in step_clean and "确认" in key:
score += 8.0
if score > best_score:
best_score = score
best_key = key
......@@ -456,6 +539,10 @@ class ElementMappingService:
# 否则动作词"选择/勾选"被下方 ^ 前缀正则剥离后只剩"会议室",丢失语义
# ("会议室选择"是创建会议页选房间的动作,不能坍缩成"会议室"去匹配区域树)
cleaned = re.sub(r"(选择|勾选)会议室", "会议室选择", cleaned)
# "选择房间"/"勾选房间" 规范化为 "房间勾选"
# 否则动作词"选择/勾选"被下方 ^ 前缀正则剥离后只剩"房间"(单 token 不达匹配阈值)
# ("房间勾选"对应会议室列表页房间卡片的勾选复选框:会议室-房间勾选)
cleaned = re.sub(r"(选择|勾选)房间", "房间勾选", cleaned)
# "选择开始时间"/"选择结束时间" 规范化为"选择时间"并按占位符保护
# 否则"选择"被剥离后只剩"开始时间"/"时间",token"时间"弱匹配"选择时间"仅1次
# 达不到 2 弱匹配阈值,或强匹配"开始时间"误配"会议列表-全部-时间"
......@@ -487,6 +574,8 @@ class ElementMappingService:
# "选择会议室"/"勾选会议室" 规范化为 "会议室选择"
# 与 _clean_step_name 保持同步,避免动作词剥离后"会议室"变成孤立 token
cleaned = re.sub(r"(选择|勾选)会议室", "会议室选择", cleaned)
# "选择房间"/"勾选房间" 规范化为 "房间勾选"(与 _clean_step_name 同步)
cleaned = re.sub(r"(选择|勾选)房间", "房间勾选", cleaned)
# "选择开始时间"/"选择结束时间" 规范化为"选择时间"并按占位符保护(与 _clean_step_name 同步)
cleaned = re.sub(r"选择(开始|结束)?时间", "__SELECT_TIME__", cleaned)
for action_word in ["点击", "单击", "双击", "输入", "填写", "填入", "选择",
......
......@@ -319,6 +319,8 @@ class ExecutionService:
# auto_login 由用例自身配置决定,不强制覆盖
# 透传单步重试次数(默认 2)
executor_config.setdefault("step_retry_count", exec_config.get("step_retry_count", 2) if exec_config else 2)
# 传入被测系统 base_url(从配置动态获取,确保切换目标环境后无需改代码)
executor_config.setdefault("base_url", settings.TARGET_URL)
executor = PlaywrightExecutor(config=executor_config)
results = []
......
......@@ -22,6 +22,7 @@ from app.schemas.login_template import (
LoginTemplateUpdate,
TemplateStepDefinition,
)
from app.config import settings
from app.utils.id_generator import generate_id
logger = logging.getLogger(__name__)
......@@ -309,7 +310,7 @@ class LoginTemplateService:
"order": 1,
"name": "访问登录页面",
"action": "navigate",
"params": {"url": "https://192.168.5.44"},
"params": {"url": settings.TARGET_URL},
"expected": ""
},
{
......@@ -414,7 +415,7 @@ class LoginTemplateService:
description="预置登录模板(含协议勾选),验证码固定 csba",
template_type="login",
target_system="统一管理平台",
base_url="https://192.168.5.44",
base_url=settings.TARGET_URL,
steps=[TemplateStepDefinition(**step) for step in login_steps],
is_default=True,
))
......@@ -430,7 +431,7 @@ class LoginTemplateService:
description="预置功能中心导航模板,可指定目标菜单",
template_type="navigation",
target_system="统一管理平台",
base_url="https://192.168.5.44",
base_url=settings.TARGET_URL,
steps=[TemplateStepDefinition(**step) for step in navigation_steps],
is_default=True,
))
......
......@@ -89,6 +89,31 @@ class PageUrlService:
self._load()
return self._enabled
def _replace_base_url(self, url: str) -> str:
"""
运行时替换 URL 的 base 部分(scheme + host)
page_url_mapping.json 中的 URL 是静态数据(含旧环境地址),
返回前根据当前系统配置 TARGET_URL 动态替换,
保证切换目标环境后直达 URL 同步生效。
Args:
url (str): 映射表中的原始 URL
Returns:
str: 替换 base 后的 URL(无 host 时原样返回)
"""
if not url:
return url
from app.config import settings
if settings.TARGET_URL:
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.netloc:
old_base = f"{parsed.scheme}://{parsed.netloc}"
return url.replace(old_base, settings.TARGET_URL.rstrip('/'), 1)
return url
# ==================== 识别 ====================
def recognize_page(
......@@ -131,7 +156,7 @@ class PageUrlService:
conf = self._score_page(page, entry_keywords)
if conf > 0:
matches.append({"page_id": pid, "name": page.get("name", ""),
"url": page.get("url", ""), "confidence": conf,
"url": self._replace_base_url(page.get("url", "")), "confidence": conf,
"skip_config": page.get("skip_steps", {})})
if not matches:
......
......@@ -27,11 +27,12 @@ from app.schemas.performance import PerformanceTaskCreate
from app.executors.performance_executor import PerformanceExecutor
from app.websocket.manager import manager
from app.utils.id_generator import generate_id
from app.config import settings
logger = logging.getLogger(__name__)
# 默认的系统登录配置(与 playwright_executor.LOGIN_CONFIG / login_template_service 一致)
_DEFAULT_BASE_URL = "https://192.168.5.44"
_DEFAULT_BASE_URL = settings.TARGET_URL
_DEFAULT_ACCOUNTS = {
"superadmin": {"username": "admin@xty", "password": "Ubains@13579"},
"admin": {"username": "admin@xty", "password": "Ubains@13579"},
......
......@@ -66,7 +66,7 @@ class SmartLocateService:
steps: List[Dict[str, Any]],
auto_login: bool = True,
navigate_menu: str = "",
page_url: str = "https://192.168.5.44",
page_url: str = settings.TARGET_URL,
use_claude: bool = True
) -> List[Dict[str, Any]]:
"""
......@@ -425,7 +425,7 @@ class SmartLocateService:
url = url_match.group(0)
else:
# 使用默认 URL
url = 'https://192.168.5.44'
url = settings.TARGET_URL
result['success'] = True
result['params']['url'] = url
......
......@@ -368,7 +368,7 @@ class SelectorMapper:
def get_page_url(self, page: str) -> Optional[str]:
"""
获取页面URL
获取页面URL(动态替换 base URL,支持运行时切换目标系统)
Args:
page (str): 页面名称
......@@ -377,7 +377,16 @@ class SelectorMapper:
Optional[str]: 页面URL
"""
try:
return self.selectors[page]["url"]
url = self.selectors[page]["url"]
# 运行时替换 base URL(SELECTORS 在模块导入时已通过 f-string 烘焙旧 URL)
from app.config import settings
if settings.TARGET_URL and url:
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.netloc:
old_base = f"{parsed.scheme}://{parsed.netloc}"
return url.replace(old_base, settings.TARGET_URL.rstrip('/'), 1)
return url
except KeyError:
return None
......
......@@ -23,6 +23,43 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from app.services.element_mapping_service import ElementMappingService
@pytest.fixture
def meeting_mapping(tmp_path):
"""创建会议流程 P1 步骤映射(含干扰项,验证评分能正确区分)"""
data = {
"version": "1.0",
"updated_at": "2026-08-18",
"source": "test",
"elements": {
# 3 个预存问题的目标条目
"创建会议-会议室列表": {"selector": ".el-table", "type": "css", "tier": "创建会议", "description": "创建会议 - 右侧会议室表格"},
"会议室-房间勾选": {"selector": ".room_item .el-checkbox", "type": "css", "tier": "会议室", "description": "会议室 - 房间卡片勾选复选框"},
"创建会议-选择时间": {"selector": ".time_btn .btns", "type": "css", "tier": "创建会议", "description": "创建会议 - 时间选择按钮组"},
# P1 表步骤 13-21 目标条目
"创建会议-议题输入框": {"selector": ".topic_item input", "type": "css", "tier": "创建会议", "description": "创建会议 - 议题输入框"},
"创建会议-编辑会议室": {"selector": ".room .delimg", "type": "css", "tier": "创建会议", "description": "创建会议 - 参会人配置模式开关(编辑/完成共用)"},
"创建会议-参会人勾选": {"selector": ".el-table:has-text('姓名') .el-checkbox", "type": "css", "tier": "创建会议", "description": "创建会议 - 参会人表格复选框"},
"创建会议-确定创建": {"selector": ".btn button.el-button", "type": "css", "tier": "创建会议", "description": "创建会议 - 底部确定创建按钮"},
"会议创建成功-查看详情": {"selector": ".el-message-box button:has-text('查看详情')", "type": "css", "tier": "会议创建成功", "description": "创建成功弹窗查看详情按钮"},
"会议详情-取消会议": {"selector": ".buttom_btn .el-button--info.is-plain", "type": "css", "tier": "会议详情", "description": "会议详情 - 底部取消会议按钮"},
"会议详情-取消确认确定": {"selector": ".el-dialog:visible .el-dialog__footer .el-button--primary", "type": "css", "tier": "会议详情", "description": "取消会议二次确认弹窗确定按钮"},
# 干扰项(验证评分能区分)
"首页-置顶功能-会议室列表": {"selector": '[data-key="meetingRoomList"]', "type": "css", "tier": "首页", "description": "会议室列表导航入口"},
"全部功能-预定2.0-会议室列表": {"selector": '[data-id="meetingRoomList"]', "type": "css", "tier": "全部功能", "description": "预定2.0会议室列表"},
"会议室-区域树": {"selector": ".area_block", "type": "css", "tier": "会议室", "description": "会议室 - 左侧区域树"},
"创建会议-参会人表格": {"selector": ".el-table .el-checkbox", "type": "css", "tier": "创建会议", "description": "创建会议 - 参会人表格"},
"会议详情-操作按钮": {"selector": ".buttom_btn", "type": "css", "tier": "会议详情", "description": "会议详情 - 底部操作按钮"},
"筛选-确定": {"selector": ".RoomFilter .filter_footer .el-button--primary", "type": "css", "tier": "会议室筛选", "description": "会议室筛选弹窗确定按钮"},
"创建会议-新建会议": {"selector": 'div:visible:has-text("新建会议")', "type": "css", "tier": "创建会议", "description": "顶部新建会议按钮"},
"创建会议-人员配置按钮": {"selector": ".personnelConfigure", "type": "css", "tier": "创建会议", "description": "右侧参会人配置按钮"},
"会议列表-全部-时间": {"selector": ".select_date .el-date-picker[type=datetimerange]", "type": "css", "tier": "会议列表", "description": "会议列表时间范围选择"},
},
}
mapping_file = tmp_path / "meeting_mapping.json"
mapping_file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return str(mapping_file)
@pytest.fixture
def sample_mapping(tmp_path):
"""创建一份最小映射表用于测试(避免依赖完整 JSON)"""
......@@ -115,6 +152,93 @@ class TestFaultTolerance:
assert svc.get_selector("点击任意", "click") is None
class TestPresetMapping:
"""3 个预存映射问题修复回归测试"""
def test_meeting_room_list(self, meeting_mapping):
"""'点击会议室列表'应命中创建会议页会议室表格,而非首页导航入口"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击会议室列表", "click")
assert result is not None
assert result["key"] == "创建会议-会议室列表"
assert result["selector"] == ".el-table"
def test_check_room(self, meeting_mapping):
"""'勾选房间'应命中会议室房间卡片复选框"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("勾选房间", "check")
assert result is not None
assert result["key"] == "会议室-房间勾选"
def test_select_time(self, meeting_mapping):
"""'点击选择时间'应命中创建会议时间按钮组,而非会议列表时间范围"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击选择时间", "click")
assert result is not None
assert result["key"] == "创建会议-选择时间"
class TestP1MeetingFlow:
"""创建会议流程 P1 表步骤 13-21 映射回归测试"""
def test_topic_input(self, meeting_mapping):
"""'会议议题输入'应命中议题输入框,而非新建会议按钮"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("会议议题输入:自动化新建会议的议题", "fill")
assert result is not None
assert result["key"] == "创建会议-议题输入框"
def test_edit_button(self, meeting_mapping):
"""'编辑按钮'应命中 .room .delimg(参会人配置模式开关)"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击左侧会议室框的编辑按钮", "click")
assert result is not None
assert result["key"] == "创建会议-编辑会议室"
assert result["selector"] == ".room .delimg"
def test_complete_button(self, meeting_mapping):
"""'完成按钮'与编辑按钮共用 .room .delimg,不应误配会议室-区域树"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击左侧会议室框的【完成】按钮", "click")
assert result is not None
assert result["key"] == "创建会议-编辑会议室"
def test_check_user(self, meeting_mapping):
"""'勾选右侧的用户'应命中参会人表格复选框,而非参会人表格或编辑按钮"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("勾选右侧的用户:admin@xty12", "check")
assert result is not None
assert result["key"] == "创建会议-参会人勾选"
def test_confirm_create(self, meeting_mapping):
"""'确定创建'应命中底部确定创建按钮"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击【确定创建】按钮", "click")
assert result is not None
assert result["key"] == "创建会议-确定创建"
def test_view_detail(self, meeting_mapping):
"""'查看详情'应命中创建成功弹窗按钮"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击弹窗的【查看详情】按钮", "click")
assert result is not None
assert result["key"] == "会议创建成功-查看详情"
def test_cancel_meeting(self, meeting_mapping):
"""'取消会议'应命中详情页底部取消按钮,而非操作按钮区"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击底部的【取消会议】按钮", "click")
assert result is not None
assert result["key"] == "会议详情-取消会议"
def test_cancel_confirm(self, meeting_mapping):
"""'二次确认确定'应命中取消会议二次确认弹窗确定按钮,而非筛选弹窗"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击二次确认弹窗的【确定】按钮", "click")
assert result is not None
assert result["key"] == "会议详情-取消确认确定"
class TestNoMatch:
"""未命中时返回 None"""
......
......@@ -41,7 +41,12 @@ export const systemApi = {
/**
* 获取钉钉对接配置
*/
async getDingTalkConfig(): Promise<{ app_key: string; app_secret: string; agent_id: number }> {
async getDingTalkConfig(): Promise<{
app_key: string
app_secret: string
agent_id: number
target_url: string
}> {
const response = await request.get('/api/system/dingtalk-config')
return response as any
},
......@@ -53,6 +58,7 @@ export const systemApi = {
app_key?: string
app_secret?: string
agent_id?: number
target_url?: string
}): Promise<any> {
const response = await request.put('/api/system/dingtalk-config', config)
return response as any
......
......@@ -590,8 +590,19 @@ const route = useRoute()
// 设置目标网页 URL(用于 AI 定位)
const { setPageUrl } = usePageUrl()
// 被测系统 URL 改为从系统配置动态获取(切换目标环境时自动生效)
const DEFAULT_TARGET_URL = 'https://192.168.5.44'
setPageUrl(DEFAULT_TARGET_URL)
// 异步加载系统配置中的被测系统 URL,覆盖默认值
import { systemApi } from '@/api/system'
systemApi
.getDingTalkConfig()
.then((config) => {
if (config.target_url) setPageUrl(config.target_url)
})
.catch(() => {
/* 静默失败,使用默认值 */
})
// ==================== 用例类型配置 ====================
......@@ -968,8 +979,17 @@ const showLocateConfig = (row: any) => {
/**
* 预览页面(在新窗口打开已登录的目标页面)
*/
let previewTargetUrl = DEFAULT_TARGET_URL
systemApi
.getDingTalkConfig()
.then((config) => {
if (config.target_url) previewTargetUrl = config.target_url
})
.catch(() => {
/* 静默失败,使用默认值 */
})
const previewPage = () => {
window.open('https://192.168.5.44/', '_blank')
window.open(previewTargetUrl + '/', '_blank')
}
/**
......@@ -1020,7 +1040,7 @@ const batchLocate = async () => {
steps: smartLocateSteps,
auto_login: true,
navigate_menu: locateConfig.value.navigate_menu || undefined,
page_url: 'https://192.168.5.44/',
page_url: previewTargetUrl + '/',
})
if (result.success && result.located_steps > 0) {
......
......@@ -264,6 +264,7 @@ import {
import type { Module } from '@/types/module'
import { moduleApi, buildModuleTree } from '@/api/modules'
import { recorderApi } from '@/api/recorder'
import { systemApi } from '@/api/system'
const router = useRouter()
......@@ -520,7 +521,16 @@ const saveCase = async () => {
}
// ==================== 生命周期 ====================
onMounted(() => {
onMounted(async () => {
// 加载系统配置中的被测系统 URL,覆盖默认值
try {
const config = await systemApi.getDingTalkConfig()
if (config.target_url) {
recorderForm.value.url = config.target_url
}
} catch {
/* 静默失败,使用默认值 */
}
loadModules()
})
</script>
......
......@@ -53,6 +53,14 @@
/>
</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 ? '保存中...' : '保存配置' }}
......@@ -83,6 +91,7 @@ interface DingTalkConfigForm {
app_key: string
app_secret: string
agent_id: number
target_url: string
}
// ==================== 响应式数据 ====================
......@@ -94,6 +103,7 @@ const form = reactive<DingTalkConfigForm>({
app_key: '',
app_secret: '',
agent_id: 0,
target_url: '',
})
/** 原始数据快照,用于重置 */
......@@ -105,6 +115,14 @@ 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',
},
],
}
// ==================== 方法 ====================
......@@ -131,6 +149,7 @@ 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 })
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论