From 4b568b00a325658ed214d8dc622ae7c444b5671d Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Tue, 14 Apr 2026 14:33:20 +0800 Subject: [PATCH 01/19] =?UTF-8?q?[feature]=E5=88=86=E7=BB=84=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=8E=86=E5=8F=B2=20=E5=8A=9F=E8=83=BD=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/v1/overview.py | 78 ++- backend/schemas/overview.py | 60 +- backend/services/overview_service.py | 118 +++- frontend/src/pages/overview/OverviewPage.tsx | 627 ++++++++++++++++++- frontend/src/routes/index.tsx | 4 + frontend/src/services/index.ts | 72 +++ spec/08_history_filter_performance_spec.md | 3 + spec/14_overview_group_history_spec.md | 139 ++++ 8 files changed, 1089 insertions(+), 12 deletions(-) create mode 100644 spec/14_overview_group_history_spec.md diff --git a/backend/api/v1/overview.py b/backend/api/v1/overview.py index 220396d..63d25bf 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 时不注入默认最近20批;须配合 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..1311901 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 = 20 + +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 模式时注入最近 20 个不重复 batch(spec/14)。 + """ + 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..62b81b2 100644 --- a/frontend/src/pages/overview/OverviewPage.tsx +++ b/frontend/src/pages/overview/OverviewPage.tsx @@ -1,3 +1,626 @@ -export default function OverviewPage() { - return
分组执行历史
; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +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()}`; +} + +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, +}; + +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 [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); + const [subtaskModalRow, setSubtaskModalRow] = useState(null); + const [subtaskChoice, setSubtaskChoice] = useState<"all-batches" | "same-batch">( + "all-batches", + ); + const subtaskInvalidWarnedRef = useRef(false); + + 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(() => { + 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, + }); + setPagination({ current: params.page ?? 1, pageSize: params.page_size ?? 20 }); + }, [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 params: OverviewQueryParams = { + page: 1, + page_size: pagination.pageSize, + 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, + }; + if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) { + params.subtask = [lockedSubtaskTrimmed]; + params.all_batches = true; + } + syncParamsToUrl(params); + setPagination((p) => ({ ...p, current: 1 })); + }; + + const handleReset = () => { + if (variant === "subtask-all-batches" && lockedSubtaskTrimmed) { + syncParamsToUrl({ + page: 1, + page_size: pagination.pageSize, + subtask: [lockedSubtaskTrimmed], + all_batches: true, + }); + } else { + syncParamsToUrl({ page: 1, page_size: pagination.pageSize }); + } + setPagination((p) => ({ ...p, current: 1 })); + }; + + 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); + setPagination({ current: pageToUse, pageSize: nextSize }); + }; + + const params = paramsFromUrl(); + const sortOrderFor = (field: string) => { + if (params.sort_field !== field) return undefined; + if (params.sort_order === "asc") return "ascend" as const; + if (params.sort_order === "desc") return "descend" as const; + return 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 = [ + { + title: "批次", + dataIndex: "batch", + width: 120, + ellipsis: true, + sorter: SORTABLE.batch, + sortOrder: sortOrderFor("batch"), + render: (val: string | null) => + val ? ( + e.stopPropagation()} + > + {val} + + ) : ( + "—" + ), + }, + { + title: "分组", + dataIndex: "subtask", + width: 120, + ellipsis: true, + sorter: SORTABLE.subtask, + sortOrder: sortOrderFor("subtask"), + render: (val: string | null, record: OverviewItem) => + val ? ( + + ) : ( + "—" + ), + }, + { + title: "执行结果", + dataIndex: "result", + width: 100, + sorter: SORTABLE.result, + sortOrder: sortOrderFor("result"), + render: (val: string | null) => { + if (!val) return "—"; + const color = val === "passed" ? "green" : val === "failed" ? "red" : "default"; + return {val}; + }, + }, + { + title: "总用例数", + dataIndex: "case_num", + width: 90, + sorter: SORTABLE.case_num, + sortOrder: sortOrderFor("case_num"), + }, + { + title: "通过数", + dataIndex: "passed_num", + width: 80, + sorter: SORTABLE.passed_num, + sortOrder: sortOrderFor("passed_num"), + }, + { + title: "失败数", + dataIndex: "failed_num", + width: 80, + sorter: SORTABLE.failed_num, + sortOrder: sortOrderFor("failed_num"), + }, + { + title: "开始时间", + dataIndex: "batch_start", + width: 160, + sorter: SORTABLE.batch_start, + sortOrder: sortOrderFor("batch_start"), + render: (v) => fmtDt(v), + }, + { + title: "结束时间", + dataIndex: "batch_end", + width: 160, + sorter: SORTABLE.batch_end, + sortOrder: sortOrderFor("batch_end"), + render: (v) => fmtDt(v), + }, + { + title: "平台", + dataIndex: "platform", + width: 100, + ellipsis: true, + sorter: SORTABLE.platform, + sortOrder: sortOrderFor("platform"), + }, + { + title: "代码分支", + dataIndex: "code_branch", + width: 110, + ellipsis: true, + sorter: SORTABLE.code_branch, + sortOrder: sortOrderFor("code_branch"), + }, + { + title: "测试报告", + dataIndex: "reports_url", + width: 88, + render: (v: string | null) => , + }, + { + title: "日志", + dataIndex: "log_url", + width: 72, + render: (v: string | null) => , + }, + { + title: "截图", + dataIndex: "screenshot_url", + width: 72, + render: (v: string | null) => , + }, + { + title: "流水线", + dataIndex: "pipeline_url", + width: 88, + render: (v: string | null) => , + }, + { + title: "创建时间", + dataIndex: "created_at", + width: 160, + sorter: SORTABLE.created_at, + sortOrder: sortOrderFor("created_at"), + render: (v) => fmtDt(v), + }, + ]; + + return ( +
+ {variant === "subtask-all-batches" && lockedSubtaskTrimmed && ( + + )} +
+ + + + + (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) + } + options={options?.subtask?.map((v) => ({ label: v, value: v })) ?? []} + /> + + + )} + + + + (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase()) + } + options={options?.platform?.map((v) => ({ label: v, value: v })) ?? []} + /> + + + + + {variant === "default" && ( - + - + - - - - - + + + + + + +
- - rowKey="id" - loading={loading} - columns={columns} - dataSource={data} - scroll={{ x: 1600 }} - pagination={{ - current: pagination.current, - pageSize: pagination.pageSize, - total, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条`, - }} - onChange={handleTableChange} - /> +
+ + 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 }} + /> +
Date: Tue, 14 Apr 2026 15:59:14 +0800 Subject: [PATCH 03/19] =?UTF-8?q?[feature]=E5=88=86=E7=BB=84=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=8E=86=E5=8F=B2=20=E9=97=AE=E9=A2=98=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/overview/OverviewPage.tsx | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/overview/OverviewPage.tsx b/frontend/src/pages/overview/OverviewPage.tsx index e6d3bc2..1130857 100644 --- a/frontend/src/pages/overview/OverviewPage.tsx +++ b/frontend/src/pages/overview/OverviewPage.tsx @@ -128,6 +128,9 @@ const SORTABLE: Record = { created_at: true, }; +/** 与 History 一致:表头 + 底部分页(含每页条数)预留高度,用于计算 Table `scroll.y` */ +const OVERVIEW_TABLE_SCROLL_RESERVE_PX = 118; + export default function OverviewPage({ variant = "default", }: { @@ -146,6 +149,9 @@ export default function OverviewPage({ "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; @@ -242,6 +248,19 @@ export default function OverviewPage({ 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; @@ -713,7 +732,7 @@ export default function OverviewPage({ -
+
rowKey="id" loading={loading} @@ -734,7 +753,7 @@ export default function OverviewPage({ disabled: loading, }} onChange={handleTableChange} - scroll={{ x: totalWidth }} + scroll={{ x: totalWidth, y: tableScrollY }} />
From ec0adb6bca56afbeca3e92719e1236ec65f642a9 Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Tue, 14 Apr 2026 16:15:52 +0800 Subject: [PATCH 04/19] =?UTF-8?q?[feature]=E5=88=86=E7=BB=84=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=8E=86=E5=8F=B2=20=E5=8A=9F=E8=83=BD=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/v1/overview.py | 2 +- backend/services/overview_service.py | 4 ++-- frontend/src/pages/overview/OverviewPage.tsx | 6 +++--- frontend/src/services/index.ts | 2 +- spec/08_history_filter_performance_spec.md | 5 +++-- spec/14_overview_group_history_spec.md | 9 +++++---- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/backend/api/v1/overview.py b/backend/api/v1/overview.py index 63d25bf..5efd3d3 100644 --- a/backend/api/v1/overview.py +++ b/backend/api/v1/overview.py @@ -38,7 +38,7 @@ async def get_overview_list( 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 时不注入默认最近20批;须配合 subtask"), + all_batches: bool = Query(False, description="为 true 时不注入默认最近30批;须配合 subtask"), db: AsyncSession = Depends(get_db), ): if all_batches and not _nonempty_subtask(subtask): diff --git a/backend/services/overview_service.py b/backend/services/overview_service.py index 1311901..0bd3c2e 100644 --- a/backend/services/overview_service.py +++ b/backend/services/overview_service.py @@ -9,7 +9,7 @@ po = PipelineOverview -DEFAULT_OVERVIEW_BATCH_LIMIT = 20 +DEFAULT_OVERVIEW_BATCH_LIMIT = 30 ALLOWED_SORT_FIELDS = { "batch", @@ -31,7 +31,7 @@ async def list_overview( ) -> Tuple[List[PipelineOverview], int]: """ 分组执行历史列表:单表 pipeline_overview。 - 未选 batch 且非 all_batches 模式时注入最近 20 个不重复 batch(spec/14)。 + 未选 batch 且非 all_batches 模式时注入最近 30 个不重复 batch(spec/14,与 History 批次数一致)。 """ eff = query if not query.all_batches and not query.batch: diff --git a/frontend/src/pages/overview/OverviewPage.tsx b/frontend/src/pages/overview/OverviewPage.tsx index 1130857..2637352 100644 --- a/frontend/src/pages/overview/OverviewPage.tsx +++ b/frontend/src/pages/overview/OverviewPage.tsx @@ -626,7 +626,7 @@ export default function OverviewPage({ type="info" showIcon style={{ marginBottom: 12 }} - message={`分组「${lockedSubtaskTrimmed}」跨全部轮次(不限制最近 20 批)`} + message={`分组「${lockedSubtaskTrimmed}」跨全部轮次(不限制最近 30 批)`} /> )}
@@ -636,7 +636,7 @@ export default function OverviewPage({