From 1507f049840453d52091006067f113462ed9e925 Mon Sep 17 00:00:00 2001 From: liangyihua Date: Sat, 25 Apr 2026 14:51:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(history):=20=E5=9C=A8=20upstream=20?= =?UTF-8?q?=E4=B9=8B=E4=B8=8A=E5=90=88=E5=85=A5=E6=9C=AC=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=EF=BC=88=E6=90=9C=E7=B4=A2=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E3=80=81OH=20=E6=97=A5=E6=8A=A5=E3=80=81=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自 upstream/master 合并后相对基线的单提交整理;原分支指针保留于 backup/feature-history-search-templates-706f1c6 Made-with: Cursor --- .gitignore | 4 + backend/api/v1/history.py | 51 ++ backend/constants/__init__.py | 1 + backend/constants/oh_daily_export_table.py | 51 ++ backend/models/__init__.py | 2 + backend/models/history_search_template.py | 39 ++ backend/schemas/history_search_template.py | 21 + backend/schemas/oh_daily_export.py | 20 + .../history_search_template_service.py | 116 +++++ backend/services/oh_daily_export_service.py | 209 ++++++++ ...V1.1.1__create_history_search_template.sql | 15 + docs/01_user_story_map.md | 1 + docs/02_prd.md | 6 +- docs/04_project_structure.md | 14 +- docs/05_technical_architecture.md | 1 + docs/07_task_breakdown_and_plan.md | 3 + docs/99_ai_project_snapshot.md | 1 + frontend/src/pages/history/HistoryPage.tsx | 446 +++++++++++++++++- frontend/src/pages/history/history-table.css | 106 +++++ frontend/src/services/index.ts | 37 ++ 20 files changed, 1139 insertions(+), 5 deletions(-) create mode 100644 backend/constants/__init__.py create mode 100644 backend/constants/oh_daily_export_table.py create mode 100644 backend/models/history_search_template.py create mode 100644 backend/schemas/history_search_template.py create mode 100644 backend/schemas/oh_daily_export.py create mode 100644 backend/services/history_search_template_service.py create mode 100644 backend/services/oh_daily_export_service.py create mode 100644 database/V1.1.1__create_history_search_template.sql diff --git a/.gitignore b/.gitignore index 8e1359e..d095521 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ Thumbs.db backend/data/* !backend/data/.gitkeep +# 日报导出表格格式已固化在 backend/constants/oh_daily_export_table.py,不提交本地 Excel 模板 +数据导出模板.xlsx +~$数据导出模板.xlsx + # Runtime .pid nohup.out diff --git a/backend/api/v1/history.py b/backend/api/v1/history.py index 4fa4194..2547c83 100644 --- a/backend/api/v1/history.py +++ b/backend/api/v1/history.py @@ -39,6 +39,7 @@ InheritSourceRecordsResponse, ) from backend.schemas.batch_report import BatchReportResponse +from backend.schemas.oh_daily_export import OhDailyExportResponse from backend.schemas.one_click_analyze import OneClickAnalyzeRequest, OneClickAnalyzeResponse from backend.schemas.one_click_bug_notify import ( OneClickBugNotifyRequest, @@ -54,8 +55,15 @@ inherit_failure_reason, ) from backend.services.batch_report_service import get_batch_report +from backend.services.oh_daily_export_service import get_oh_daily_export from backend.services.one_click_analyze_service import one_click_analyze from backend.services.one_click_bug_notify_service import one_click_bug_notify +from backend.schemas.history_search_template import HistorySearchTemplateCreate, HistorySearchTemplateItem +from backend.services.history_search_template_service import ( + create_search_template, + delete_search_template, + list_search_templates, +) # 创建一个路由器实例: # - prefix="/history": 这个路由器下的所有端点都自动加上 /history 前缀 @@ -167,6 +175,49 @@ async def get_batch_report_endpoint( return await get_batch_report(db, start_time) +@router.get("/oh-daily-export", response_model=OhDailyExportResponse) +async def get_oh_daily_export_endpoint( + start_time: str = Query(..., description="轮次(批次),与日报弹窗所选批次一致"), + db: AsyncSession = Depends(get_db), + _: dict = Depends(get_current_user), +): + """OH 平台(白名单 platform)日报:按模板维度统计各主模块分类用例数与通过率,返回可复制 TSV。""" + return await get_oh_daily_export(db, start_time) + + +@router.get("/search-templates", response_model=List[HistorySearchTemplateItem]) +async def get_history_search_templates( + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_user), +): + """当前登录用户的历史页搜索模板列表(按更新时间倒序)。""" + employee_id = payload.get("sub", "") + return await list_search_templates(db, employee_id) + + +@router.post("/search-templates", response_model=HistorySearchTemplateItem) +async def post_history_search_template( + body: HistorySearchTemplateCreate, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_user), +): + """保存当前筛选条件为搜索模板(每用户最多 10 条)。""" + employee_id = payload.get("sub", "") + return await create_search_template(db, employee_id, body) + + +@router.delete("/search-templates/{template_id}") +async def delete_history_search_template( + template_id: int, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_user), +): + """删除指定搜索模板(仅本人)。""" + employee_id = payload.get("sub", "") + await delete_search_template(db, employee_id, template_id) + return {"success": True, "message": "删除成功"} + + # @router.get("") 定义一个 GET 请求的端点 # 完整路径: /api/v1/history(prefix="/history" + "" = "/history") # response_model=PageResponse[HistoryItem] 的作用: diff --git a/backend/constants/__init__.py b/backend/constants/__init__.py new file mode 100644 index 0000000..ae9331b --- /dev/null +++ b/backend/constants/__init__.py @@ -0,0 +1 @@ +# 业务常量包(无运行时副作用,供 Service 等引用) diff --git a/backend/constants/oh_daily_export_table.py b/backend/constants/oh_daily_export_table.py new file mode 100644 index 0000000..5a1103c --- /dev/null +++ b/backend/constants/oh_daily_export_table.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +OH 平台「日报数据」导出 — 表格格式定义(唯一权威来源)。 + +仓库不依赖「数据导出模板.xlsx」;若需调整列名、行顺序或主模块归属,只改本文件。 + +**逻辑布局(与 Excel 粘贴后一致,共 7 行 × 6 列)** + +- 第 1 行(表头):`OH_DAILY_EXPORT_HEADER_ROW` — 组件、Total、Success、Fail、NewFail、通过率。 +- 第 2~7 行:A 列为组件分类中文名;B~F 列为该分类的 total、success、fail、newFail、通过率(文本)。 + +**导出形态**:制表符分隔(TSV),每行 6 个字段。 + +**统计口径**(由 Service 实现,与本文件分工):单批次 + OH 平台白名单 + 本文件列出的 main_module; +按 case_name 去重聚合。**NewFail**:在「小于当前批次且最大的上一批次 B」中该用例为成功,在当前批次 A 中为失败的去重用例数(无满足条件的 B 时为 0)。 +""" + +from typing import List, Tuple + +# ----- 表头:第 1 行 A1~F1 ----- +OH_DAILY_EXPORT_HEADER_ROW: Tuple[str, ...] = ( + "组件", + "Total", + "Success", + "Fail", + "NewFail", + "通过率", +) + +# ----- 数据行:A 列展示名 + 纳入该行的 main_module 精确匹配列表(顺序即导出行序) ----- +OH_DAILY_EXPORT_ROWS: List[Tuple[str, Tuple[str, ...]]] = [ + ("后端 & DFX", ("App", "Terminal", "LOG", "Git", "GIT", "TrustWorkspace")), + ("编辑器 & 前端组件", ("Problem", "Hover", "Other", "Settings", "Editor", "Explorer", "FileExplorer")), + ("IDE框架", ("Workbench", "Window", "webview", "Scaffold", "Notification")), + ( + "插件生态 & 调试", + ( + "AIChatView", + "LSP", + "PluginLSP", + "PluginDebug", + "PluginAPI", + "PluginE", + "PluginC", + "Output", + "Debug", + ), + ), + ("智能辅助编写", ("AIFE",)), + ("智能辅助阅读", ("AIPI",)), +] diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 51ba813..8f600c7 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -9,6 +9,7 @@ from backend.models.case_offline_type import CaseOfflineType from backend.models.sys_audit_log import SysAuditLog from backend.models.report_snapshot import ReportSnapshot +from backend.models.history_search_template import HistorySearchTemplate __all__ = [ "Base", @@ -22,4 +23,5 @@ "CaseOfflineType", "SysAuditLog", "ReportSnapshot", + "HistorySearchTemplate", ] diff --git a/backend/models/history_search_template.py b/backend/models/history_search_template.py new file mode 100644 index 0000000..5d1205a --- /dev/null +++ b/backend/models/history_search_template.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import Base + + +class HistorySearchTemplate(Base): + __tablename__ = "history_search_template" + __table_args__ = ( + UniqueConstraint("employee_id", "name", name="uk_hst_employee_name"), + Index("idx_hst_employee_id", "employee_id"), + {"extend_existing": True}, + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + employee_id: Mapped[str] = mapped_column( + String(20), + ForeignKey("ums_email.employee_id", name="fk_hst_employee"), + nullable=False, + comment="工号", + ) + name: Mapped[str] = mapped_column(String(100), nullable=False, comment="模板名称") + query_json: Mapped[str] = mapped_column(Text, nullable=False, comment="筛选条件 JSON") + created_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ) + updated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + server_default=text("CURRENT_TIMESTAMP"), + server_onupdate=text("CURRENT_TIMESTAMP"), + comment="更新时间", + ) diff --git a/backend/schemas/history_search_template.py b/backend/schemas/history_search_template.py new file mode 100644 index 0000000..e4ecad3 --- /dev/null +++ b/backend/schemas/history_search_template.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from backend.schemas.history import HistoryQuery + + +class HistorySearchTemplateCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="模板名称") + query_params: HistoryQuery = Field(..., description="与列表筛选一致的查询参数快照") + + +class HistorySearchTemplateItem(BaseModel): + id: int + name: str + query_params: HistoryQuery + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = {"from_attributes": True} diff --git a/backend/schemas/oh_daily_export.py b/backend/schemas/oh_daily_export.py new file mode 100644 index 0000000..39cf797 --- /dev/null +++ b/backend/schemas/oh_daily_export.py @@ -0,0 +1,20 @@ +# ============================================================ +# OH 平台日报数据导出 — Schema +# ============================================================ + +from typing import List + +from pydantic import BaseModel, Field + + +class OhDailyExportResponse(BaseModel): + """GET /history/oh-daily-export 响应;export_text 布局见 `backend.constants.oh_daily_export_table`。""" + + model_config = {"from_attributes": True} + + start_time: str = Field(..., description="轮次(批次)") + platform_filter: List[str] = Field( + ..., + description="参与统计的 pipeline_history.platform 取值(白名单)", + ) + export_text: str = Field(..., description="制表符分隔文本,可直接粘贴到 Excel") diff --git a/backend/services/history_search_template_service.py b/backend/services/history_search_template_service.py new file mode 100644 index 0000000..f70394d --- /dev/null +++ b/backend/services/history_search_template_service.py @@ -0,0 +1,116 @@ +import json +import logging +from typing import List + +from fastapi import HTTPException, status +from sqlalchemy import and_, delete, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models.history_search_template import HistorySearchTemplate +from backend.schemas.history import HistoryQuery +from backend.schemas.history_search_template import HistorySearchTemplateCreate, HistorySearchTemplateItem + +logger = logging.getLogger(__name__) + +MAX_TEMPLATES_PER_USER = 10 + + +async def list_search_templates(db: AsyncSession, employee_id: str) -> List[HistorySearchTemplateItem]: + if not employee_id or not str(employee_id).strip(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") + eid = str(employee_id).strip() + stmt = ( + select(HistorySearchTemplate) + .where(HistorySearchTemplate.employee_id == eid) + .order_by(HistorySearchTemplate.updated_at.desc(), HistorySearchTemplate.id.desc()) + ) + result = await db.execute(stmt) + rows = result.scalars().all() + items: List[HistorySearchTemplateItem] = [] + for row in rows: + try: + raw = json.loads(row.query_json) + q = HistoryQuery.model_validate(raw) + except Exception: + logger.exception("搜索模板 query_json 解析失败 template_id=%s", row.id) + continue + items.append( + HistorySearchTemplateItem( + id=int(row.id), + name=row.name, + query_params=q, + created_at=row.created_at, + updated_at=row.updated_at, + ) + ) + return items + + +async def create_search_template( + db: AsyncSession, employee_id: str, body: HistorySearchTemplateCreate +) -> HistorySearchTemplateItem: + if not employee_id or not str(employee_id).strip(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") + eid = str(employee_id).strip() + name = body.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="模板名称不能为空") + + cnt_stmt = select(func.count()).select_from(HistorySearchTemplate).where(HistorySearchTemplate.employee_id == eid) + cnt_result = await db.execute(cnt_stmt) + count_val = int(cnt_result.scalar_one() or 0) + if count_val >= MAX_TEMPLATES_PER_USER: + logger.warning("用户搜索模板已达上限 employee_id=%s count=%s", eid, count_val) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="模板已达上限,请先删除模板", + ) + + payload = body.query_params.model_dump(mode="json") + row = HistorySearchTemplate( + employee_id=eid, + name=name, + query_json=json.dumps(payload, ensure_ascii=False), + ) + db.add(row) + try: + await db.commit() + await db.refresh(row) + except IntegrityError: + await db.rollback() + logger.warning("搜索模板名称重复 employee_id=%s name=%s", eid, name) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="模板名称已存在!", + ) + except Exception: + await db.rollback() + logger.exception("创建搜索模板失败 employee_id=%s", eid) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="保存失败") + + logger.info("已创建历史搜索模板 id=%s employee_id=%s name=%s", row.id, eid, name) + return HistorySearchTemplateItem( + id=int(row.id), + name=row.name, + query_params=body.query_params, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +async def delete_search_template(db: AsyncSession, employee_id: str, template_id: int) -> None: + if not employee_id or not str(employee_id).strip(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") + eid = str(employee_id).strip() + stmt = delete(HistorySearchTemplate).where( + and_( + HistorySearchTemplate.id == template_id, + HistorySearchTemplate.employee_id == eid, + ) + ) + result = await db.execute(stmt) + if result.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在") + await db.commit() + logger.info("已删除历史搜索模板 id=%s employee_id=%s", template_id, eid) diff --git a/backend/services/oh_daily_export_service.py b/backend/services/oh_daily_export_service.py new file mode 100644 index 0000000..90e66e5 --- /dev/null +++ b/backend/services/oh_daily_export_service.py @@ -0,0 +1,209 @@ +# ============================================================ +# OH 平台日报数据导出 — Service +# ============================================================ + +import logging +from typing import Dict, List, Optional, Tuple + +from fastapi import HTTPException, status +from sqlalchemy import and_, case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.constants.oh_daily_export_table import ( + OH_DAILY_EXPORT_HEADER_ROW, + OH_DAILY_EXPORT_ROWS, +) +from backend.models.pipeline_history import PipelineHistory as Ph +from backend.schemas.oh_daily_export import OhDailyExportResponse + +logger = logging.getLogger(__name__) + +# 与线上一致时可扩展;精确匹配禁止模糊 LIKE,以免误计其它平台 +OH_PLATFORMS: Tuple[str, ...] = ("oh",) + + +def _non_empty_case_name_condition(): + """排除 NULL 与仅空白用例名。""" + return and_(Ph.case_name.isnot(None), func.length(func.trim(Ph.case_name)) > 0) + + +def _inner_category_case_flags(batch: str, modules: Tuple[str, ...]): + """ + 单分类、单批次、OH 平台下按 case_name 聚合后的子查询列: + has_fail=1 表示该用例在本批次任一条 failed/error;has_pass=1 表示任一条 passed。 + """ + has_fail = func.max( + case((Ph.case_result.in_(["failed", "error"]), 1), else_=0) + ).label("has_fail") + has_pass = func.max(case((Ph.case_result == "passed", 1), else_=0)).label("has_pass") + return ( + select(Ph.case_name.label("case_name"), has_fail, has_pass) + .where( + Ph.start_time == batch, + Ph.platform.in_(list(OH_PLATFORMS)), + Ph.main_module.in_(list(modules)), + _non_empty_case_name_condition(), + ) + .group_by(Ph.case_name) + .subquery() + ) + + +def _outcome_from_flags(has_fail: int, has_pass: int) -> str: + if int(has_fail or 0) == 1: + return "fail" + if int(has_fail or 0) == 0 and int(has_pass or 0) == 1: + return "success" + return "other" + + +async def _aggregate_category( + db: AsyncSession, + batch: str, + modules: Tuple[str, ...], +) -> Tuple[int, int, int]: + """ + 单分类、单批次、OH 白名单平台下: + 按 case_name 聚合:任一条 failed/error 则该用例计为 fail;否则若存在 passed 则计 success;其余计 other。 + 返回 (total, success, fail)。 + """ + inner = _inner_category_case_flags(batch, modules) + + n_total = func.count().label("n_total") + n_success = func.coalesce( + func.sum( + case( + (and_(inner.c.has_fail == 0, inner.c.has_pass == 1), 1), + else_=0, + ) + ), + 0, + ).label("n_success") + n_fail = func.coalesce( + func.sum(case((inner.c.has_fail == 1, 1), else_=0)), + 0, + ).label("n_fail") + + stmt = select(n_total, n_success, n_fail).select_from(inner) + row = (await db.execute(stmt)).one() + return int(row.n_total or 0), int(row.n_success or 0), int(row.n_fail or 0) + + +async def _case_outcomes_for_category( + db: AsyncSession, + batch: str, + modules: Tuple[str, ...], +) -> Dict[str, str]: + """case_name -> 'success' | 'fail' | 'other',与 _aggregate_category 口径一致。""" + inner = _inner_category_case_flags(batch, modules) + stmt = select(inner.c.case_name, inner.c.has_fail, inner.c.has_pass).select_from(inner) + result = await db.execute(stmt) + out: Dict[str, str] = {} + for cn, hf, hp in result.all(): + if cn is None: + continue + key = str(cn).strip() + if not key: + continue + out[key] = _outcome_from_flags(int(hf or 0), int(hp or 0)) + return out + + +async def _previous_batch_strictly_before( + db: AsyncSession, + batch_a: str, +) -> Optional[str]: + """ + 在 pipeline_history 中出现过的批次里,取严格小于 batch_a 的最大 start_time 作为上一批 B。 + 与字符串/字典序一致;若不存在更小的批次则返回 None。 + """ + stmt = ( + select(func.max(Ph.start_time)) + .where( + Ph.start_time.isnot(None), + func.length(func.trim(Ph.start_time)) > 0, + Ph.start_time < batch_a, + ) + ) + val = (await db.execute(stmt)).scalar_one_or_none() + if val is None: + return None + s = str(val).strip() + return s if s else None + + +async def _new_fail_for_category( + db: AsyncSession, + batch_a: str, + batch_b: Optional[str], + modules: Tuple[str, ...], +) -> int: + """ + NewFail:批次 B 中 success,批次 A 中 fail 的用例数(同一 case_name,同一分类与 OH 平台口径)。 + 无上一批 B 时为 0。 + """ + if not batch_b: + return 0 + out_a = await _case_outcomes_for_category(db, batch_a, modules) + out_b = await _case_outcomes_for_category(db, batch_b, modules) + n = 0 + for cn, st_a in out_a.items(): + if st_a != "fail": + continue + if out_b.get(cn) == "success": + n += 1 + return n + + +def _build_tsv_lines(rows_data: List[Tuple[str, int, int, int, int, str]]) -> str: + """ + rows_data: (label, total, success, fail, new_fail, pass_rate_display) + 表头与行序见 backend.constants.oh_daily_export_table。 + """ + lines: List[str] = [] + lines.append("\t".join(OH_DAILY_EXPORT_HEADER_ROW)) + for label, total, success, fail, new_fail, rate_str in rows_data: + lines.append( + f"{label}\t{total}\t{success}\t{fail}\t{new_fail}\t{rate_str}" + ) + return "\n".join(lines) + + +async def get_oh_daily_export(db: AsyncSession, start_time: str) -> OhDailyExportResponse: + batch = (start_time or "").strip() + if not batch: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="start_time 不能为空", + ) + + batch_prev = await _previous_batch_strictly_before(db, batch) + + rows_for_tsv: List[Tuple[str, int, int, int, int, str]] = [] + summary_log: List[str] = [] + + for label, modules in OH_DAILY_EXPORT_ROWS: + total, success, fail = await _aggregate_category(db, batch, modules) + new_fail = await _new_fail_for_category(db, batch, batch_prev, modules) + if total > 0: + rate_str = f"{(success / total * 100):.2f}%" + else: + rate_str = "0%" + rows_for_tsv.append((label, total, success, fail, new_fail, rate_str)) + summary_log.append(f"{label}={total}/{success}/{fail}/nf={new_fail}") + + export_text = _build_tsv_lines(rows_for_tsv) + + logger.info( + "OH 日报导出成功 batch=%s prev_batch=%s platforms=%s %s", + batch, + batch_prev or "", + ",".join(OH_PLATFORMS), + " ".join(summary_log), + ) + + return OhDailyExportResponse( + start_time=batch, + platform_filter=list(OH_PLATFORMS), + export_text=export_text, + ) diff --git a/database/V1.1.1__create_history_search_template.sql b/database/V1.1.1__create_history_search_template.sql new file mode 100644 index 0000000..8d301f3 --- /dev/null +++ b/database/V1.1.1__create_history_search_template.sql @@ -0,0 +1,15 @@ +-- 新建历史页搜索模板表 history_search_template(按工号绑定,与 JWT sub 一致) +-- MySQL 5.7,字符集 utf8mb4,排序规则 utf8mb4_unicode_ci + +CREATE TABLE `history_search_template` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `employee_id` varchar(20) NOT NULL COMMENT '工号,与 JWT sub、ums_email 一致', + `name` varchar(100) NOT NULL COMMENT '模板名称', + `query_json` text NOT NULL COMMENT '筛选条件 JSON(与 HistoryQuery 字段一致)', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hst_employee_name` (`employee_id`,`name`), + KEY `idx_hst_employee_id` (`employee_id`), + CONSTRAINT `fk_hst_employee` FOREIGN KEY (`employee_id`) REFERENCES `ums_email` (`employee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/01_user_story_map.md b/docs/01_user_story_map.md index d091519..8063476 100644 --- a/docs/01_user_story_map.md +++ b/docs/01_user_story_map.md @@ -26,6 +26,7 @@ * **Story 1.3: 多条件筛选与搜索** * **描述:** 作为开发人员,希望能够通过用例ID、模块名称、执行状态对结果进行组合筛选,以便从海量数据中过滤出想要观测分析的数据。 * **补充(已实现):** 详细执行历史支持各字符串维度 **IN 多选** 与 URL **`*_contains` 子串**(互斥)、下拉搜索后「全部」写入子串、子串以灰色 Tag 展示;规约见 `spec/07`、`spec/08`,与 `docs/02_prd.md` Story 1.3 一致。 +* **补充(搜索模板,见 PRD Story 1.3):** 工具栏在「筛选确认」后可保存当前条件为具名模板(服务端按登录用户持久化,最多 10 条);「一键生成通报」同区域展示模板条以一键查询或删除;重名/空名/上限与删除时保留当前查询等行为以 PRD 为准。 diff --git a/docs/02_prd.md b/docs/02_prd.md index c3e0e4d..96d78a9 100644 --- a/docs/02_prd.md +++ b/docs/02_prd.md @@ -94,7 +94,8 @@ #### Story 1.3: 多条件筛选与搜索 - **描述:** 作为开发人员,希望能够通过用例名称(`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`。 +- **搜索模板(与账户绑定):** 作为开发人员,希望能够将当前筛选条件保存为具名模板并一键复用,以便高频筛选组合无需重复填写。规则摘要:**「保存模板」**位于工具栏「一键生成通报」之后;仅当用户通过工具栏点击 **「筛选确认」** 完成一次查询后按钮可点,保存成功后按钮重新置灰直至再次「筛选确认」。点击保存弹出命名弹窗:须输入非空名称,**确定**在名称为空时不可用;名称与已有模板(trim 后)重复时输入框报错并提示「模板名称已存在!」;**每登录用户最多保存 10 个**模板,超出时提示「模板已达上限,请先删除模板」。在「一键生成通报」同一工具栏区域靠后展示灰色模板条(名称过长省略、可 `Tooltip` 看全名;展示区自适应换行,**至多约两行**高度,超出容器时对名称截断省略);点击模板条主体即将对应条件写入 URL 并触发列表查询;点击条上 **×** 先确认「是否删除该模板?」,确定后删除;若删除的是当前正在用于查询的模板,**不改动当前 URL 与列表状态**。模板数据持久化在服务端表 **`history_search_template`**(字段含工号 `employee_id` 与 JSON 条件快照),经 **`GET/POST/DELETE /api/v1/history/search-templates`** 维护,**与当前登录用户(JWT `sub` 工号)绑定**,跨设备一致。**钻取页**(`/history/case-executions`)应用模板时须 **保留** 钻取锚定的用例名/平台/分支条件,仅合并其余筛选维度。 +- **数据来源:** 主要查询 `pipeline_history` 表;失败跟踪人、失败原因等跨表筛选由后端以 **EXISTS** 实现(禁止大表 JOIN),详见 `spec/07_history_filter_query_spec.md`。搜索模板读写 `history_search_template`(与 `ums_email` 工号关联),DDL 见 `database/V1.1.1__create_history_search_template.sql`。 --- @@ -286,7 +287,8 @@ - **数据源:** `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 详情;用例名可链至钻取页 +- **搜索模板:** 见 Story 1.3「搜索模板」;条件快照与 `HistoryQueryParams` / 后端 `HistoryQuery` 一致,按用户存表 `history_search_template`,每用户上限 10 条。 +- **操作:** 点击行打开 Drawer 详情;用例名可链至钻取页;保存/应用/删除搜索模板见 Story 1.3。 ### 5.5 用例详情交互 (Detail Interaction) diff --git a/docs/04_project_structure.md b/docs/04_project_structure.md index b594935..ee39b9c 100644 --- a/docs/04_project_structure.md +++ b/docs/04_project_structure.md @@ -100,6 +100,15 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | `cases.py` | 用例管理 Schema | 🔲 占位 | | `report.py` | 总结报告 Schema | 🔲 占位 | | `notification.py` | 通知配置 Schema | 🔲 占位 | +| `oh_daily_export.py` | `GET /history/oh-daily-export`:`OhDailyExportResponse`(`export_text` 为 TSV) | ✅ 已实现 | + +### `constants/` — 业务常量(无 I/O) + +表格结构、枚举类定义等放在此包,避免依赖仓库外的 Excel 模板文件。 + +| 文件 | 说明 | +|------|------| +| `oh_daily_export_table.py` | OH「日报数据」导出:**首行表头(组件、Total、Success、Fail、NewFail、通过率)、六类数据行及每类对应的 `main_module` 白名单**;`oh_daily_export_service` 据此拼装 TSV。格式以本文件为唯一权威来源 | ### `api/` — API 路由层 @@ -108,7 +117,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 路由前缀 | 说明 | 实现状态 | |------|---------|------|---------| | `router.py` | `/api/v1` | 总路由注册,将所有子模块路由挂载到 `/api/v1` 下 | ✅ 已实现 | -| `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/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` 子串);`GET /history/batch-report` 轮次通报;`GET /history/oh-daily-export` OH 日报 TSV(格式见 `constants/oh_daily_export_table.py`);`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` | 分组概览接口 | 🔲 占位 | @@ -125,6 +134,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 说明 | 实现状态 | |------|------|---------| | `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) | ✅ 已实现 | +| `oh_daily_export_service.py` | `get_oh_daily_export` — 单批次、`pipeline_history.platform` 白名单(当前为 `oh`)、`constants/oh_daily_export_table` 主模块分组;按用例聚合 total/success/fail、**NewFail**(上一批 B 成功且本批 A 失败)、通过率,拼装 TSV | ✅ 已实现 | | `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 +220,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自 | 文件 | 说明 | 实现状态 | |------|------|---------| -| `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/HistoryPage.tsx` | 详细执行历史页面。Table 展示 pipeline_history 数据(含跟踪人、失败原因列),支持分页与多维度筛选(字符串维度下拉可搜索;有匹配候选项且搜索非空时首行「全部」应用子串筛选,与 URL `*_contains` 同步;`allowClear` 清除该维度 IN 与子串),Drawer 含基本信息区、失败归因区(仅 failed 时展示)、外部链接区;用例名链至钻取页;工具栏含分析处理、继承、一键分析、**一键通知**(spec/13)、一键生成通报、**日报数据**(`GET /history/oh-daily-export`,批次子串搜索弹窗 + TSV 复制);**搜索模板**:「保存模板」在「日报数据」之后,仅「筛选确认」后可保存(`historyApi` 调 `GET/POST/DELETE /history/search-templates`,每用户最多 10 条,交互与校验见 `docs/02_prd.md` Story 1.3);模板条展示于通报按钮同区域后部,点击应用查询、× 删除确认,删除当前所用模板时不改 URL;钻取模式应用模板时保留用例/平台/分支锚定。“已分析”列新增行级「分析」按钮,点击后复用与工具栏「分析处理」相同的弹窗与提交流程;**分析处理**弹窗在失败类型为 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` | 首页大盘 | 🔲 占位 | diff --git a/docs/05_technical_architecture.md b/docs/05_technical_architecture.md index 8b710ad..3f83a77 100644 --- a/docs/05_technical_architecture.md +++ b/docs/05_technical_architecture.md @@ -160,6 +160,7 @@ Page 组件 setState → Ant Design Table 渲染 - 前端 TypeScript 接口与后端 Pydantic Schema 字段名严格一致(snake_case) - 分页统一使用 `PageResponse` 泛型接口 `{ items, total, page, page_size }` - Axios 响应拦截器直接提取 `response.data`,API 方法返回业务数据 +- 详细执行历史 **搜索模板** 经 `GET/POST/DELETE /api/v1/history/search-templates` 存表 `history_search_template`(`employee_id` 与 JWT `sub` 一致);应用模板时前端将快照写入 URL 后仍走 `historyApi.list` 拉数(规约见 `docs/02_prd.md` Story 1.3) --- diff --git a/docs/07_task_breakdown_and_plan.md b/docs/07_task_breakdown_and_plan.md index a372525..dddbc4b 100644 --- a/docs/07_task_breakdown_and_plan.md +++ b/docs/07_task_breakdown_and_plan.md @@ -234,6 +234,7 @@ oh: |------|------| | History 批次筛选 | ✅ 已实现 | | History 字符串维度子串筛选(`*_contains`)与下拉「全部」、子串灰色 Tag | ✅ 已实现(`HistoryStringMultiFilter`、`spec/07` §8) | +| History 搜索模板(`history_search_template`、每用户 10 条、`/history/search-templates`) | ✅ 已实现 | | 失败原因标注(单条/批量) | ✅ 已实现 | | History 行级「分析」快捷入口 | ✅ 已实现(“已分析”列内按钮,复用分析处理弹窗) | | 分析处理「详细原因」历史联想 | ✅ 已实现(基于本地缓存的输入联想) | @@ -269,3 +270,5 @@ oh: | 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` | +| 2026-04-24 | 0.7 | 补充 History **搜索模板** 产品说明:`02_prd` Story 1.3 / §5.4、`01_user_story_map` Story 1.3、`04_project_structure` HistoryPage 行、`05_technical_architecture` §4.2、本节能力表与 `99_ai_project_snapshot`;上限 10 条、钻取锚定保留 | +| 2026-04-24 | 0.8 | 搜索模板改为**后端按账户存储**:迁移 `V1.1.1__create_history_search_template.sql`、`history_search_template` ORM 与 `/history/search-templates` API;前端 `historyApi` + `HistoryPage`;同步 `02_prd` / `04` / `05` / `99` / `01_user_story_map` | diff --git a/docs/99_ai_project_snapshot.md b/docs/99_ai_project_snapshot.md index 48d335b..5336b01 100644 --- a/docs/99_ai_project_snapshot.md +++ b/docs/99_ai_project_snapshot.md @@ -48,6 +48,7 @@ api/v1/ → services/ → schemas/ → models/ ## 实现成熟度地图 - **已非常成熟**:`history` 模块(`HistoryPage.tsx` 与 `HistoryStringMultiFilter.tsx`;主页面约 2100+ 行量级,含多维度筛选含 `*_contains`、一键功能 Drawer/弹窗)、失败标注、失败原因继承、一键分析、一键通知 WeLink、首页大盘、登录认证、DB schema 校验、容器部署。 +- **已实现**:详细执行历史 **搜索模板**(表 `history_search_template`、每用户 10 条、`/api/v1/history/search-templates`,工具栏保存/模板条应用与删除),规约见 `docs/02_prd.md` Story 1.3。 - **仍是占位**:分组概览、用例管理、**总结报告(report_snapshot 表已建未用)**、**通知中心(定时催办、防打扰)**、管理员后台(用户/模块/字典 CRUD 前后端)、**sys_audit_log 审计写入**。 ## 规约(spec 文件位置) diff --git a/frontend/src/pages/history/HistoryPage.tsx b/frontend/src/pages/history/HistoryPage.tsx index 8337b0b..d4472b6 100644 --- a/frontend/src/pages/history/HistoryPage.tsx +++ b/frontend/src/pages/history/HistoryPage.tsx @@ -16,6 +16,7 @@ import { Row, Col, Select, + Space, Spin, Table, Tabs, @@ -23,7 +24,7 @@ import { Tooltip, Typography, } from "antd"; -import { EyeOutlined } from "@ant-design/icons"; +import { CloseOutlined, EyeOutlined } from "@ant-design/icons"; import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; import { HistoryStringMultiFilter } from "./HistoryStringMultiFilter"; import { @@ -38,12 +39,25 @@ import { type InheritSourceOptions, type InheritSourceRecordItem, type BatchReportResponse, + type HistorySearchTemplateItem, } from "../../services"; import AIFailureAnalysisTab from "./components/ai_analysis/AIFailureAnalysisTab"; const { Text, Title, Paragraph } = Typography; const REASON_CACHE_KEY = "history_failure_reason_cache"; const REASON_CACHE_LIMIT = 30; +const MAX_HISTORY_SEARCH_TEMPLATES = 10; + +function extractApiDetail(err: unknown): string { + const ax = err as { response?: { data?: { detail?: unknown } } }; + const d = ax?.response?.data?.detail; + if (typeof d === "string") return d; + if (Array.isArray(d)) { + const first = d[0] as { msg?: string } | undefined; + if (first?.msg) return String(first.msg); + } + return "操作失败"; +} /** 轮次群通告正文(与产品约定模板一致) */ function buildRollingReportMarkdown(data: BatchReportResponse): string { @@ -79,6 +93,19 @@ function buildRollingReportMarkdown(data: BatchReportResponse): string { return lines.join("\n"); } +/** 将后端拼装的日报 TSV 解析为表格行(每行 6 列,与 `oh_daily_export_table` 模板一致) */ +function parseDailyExportTsv(tsv: string): string[][] | null { + const trimmed = tsv.trim(); + if (!trimmed) return null; + const rows = trimmed.split(/\r?\n/).map((line) => line.split("\t")); + if (rows.length < 1) return null; + return rows.map((cells) => { + const next = [...cells]; + while (next.length < 6) next.push(""); + return next.length > 6 ? next.slice(0, 6) : next; + }); +} + /** 钻取页链接(spec/12),新标签打开 */ function caseExecutionsDrilldownHref(record: HistoryItem): string { const qs = new URLSearchParams(); @@ -266,6 +293,14 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const [reportModalVisible, setReportModalVisible] = useState(false); const [reportLoading, setReportLoading] = useState(false); const [reportText, setReportText] = useState(""); + /** 日报数据弹窗:批次子串搜索 + OH 平台 TSV */ + const [dailyExportModalOpen, setDailyExportModalOpen] = useState(false); + const [dailyBatchSearchInput, setDailyBatchSearchInput] = useState(""); + const [dailyFilteredBatches, setDailyFilteredBatches] = useState([]); + const [dailySelectedBatch, setDailySelectedBatch] = useState(null); + const [dailyExportText, setDailyExportText] = useState(""); + const [dailySearchLoading, setDailySearchLoading] = useState(false); + const [dailyExportFetchLoading, setDailyExportFetchLoading] = useState(false); const [inheritBatchOptions, setInheritBatchOptions] = useState([]); const [inheritBatchOptionsLoading, setInheritBatchOptionsLoading] = useState(false); const [inheritSourceOptions, setInheritSourceOptions] = useState({ @@ -278,6 +313,18 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const [inheritSourceRecordsLoading, setInheritSourceRecordsLoading] = useState(false); const inheritMode = Form.useWatch("inherit_mode", inheritForm); + const [searchTemplates, setSearchTemplates] = useState([]); + const [searchTemplatesLoading, setSearchTemplatesLoading] = useState(false); + /** 仅当用户点击「筛选确认」后为 true;保存成功或「筛选重置」后为 false */ + const [canSaveSearchTemplate, setCanSaveSearchTemplate] = useState(false); + const lastToolbarQueryRef = useRef(null); + const [saveSearchTemplateModalOpen, setSaveSearchTemplateModalOpen] = useState(false); + const [saveSearchTemplateName, setSaveSearchTemplateName] = useState(""); + const [saveSearchTemplateNameDuplicate, setSaveSearchTemplateNameDuplicate] = useState(false); + const [saveSearchTemplateSubmitting, setSaveSearchTemplateSubmitting] = useState(false); + /** 当前用于查询的模板 id(删除该模板时不改 URL) */ + const [activeSearchTemplateId, setActiveSearchTemplateId] = useState(null); + /** 中间表格区高度(视口剩余),供 Ant Design Table `scroll.y` 使用 */ const tableAreaRef = useRef(null); const [tableScrollY, setTableScrollY] = useState(300); @@ -420,6 +467,22 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { fetchOptions(); }, []); + const loadSearchTemplates = useCallback(async () => { + setSearchTemplatesLoading(true); + try { + const list = await historyApi.listSearchTemplates(); + setSearchTemplates(list); + } catch { + setSearchTemplates([]); + } finally { + setSearchTemplatesLoading(false); + } + }, []); + + useEffect(() => { + void loadSearchTemplates(); + }, [loadSearchTemplates]); + useEffect(() => { try { const raw = localStorage.getItem(REASON_CACHE_KEY); @@ -525,6 +588,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { }, []); const handleFilterChange = () => { + const prev = paramsFromUrl(); const values = form.getFieldsValue(); const inOrContains = (arr: string[] | undefined, c: string | undefined) => { if (arr?.length) return { list: arr as string[], contains: undefined }; @@ -565,12 +629,18 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { failure_owner_contains: fo.contains, failed_type: ft.list, failed_type_contains: ft.contains, + sort_field: prev.sort_field, + sort_order: prev.sort_order, }; syncParamsToUrl(params); setPagination((p) => ({ ...p, current: 1 })); + lastToolbarQueryRef.current = params; + setCanSaveSearchTemplate(true); }; const handleReset = () => { + setCanSaveSearchTemplate(false); + lastToolbarQueryRef.current = null; if (drilldown && drilldownAnchorRef.current) { syncParamsToUrl({ page: 1, @@ -586,6 +656,97 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { setPagination((p) => ({ ...p, current: 1 })); }; + const openSaveSearchTemplateModal = () => { + if (searchTemplates.length >= MAX_HISTORY_SEARCH_TEMPLATES) { + message.warning("模板已达上限,请先删除模板"); + return; + } + if (!lastToolbarQueryRef.current) return; + setSaveSearchTemplateName(""); + setSaveSearchTemplateNameDuplicate(false); + setSaveSearchTemplateModalOpen(true); + }; + + const handleSaveSearchTemplateModalCancel = () => { + setSaveSearchTemplateModalOpen(false); + setSaveSearchTemplateName(""); + setSaveSearchTemplateNameDuplicate(false); + }; + + const onSaveSearchTemplateNameChange = (v: string) => { + setSaveSearchTemplateName(v); + const t = v.trim(); + if (!t) { + setSaveSearchTemplateNameDuplicate(false); + return; + } + const dup = searchTemplates.some((x) => x.name.trim() === t); + setSaveSearchTemplateNameDuplicate(dup); + }; + + const handleSaveSearchTemplateOk = async () => { + const name = saveSearchTemplateName.trim(); + if (!name || saveSearchTemplateNameDuplicate) return; + const qp = lastToolbarQueryRef.current; + if (!qp) return; + setSaveSearchTemplateSubmitting(true); + try { + await historyApi.createSearchTemplate({ name, query_params: qp }); + message.success("模板已保存"); + setSaveSearchTemplateModalOpen(false); + setSaveSearchTemplateName(""); + setSaveSearchTemplateNameDuplicate(false); + setCanSaveSearchTemplate(false); + lastToolbarQueryRef.current = null; + await loadSearchTemplates(); + } catch (e: unknown) { + const msg = extractApiDetail(e); + if (msg.includes("模板名称已存在")) { + setSaveSearchTemplateNameDuplicate(true); + } else { + message.error(msg); + } + } finally { + setSaveSearchTemplateSubmitting(false); + } + }; + + const applySearchTemplate = (tpl: HistorySearchTemplateItem) => { + const base: HistoryQueryParams = { ...tpl.query_params }; + if (drilldown && drilldownAnchorRef.current) { + base.case_name = drilldownAnchorRef.current.case_name; + base.case_name_contains = drilldownAnchorRef.current.case_name_contains; + base.platform = drilldownAnchorRef.current.platform; + base.code_branch = drilldownAnchorRef.current.code_branch; + } + syncParamsToUrl(base); + setPagination({ + current: base.page ?? 1, + pageSize: base.page_size ?? 20, + }); + setActiveSearchTemplateId(tpl.id); + }; + + const confirmDeleteSearchTemplate = (tpl: HistorySearchTemplateItem) => { + Modal.confirm({ + title: "是否删除该模板?", + okText: "确定", + cancelText: "取消", + onOk: async () => { + try { + await historyApi.deleteSearchTemplate(tpl.id); + message.success("已删除模板"); + if (activeSearchTemplateId === tpl.id) { + setActiveSearchTemplateId(null); + } + await loadSearchTemplates(); + } catch (e: unknown) { + message.error(extractApiDetail(e)); + } + }, + }); + }; + const handleTableChange = ( pag: TablePaginationConfig, _filters: Record, @@ -1009,6 +1170,102 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { }; /** 非 HTTPS 或浏览器限制时 clipboard API 常失败,回退到 execCommand */ + const openDailyExportModal = () => { + setDailyExportModalOpen(true); + setDailyBatchSearchInput(""); + setDailyFilteredBatches([]); + setDailySelectedBatch(null); + setDailyExportText(""); + }; + + const handleDailyExportModalClose = () => { + setDailyExportModalOpen(false); + setDailyBatchSearchInput(""); + setDailyFilteredBatches([]); + setDailySelectedBatch(null); + setDailyExportText(""); + }; + + const handleDailyBatchSearch = async () => { + setDailySearchLoading(true); + try { + let batches: string[] = []; + if (options?.start_time?.length) { + batches = options.start_time; + } else { + const opts = await historyApi.options(); + setOptions(opts); + batches = opts.start_time ?? []; + } + const kw = dailyBatchSearchInput.trim().toLowerCase(); + const filtered = kw + ? batches.filter((b) => (b ?? "").toString().toLowerCase().includes(kw)) + : [...batches]; + setDailyFilteredBatches(filtered); + if (!filtered.length) { + message.info(kw ? "没有匹配的批次" : "暂无批次数据"); + } + } catch (e: unknown) { + message.error(extractApiDetail(e) || "加载批次列表失败"); + setDailyFilteredBatches([]); + } finally { + setDailySearchLoading(false); + } + }; + + const handleDailySelectBatch = async (batch: string) => { + setDailySelectedBatch(batch); + setDailyExportText(""); + setDailyExportFetchLoading(true); + try { + const res = await historyApi.ohDailyExport(batch); + setDailyExportText(res.export_text ?? ""); + } catch (e: unknown) { + const err = e as { response?: { data?: { detail?: string } }; message?: string }; + message.error(err?.response?.data?.detail || err?.message || "生成日报数据失败"); + } finally { + setDailyExportFetchLoading(false); + } + }; + + const handleCopyDailyExport = async () => { + if (!dailyExportText) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(dailyExportText); + message.success("已复制到剪贴板"); + return; + } + } catch { + /* 走下方回退 */ + } + const ta = document.createElement("textarea"); + ta.value = dailyExportText; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.width = "1px"; + ta.style.height = "1px"; + ta.style.opacity = "0"; + ta.style.pointerEvents = "none"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + ta.setSelectionRange(0, dailyExportText.length); + let ok = false; + try { + ok = document.execCommand("copy"); + } finally { + document.body.removeChild(ta); + } + if (ok) { + message.success("已复制到剪贴板"); + } else { + message.error("复制失败,请手动选择文本复制"); + } + }; + const handleCopyReport = async () => { if (!reportText) return; try { @@ -1588,9 +1845,80 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { 一键生成通报 + + + + + + + + +
+ {searchTemplates.map((tpl) => ( +
+ + + + +
+ ))} +
+
+ + void handleSaveSearchTemplateOk()} + onCancel={handleSaveSearchTemplateModalCancel} + confirmLoading={saveSearchTemplateSubmitting} + okText="确定" + cancelText="取消" + okButtonProps={{ + disabled: + !saveSearchTemplateName.trim() || saveSearchTemplateNameDuplicate || saveSearchTemplateSubmitting, + }} + destroyOnClose + > +
请输入模板名称
+ {saveSearchTemplateNameDuplicate ? ( +
模板名称已存在!
+ ) : null} + onSaveSearchTemplateNameChange(e.target.value)} + maxLength={100} + status={saveSearchTemplateNameDuplicate ? "error" : undefined} + /> +
+
rowKey="id" @@ -1812,6 +2140,122 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { + void handleCopyDailyExport()} + disabled={dailyExportFetchLoading || !dailyExportText} + > + 复制全文 + , + , + ]} + destroyOnClose + > + + 批次与页面「批次」筛选项同源;输入关键字后点「搜索」为子串匹配(不区分大小写);留空搜索展示全部批次。点击某批次生成 + OH 平台(platform=oh)日报;下方为表格预览(样式接近 Excel),请用「复制全文」粘贴到 + Excel(保留制表符分列)。 + + + setDailyBatchSearchInput(e.target.value)} + onPressEnter={() => void handleDailyBatchSearch()} + allowClear + /> + + +
+ {dailyFilteredBatches.length === 0 ? ( + + {dailySearchLoading ? "加载中…" : "点击「搜索」加载批次列表,或调整关键字后再搜"} + + ) : ( + dailyFilteredBatches.map((b) => ( +
void handleDailySelectBatch(b)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void handleDailySelectBatch(b); + } + }} + style={{ + padding: "6px 8px", + cursor: "pointer", + borderRadius: 4, + marginBottom: 4, + background: dailySelectedBatch === b ? "#e6f7ff" : "transparent", + }} + > + {b} +
+ )) + )} +
+ + {(() => { + if (dailyExportFetchLoading) { + return ( +
+ 正在生成日报数据… +
+ ); + } + const rows = parseDailyExportTsv(dailyExportText); + if (!rows?.length) { + return ( +
+ + {dailySelectedBatch ? "该批次暂无导出内容或加载失败" : "请先搜索并点击上方批次"} + +
+ ); + } + return ( +
+ + + {rows.map((cells, ri) => ( + + {cells.map((cell, ci) => ( + + ))} + + ))} + +
{cell}
+
+ ); + })()} +
+
+ { + return request.get("/history/oh-daily-export", { + params: { start_time: startTime }, + }) as any; + }, + /** 当前用户的历史页搜索模板列表 */ + listSearchTemplates(): Promise { + return request.get("/history/search-templates") as any; + }, + /** 保存搜索模板(每用户最多 10 条) */ + createSearchTemplate(data: { + name: string; + query_params: HistoryQueryParams; + }): Promise { + return request.post("/history/search-templates", data) as any; + }, + /** 删除搜索模板 */ + deleteSearchTemplate(templateId: number): Promise<{ success: boolean; message: string }> { + return request.delete(`/history/search-templates/${templateId}`) as any; + }, }; // --- Dashboard API ---