From 9bc3a3f54c8393a1500875cb10394d66988f727c Mon Sep 17 00:00:00 2001 From: liangyihua Date: Thu, 23 Apr 2026 16:37:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(history):=20=E5=AD=90=E4=B8=B2=E7=AD=9B?= =?UTF-8?q?=E9=80=89=20*=5Fcontains=E3=80=81UI=20=E4=B8=8E=E8=A7=84?= =?UTF-8?q?=E7=BA=A6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:HistoryQuery/API/list_history 支持各维度 *_contains(LIKE ESCAPE !,转义 !/%/_);与 IN 互斥;pfr 子串 EXISTS;case_name_contains 与 start_time_contains 不注入默认最近 30 批 - 前端:HistoryStringMultiFilter(搜索、全部、子串 Tag)、URL 与表单同步 - spec/07、08、12 与 docs 多文件同步 Made-with: Cursor --- backend/api/v1/history.py | 20 + backend/schemas/history.py | 13 +- backend/services/history_service.py | 102 +++++- docs/01_user_story_map.md | 1 + docs/02_prd.md | 12 +- docs/04_project_structure.md | 11 +- docs/05_technical_architecture.md | 2 +- docs/07_task_breakdown_and_plan.md | 2 + docs/99_ai_project_snapshot.md | 4 +- frontend/src/pages/history/HistoryPage.tsx | 341 +++++++++--------- .../history/HistoryStringMultiFilter.tsx | 154 ++++++++ frontend/src/pages/history/history-table.css | 40 ++ frontend/src/services/index.ts | 26 ++ spec/07_history_filter_query_spec.md | 20 +- spec/08_history_filter_performance_spec.md | 21 +- spec/12_history_case_drilldown_spec.md | 12 +- 16 files changed, 566 insertions(+), 215 deletions(-) create mode 100644 frontend/src/pages/history/HistoryStringMultiFilter.tsx diff --git a/backend/api/v1/history.py b/backend/api/v1/history.py index 0a676be..4fa4194 100644 --- a/backend/api/v1/history.py +++ b/backend/api/v1/history.py @@ -193,6 +193,16 @@ async def get_history_list( code_branch: Optional[List[str]] = Query(None), # 筛选代码分支(多选) failure_owner: Optional[List[str]] = Query(None), # 筛选失败跟踪人(多选) failed_type: Optional[List[str]] = Query(None), # 筛选失败原因(多选) + start_time_contains: Optional[str] = Query(None, max_length=200), + subtask_contains: Optional[str] = Query(None, max_length=200), + case_name_contains: Optional[str] = Query(None, max_length=200), + main_module_contains: Optional[str] = Query(None, max_length=200), + case_result_contains: Optional[str] = Query(None, max_length=200), + case_level_contains: Optional[str] = Query(None, max_length=200), + platform_contains: Optional[str] = Query(None, max_length=200), + code_branch_contains: Optional[str] = Query(None, max_length=200), + failure_owner_contains: Optional[str] = Query(None, max_length=200), + failed_type_contains: Optional[str] = Query(None, max_length=200), sort_field: Optional[str] = Query(None), # 排序列 sort_order: Optional[str] = Query(None), # 排序方向:asc / desc # Depends(get_db) 是 FastAPI 的核心特性"依赖注入": @@ -219,6 +229,16 @@ async def get_history_list( code_branch=code_branch, failure_owner=failure_owner, failed_type=failed_type, + start_time_contains=start_time_contains, + subtask_contains=subtask_contains, + case_name_contains=case_name_contains, + main_module_contains=main_module_contains, + case_result_contains=case_result_contains, + case_level_contains=case_level_contains, + platform_contains=platform_contains, + code_branch_contains=code_branch_contains, + failure_owner_contains=failure_owner_contains, + failed_type_contains=failed_type_contains, sort_field=sort_field, sort_order=sort_order, ) diff --git a/backend/schemas/history.py b/backend/schemas/history.py index 2d6ea6a..bd0f20a 100644 --- a/backend/schemas/history.py +++ b/backend/schemas/history.py @@ -12,7 +12,7 @@ from typing import List, Optional # BaseModel: Pydantic 的基类,所有 Schema 都继承它以获得自动校验能力 -from pydantic import BaseModel +from pydantic import BaseModel, Field # PageRequest: 我们自定义的分页请求基类(包含 page 和 page_size 字段) from backend.schemas.common import PageRequest @@ -72,6 +72,17 @@ class HistoryQuery(PageRequest): code_branch: Optional[List[str]] = None # 按代码分支筛选(多选) failure_owner: Optional[List[str]] = None # 按失败跟踪人筛选(多选) failed_type: Optional[List[str]] = None # 按失败原因筛选(多选) + # 子串筛选(与对应 IN 列表互斥;Service 层有非空 *_contains 时忽略同维度的 IN) + start_time_contains: Optional[str] = Field(None, max_length=200) + subtask_contains: Optional[str] = Field(None, max_length=200) + case_name_contains: Optional[str] = Field(None, max_length=200) + main_module_contains: Optional[str] = Field(None, max_length=200) + case_result_contains: Optional[str] = Field(None, max_length=200) + case_level_contains: Optional[str] = Field(None, max_length=200) + platform_contains: Optional[str] = Field(None, max_length=200) + code_branch_contains: Optional[str] = Field(None, max_length=200) + failure_owner_contains: Optional[str] = Field(None, max_length=200) + failed_type_contains: Optional[str] = Field(None, max_length=200) sort_field: Optional[str] = None # 排序列(如 start_time, case_name) sort_order: Optional[str] = None # 排序方向:asc / desc diff --git a/backend/services/history_service.py b/backend/services/history_service.py index 9f56c85..c043122 100644 --- a/backend/services/history_service.py +++ b/backend/services/history_service.py @@ -29,9 +29,34 @@ "case_level", "analyzed", "platform", "code_branch", "created_at", } +# LIKE … ESCAPE 使用单字符 `!`,避免反斜杠在 Python / 方言编译层被多重解释;与 _like_escape_literal 一致。 +_LIKE_ESCAPE_CHAR = "!" + + +def _non_empty_str(value: Optional[str]) -> bool: + return value is not None and str(value).strip() != "" + + +def _like_escape_literal(value: str) -> str: + return ( + value.replace(_LIKE_ESCAPE_CHAR, _LIKE_ESCAPE_CHAR + _LIKE_ESCAPE_CHAR) + .replace("%", _LIKE_ESCAPE_CHAR + "%") + .replace("_", _LIKE_ESCAPE_CHAR + "_") + ) + + +def _like_substring(column, raw: Optional[str]) -> Optional[object]: + if not _non_empty_str(raw): + return None + inner = _like_escape_literal(str(raw).strip()) + pattern = f"%{inner}%" + return column.like(pattern, escape=_LIKE_ESCAPE_CHAR) + def _has_non_empty_case_name_filter(query: HistoryQuery) -> bool: - """Spec 08 §3.1.1:已选用例名且至少一项非空时,不注入默认 N 批。""" + """Spec 08 §3.1.1:已选用例名(IN 或子串)且有效时,不注入默认 N 批。""" + if _non_empty_str(query.case_name_contains): + return True if not query.case_name: return False for s in query.case_name: @@ -40,6 +65,17 @@ def _has_non_empty_case_name_filter(query: HistoryQuery) -> bool: return False +def _skip_default_start_time_injection(query: HistoryQuery) -> bool: + """已显式约束轮次(IN 或子串)或已选用例名(IN/子串)时,不注入默认最近 N 批。""" + if query.start_time: + return True + if _non_empty_str(query.start_time_contains): + return True + if _has_non_empty_case_name_filter(query): + return True + return False + + async def list_history( db: AsyncSession, query: HistoryQuery ) -> Tuple[ @@ -58,13 +94,13 @@ async def list_history( ]: """ 按 Spec 07:条件合并 + EXISTS 跨表筛选,禁止 JOIN、禁止结果集驱动。 - 按 Spec 08:未选 start_time 时注入默认最近 30 批;若已选非空 case_name 则不注入(§3.1.1,全时间范围)。 + 按 Spec 08:未选 start_time 时注入默认最近 30 批;若已传非空 start_time_contains 或已选非空 case_name(IN/子串)则不注入(显式筛选不叠默认批次)。 1. 仅查 pipeline_history 主表,跨表条件通过 EXISTS 子查询 2. 主查询完成后,根据当前页 (case_name, start_time, platform) 批量查 pfr 拼装 failure_owner、failed_type 3. 根据当前页 main_module 批量查 ums_module_owner(及必要时 ums_email),拼装用例开发责任人展示串 """ - # ===== 第零步:未选 start_time 时注入默认最近 30 批(Spec 08);§3.1.1 例外见 _has_non_empty_case_name_filter - if not query.start_time and not _has_non_empty_case_name_filter(query): + # ===== 第零步:未选 start_time 时注入默认最近 30 批(Spec 08);显式轮次子串/用例名筛选时不注入 + if not _skip_default_start_time_injection(query): default_batches_stmt = ( select(ph.start_time) .where(ph.start_time.is_not(None)) @@ -82,34 +118,70 @@ async def list_history( # ===== 第一步:构建主表查询(无 JOIN)===== stmt = select(ph) - if query.start_time: + st_like = _like_substring(ph.start_time, query.start_time_contains) + if st_like is not None: + stmt = stmt.where(st_like) + elif query.start_time: stmt = stmt.where(ph.start_time.in_(query.start_time)) - if query.subtask: + sub_like = _like_substring(ph.subtask, query.subtask_contains) + if sub_like is not None: + stmt = stmt.where(sub_like) + elif query.subtask: stmt = stmt.where(ph.subtask.in_(query.subtask)) - if query.case_name: + cn_like = _like_substring(ph.case_name, query.case_name_contains) + if cn_like is not None: + stmt = stmt.where(cn_like) + elif query.case_name: stmt = stmt.where(ph.case_name.in_(query.case_name)) - if query.main_module: + mm_like = _like_substring(ph.main_module, query.main_module_contains) + if mm_like is not None: + stmt = stmt.where(mm_like) + elif query.main_module: stmt = stmt.where(ph.main_module.in_(query.main_module)) - if query.case_result: + cr_like = _like_substring(ph.case_result, query.case_result_contains) + if cr_like is not None: + stmt = stmt.where(cr_like) + elif query.case_result: stmt = stmt.where(ph.case_result.in_(query.case_result)) - if query.case_level: + cl_like = _like_substring(ph.case_level, query.case_level_contains) + if cl_like is not None: + stmt = stmt.where(cl_like) + elif query.case_level: stmt = stmt.where(ph.case_level.in_(query.case_level)) if query.analyzed: stmt = stmt.where(ph.analyzed.in_(query.analyzed)) - if query.platform: + pl_like = _like_substring(ph.platform, query.platform_contains) + if pl_like is not None: + stmt = stmt.where(pl_like) + elif query.platform: stmt = stmt.where(ph.platform.in_(query.platform)) - if query.code_branch: + cb_like = _like_substring(ph.code_branch, query.code_branch_contains) + if cb_like is not None: + stmt = stmt.where(cb_like) + elif query.code_branch: stmt = stmt.where(ph.code_branch.in_(query.code_branch)) # 跨表筛选:EXISTS 子查询(Spec 4.2),执行键三字段精确匹配 - if query.failure_owner or query.failed_type: + has_pfr_filters = ( + query.failure_owner + or query.failed_type + or _non_empty_str(query.failure_owner_contains) + or _non_empty_str(query.failed_type_contains) + ) + if has_pfr_filters: exists_conds = [ pfr.case_name == ph.case_name, pfr.failed_batch == ph.start_time, pfr.platform == ph.platform, ] - if query.failed_type: + ft_like = _like_substring(pfr.failed_type, query.failed_type_contains) + if ft_like is not None: + exists_conds.append(ft_like) + elif query.failed_type: exists_conds.append(pfr.failed_type.in_(query.failed_type)) - if query.failure_owner: + fo_like = _like_substring(pfr.owner, query.failure_owner_contains) + if fo_like is not None: + exists_conds.append(fo_like) + elif query.failure_owner: exists_conds.append(pfr.owner.in_(query.failure_owner)) stmt = stmt.where(exists(select(1).select_from(pfr).where(and_(*exists_conds)))) diff --git a/docs/01_user_story_map.md b/docs/01_user_story_map.md index aec19e6..d091519 100644 --- a/docs/01_user_story_map.md +++ b/docs/01_user_story_map.md @@ -25,6 +25,7 @@ * **Story 1.3: 多条件筛选与搜索** * **描述:** 作为开发人员,希望能够通过用例ID、模块名称、执行状态对结果进行组合筛选,以便从海量数据中过滤出想要观测分析的数据。 +* **补充(已实现):** 详细执行历史支持各字符串维度 **IN 多选** 与 URL **`*_contains` 子串**(互斥)、下拉搜索后「全部」写入子串、子串以灰色 Tag 展示;规约见 `spec/07`、`spec/08`,与 `docs/02_prd.md` Story 1.3 一致。 diff --git a/docs/02_prd.md b/docs/02_prd.md index a10f0f0..c3e0e4d 100644 --- a/docs/02_prd.md +++ b/docs/02_prd.md @@ -93,8 +93,8 @@ - **数据来源:** 批次级 → `pipeline_overview`(按 `batch` 过滤);分组级 → `pipeline_overview`(按 `batch` + `subtask` 过滤);用例级 → `pipeline_history`(按 `start_time` + `subtask` 过滤)。 #### Story 1.3: 多条件筛选与搜索 -- **描述:** 作为开发人员,希望能够通过用例名称(`case_name`)、模块名称(`main_module` / `module`)、执行状态(`case_result`)、平台(`platform`)、批次(`start_time`)、是否已分析(`analyzed`)等字段对结果进行组合筛选,以便从海量数据中过滤出想要观测分析的数据。 -- **数据来源:** 主要查询 `pipeline_history` 表,可 JOIN `pipeline_failure_reason` 获取归因信息。 +- **描述:** 作为开发人员,希望能够通过用例名称(`case_name`)、模块名称(`main_module` / `module`)、执行状态(`case_result`)、平台(`platform`)、批次(`start_time`)、是否已分析(`analyzed`)等字段对结果进行组合筛选,以便从海量数据中过滤出想要观测分析的数据。详细执行历史页在 **IN 多选** 之外,对各字符串维度支持 **URL 参数 `*_contains` 子串筛选**(与对应维度 IN 互斥),下拉内可对候选项搜索并一键「全部」写入子串条件;子串条件在界面中以灰色标签展示(见 `spec/07`、`spec/08` 与 `docs/04_project_structure.md`)。 +- **数据来源:** 主要查询 `pipeline_history` 表;失败跟踪人、失败原因等跨表筛选由后端以 **EXISTS** 实现(禁止大表 JOIN),详见 `spec/07_history_filter_query_spec.md`。 --- @@ -283,10 +283,10 @@ ### 5.4 详细执行历史列表 -- **数据源:** `pipeline_history`,可 LEFT JOIN `pipeline_failure_reason` -- **列字段:** 批次、分组、用例名、主模块、执行结果、用例级别、负责人、是否已分析、平台、代码分支、创建时间 -- **筛选条件:** 批次、分组、用例名(模糊搜索)、主模块、执行结果、是否已分析、平台 -- **操作:** 点击行展开详情 +- **数据源:** `pipeline_history`;失败归因展示字段通过服务层批量查询 `pipeline_failure_reason` 拼装(**禁止**列表主查询与 `pipeline_failure_reason` 大结果集 JOIN,见 `spec/07`)。 +- **列字段:** 批次、分组、用例名、主模块、执行结果、用例级别、负责人(用例开发责任人展示)、是否已分析、平台、代码分支、创建时间 +- **筛选条件:** 批次、分组、用例名、主模块、执行结果、用例级别、是否已分析、平台、代码分支、失败跟踪人、失败原因等;各字符串维度支持 **多选 IN**(URL 多值)或 **子串 `*_contains`**(URL 单值,与同维 IN 互斥);未选批次时列表默认最近 30 批,**已选用例名(IN 或 `case_name_contains`)且无批次时不注入**(全时间范围,见 `spec/08` §3.1.1、`spec/12` 钻取)。 +- **操作:** 点击行打开 Drawer 详情;用例名可链至钻取页 ### 5.5 用例详情交互 (Detail Interaction) diff --git a/docs/04_project_structure.md b/docs/04_project_structure.md index 92f8075..b594935 100644 --- a/docs/04_project_structure.md +++ b/docs/04_project_structure.md @@ -90,7 +90,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 说明 | 实现状态 | |------|------|---------| | `common.py` | 通用模型:`PageRequest`(分页请求基类)、`PageResponse`(分页响应泛型)、`ApiResponse`(通用响应包装) | ✅ 已实现 | -| `history.py` | 执行明细:`HistoryItem`(含 failure_owner、failed_type 关联字段)、`HistoryQuery`(支持按轮次/结果/平台/跟踪人/失败原因筛选)、`HistoryFilterOptions` | ✅ 已实现 | +| `history.py` | 执行明细:`HistoryItem`(含 failure_owner、failed_type 关联字段)、`HistoryQuery`(按轮次/结果/平台/跟踪人/失败原因等 IN 多选,及同维度 `*_contains` 子串;非空 `contains` 时忽略该维度的 IN)、`HistoryFilterOptions` | ✅ 已实现 | | `one_click_analyze.py` | 一键分析:`OneClickAnalyzeRequest`、`OneClickAnalyzeResponse`(规约 spec/11) | ✅ 已实现 | | `one_click_bug_notify.py` | 一键通知:`OneClickBugNotifyRequest`、`OneClickBugNotifyResponse`(规约 spec/13) | ✅ 已实现 | | `auth.py` | 认证模块 Schema | 🔲 占位 | @@ -108,7 +108,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 路由前缀 | 说明 | 实现状态 | |------|---------|------|---------| | `router.py` | `/api/v1` | 总路由注册,将所有子模块路由挂载到 `/api/v1` 下 | ✅ 已实现 | -| `v1/history.py` | `/api/v1/history` | 执行明细:`GET /history` 分页筛选(未选批次时默认最近 30 批;已选非空 `case_name` 且无批次时不注入,见 Spec 08 §3.1.1);`POST /history/failure-process` 失败标注(仍为 bug 且跟踪人变更时可向新跟踪人发 WeLink);`POST /history/inherit-failure-reason` 继承;`POST /history/one-click-analyze` 一键分析(整批 bug);`POST /history/one-click-bug-notify` 一键通知(WeLink,spec/13) | ✅ 已实现 | +| `v1/history.py` | `/api/v1/history` | 执行明细:`GET /history` 分页筛选(未选批次时默认最近 30 批;已传非空 `start_time_contains` 或已选非空 `case_name` / `case_name_contains` 且无批次 IN 时不注入,见 Spec 08 §3.1 / §3.1.1;各维度可选 `*_contains` 子串);`POST /history/failure-process` 失败标注(仍为 bug 且跟踪人变更时可向新跟踪人发 WeLink);`POST /history/inherit-failure-reason` 继承;`POST /history/one-click-analyze` 一键分析(整批 bug);`POST /history/one-click-bug-notify` 一键通知(WeLink,spec/13) | ✅ 已实现 | | `v1/auth.py` | `/api/v1/auth` | 认证接口(登录/登出) | 🔲 占位 | | `v1/dashboard.py` | `/api/v1/dashboard` | 数据看板接口 | 🔲 占位 | | `v1/overview.py` | `/api/v1/overview` | 分组概览接口 | 🔲 占位 | @@ -124,7 +124,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 说明 | 实现状态 | |------|------|---------| -| `history_service.py` | `list_history(db, query)` — 单表分步查询(禁止 JOIN):未选批次且无有效 `case_name` 时注入最近 30 批(Spec 08);已选非空 `case_name` 且无批次时不注入(Spec 08 §3.1.1,配合 spec/12 钻取页);再批量查 pfr、`case_dev_owner_helpers`;`get_history_options()` 独立去重;`case_result` 筛选项含 passed/failed/error/skip(见 spec/02) | ✅ 已实现 | +| `history_service.py` | `list_history(db, query)` — 单表分步查询(禁止 JOIN):未选批次、无 `start_time_contains`、且无有效 `case_name`(IN 或 `case_name_contains`)时注入最近 30 批(Spec 08);已传轮次子串或已选用例名筛选且无批次 IN 时不注入;主表与 pfr 维度子串为 `LIKE … ESCAPE '!'` 且转义 `!`/`%`/`_`;再批量查 pfr、`case_dev_owner_helpers`;`get_history_options()` 独立去重;`case_result` 筛选项含 passed/failed/error/skip(见 spec/02) | ✅ 已实现 | | `case_dev_owner_helpers.py` | `build_module_to_case_dev_owner_display`、`format_case_dev_owner_display` — main_module 与 `ums_module_owner`/`ums_email` 解析(列表与一键分析复用) | ✅ 已实现 | | `failed_type_helpers.py` | `get_bug_failed_type_value` — 从 `case_failed_type` 解析 bug 字典原值(一键分析、一键通知、失败标注流转通知复用) | ✅ 已实现 | | `owner_parsing.py` | `parse_employee_id_from_owner` — 从跟踪人展示串解析工号(失败标注、一键通知) | ✅ 已实现 | @@ -210,7 +210,8 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 说明 | 实现状态 | |------|------|---------| -| `history/HistoryPage.tsx` | 详细执行历史页面。Table 展示 pipeline_history 数据(含跟踪人、失败原因列),支持分页与多维度筛选,Drawer 含基本信息区、失败归因区(仅 failed 时展示)、外部链接区;用例名链至钻取页;工具栏含分析处理、继承、一键分析、**一键通知**(spec/13)、一键生成通报;“已分析”列新增行级「分析」按钮,点击后复用与工具栏「分析处理」相同的弹窗与提交流程;**分析处理**弹窗在失败类型为 bug 时跟踪人为可编辑输入,默认按模块带出「姓名 工号」(spec/04);「详细原因」为多行 `Input.TextArea`,有本地缓存时上方提供可搜索 `Select`(`localStorage`)一键写入历史文案;失败标注 Modal 宽度约 580px、`body` 设 `maxHeight`+纵向滚动,避免内容与页脚重叠。布局:主内容区内占满剩余高度,筛选区与表头、分页固定,**仅表体区域纵向滚动**(`ResizeObserver` + `scroll.y`);样式见 `history-table.css` | ✅ 已实现 | +| `history/HistoryPage.tsx` | 详细执行历史页面。Table 展示 pipeline_history 数据(含跟踪人、失败原因列),支持分页与多维度筛选(字符串维度下拉可搜索;有匹配候选项且搜索非空时首行「全部」应用子串筛选,与 URL `*_contains` 同步;`allowClear` 清除该维度 IN 与子串),Drawer 含基本信息区、失败归因区(仅 failed 时展示)、外部链接区;用例名链至钻取页;工具栏含分析处理、继承、一键分析、**一键通知**(spec/13)、一键生成通报;“已分析”列新增行级「分析」按钮,点击后复用与工具栏「分析处理」相同的弹窗与提交流程;**分析处理**弹窗在失败类型为 bug 时跟踪人为可编辑输入,默认按模块带出「姓名 工号」(spec/04);「详细原因」为多行 `Input.TextArea`,有本地缓存时上方提供可搜索 `Select`(`localStorage`)一键写入历史文案;失败标注 Modal 宽度约 580px、`body` 设 `maxHeight`+纵向滚动,避免内容与页脚重叠。布局:主内容区内占满剩余高度,筛选区与表头、分页固定,**仅表体区域纵向滚动**(`ResizeObserver` + `scroll.y`);样式见 `history-table.css` | ✅ 已实现 | +| `history/HistoryStringMultiFilter.tsx` | 历史页字符串多选筛选项:搜索、`dropdownRender` 内「全部」(不展示数量)、子串条件以灰色 `Tag` 与多选同框展示(`history-table.css`)、隐藏 `*_contains` 与 `Select` 的 Form 联动 | ✅ 已实现 | | `history/CaseExecutionsHistoryPage.tsx` | 用例执行历史钻取壳组件,渲染 `HistoryPage drilldown`(`/history/case-executions`),规约 spec/12 | ✅ 已实现 | | `dashboard/DashboardPage.tsx` | 首页大盘 | 🔲 占位 | | `overview/OverviewPage.tsx` | 分组执行历史 | 🔲 占位 | @@ -373,7 +374,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 ``` 前端浏览器 - │ GET /api/v1/history?page=1&page_size=20 + │ GET /api/v1/history?page=1&page_size=20(及各筛选 query,含可选 *_contains 单值) ▼ FastAPI API 层 (api/v1/history.py) │ 参数校验 → 依赖注入 get_db diff --git a/docs/05_technical_architecture.md b/docs/05_technical_architecture.md index e6bd26d..8b710ad 100644 --- a/docs/05_technical_architecture.md +++ b/docs/05_technical_architecture.md @@ -91,7 +91,7 @@ ### 3.2 请求处理流程 -以 `GET /api/v1/history?page=1&case_result=failed` 为例: +以 `GET /api/v1/history?page=1&case_result=failed` 为例(另可带各维度 `*_contains` 子串参数,与同维多选互斥,由 `HistoryQuery` / `HistoryQueryParams` 承载): ``` 浏览器 diff --git a/docs/07_task_breakdown_and_plan.md b/docs/07_task_breakdown_and_plan.md index 4088212..a372525 100644 --- a/docs/07_task_breakdown_and_plan.md +++ b/docs/07_task_breakdown_and_plan.md @@ -233,6 +233,7 @@ oh: | 能力 | 状态 | |------|------| | History 批次筛选 | ✅ 已实现 | +| History 字符串维度子串筛选(`*_contains`)与下拉「全部」、子串灰色 Tag | ✅ 已实现(`HistoryStringMultiFilter`、`spec/07` §8) | | 失败原因标注(单条/批量) | ✅ 已实现 | | History 行级「分析」快捷入口 | ✅ 已实现(“已分析”列内按钮,复用分析处理弹窗) | | 分析处理「详细原因」历史联想 | ✅ 已实现(基于本地缓存的输入联想) | @@ -267,3 +268,4 @@ oh: | 2026-03-03 | 0.2 | 按真实使用流程重写:功能优先级、Report MVP、通知最小方案、开发阶段划分 | | 2026-03-03 | 0.3 | 补充:流转指派通知(预留 API);总结格式固定为 rolling 线看护进展通告(按 platform、跟踪人、模块统计) | | 2026-04-22 | 0.5 | 同步 History 优化:新增“已分析”列行级分析按钮;分析处理弹窗“详细原因”新增历史缓存与联想输入 | +| 2026-04-22 | 0.6 | 同步 History 筛选优化:`*_contains` 与 IN 互斥、钻取与 Spec 08 例外含 `case_name_contains`;前端 `HistoryStringMultiFilter`(子串 Tag、下拉「全部」不展示数量);更新 `spec/07`、`spec/08`、`02_prd`、`05_technical_architecture`、`99_ai_project_snapshot` | diff --git a/docs/99_ai_project_snapshot.md b/docs/99_ai_project_snapshot.md index 1dd77e0..48d335b 100644 --- a/docs/99_ai_project_snapshot.md +++ b/docs/99_ai_project_snapshot.md @@ -47,14 +47,14 @@ api/v1/ → services/ → schemas/ → models/ ## 实现成熟度地图 -- **已非常成熟**:`history` 模块(HistoryPage.tsx ≈2010 行,含所有一键功能的 Drawer/弹窗)、失败标注、失败原因继承、一键分析、一键通知 WeLink、首页大盘、登录认证、DB schema 校验、容器部署。 +- **已非常成熟**:`history` 模块(`HistoryPage.tsx` 与 `HistoryStringMultiFilter.tsx`;主页面约 2100+ 行量级,含多维度筛选含 `*_contains`、一键功能 Drawer/弹窗)、失败标注、失败原因继承、一键分析、一键通知 WeLink、首页大盘、登录认证、DB schema 校验、容器部署。 - **仍是占位**:分组概览、用例管理、**总结报告(report_snapshot 表已建未用)**、**通知中心(定时催办、防打扰)**、管理员后台(用户/模块/字典 CRUD 前后端)、**sys_audit_log 审计写入**。 ## 规约(spec 文件位置) - 真正的功能规约在根目录 `spec/`(14 份编号文件,`01` 到 `13`)——**不是** `openspec/specs/`(空的)。 - 关键规约: - - `spec/07_history_filter_query`、`spec/08_history_filter_performance`:history 列表**禁止 JOIN**,必须 EXISTS + 批量补齐 + 默认最近 30 批。 + - `spec/07_history_filter_query`、`spec/08_history_filter_performance`:history 列表**禁止 JOIN**,必须 EXISTS + 批量补齐 + 默认最近 30 批;主表/跨表字符串维度的 **`*_contains` 子串 `LIKE`**(与 IN 互斥、转义通配符)及 §3.1.1 用例名子串与 IN 同等不注入默认批次。 - `spec/11_one_click_batch_analyze`、`spec/13_one_click_bug_notify`:一键功能的契约。 - `spec/04_failure_process`:失败标注 + 跟踪人流转 + WeLink 通知联动。 diff --git a/frontend/src/pages/history/HistoryPage.tsx b/frontend/src/pages/history/HistoryPage.tsx index aee8a5a..cca6438 100644 --- a/frontend/src/pages/history/HistoryPage.tsx +++ b/frontend/src/pages/history/HistoryPage.tsx @@ -24,6 +24,7 @@ import { } from "antd"; import { EyeOutlined } from "@ant-design/icons"; import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import { HistoryStringMultiFilter } from "./HistoryStringMultiFilter"; import { historyApi, type HistoryItem, @@ -281,6 +282,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { /** 钻取页首次有效 URL 快照,用于「筛选重置」恢复用例名/平台/分支 */ const drilldownAnchorRef = useRef<{ case_name?: string[]; + case_name_contains?: string; platform?: string[]; code_branch?: string[]; } | null>(null); @@ -296,6 +298,12 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { if (vals.length === 0) return undefined; return vals.map((v) => parseInt(v, 10)).filter((n) => !isNaN(n)); }; + const getTrimmed = (key: string) => { + const v = searchParams.get(key); + if (v == null) return undefined; + const t = String(v).trim(); + return t ? t : undefined; + }; return { page: searchParams.get("page") ? parseInt(searchParams.get("page")!, 10) : 1, page_size: searchParams.get("page_size") @@ -312,6 +320,16 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { code_branch: getList("code_branch"), failure_owner: getList("failure_owner"), failed_type: getList("failed_type"), + start_time_contains: getTrimmed("start_time_contains"), + subtask_contains: getTrimmed("subtask_contains"), + case_name_contains: getTrimmed("case_name_contains"), + main_module_contains: getTrimmed("main_module_contains"), + case_result_contains: getTrimmed("case_result_contains"), + case_level_contains: getTrimmed("case_level_contains"), + platform_contains: getTrimmed("platform_contains"), + code_branch_contains: getTrimmed("code_branch_contains"), + failure_owner_contains: getTrimmed("failure_owner_contains"), + failed_type_contains: getTrimmed("failed_type_contains"), sort_field: searchParams.get("sort_field") || undefined, sort_order: searchParams.get("sort_order") || undefined, }; @@ -337,6 +355,21 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { appendList("code_branch", params.code_branch); appendList("failure_owner", params.failure_owner); appendList("failed_type", params.failed_type); + const appendTrimmed = (key: string, val?: string) => { + if (val == null) return; + const t = String(val).trim(); + if (t) next.set(key, t); + }; + appendTrimmed("start_time_contains", params.start_time_contains); + appendTrimmed("subtask_contains", params.subtask_contains); + appendTrimmed("case_name_contains", params.case_name_contains); + appendTrimmed("main_module_contains", params.main_module_contains); + appendTrimmed("case_result_contains", params.case_result_contains); + appendTrimmed("case_level_contains", params.case_level_contains); + appendTrimmed("platform_contains", params.platform_contains); + appendTrimmed("code_branch_contains", params.code_branch_contains); + appendTrimmed("failure_owner_contains", params.failure_owner_contains); + appendTrimmed("failed_type_contains", params.failed_type_contains); if (params.sort_field) next.set("sort_field", params.sort_field); if (params.sort_order) next.set("sort_order", params.sort_order); setSearchParams(next, { replace: true }); @@ -407,7 +440,9 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { return; } const params = paramsFromUrl(); - const hasCase = params.case_name?.some((n) => n && String(n).trim()); + const hasCase = + !!params.case_name?.some((n) => n && String(n).trim()) || + !!(params.case_name_contains && String(params.case_name_contains).trim()); if (hasCase) { drilldownInvalidWarnedRef.current = false; } else if (!drilldownInvalidWarnedRef.current) { @@ -430,6 +465,16 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { code_branch: params.code_branch, failure_owner: params.failure_owner, failed_type: params.failed_type, + start_time_contains: params.start_time_contains, + subtask_contains: params.subtask_contains, + case_name_contains: params.case_name_contains, + main_module_contains: params.main_module_contains, + case_result_contains: params.case_result_contains, + case_level_contains: params.case_level_contains, + platform_contains: params.platform_contains, + code_branch_contains: params.code_branch_contains, + failure_owner_contains: params.failure_owner_contains, + failed_type_contains: params.failed_type_contains, }); setPagination({ current: params.page ?? 1, pageSize: params.page_size ?? 20 }); }, [searchParams]); @@ -437,7 +482,9 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { useEffect(() => { const params = paramsFromUrl(); if (drilldown) { - const hasCase = params.case_name?.some((n) => n && String(n).trim()); + const hasCase = + !!params.case_name?.some((n) => n && String(n).trim()) || + !!(params.case_name_contains && String(params.case_name_contains).trim()); if (!hasCase) { setData([]); setTotal(0); @@ -446,6 +493,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { if (drilldownAnchorRef.current === null) { drilldownAnchorRef.current = { case_name: params.case_name, + case_name_contains: params.case_name_contains, platform: params.platform, code_branch: params.code_branch, }; @@ -475,20 +523,45 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const handleFilterChange = () => { const values = form.getFieldsValue(); + const inOrContains = (arr: string[] | undefined, c: string | undefined) => { + if (arr?.length) return { list: arr as string[], contains: undefined }; + const t = c != null && String(c).trim(); + return { list: undefined, contains: t ? String(c).trim() : undefined }; + }; + const st = inOrContains(values.start_time, values.start_time_contains); + const su = inOrContains(values.subtask, values.subtask_contains); + const cn = inOrContains(values.case_name, values.case_name_contains); + const mm = inOrContains(values.main_module, values.main_module_contains); + const cr = inOrContains(values.case_result, values.case_result_contains); + const cl = inOrContains(values.case_level, values.case_level_contains); + const pl = inOrContains(values.platform, values.platform_contains); + const cb = inOrContains(values.code_branch, values.code_branch_contains); + const fo = inOrContains(values.failure_owner, values.failure_owner_contains); + const ft = inOrContains(values.failed_type, values.failed_type_contains); const params: HistoryQueryParams = { page: 1, page_size: pagination.pageSize, - start_time: values.start_time?.length ? values.start_time : undefined, - subtask: values.subtask?.length ? values.subtask : undefined, - case_name: values.case_name?.length ? values.case_name : undefined, - main_module: values.main_module?.length ? values.main_module : undefined, - case_result: values.case_result?.length ? values.case_result : undefined, - case_level: values.case_level?.length ? values.case_level : undefined, + start_time: st.list, + start_time_contains: st.contains, + subtask: su.list, + subtask_contains: su.contains, + case_name: cn.list, + case_name_contains: cn.contains, + main_module: mm.list, + main_module_contains: mm.contains, + case_result: cr.list, + case_result_contains: cr.contains, + case_level: cl.list, + case_level_contains: cl.contains, analyzed: values.analyzed?.length ? values.analyzed : undefined, - platform: values.platform?.length ? values.platform : undefined, - code_branch: values.code_branch?.length ? values.code_branch : undefined, - failure_owner: values.failure_owner?.length ? values.failure_owner : undefined, - failed_type: values.failed_type?.length ? values.failed_type : undefined, + platform: pl.list, + platform_contains: pl.contains, + code_branch: cb.list, + code_branch_contains: cb.contains, + failure_owner: fo.list, + failure_owner_contains: fo.contains, + failed_type: ft.list, + failed_type_contains: ft.contains, }; syncParamsToUrl(params); setPagination((p) => ({ ...p, current: 1 })); @@ -500,6 +573,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { page: 1, page_size: pagination.pageSize, case_name: drilldownAnchorRef.current.case_name, + case_name_contains: drilldownAnchorRef.current.case_name_contains, platform: drilldownAnchorRef.current.platform, code_branch: drilldownAnchorRef.current.code_branch, }); @@ -1304,7 +1378,11 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { if (!drilldown) return ""; const p = paramsFromUrl(); const names = p.case_name?.filter((n) => n && String(n).trim()) ?? []; - return names.length ? names.join("、") : ""; + const sub = p.case_name_contains?.trim(); + if (names.length && sub) return `${names.join("、")};子串:${sub}`; + if (names.length) return names.join("、"); + if (sub) return `子串:${sub}`; + return ""; })(); return ( @@ -1325,114 +1403,71 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
- - - (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) - } - options={options?.subtask?.map((v) => ({ label: v, value: v })) ?? []} - /> - + ({ label: v, value: v })) ?? []} + /> - - - (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) - } - options={options?.main_module?.map((v) => ({ label: v, value: v })) ?? []} - /> - + ({ label: v, value: v })) ?? []} + /> - - - (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) - } - options={options?.case_level?.map((v) => ({ label: v, value: v })) ?? []} - /> - + ({ label: v, value: v })) ?? []} + /> @@ -1454,72 +1489,44 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { - - - (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) - } - options={options?.code_branch?.map((v) => ({ label: v, value: v })) ?? []} - /> - + ({ label: v, value: v })) ?? []} + /> - - - (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) - } - options={options?.failed_type?.map((v) => ({ label: v, value: v })) ?? []} - /> - + ({ label: v, value: v })) ?? []} + /> diff --git a/frontend/src/pages/history/HistoryStringMultiFilter.tsx b/frontend/src/pages/history/HistoryStringMultiFilter.tsx new file mode 100644 index 0000000..373c033 --- /dev/null +++ b/frontend/src/pages/history/HistoryStringMultiFilter.tsx @@ -0,0 +1,154 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { Form, Input, Select, Tag, Tooltip } from "antd"; +import type { FormInstance } from "antd/es/form"; + +export type HistoryStringFilterKey = + | "start_time" + | "subtask" + | "case_name" + | "main_module" + | "case_result" + | "case_level" + | "platform" + | "code_branch" + | "failure_owner" + | "failed_type"; + +function containsFieldName(name: HistoryStringFilterKey): string { + return `${name}_contains`; +} + +export interface HistoryStringMultiFilterProps { + name: HistoryStringFilterKey; + label: string; + options: { label: string; value: string }[]; + loading?: boolean; + /** 与外层 Form `disabled` 一致,用于禁用子串 Tag 关闭等 */ + disabled?: boolean; + /** 无子串、无多选时的占位说明 */ + placeholder?: string; + form: FormInstance; +} + +/** + * 执行历史页字符串多选筛选项:支持搜索、下拉首行「全部」(有匹配候选项且搜索非空时展示)、 + * 与隐藏字段 `name_contains` 同步;选具体项或清空时清除子串条件。 + */ +export function HistoryStringMultiFilter(props: HistoryStringMultiFilterProps) { + const { name, label, options, loading, disabled, placeholder, form } = props; + const cname = containsFieldName(name); + const [searchText, setSearchText] = useState(""); + const suppressNextEmptyChangeRef = useRef(false); + + const containsVal = Form.useWatch(cname, form) as string | undefined; + const trimContains = useMemo(() => { + const t = containsVal != null ? String(containsVal).trim() : ""; + return t || undefined; + }, [containsVal]); + + const filtered = useMemo(() => { + const q = searchText.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => (o.label ?? "").toString().toLowerCase().includes(q)); + }, [options, searchText]); + + const showAllRow = filtered.length > 0 && searchText.trim() !== ""; + + const onSelectAllMatched = useCallback(() => { + const kw = searchText.trim(); + if (!kw) return; + suppressNextEmptyChangeRef.current = true; + form.setFieldsValue({ + [name]: undefined, + [cname]: kw, + }); + setSearchText(""); + }, [cname, form, name, searchText]); + + const onSelectChange = useCallback( + (vals: string[] | undefined) => { + if (vals?.length) { + suppressNextEmptyChangeRef.current = false; + form.setFieldsValue({ [cname]: undefined }); + return; + } + if (suppressNextEmptyChangeRef.current) { + suppressNextEmptyChangeRef.current = false; + return; + } + form.setFieldsValue({ [cname]: undefined }); + }, + [cname, form] + ); + + return ( + <> + + +
+ {trimContains ? ( + + { + e.preventDefault(); + form.setFieldsValue({ [cname]: undefined }); + }} + > + 子串 {trimContains} + + + ) : null} + +