From b2dd07d8e89e2b2cf3684c339627aa3ebe62f8f5 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sat, 20 Jun 2026 08:01:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:[FL-158][ATP=E6=A8=A1=E5=9E=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E4=B8=80=E8=87=B4=E6=80=A7=E4=BC=98?= =?UTF-8?q?=E5=8C=96]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- api/app/api/v1/atp_assets.py | 4 + api/app/services/atp_asset_service.py | 12 +- api/tests/test_atp_asset_service.py | 40 +++ memory/2026-06-20.md | 29 ++ web/src/app/admin/atp-models/page.tsx | 410 +++++++++++++++++--------- web/src/app/globals.css | 13 + 6 files changed, 364 insertions(+), 144 deletions(-) diff --git a/api/app/api/v1/atp_assets.py b/api/app/api/v1/atp_assets.py index 1807a5d..b2759ec 100644 --- a/api/app/api/v1/atp_assets.py +++ b/api/app/api/v1/atp_assets.py @@ -59,6 +59,8 @@ def get_atp_asset_list( voltage_level: str | None = Query(default=None), tower_type: str | None = Query(default=None), scene_type: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), db: Session = Depends(get_db), ) -> AtpAssetListResponse: @@ -69,6 +71,8 @@ def get_atp_asset_list( voltage_level=voltage_level, tower_type=tower_type, scene_type=scene_type, + limit=limit, + offset=offset, ) diff --git a/api/app/services/atp_asset_service.py b/api/app/services/atp_asset_service.py index 7a763e5..b5328cb 100644 --- a/api/app/services/atp_asset_service.py +++ b/api/app/services/atp_asset_service.py @@ -496,6 +496,7 @@ def serialize_asset( voltage_level=item.voltage_level, tower_type=item.tower_type, scene_type=item.scene_type, + arrester_config=item.arrester_config, latest_release_no=item.latest_release_no, active_release_no=item.active_release_no, active_release_id=active_release.id if active_release else None, @@ -653,6 +654,8 @@ def list_assets( voltage_level: str | None, tower_type: str | None, scene_type: str | None, + limit: int = 50, + offset: int = 0, ) -> AtpAssetListResponse: stmt = select(AtpAsset) total_stmt = select(func.count()).select_from(AtpAsset) @@ -681,7 +684,11 @@ def list_assets( stmt = stmt.where(AtpAsset.scene_type == scene_type.strip()) total_stmt = total_stmt.where(AtpAsset.scene_type == scene_type.strip()) - items = db.execute(stmt.order_by(AtpAsset.update_date.desc(), AtpAsset.code.asc())).scalars().all() + items = db.execute( + stmt.order_by(AtpAsset.update_date.desc(), AtpAsset.code.asc()) + .limit(limit) + .offset(offset) + ).scalars().all() total = int(db.scalar(total_stmt) or 0) asset_ids = [item.id for item in items] release_count_map = _load_asset_release_count_map(db, asset_ids) @@ -729,6 +736,7 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id: voltage_level=_normalize_optional_str(payload.voltage_level), tower_type=_normalize_optional_str(payload.tower_type), scene_type=_normalize_optional_str(payload.scene_type), + arrester_config=_normalize_optional_str(payload.arrester_config), latest_release_no=0, active_release_no=None, create_user=actor_user_id, @@ -771,6 +779,8 @@ def update_asset( item.tower_type = _normalize_optional_str(update_data["tower_type"]) if "scene_type" in update_data: item.scene_type = _normalize_optional_str(update_data["scene_type"]) + if "arrester_config" in update_data: + item.arrester_config = _normalize_optional_str(update_data["arrester_config"]) item.update_user = actor_user_id item.update_date = utcnow() diff --git a/api/tests/test_atp_asset_service.py b/api/tests/test_atp_asset_service.py index 608fbc1..1003b8a 100644 --- a/api/tests/test_atp_asset_service.py +++ b/api/tests/test_atp_asset_service.py @@ -177,6 +177,46 @@ def test_create_release_from_archive_requires_asset_dimensions(tmp_path) -> None session.close() +def test_list_assets_paginates_after_filtering(tmp_path) -> None: + testing_session = _build_sessionmaker() + session: Session = testing_session() + try: + _seed_vfs_mount(session, root_dir=tmp_path / "vfs") + for index in range(4): + created = atp_asset_service.create_asset( + session, + AtpAssetCreateRequest( + code=f"ATP-ASSET-PAGE-{index}", + name=f"分页模型 {index}", + voltage_level="220", + tower_type="sihuita", + scene_type="fanji", + arrester_config="M123", + ), + actor_user_id="tester", + ) + assert created is not None + assert created.arrester_config == "M123" + + result = atp_asset_service.list_assets( + session, + keyword="分页模型", + status_filter=None, + voltage_level=None, + tower_type=None, + scene_type=None, + limit=2, + offset=1, + ) + + assert result.total == 4 + assert len(result.items) == 2 + assert [item.code for item in result.items] == ["ATP-ASSET-PAGE-2", "ATP-ASSET-PAGE-1"] + assert [item.arrester_config for item in result.items] == ["M123", "M123"] + finally: + session.close() + + def test_run_release_dry_run_materializes_directory(tmp_path, monkeypatch) -> None: testing_session = _build_sessionmaker() monkeypatch.setattr(core_database, "SessionLocal", testing_session) diff --git a/memory/2026-06-20.md b/memory/2026-06-20.md index 1cd1716..bdc8d51 100644 --- a/memory/2026-06-20.md +++ b/memory/2026-06-20.md @@ -193,3 +193,32 @@ - 风险与关注点: - 改动仅影响任务监控页前端展示、筛选布局、错误反馈和移动端呈现,不改变 `/api/v1/admin/flower/*` 接口路径、请求/响应字段或权限语义。 - `web/package-lock.json` 此次同步了 `web/package.json` 已有依赖条目,变动较大但不改变业务代码。 + +# Work Log - ATP 模型管理页面一致性优化(FL-158) + +- 背景: + - ATP 模型管理页需要对齐用户管理页的反馈机制、表格分页、移动卡片和操作入口规范。 + +- 本次处理: + - ATP 模型页移除 `App.useApp()` 与页面内 `Alert`,统一改为 `error/success` state + `useToastFeedback`。 + - ATP 资产列表接口补齐 `limit/offset` 查询参数,服务层在筛选后分页并保持 `total` 为当前筛选总数。 + - ATP 资产服务补齐 `arrester_config` 的创建、更新与序列化,保证页面必填字段能被持久化和回显。 + - 表格对齐用户管理页:`tableLayout="fixed"`、仅纵向滚动、服务端分页、空态文案、状态 Tag 展示和操作列宽度。 + - 移动端卡片对齐用户管理页:搜索表单布局、卡片标题状态、右上角编辑/更多菜单、字段网格、累积加载与已加载全部提示。 + - 新增 `test_list_assets_paginates_after_filtering` 覆盖 ATP 资产列表筛选后分页行为。 + +- 验证: + - 基线:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/atp-models/page.tsx` 通过,仅存在用户页 1 条既有 unused eslint-disable warning;ATP 页有 6 条既有 warning。 + - 基线:`npm --workspace web exec tsc --noEmit` 通过。 + - 基线:`python3 -m py_compile api/app/api/v1/atp_assets.py api/app/services/atp_asset_service.py api/tests/test_atp_asset_service.py` 通过。 + - 基线:`python3 -m pytest api/tests/test_atp_asset_service.py` 因系统 Python 缺少 `fastapi` 无法收集测试。 + - 修改后:`npm --workspace web exec eslint src/app/admin/atp-models/page.tsx --max-warnings=0` 通过。 + - 修改后:`npm --workspace web exec tsc --noEmit` 通过。 + - 修改后:`python3 -m py_compile api/app/api/v1/atp_assets.py api/app/services/atp_asset_service.py api/tests/test_atp_asset_service.py` 通过。 + - 修改后:`UV_CACHE_DIR=/tmp/fquiz-uv-cache UV_PYTHON_INSTALL_DIR=/tmp/fquiz-uv-python /home/jenkins/.local/bin/uv run --python 3.11 --with pytest --with fastapi --with pydantic-settings --with sqlalchemy --with PyJWT --with argon2-cffi --with email-validator --with python-multipart --with psycopg[binary] pytest api/tests/test_atp_asset_service.py` 通过,5 passed,存在 1 条既有 SQLAlchemy relationship warning。 + - 修改后:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/atp-models/page.tsx` 通过,仍仅用户页 1 条既有 warning。 + +- 风险与关注点: + - `/api/v1/atp/assets` 新增可选 `limit/offset` 参数;未传参默认返回前 50 条,响应字段不变。 + - ATP 资产创建/更新现在会按既有 schema 持久化 `arrester_config`,修复此前前端提交但服务层丢弃该字段的问题。 + - 改动不改变 ATP 模型删除、版本、运行接口字段或权限语义。 diff --git a/web/src/app/admin/atp-models/page.tsx b/web/src/app/admin/atp-models/page.tsx index 55af1a7..3940f39 100644 --- a/web/src/app/admin/atp-models/page.tsx +++ b/web/src/app/admin/atp-models/page.tsx @@ -3,8 +3,6 @@ import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - Alert, - App, Button, Card, Col, @@ -15,7 +13,6 @@ import { Modal, Popconfirm, Row, - Select, Space, Spin, Table, @@ -24,7 +21,7 @@ import { type CardProps, type MenuProps, } from "antd"; -import { MoreOutlined } from "@ant-design/icons"; +import { EditOutlined, MoreOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react"; @@ -32,6 +29,7 @@ import { AdminPageLoading } from "@/components/admin-page-loading"; import { useAuth } from "@/components/auth-provider"; import { CreatableSingleSelect } from "@/components/creatable-single-select"; import { useMobileDetection } from "@/hooks/use-mobile-detection"; +import { useToastFeedback } from "@/hooks/use-toast-feedback"; import { readApiError } from "@/lib/api"; import { getAtpAssetStatusDisplay } from "@/lib/atp-asset-display"; import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth"; @@ -173,34 +171,47 @@ const ATP_TABLE_VIEWPORT_GAP = 40; const ATP_TABLE_FALLBACK_RESERVE = 220; export default function AtpModelsPage() { - const { message } = App.useApp(); const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const queryClient = useQueryClient(); const [form] = Form.useForm(); const isMobile = useMobileDetection(); const [keywordInput, setKeywordInput] = useState(""); - const [keyword, setKeyword] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); const keywordDebounceTimeoutRef = useRef(null); const [editingAsset, setEditingAsset] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [tableScrollY, setTableScrollY] = useState(ATP_TABLE_MIN_SCROLL_Y); const tableScrollAnchorRef = useRef(null); const viewMode: "table" | "card" = isMobile ? "card" : "table"; + const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); + const [cardViewPage, setCardViewPage] = useState(1); + const [allLoadedAssets, setAllLoadedAssets] = useState([]); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const pageCardRef = useRef(null); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage"); const canManage = hasPermission("atp.manage"); + const { current: paginationCurrent, pageSize: paginationPageSize } = pagination; + + const trimmedKeyword = searchKeyword.trim(); + const assetsQueryParams = useMemo(() => { + const params = new URLSearchParams(); + params.set("limit", String(paginationPageSize)); + params.set("offset", String((paginationCurrent - 1) * paginationPageSize)); + if (trimmedKeyword) { + params.set("keyword", trimmedKeyword); + } + return params.toString(); + }, [paginationCurrent, paginationPageSize, trimmedKeyword]); const assetsQuery = useQuery({ - queryKey: ["atp-assets", keyword], + queryKey: ["atp-assets", assetsQueryParams], enabled: Boolean(user && canRead), queryFn: async () => { - const searchParams = new URLSearchParams(); - if (keyword.trim()) { - searchParams.set("keyword", keyword.trim()); - } - const suffix = searchParams.toString(); - const response = await fetchWithAuth(`/api/v1/atp/assets${suffix ? `?${suffix}` : ""}`); + const response = await fetchWithAuth(`/api/v1/atp/assets?${assetsQueryParams}`); if (!response.ok) { throw new Error(await readApiError(response)); } @@ -229,13 +240,15 @@ export default function AtpModelsPage() { }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["atp-assets"] }); - message.success(editingAsset ? "模型已更新" : "模型已创建"); + setSuccess(editingAsset ? "模型已更新" : "模型已创建"); + setError(""); setModalOpen(false); setEditingAsset(null); form.resetFields(); }, onError: (candidate) => { - message.error(candidate instanceof Error ? candidate.message : "保存模型失败"); + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "保存模型失败"); }, }); @@ -248,13 +261,38 @@ export default function AtpModelsPage() { }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["atp-assets"] }); - message.success("模型已删除"); + setSuccess("模型已删除"); + setError(""); }, onError: (candidate) => { - message.error(candidate instanceof Error ? candidate.message : "删除模型失败"); + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "删除模型失败"); }, }); + const openCreateModal = useCallback(() => { + setError(""); + setSuccess(""); + setEditingAsset(null); + form.setFieldsValue(EMPTY_FORM); + setModalOpen(true); + }, [form]); + + const openEditModal = useCallback((item: AtpAssetSummary) => { + setError(""); + setSuccess(""); + setEditingAsset(item); + form.setFieldsValue(toFormValues(item)); + setModalOpen(true); + }, [form]); + + const closeModal = () => { + if (saveMutation.isPending) return; + setModalOpen(false); + setEditingAsset(null); + form.resetFields(); + }; + const handleKeywordChange = (value: string) => { setKeywordInput(value); @@ -263,7 +301,10 @@ export default function AtpModelsPage() { } keywordDebounceTimeoutRef.current = setTimeout(() => { - setKeyword(value); + setSearchKeyword(value); + setPagination((previous) => ({ ...previous, current: 1 })); + setCardViewPage(1); + setAllLoadedAssets([]); }, 500); }; @@ -275,11 +316,72 @@ export default function AtpModelsPage() { }; }, []); - const assetItems = assetsQuery.data?.items ?? []; + const assetItems = useMemo(() => assetsQuery.data?.items ?? [], [assetsQuery.data?.items]); const voltageLevelOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.voltage_level, DEFAULT_VOLTAGE_LEVELS), [assetItems]); const towerTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.tower_type, DEFAULT_TOWER_TYPES), [assetItems]); const sceneTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.scene_type, DEFAULT_SCENE_TYPES), [assetItems]); const arresterConfigOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.arrester_config, DEFAULT_ARRESTER_CONFIGS), [assetItems]); + const assetTotal = assetsQuery.data?.total ?? 0; + const queryError = assetsQuery.error instanceof Error ? assetsQuery.error.message : ""; + const anyError = error || queryError; + + useToastFeedback({ + errorMessage: anyError, + successMessage: success, + clearError: () => setError(""), + clearSuccess: () => setSuccess(""), + }); + + useEffect(() => { + if (viewMode !== "card" || assetsQuery.isLoading) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + if (cardViewPage === 1) { + setAllLoadedAssets(() => assetItems); + } else { + setAllLoadedAssets((previous) => { + if (assetItems.length === 0) { + return previous; + } + const existingIds = new Set(previous.map((item) => item.id)); + const newAssets = assetItems.filter((item) => !existingIds.has(item.id)); + return [...previous, ...newAssets]; + }); + } + setIsLoadingMore(false); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [assetItems, assetsQuery.isLoading, viewMode, cardViewPage]); + + useEffect(() => { + if (viewMode !== "card") return; + + const pageCard = pageCardRef.current; + if (!pageCard) return; + + const cardBody = pageCard.querySelector(".ant-card-body"); + if (!cardBody) return; + + const handleScroll = () => { + if (isLoadingMore || assetsQuery.isLoading) return; + + const { scrollTop, scrollHeight, clientHeight } = cardBody; + + if (scrollTop + clientHeight >= scrollHeight - 100 && allLoadedAssets.length < assetTotal) { + setIsLoadingMore(true); + setCardViewPage((previous) => previous + 1); + setPagination((previous) => ({ ...previous, current: previous.current + 1 })); + } + }; + + cardBody.addEventListener("scroll", handleScroll); + return () => cardBody.removeEventListener("scroll", handleScroll); + }, [allLoadedAssets.length, assetTotal, assetsQuery.isLoading, isLoadingMore, viewMode]); const updateTableScrollY = useCallback(() => { if (typeof window === "undefined") { @@ -312,7 +414,7 @@ export default function AtpModelsPage() { return; } window.requestAnimationFrame(updateTableScrollY); - }, [assetsQuery.error, keyword, assetItems.length, assetsQuery.isFetching, updateTableScrollY]); + }, [anyError, paginationCurrent, paginationPageSize, assetItems.length, assetsQuery.isFetching, updateTableScrollY]); useEffect(() => { if (typeof window === "undefined") { @@ -364,10 +466,20 @@ export default function AtpModelsPage() { ), }, + { + title: "状态", + dataIndex: "status", + width: 96, + align: "center", + render: (value: string) => { + const display = getAtpAssetStatusDisplay(value); + return {display.label}; + }, + }, { title: "业务维度", key: "dimensions", - width: 240, + width: 220, render: (_, item) => ( {item.voltage_level || "未设置电压等级"} @@ -380,7 +492,7 @@ export default function AtpModelsPage() { { title: "当前版本", key: "release", - width: 180, + width: 160, render: (_, item) => ( {item.active_release_tag || (item.active_release_no ? `r${item.active_release_no}` : "-")} @@ -393,26 +505,18 @@ export default function AtpModelsPage() { { title: "更新时间", key: "update_date", - width: 180, + width: 170, dataIndex: "update_date", render: (value: string) => formatDateTime(value), }, { title: "操作", key: "actions", - width: 240, + width: 180, render: (_, item) => { const deleteLoading = deleteMutation.isPending; const rowBusy = deleteLoading; - const moreMenuItems: MenuProps["items"] = [ - // 未来可在此添加更多操作,如: - // - 查看版本历史 - // - 导出模型配置 - // - 复制模型 - // - 归档/取消归档 - ]; - return ( @@ -423,11 +527,7 @@ export default function AtpModelsPage() { @@ -444,29 +544,45 @@ export default function AtpModelsPage() { 删除 - {moreMenuItems && moreMenuItems.length > 0 && ( - - - - - deleteMutation.mutate(item.id)} - disabled={!canManage || rowBusy} - > - - - {moreMenuItems && moreMenuItems.length > 0 && ( - - )} > -
- - handleKeywordChange(event.target.value)} - placeholder="按编码 / 名称 / 描述搜索" - /> - -
- - {assetsQuery.error instanceof Error ? ( - - ) : null} + {viewMode === "card" ? ( +
+ + handleKeywordChange(event.target.value)} + placeholder="按编码/名称/描述搜索" + /> + +
+ ) : ( +
+ + handleKeywordChange(event.target.value)} + placeholder="按编码/名称/描述搜索" + /> + +
+ )} {viewMode === "table" ? (
), }} - pagination={false} - scroll={{ x: 1080, y: tableScrollY }} + pagination={{ + current: pagination.current, + pageSize: pagination.pageSize, + total: Math.max(assetTotal, 1), + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: () => `共 ${assetTotal} 条`, + hideOnSinglePage: false, + style: { marginBottom: 0 }, + onChange: (page, pageSize) => { + setPagination({ current: page, pageSize }); + }, + }} + scroll={{ y: tableScrollY }} />
) : ( -
- {assetsQuery.isLoading ? ( +
+ {assetsQuery.isLoading && allLoadedAssets.length === 0 ? (
- ) : assetItems.length === 0 ? ( + ) : allLoadedAssets.length === 0 ? (
) : ( - - {assetItems.map((item) => ( - - {renderAtpModelCard(item)} - - ))} - +
+ + {allLoadedAssets.map((item) => ( + + {renderAtpModelCard(item)} + + ))} + + {isLoadingMore && ( +
+ +
+ )} + {allLoadedAssets.length >= assetTotal && allLoadedAssets.length > 0 && ( +
+ + 已加载全部 {allLoadedAssets.length} 条数据 + +
+ )} +
)}
)} @@ -658,21 +783,20 @@ export default function AtpModelsPage() { { - setModalOpen(false); - setEditingAsset(null); - form.resetFields(); - }} + onCancel={closeModal} onOk={() => void form.submit()} confirmLoading={saveMutation.isPending} destroyOnClose width={760} + okText={saveMutation.isPending ? "提交中..." : editingAsset ? "保存修改" : "创建模型"} + cancelText="取消" > form={form} layout="vertical" initialValues={EMPTY_FORM} onFinish={(values) => void saveMutation.mutateAsync(values)} + autoComplete="off" > @@ -703,6 +827,6 @@ export default function AtpModelsPage() { - +
); } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 7631c46..30ee642 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1032,6 +1032,12 @@ body { flex-direction: column; } +.admin-atp-models-card-view-content { + min-height: 0; + flex: 1; + padding: 2px 2px 4px; +} + .admin-atp-models-card-view-state { display: flex; min-height: 240px; @@ -1062,6 +1068,13 @@ body { padding-block: 14px; } +.admin-atp-models-model-card-field { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 8px; + align-items: baseline; +} + .lightning-table-anchor .ant-table-body { min-height: var(--lightning-table-body-min-height, 180px); }