diff --git a/backend/api/v1/overview.py b/backend/api/v1/overview.py
index 220396d..5efd3d3 100644
--- a/backend/api/v1/overview.py
+++ b/backend/api/v1/overview.py
@@ -1,8 +1,76 @@
-from fastapi import APIRouter
+from typing import List, Optional
-router = APIRouter(prefix="/overview", tags=["分组概览"])
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import ValidationError
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.core.database import get_db
+from backend.schemas.common import PageResponse
+from backend.schemas.overview import OverviewFilterOptions, OverviewItem, OverviewQuery
+from backend.services.overview_service import get_overview_options, list_overview
-@router.get("")
-async def list_overview():
- return {"message": "TODO"}
+router = APIRouter(prefix="/overview", tags=["分组执行历史"])
+
+
+def _nonempty_subtask(subtask: Optional[List[str]]) -> bool:
+ if not subtask:
+ return False
+ for s in subtask:
+ if s is not None and str(s).strip():
+ return True
+ return False
+
+
+@router.get("/options", response_model=OverviewFilterOptions)
+async def get_overview_options_endpoint(db: AsyncSession = Depends(get_db)):
+ """分组执行历史筛选项(单表去重)。"""
+ return await get_overview_options(db)
+
+
+@router.get("", response_model=PageResponse[OverviewItem])
+async def get_overview_list(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ batch: Optional[List[str]] = Query(None),
+ subtask: Optional[List[str]] = Query(None),
+ platform: Optional[List[str]] = Query(None),
+ code_branch: Optional[List[str]] = Query(None),
+ result: Optional[List[str]] = Query(None),
+ sort_field: Optional[str] = Query(None),
+ sort_order: Optional[str] = Query(None),
+ all_batches: bool = Query(False, description="为 true 时不注入默认最近30批;须配合 subtask"),
+ db: AsyncSession = Depends(get_db),
+):
+ if all_batches and not _nonempty_subtask(subtask):
+ raise HTTPException(
+ status_code=422,
+ detail="全部分组跨轮次查询时必须指定分组(subtask)",
+ )
+ try:
+ query = OverviewQuery(
+ page=page,
+ page_size=page_size,
+ batch=batch,
+ subtask=subtask,
+ platform=platform,
+ code_branch=code_branch,
+ result=result,
+ sort_field=sort_field,
+ sort_order=sort_order,
+ all_batches=all_batches,
+ )
+ except ValidationError as e:
+ parts = [str(err.get("msg", "")) for err in e.errors()]
+ raise HTTPException(
+ status_code=422,
+ detail="; ".join(p for p in parts if p) or "参数校验失败",
+ ) from e
+
+ rows, total = await list_overview(db, query)
+ items = [OverviewItem.model_validate(r) for r in rows]
+ return PageResponse(
+ items=items,
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
diff --git a/backend/schemas/overview.py b/backend/schemas/overview.py
index aa2d733..c60b069 100644
--- a/backend/schemas/overview.py
+++ b/backend/schemas/overview.py
@@ -1,5 +1,59 @@
-from pydantic import BaseModel
+from datetime import datetime
+from typing import List, Optional
+from pydantic import BaseModel, field_validator
-class OverviewListResponse(BaseModel):
- pass
+from backend.schemas.common import PageRequest
+
+OVERVIEW_RESULT_ALLOWED = frozenset({"passed", "failed"})
+
+
+class OverviewItem(BaseModel):
+ id: int
+ batch: Optional[str] = None
+ subtask: Optional[str] = None
+ result: Optional[str] = None
+ case_num: Optional[str] = None
+ batch_start: Optional[datetime] = None
+ batch_end: Optional[datetime] = None
+ reports_url: Optional[str] = None
+ log_url: Optional[str] = None
+ screenshot_url: Optional[str] = None
+ pipeline_url: Optional[str] = None
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+ passed_num: Optional[int] = None
+ failed_num: Optional[int] = None
+ platform: Optional[str] = None
+ code_branch: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+
+class OverviewQuery(PageRequest):
+ batch: Optional[List[str]] = None
+ subtask: Optional[List[str]] = None
+ platform: Optional[List[str]] = None
+ code_branch: Optional[List[str]] = None
+ result: Optional[List[str]] = None
+ sort_field: Optional[str] = None
+ sort_order: Optional[str] = None
+ all_batches: bool = False
+
+ @field_validator("result", mode="before")
+ @classmethod
+ def validate_result_values(cls, v: Optional[List[str]]) -> Optional[List[str]]:
+ if not v:
+ return v
+ for item in v:
+ if item is not None and str(item) not in OVERVIEW_RESULT_ALLOWED:
+ raise ValueError("result 仅允许 passed、failed")
+ return v
+
+
+class OverviewFilterOptions(BaseModel):
+ batch: List[str] = []
+ subtask: List[str] = []
+ platform: List[str] = []
+ code_branch: List[str] = []
+ result: List[str] = ["passed", "failed"]
diff --git a/backend/services/overview_service.py b/backend/services/overview_service.py
index db6586f..0bd3c2e 100644
--- a/backend/services/overview_service.py
+++ b/backend/services/overview_service.py
@@ -1,2 +1,116 @@
-class OverviewService:
- pass
+from typing import List, Optional, Tuple
+
+from sqlalchemy import cast, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.types import Integer
+
+from backend.models.pipeline_overview import PipelineOverview
+from backend.schemas.overview import OverviewFilterOptions, OverviewQuery
+
+po = PipelineOverview
+
+DEFAULT_OVERVIEW_BATCH_LIMIT = 30
+
+ALLOWED_SORT_FIELDS = {
+ "batch",
+ "subtask",
+ "result",
+ "case_num",
+ "batch_start",
+ "batch_end",
+ "passed_num",
+ "failed_num",
+ "platform",
+ "code_branch",
+ "created_at",
+}
+
+
+async def list_overview(
+ db: AsyncSession, query: OverviewQuery
+) -> Tuple[List[PipelineOverview], int]:
+ """
+ 分组执行历史列表:单表 pipeline_overview。
+ 未选 batch 且非 all_batches 模式时注入最近 30 个不重复 batch(spec/14,与 History 批次数一致)。
+ """
+ eff = query
+ if not query.all_batches and not query.batch:
+ default_batches_stmt = (
+ select(po.batch)
+ .where(po.batch.is_not(None))
+ .where(po.batch.like("20%"))
+ .distinct()
+ .order_by(po.batch.desc())
+ .limit(DEFAULT_OVERVIEW_BATCH_LIMIT)
+ )
+ default_result = await db.execute(default_batches_stmt)
+ default_batches = [r[0] for r in default_result.all() if r[0]]
+ if default_batches:
+ eff = query.model_copy(update={"batch": default_batches})
+ else:
+ return [], 0
+
+ stmt = select(po)
+ if eff.batch:
+ stmt = stmt.where(po.batch.in_(eff.batch))
+ if eff.subtask:
+ stmt = stmt.where(po.subtask.in_(eff.subtask))
+ if eff.platform:
+ stmt = stmt.where(po.platform.in_(eff.platform))
+ if eff.code_branch:
+ stmt = stmt.where(po.code_branch.in_(eff.code_branch))
+ if eff.result:
+ stmt = stmt.where(po.result.in_(eff.result))
+
+ count_stmt = select(func.count()).select_from(stmt.subquery())
+ total = (await db.execute(count_stmt)).scalar() or 0
+
+ sort_field = eff.sort_field
+ sort_order = (eff.sort_order or "").lower()
+ if (
+ sort_field
+ and sort_field in ALLOWED_SORT_FIELDS
+ and sort_order in ("asc", "desc")
+ ):
+ if sort_field == "case_num":
+ sort_col = cast(po.case_num, Integer)
+ else:
+ sort_col = getattr(po, sort_field)
+ if sort_order == "asc":
+ stmt = stmt.order_by(sort_col.asc())
+ else:
+ stmt = stmt.order_by(sort_col.desc())
+ else:
+ stmt = stmt.order_by(po.batch.desc(), po.subtask.asc())
+
+ stmt = stmt.offset((eff.page - 1) * eff.page_size).limit(eff.page_size)
+ result = await db.execute(stmt)
+ rows = result.scalars().all()
+ return rows, total
+
+
+async def get_overview_options(db: AsyncSession) -> OverviewFilterOptions:
+ async def _distinct(column, desc: bool = False, prefix: Optional[str] = None) -> List[str]:
+ s = (
+ select(column)
+ .where(column.is_not(None))
+ .where(column != "")
+ )
+ if prefix is not None:
+ s = s.where(column.like(prefix + "%"))
+ s = s.distinct().order_by(column.desc() if desc else column.asc())
+ r = await db.execute(s)
+ return [row[0] for row in r.all() if row[0]]
+
+ batch = await _distinct(po.batch, desc=True, prefix="20")
+ subtask = await _distinct(po.subtask)
+ platform = await _distinct(po.platform)
+ code_branch = await _distinct(po.code_branch)
+
+ return OverviewFilterOptions(
+ batch=batch,
+ subtask=subtask,
+ platform=platform,
+ code_branch=code_branch,
+ result=["passed", "failed"],
+ )
diff --git a/frontend/src/pages/overview/OverviewPage.tsx b/frontend/src/pages/overview/OverviewPage.tsx
index 69f8ce1..2637352 100644
--- a/frontend/src/pages/overview/OverviewPage.tsx
+++ b/frontend/src/pages/overview/OverviewPage.tsx
@@ -1,3 +1,780 @@
-export default function OverviewPage() {
- return
分组执行历史
;
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import { Resizable } from "react-resizable";
+import "react-resizable/css/styles.css";
+import "../history/history-table.css";
+import {
+ Alert,
+ Button,
+ Col,
+ Form,
+ Modal,
+ Radio,
+ Row,
+ Select,
+ Table,
+ Tag,
+ Typography,
+ message,
+} from "antd";
+import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
+import {
+ overviewApi,
+ type OverviewItem,
+ type OverviewFilterOptions,
+ type OverviewQueryParams,
+} from "../../services";
+
+const { Text } = Typography;
+
+export type OverviewPageVariant = "default" | "subtask-all-batches";
+
+function openInNewTab(href: string) {
+ const a = document.createElement("a");
+ a.href = href;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+}
+
+function UrlLink({
+ url,
+ label,
+}: {
+ url: string | null | undefined;
+ label: string;
+}) {
+ if (url) {
+ const href =
+ url.startsWith("http://") || url.startsWith("https://") ? url : `https://${url}`;
+ return (
+ e.stopPropagation()}>
+ {label}
+
+ );
+ }
+ return 暂无;
+}
+
+function fmtDt(s: string | null | undefined): string {
+ if (!s) return "—";
+ return String(s).replace("T", " ").slice(0, 19);
+}
+
+function historyBatchHref(batch: string | null | undefined): string {
+ const q = new URLSearchParams();
+ if (batch) q.append("start_time", batch);
+ return `/history?${q.toString()}`;
+}
+
+function ResizableTitle(
+ props: React.HTMLAttributes & {
+ onResize?: (e: React.SyntheticEvent, data: { size: { width: number; height: number } }) => void;
+ width?: number;
+ },
+) {
+ const { onResize, width, ...restProps } = props;
+ if (!width || !onResize) return | ;
+ return (
+ e.stopPropagation()}
+ />
+ }
+ onResize={onResize}
+ draggableOpts={{ enableUserSelectHack: false }}
+ >
+ |
+
+ );
+}
+
+const DEFAULT_WIDTHS: Record = {
+ batch: 120,
+ subtask: 120,
+ result: 100,
+ case_num: 90,
+ passed_num: 80,
+ failed_num: 80,
+ batch_start: 160,
+ batch_end: 160,
+ platform: 100,
+ code_branch: 110,
+ reports_url: 88,
+ log_url: 72,
+ pipeline_url: 88,
+ created_at: 160,
+};
+
+const SORTABLE: Record = {
+ batch: true,
+ subtask: true,
+ result: true,
+ case_num: true,
+ batch_start: true,
+ batch_end: true,
+ passed_num: true,
+ failed_num: true,
+ platform: true,
+ code_branch: true,
+ created_at: true,
+};
+
+/** 与 History 一致:表头 + 底部分页(含每页条数)预留高度,用于计算 Table `scroll.y` */
+const OVERVIEW_TABLE_SCROLL_RESERVE_PX = 118;
+
+export default function OverviewPage({
+ variant = "default",
+}: {
+ variant?: OverviewPageVariant;
+}) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [form] = Form.useForm();
+ const [data, setData] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [options, setOptions] = useState(null);
+ const [optionsLoading, setOptionsLoading] = useState(false);
+ const [colWidths, setColWidths] = useState>(DEFAULT_WIDTHS);
+ const [subtaskModalRow, setSubtaskModalRow] = useState(null);
+ const [subtaskChoice, setSubtaskChoice] = useState<"all-batches" | "same-batch">(
+ "all-batches",
+ );
+ const subtaskInvalidWarnedRef = useRef(false);
+ /** 表格区域高度(与 history-table.css 中 flex 布局配合,必须设置 scroll.y 否则表体无限增高、分页被 overflow:hidden 裁掉) */
+ const tableAreaRef = useRef(null);
+ const [tableScrollY, setTableScrollY] = useState(300);
+
+ const lockedSubtask = variant === "subtask-all-batches" ? searchParams.get("subtask") : null;
+ const lockedSubtaskTrimmed = lockedSubtask?.trim() || null;
+
+ const paramsFromUrl = useCallback((): OverviewQueryParams => {
+ const getList = (key: string) => {
+ const vals = searchParams.getAll(key);
+ return vals.length > 0 ? vals : undefined;
+ };
+ const base: OverviewQueryParams = {
+ page: searchParams.get("page") ? parseInt(searchParams.get("page")!, 10) : 1,
+ page_size: searchParams.get("page_size")
+ ? parseInt(searchParams.get("page_size")!, 10)
+ : 20,
+ batch: getList("batch"),
+ subtask: getList("subtask"),
+ platform: getList("platform"),
+ code_branch: getList("code_branch"),
+ result: getList("result"),
+ sort_field: searchParams.get("sort_field") || undefined,
+ sort_order: searchParams.get("sort_order") || undefined,
+ };
+ if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) {
+ return {
+ ...base,
+ subtask: [lockedSubtaskTrimmed],
+ all_batches: true,
+ };
+ }
+ return base;
+ }, [searchParams, variant, lockedSubtaskTrimmed]);
+
+ const syncParamsToUrl = useCallback(
+ (params: OverviewQueryParams) => {
+ const next = new URLSearchParams();
+ if (params.page && params.page > 1) next.set("page", String(params.page));
+ if (params.page_size && params.page_size !== 20)
+ next.set("page_size", String(params.page_size));
+ const appendList = (key: string, vals?: string[]) => {
+ if (vals?.length) vals.forEach((v) => next.append(key, v));
+ };
+ if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) {
+ next.set("subtask", lockedSubtaskTrimmed);
+ } else {
+ appendList("subtask", params.subtask);
+ }
+ appendList("batch", params.batch);
+ appendList("platform", params.platform);
+ appendList("code_branch", params.code_branch);
+ appendList("result", params.result);
+ 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 });
+ },
+ [setSearchParams, variant, lockedSubtaskTrimmed],
+ );
+
+ const fetchData = async (params: OverviewQueryParams) => {
+ setLoading(true);
+ try {
+ const res = await overviewApi.list(params);
+ setData(res.items);
+ setTotal(res.total);
+ } catch (e: unknown) {
+ const err = e as {
+ code?: string;
+ message?: string;
+ response?: { status?: number; data?: { detail?: string } };
+ };
+ if (err.code === "ECONNABORTED" || err.message?.toLowerCase().includes("timeout")) {
+ message.error("请求超时,请缩小筛选范围(如选择批次或平台)后重试");
+ } else if (err.response?.status && err.response.status >= 500) {
+ message.error("服务异常,请稍后重试");
+ } else {
+ const detail = err.response?.data?.detail;
+ message.error(typeof detail === "string" && detail ? detail : "加载失败");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchOptions = async () => {
+ setOptionsLoading(true);
+ try {
+ const opts = await overviewApi.options();
+ setOptions(opts);
+ } finally {
+ setOptionsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchOptions();
+ }, []);
+
+ useEffect(() => {
+ const el = tableAreaRef.current;
+ if (!el) return;
+ const update = () => {
+ const h = el.getBoundingClientRect().height;
+ setTableScrollY(Math.max(120, Math.floor(h - OVERVIEW_TABLE_SCROLL_RESERVE_PX)));
+ };
+ update();
+ const ro = new ResizeObserver(() => update());
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (variant !== "subtask-all-batches") {
+ subtaskInvalidWarnedRef.current = false;
+ return;
+ }
+ if (lockedSubtaskTrimmed) {
+ subtaskInvalidWarnedRef.current = false;
+ } else if (!subtaskInvalidWarnedRef.current) {
+ subtaskInvalidWarnedRef.current = true;
+ message.error("链接无效:缺少分组参数 subtask");
+ }
+ }, [variant, lockedSubtaskTrimmed]);
+
+ useEffect(() => {
+ const params = paramsFromUrl();
+ if (variant === "subtask-all-batches" && !lockedSubtaskTrimmed) {
+ form.setFieldsValue({
+ batch: undefined,
+ platform: undefined,
+ code_branch: undefined,
+ result: undefined,
+ });
+ setData([]);
+ setTotal(0);
+ return;
+ }
+ form.setFieldsValue({
+ batch: params.batch,
+ subtask: variant === "default" ? params.subtask : undefined,
+ platform: params.platform,
+ code_branch: params.code_branch,
+ result: params.result,
+ });
+ }, [searchParams, variant, lockedSubtaskTrimmed, form, paramsFromUrl]);
+
+ useEffect(() => {
+ const params = paramsFromUrl();
+ if (variant === "subtask-all-batches" && !lockedSubtaskTrimmed) {
+ return;
+ }
+ fetchData({
+ ...params,
+ page: params.page ?? 1,
+ page_size: params.page_size ?? 20,
+ });
+ }, [searchParams, variant, lockedSubtaskTrimmed, paramsFromUrl]);
+
+ const handleFilterChange = () => {
+ const values = form.getFieldsValue();
+ const cur = paramsFromUrl();
+ const params: OverviewQueryParams = {
+ page: 1,
+ page_size: cur.page_size ?? 20,
+ batch: values.batch?.length ? values.batch : undefined,
+ subtask:
+ variant === "default" && values.subtask?.length ? values.subtask : undefined,
+ platform: values.platform?.length ? values.platform : undefined,
+ code_branch: values.code_branch?.length ? values.code_branch : undefined,
+ result: values.result?.length ? values.result : undefined,
+ sort_field: cur.sort_field,
+ sort_order: cur.sort_order,
+ };
+ if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) {
+ params.subtask = [lockedSubtaskTrimmed];
+ params.all_batches = true;
+ }
+ syncParamsToUrl(params);
+ };
+
+ const handleReset = () => {
+ const cur = paramsFromUrl();
+ if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) {
+ syncParamsToUrl({
+ page: 1,
+ page_size: cur.page_size ?? 20,
+ subtask: [lockedSubtaskTrimmed],
+ all_batches: true,
+ });
+ } else {
+ syncParamsToUrl({ page: 1, page_size: cur.page_size ?? 20 });
+ }
+ };
+
+ const handleTableChange = (
+ pag: TablePaginationConfig,
+ _filters: Record,
+ sorter: unknown,
+ ) => {
+ const nextPage = pag.current ?? 1;
+ const nextSize = pag.pageSize ?? 20;
+ const params = paramsFromUrl();
+ const sort = Array.isArray(sorter)
+ ? (sorter as { field?: string; order?: string }[])[0]
+ : (sorter as { field?: string; order?: string });
+ const sortField = (typeof sort?.field === "string" ? sort.field : undefined) || undefined;
+ const sortOrder =
+ sort?.order === "ascend" ? "asc" : sort?.order === "descend" ? "desc" : undefined;
+ const sortChanged = sortField !== params.sort_field || sortOrder !== params.sort_order;
+ const pageToUse = sortChanged ? 1 : nextPage;
+ const nextParams: OverviewQueryParams = {
+ ...params,
+ page: pageToUse,
+ page_size: nextSize,
+ sort_field: sortField,
+ sort_order: sortOrder,
+ };
+ if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) {
+ nextParams.subtask = [lockedSubtaskTrimmed];
+ nextParams.all_batches = true;
+ }
+ syncParamsToUrl(nextParams);
+ };
+
+ const handleResize = useCallback(
+ (key: string) =>
+ (_: React.SyntheticEvent, data: { size: { width: number; height: number } }) => {
+ setColWidths((prev) => ({ ...prev, [key]: data.size.width }));
+ },
+ [],
+ );
+
+ const urlQuery = paramsFromUrl();
+ const pageCurrent = urlQuery.page ?? 1;
+ const pageSize = urlQuery.page_size ?? 20;
+
+ const sortField = searchParams.get("sort_field") || undefined;
+ const sortOrderUrl =
+ searchParams.get("sort_order") === "asc"
+ ? "ascend"
+ : searchParams.get("sort_order") === "desc"
+ ? "descend"
+ : undefined;
+ const sortOrderFor = (field: string) => (field === sortField ? sortOrderUrl : undefined);
+
+ const confirmSubtaskModal = () => {
+ if (!subtaskModalRow) return;
+ const st = subtaskModalRow.subtask ?? "";
+ const bt = subtaskModalRow.batch ?? "";
+ if (subtaskChoice === "all-batches") {
+ const q = new URLSearchParams();
+ q.set("subtask", st);
+ openInNewTab(`${window.location.origin}/overview/subtask-executions?${q.toString()}`);
+ } else {
+ const q = new URLSearchParams();
+ if (bt) q.append("start_time", bt);
+ if (st) q.append("subtask", st);
+ openInNewTab(`${window.location.origin}/history?${q.toString()}`);
+ }
+ setSubtaskModalRow(null);
+ };
+
+ const columns: ColumnsType = useMemo(
+ () => [
+ {
+ title: "批次",
+ dataIndex: "batch",
+ width: colWidths.batch,
+ ellipsis: true,
+ sorter: SORTABLE.batch,
+ sortOrder: sortOrderFor("batch"),
+ render: (val: string | null) =>
+ val ? (
+ e.stopPropagation()}
+ >
+ {val}
+
+ ) : (
+ "—"
+ ),
+ onHeaderCell: () => ({
+ width: colWidths.batch,
+ onResize: handleResize("batch"),
+ }),
+ },
+ {
+ title: "分组",
+ dataIndex: "subtask",
+ width: colWidths.subtask,
+ ellipsis: true,
+ sorter: SORTABLE.subtask,
+ sortOrder: sortOrderFor("subtask"),
+ render: (val: string | null, record: OverviewItem) =>
+ val ? (
+
+ ) : (
+ "—"
+ ),
+ onHeaderCell: () => ({
+ width: colWidths.subtask,
+ onResize: handleResize("subtask"),
+ }),
+ },
+ {
+ title: "执行结果",
+ dataIndex: "result",
+ width: colWidths.result,
+ sorter: SORTABLE.result,
+ sortOrder: sortOrderFor("result"),
+ render: (val: string | null) => {
+ if (!val) return "—";
+ const color = val === "passed" ? "green" : val === "failed" ? "red" : "default";
+ return {val};
+ },
+ onHeaderCell: () => ({
+ width: colWidths.result,
+ onResize: handleResize("result"),
+ }),
+ },
+ {
+ title: "总用例数",
+ dataIndex: "case_num",
+ width: colWidths.case_num,
+ sorter: SORTABLE.case_num,
+ sortOrder: sortOrderFor("case_num"),
+ onHeaderCell: () => ({
+ width: colWidths.case_num,
+ onResize: handleResize("case_num"),
+ }),
+ },
+ {
+ title: "通过数",
+ dataIndex: "passed_num",
+ width: colWidths.passed_num,
+ sorter: SORTABLE.passed_num,
+ sortOrder: sortOrderFor("passed_num"),
+ onHeaderCell: () => ({
+ width: colWidths.passed_num,
+ onResize: handleResize("passed_num"),
+ }),
+ },
+ {
+ title: "失败数",
+ dataIndex: "failed_num",
+ width: colWidths.failed_num,
+ sorter: SORTABLE.failed_num,
+ sortOrder: sortOrderFor("failed_num"),
+ onHeaderCell: () => ({
+ width: colWidths.failed_num,
+ onResize: handleResize("failed_num"),
+ }),
+ },
+ {
+ title: "开始时间",
+ dataIndex: "batch_start",
+ width: colWidths.batch_start,
+ sorter: SORTABLE.batch_start,
+ sortOrder: sortOrderFor("batch_start"),
+ render: (v) => fmtDt(v),
+ onHeaderCell: () => ({
+ width: colWidths.batch_start,
+ onResize: handleResize("batch_start"),
+ }),
+ },
+ {
+ title: "结束时间",
+ dataIndex: "batch_end",
+ width: colWidths.batch_end,
+ sorter: SORTABLE.batch_end,
+ sortOrder: sortOrderFor("batch_end"),
+ render: (v) => fmtDt(v),
+ onHeaderCell: () => ({
+ width: colWidths.batch_end,
+ onResize: handleResize("batch_end"),
+ }),
+ },
+ {
+ title: "平台",
+ dataIndex: "platform",
+ width: colWidths.platform,
+ ellipsis: true,
+ sorter: SORTABLE.platform,
+ sortOrder: sortOrderFor("platform"),
+ onHeaderCell: () => ({
+ width: colWidths.platform,
+ onResize: handleResize("platform"),
+ }),
+ },
+ {
+ title: "代码分支",
+ dataIndex: "code_branch",
+ width: colWidths.code_branch,
+ ellipsis: true,
+ sorter: SORTABLE.code_branch,
+ sortOrder: sortOrderFor("code_branch"),
+ onHeaderCell: () => ({
+ width: colWidths.code_branch,
+ onResize: handleResize("code_branch"),
+ }),
+ },
+ {
+ title: "测试报告",
+ dataIndex: "reports_url",
+ width: colWidths.reports_url,
+ render: (v: string | null) => ,
+ onHeaderCell: () => ({
+ width: colWidths.reports_url,
+ onResize: handleResize("reports_url"),
+ }),
+ },
+ {
+ title: "日志",
+ dataIndex: "log_url",
+ width: colWidths.log_url,
+ render: (v: string | null) => ,
+ onHeaderCell: () => ({
+ width: colWidths.log_url,
+ onResize: handleResize("log_url"),
+ }),
+ },
+ {
+ title: "流水线",
+ dataIndex: "pipeline_url",
+ width: colWidths.pipeline_url,
+ render: (v: string | null) => ,
+ onHeaderCell: () => ({
+ width: colWidths.pipeline_url,
+ onResize: handleResize("pipeline_url"),
+ }),
+ },
+ {
+ title: "创建时间",
+ dataIndex: "created_at",
+ width: colWidths.created_at,
+ sorter: SORTABLE.created_at,
+ sortOrder: sortOrderFor("created_at"),
+ render: (v) => fmtDt(v),
+ onHeaderCell: () => ({
+ width: colWidths.created_at,
+ onResize: handleResize("created_at"),
+ }),
+ },
+ ],
+ [colWidths, sortField, sortOrderUrl, handleResize],
+ );
+
+ const totalWidth = useMemo(
+ () => Object.values(colWidths).reduce((a, b) => a + b, 0),
+ [colWidths],
+ );
+
+ return (
+
+ {variant === "subtask-all-batches" && lockedSubtaskTrimmed && (
+
+ )}
+
+
+
+
+ rowKey="id"
+ loading={loading}
+ columns={columns}
+ dataSource={data}
+ size="small"
+ components={{
+ header: {
+ cell: ResizableTitle,
+ },
+ }}
+ pagination={{
+ current: pageCurrent,
+ pageSize,
+ total,
+ showSizeChanger: true,
+ showTotal: (t) => `共 ${t} 条`,
+ disabled: loading,
+ }}
+ onChange={handleTableChange}
+ scroll={{ x: totalWidth, y: tableScrollY }}
+ />
+
+
+ setSubtaskModalRow(null)}
+ okText="打开"
+ cancelText="取消"
+ destroyOnClose
+ >
+ 请选择如何查看分组「{subtaskModalRow?.subtask ?? ""}」:
+ setSubtaskChoice(e.target.value)}
+ >
+ 跨全部轮次(分组执行历史,不限制最近 30 批)
+ 当前批次下的用例明细(详细执行历史)
+
+
+
+ );
}
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx
index 1b67d0a..e1c8bdc 100644
--- a/frontend/src/routes/index.tsx
+++ b/frontend/src/routes/index.tsx
@@ -49,6 +49,10 @@ export default function AppRoutes() {
children: [
{ index: true, element: },
{ path: "overview", element: },
+ {
+ path: "overview/subtask-executions",
+ element: ,
+ },
{ path: "history", element: },
{ path: "history/case-executions", element: },
{
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
index 0ebfc7d..520765e 100644
--- a/frontend/src/services/index.ts
+++ b/frontend/src/services/index.ts
@@ -311,3 +311,75 @@ export const dashboardApi = {
params: { limit, code_branch: codeBranch },
}) as Promise<{ items: BatchTrendItem[] }>,
};
+
+// --- Overview(分组执行历史,pipeline_overview,spec/14)---
+
+export interface OverviewItem {
+ id: number;
+ batch: string | null;
+ subtask: string | null;
+ result: string | null;
+ case_num: string | null;
+ batch_start: string | null;
+ batch_end: string | null;
+ reports_url: string | null;
+ log_url: string | null;
+ screenshot_url: string | null;
+ pipeline_url: string | null;
+ created_at: string | null;
+ updated_at: string | null;
+ passed_num: number | null;
+ failed_num: number | null;
+ platform: string | null;
+ code_branch: string | null;
+}
+
+export interface OverviewQueryParams {
+ page?: number;
+ page_size?: number;
+ batch?: string[];
+ subtask?: string[];
+ platform?: string[];
+ code_branch?: string[];
+ result?: string[];
+ sort_field?: string;
+ sort_order?: string;
+ /** 为 true 时不注入默认最近 30 批;须带 subtask(专用分组页) */
+ all_batches?: boolean;
+}
+
+export interface OverviewFilterOptions {
+ batch: string[];
+ subtask: string[];
+ platform: string[];
+ code_branch: string[];
+ result: string[];
+}
+
+function overviewToSearchParams(params?: OverviewQueryParams): URLSearchParams {
+ const p = new URLSearchParams();
+ if (!params) return p;
+ p.set("page", String(params.page ?? 1));
+ p.set("page_size", String(params.page_size ?? 20));
+ const appendList = (key: string, vals?: string[]) => {
+ if (vals?.length) vals.forEach((v) => p.append(key, v));
+ };
+ appendList("batch", params.batch);
+ appendList("subtask", params.subtask);
+ appendList("platform", params.platform);
+ appendList("code_branch", params.code_branch);
+ appendList("result", params.result);
+ if (params.sort_field) p.set("sort_field", params.sort_field);
+ if (params.sort_order) p.set("sort_order", params.sort_order);
+ if (params.all_batches) p.set("all_batches", "true");
+ return p;
+}
+
+export const overviewApi = {
+ list(params?: OverviewQueryParams): Promise> {
+ return request.get("/overview", { params: overviewToSearchParams(params) }) as any;
+ },
+ options(): Promise {
+ return request.get("/overview/options") as any;
+ },
+};
diff --git a/spec/08_history_filter_performance_spec.md b/spec/08_history_filter_performance_spec.md
index 396844e..64f30c7 100644
--- a/spec/08_history_filter_performance_spec.md
+++ b/spec/08_history_filter_performance_spec.md
@@ -77,6 +77,8 @@
**不满足上述条件时**:仍按 §3.1,在未选 `start_time` 时注入最近 N 批。
+**关联规约**:分组执行历史(`pipeline_overview`,`GET /api/v1/overview`)在未选批次时的默认轮次注入与 §3.1 **思路相同**(`LIKE '20%'`、`ORDER BY` 降序取最近 N 个不重复轮次),主表为 `pipeline_overview`、字段为 **`batch`**,**`N = 30`**(与 History 的 `start_time` 批次数一致)。详见 [spec/14_overview_group_history_spec.md](14_overview_group_history_spec.md)。
+
### 3.2 前端:批次筛选 placeholder 提示
**修改位置**:`HistoryPage.tsx` 中批次(start_time)的 `Select` 组件
@@ -161,3 +163,5 @@ flowchart TD
| 1.0 | 2025-03 | 初版:问题分析、设计约束、实现规格、验收标准 |
| 1.1 | 2025-03 | 扩展注入规则:未选 start_time 即注入;新增批次 placeholder 提示 |
| 1.2 | 2026-03-23 | 新增 §3.1.1:已选非空 case_name 且无 start_time 时不注入 N 批(配合 spec/12 用例钻取) |
+| 1.3 | 2026-04-11 | §3.1 末新增关联规约:分组执行历史默认批次注入见 spec/14(表 pipeline_overview) |
+| 1.4 | 2026-04-11 | 关联规约修订:分组执行历史默认 N 批与 History 一致为 **30**(原 spec/14 曾约定 20) |
diff --git a/spec/14_overview_group_history_spec.md b/spec/14_overview_group_history_spec.md
new file mode 100644
index 0000000..43b03c7
--- /dev/null
+++ b/spec/14_overview_group_history_spec.md
@@ -0,0 +1,140 @@
+# 分组执行历史(Overview)规格说明
+
+> 本文档定义「分组执行历史」页(`pipeline_overview` 只读列表)的前后端契约,与 [spec/08_history_filter_performance_spec.md](08_history_filter_performance_spec.md)(默认批次注入思路)、[spec/12_history_case_drilldown_spec.md](12_history_case_drilldown_spec.md)(外链与安全属性)对齐。
+
+---
+
+## 一、范围与数据源
+
+| 项 | 说明 |
+|----|------|
+| 主表 | `pipeline_overview`(只读,禁止 ALTER/DROP,见项目红线) |
+| 页面路由 | 主列表:`/overview`;分组跨轮次专用页:`/overview/subtask-executions` |
+| API | `GET /api/v1/overview`(分页列表)、`GET /api/v1/overview/options`(筛选项) |
+
+---
+
+## 二、与首页、详细执行历史的关系
+
+1. **首页大盘**:点击批次**仍直达**「详细执行历史」`/history?start_time=...`(**不**强制经过 `/overview`)。本页为独立菜单入口。
+2. **筛选对齐**:与「详细执行历史」可对齐的字段尽量一致:**批次**(对应 History 的 `start_time`)、**分组**(`subtask`)、**平台**(`platform`)、**代码分支**(`code_branch`)、**执行结果**(Overview 为 `result`,主要为 `passed` / `failed`)。**不出现**用例级、归因类筛选(如 `case_name`、`failed_type`、`failure_owner`、`analyzed` 等)。
+
+---
+
+## 三、默认批次注入(列表型,性能规约)
+
+**适用接口**:`GET /api/v1/overview`(主列表模式,见 §6.3 例外)。
+
+**触发条件**:请求中 **`batch` 未传或为空列表**时注入。
+
+**逻辑**(与 spec/08 §3.1 **思路相同**,主表与字段不同):
+
+1. 从 `pipeline_overview` 取 **`batch`** 的最近 **N** 个不重复值;
+2. `batch IS NOT NULL`,`batch LIKE '20%'`,`DISTINCT`,`ORDER BY batch DESC`,`LIMIT N`;
+3. **N = 30**(与 History 未选批次时注入批次数一致;字段分别为 `batch` / `start_time`);
+4. 将结果注入为 `batch IN (...)`,与用户其它条件 AND 组合。
+
+**不触发**:用户已选 `batch`(非空列表)时,不注入。
+
+**专用分组页例外**:见 §6.3,**禁止**套用上述注入。
+
+---
+
+## 四、排序
+
+| 场景 | 规则 |
+|------|------|
+| 默认(未传 `sort_field`) | `batch` **降序**,`subtask` **升序**(字符串) |
+| 用户指定列 | `sort_field` + `sort_order`(`asc` / `desc`),允许字段见 §5.2 |
+| `case_num` | 列类型为 `varchar`,**按数值排序**时使用 `CAST(case_num AS SIGNED)`(与团队脏数据约定一致;无法转换时 MySQL 按 0 处理) |
+
+---
+
+## 五、API 契约
+
+### 5.1 `GET /api/v1/overview/options`
+
+返回各字段去重选项(单表 `pipeline_overview`),供 Select 使用:
+
+- `batch`:`batch` 非空,优先 `LIKE '20%'` 且降序(与列表注入口径一致,便于选最近轮次)
+- `subtask`、`platform`、`code_branch`:非空去重,升序
+- `result`:**固定枚举** `["passed", "failed"]`(不依赖库内脏值枚举)
+
+### 5.2 `GET /api/v1/overview` 查询参数
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `page` | int | 默认 1 |
+| `page_size` | int | 默认 20,上限 100 |
+| `batch` | string[] | 多选批次,与 History `start_time` 同口径字符串 |
+| `subtask` | string[] | 多选分组 |
+| `platform` | string[] | 多选 |
+| `code_branch` | string[] | 多选 |
+| `result` | string[] | 多选,仅允许 `passed`、`failed` |
+| `sort_field` | string | 可选;允许:`batch`,`subtask`,`result`,`case_num`,`batch_start`,`batch_end`,`passed_num`,`failed_num`,`platform`,`code_branch`,`created_at` |
+| `sort_order` | string | `asc` / `desc` |
+| `all_batches` | bool | 默认 `false`;为 `true` 时见 §6.3 |
+
+**响应**:`PageResponse[OverviewItem]`,`OverviewItem` 字段与 ORM `PipelineOverview` 一致(snake_case)。
+
+---
+
+## 六、前端交互
+
+### 6.1 表格列(PRD §5.3 + 外链)
+
+除 PRD 所列:批次、分组、执行结果、总用例数、通过数、失败数、开始/结束时间、平台、代码分支外,须**展示**外链列:
+
+- `reports_url`、`log_url`、`screenshot_url`、`pipeline_url`
+- 有 URL:新窗口打开,`target="_blank"`,`rel="noopener noreferrer"`(对齐 spec/12)
+- 无 URL:文案「暂无」
+- **仅单元格内链接/可点击文字**触发跳转;**整行不承担**钻取(与 History 用例名列一致)
+
+### 6.2 批次列点击
+
+新标签页打开:
+
+`/history?start_time=`
+
+(该批次**全部用例**,与 History 多选批次参数形式一致时可使用多个 `start_time` 同名参数;单批次时传一个即可。)
+
+### 6.3 分组列点击 — Modal 二选一
+
+点击当前行的 **`subtask`** 时弹出 **Modal**,用户选择:
+
+- **A**:新标签页打开 **`/overview/subtask-executions?subtask=`**
+ - 仅展示该 **分组** 在 **所有轮次** 的 `pipeline_overview` 行。
+ - 对应列表请求:`GET /api/v1/overview?all_batches=true&subtask=...`(及分页等),**不得**注入 §3 的「最近 30 批」。
+- **B**:新标签页打开 **`/history?start_time=&subtask=`**
+ - 同批同组的用例明细。
+
+**校验**:`all_batches=true` 时,服务端须要求 **`subtask` 至少包含一个非空**;否则返回 **422** 并附中文说明。
+
+### 6.4 URL 与 placeholder
+
+- 主列表筛选条件与分页、排序与 **URL Query** 同步(对齐 `HistoryPage` 模式:`replace`、省略默认值)。
+- 批次 Select **placeholder**:「不选则默认最近30批」(与 History 批次 placeholder、spec/08 对称)。
+
+### 6.5 错误处理
+
+列表请求:`try/catch`,超时 / 5xx / 4xx 提示与 spec/08 §3.3 一致;失败不清空表格数据。
+
+---
+
+## 七、实现分层(项目契约)
+
+- **Model**:已有 `PipelineOverview`
+- **Schema**:`backend/schemas/overview.py` — `OverviewItem`、`OverviewQuery`、`OverviewFilterOptions`
+- **Service**:`backend/services/overview_service.py` — `list_overview`、`get_overview_options`(纯 async 函数)
+- **API**:`backend/api/v1/overview.py` — `response_model=PageResponse[OverviewItem]` / `OverviewFilterOptions`
+
+Python 3.8:`Optional[]` 标注;日志遵守 `docs/06_logging_guide.md`(关键异常 WARNING/ERROR,避免冗余 INFO)。
+
+---
+
+## 八、版本与变更
+
+| 版本 | 日期 | 说明 |
+|------|------|------|
+| 1.0 | 2026-04-11 | 初版:默认批次注入、排序、筛选、外链、batch/subtask 链与 Modal、subtask 专用页与 `all_batches` |
+| 1.1 | 2026-04-11 | 默认注入 N 由 20 调整为 **30**,与详细执行历史一致 |