diff --git a/MEMORY.md b/MEMORY.md index f94f37b..9b71d0f 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1089,6 +1089,15 @@ - 后端统计口径要求:`total` 必须与当前检索/过滤条件一致(不是全量总数)。 - 前端 `/admin/users` 采用“检索条件 + 分页状态”驱动请求,不再固定拉取 `limit=200` 全量列表。 +## 杆塔模型管理检索与分页口径(2026-06-20) + +- 杆塔模型列表接口 `GET /api/v1/tower-models` 支持查询参数: + - `limit` / `offset`:分页 + - `keyword`:按 `code/name/tower_type` 模糊检索 + - `enabled`:启用状态过滤(`true|false`) +- 后端统计口径要求:`total` 与当前检索/过滤条件一致,分页仅作用于返回 `items`。 +- 前端 `/admin/tower-models` 采用与 `/admin/users` 一致的“检索条件 + 分页状态”驱动请求;移动端卡片视图按服务端分页累积数据,筛选切换需重置页码与累积列表。 + ## 用户管理编辑口径(2026-05-01) - 用户信息更新接口 `PATCH /api/v1/users/{user_id}` 当前支持: diff --git a/api/app/api/v1/tower_models.py b/api/app/api/v1/tower_models.py index 9faa549..8ff3a70 100644 --- a/api/app/api/v1/tower_models.py +++ b/api/app/api/v1/tower_models.py @@ -33,6 +33,8 @@ router = APIRouter(prefix="/tower-models", tags=["tower-models"]) @router.get("", response_model=TowerModelListResponse) def get_tower_model_list( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), keyword: str | None = Query(default=None), enabled: bool | None = Query(default=None), _: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")), @@ -40,6 +42,8 @@ def get_tower_model_list( ) -> TowerModelListResponse: return list_tower_models( db, + limit=limit, + offset=offset, keyword=keyword, enabled=enabled, ) diff --git a/api/app/services/tower_model_service.py b/api/app/services/tower_model_service.py index 822e50c..889a04c 100644 --- a/api/app/services/tower_model_service.py +++ b/api/app/services/tower_model_service.py @@ -74,6 +74,8 @@ def serialize_tower_model(item: TowerModel) -> TowerModelSummary: def list_tower_models( db: Session, *, + limit: int, + offset: int, keyword: str | None, enabled: bool | None, ) -> TowerModelListResponse: @@ -98,6 +100,8 @@ def list_tower_models( total = int(db.scalar(total_stmt) or 0) items = db.execute( stmt.order_by(TowerModel.sort_order.asc(), TowerModel.code.asc()) + .offset(offset) + .limit(limit) ).scalars().all() return TowerModelListResponse( items=[serialize_tower_model(item) for item in items], diff --git a/api/tests/test_tower_model_service.py b/api/tests/test_tower_model_service.py new file mode 100644 index 0000000..93e1af3 --- /dev/null +++ b/api/tests/test_tower_model_service.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from api.app import models # noqa: F401 +from api.app.core.database import Base +from api.app.models.tower_model import TowerModel +from api.app.services.tower_model_service import list_tower_models + + +class TowerModelServiceTest(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine( + "sqlite+pysqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + self.SessionLocal = sessionmaker( + bind=self.engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, + ) + Base.metadata.create_all(bind=self.engine) + self.session = self.SessionLocal() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def test_list_tower_models_applies_limit_and_offset(self) -> None: + for index in range(5): + self.session.add( + TowerModel( + code=f"TM-{index}", + name=f"Tower Model {index}", + tower_type="直线", + is_enabled=True, + sort_order=index, + ) + ) + self.session.commit() + + page = list_tower_models( + self.session, + limit=2, + offset=1, + keyword=None, + enabled=None, + ) + + self.assertEqual(page.total, 5) + self.assertEqual([item.code for item in page.items], ["TM-1", "TM-2"]) + + def test_list_tower_models_filters_before_paginating(self) -> None: + self.session.add_all( + [ + TowerModel(code="A-ZX", name="直线模型 A", tower_type="直线", is_enabled=True, sort_order=1), + TowerModel(code="B-NZ", name="耐张模型 B", tower_type="耐张", is_enabled=False, sort_order=2), + TowerModel(code="C-ZX", name="直线模型 C", tower_type="直线", is_enabled=True, sort_order=3), + ] + ) + self.session.commit() + + page = list_tower_models( + self.session, + limit=1, + offset=0, + keyword="直线", + enabled=True, + ) + + self.assertEqual(page.total, 2) + self.assertEqual(len(page.items), 1) + self.assertTrue(page.items[0].is_enabled) + self.assertEqual(page.items[0].tower_type, "直线") + + +if __name__ == "__main__": + unittest.main() diff --git a/memory/2026-06-20.md b/memory/2026-06-20.md index 5d51a63..3bbf4f1 100644 --- a/memory/2026-06-20.md +++ b/memory/2026-06-20.md @@ -261,3 +261,29 @@ - 风险与关注点: - 改动仅影响 `/admin/syslog` 前端展示、筛选排布、表格滚动和移动卡片视觉,不改变 `/api/v1/admin/audit-logs` 接口路径、请求/响应字段或权限语义。 + +# Work Log - 杆塔模型管理页面一致性优化(FL-159) + +- 背景: + - 杆塔模型管理页需要对齐用户管理页的列表布局、筛选分页、反馈提示、移动卡片和表格滚动规范。 + +- 本次处理: + - 杆塔模型列表接口补齐 `limit/offset` 查询参数,service 层按筛选条件统计 `total` 后再分页返回 `items`。 + - 前端 `/admin/tower-models` 列表请求改为由关键词、状态筛选和分页状态共同驱动,移动端无限滚动改为服务端分页累积数据。 + - 成功/失败提示统一走 `success/error` state + `useToastFeedback`,移除页面内直接调用 `App.useApp().message` 的分散反馈方式。 + - 表格对齐用户管理页:`tableLayout="fixed"`、仅纵向滚动、180px 最小表格体高度、操作区 `Space wrap`、状态 Tag 颜色统一为 `green/default`。 + - 移动卡片字段列宽从 `72px` 调整为 `64px`,模型图片预览缩略图切换为 AntD `Image` 包装以消除页面级 `` lint 警告。 + - 新增 `api/tests/test_tower_model_service.py` 覆盖杆塔模型列表分页与筛选后分页行为。 + - 为通过全量 Next build 类型门禁,同步补齐后台页面 AntD `Card` 包装组件的 ref 类型,并修正线路参数页少量 JSX 展示值类型。 + +- 验证: + - 基线:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/tower-models/page.tsx` 通过,存在用户页 1 条既有 unused eslint-disable warning 与塔模型页 1 条既有 no-img-element warning。 + - 基线:`npm --workspace web exec tsc --noEmit --pretty false` 通过。 + - 修改后:`npm --workspace web exec eslint src/app/admin/tower-models/page.tsx --max-warnings=0` 通过。 + - 修改后:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/tower-models/page.tsx` 通过,仍仅用户页 1 条既有 warning。 + - 修改后:`npm --workspace web exec tsc --noEmit --pretty false` 通过。 + - 修改后:`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 fastapi --with pydantic-settings --with sqlalchemy --with PyJWT --with argon2-cffi --with email-validator --with python-multipart --with psycopg[binary] -m unittest api.tests.test_tower_model_service` 通过,存在 1 条既有 SQLAlchemy relationship warning。 + - 修改后:`npm run build:web` 通过,仍输出既有 multiple lockfiles 与 middleware/proxy 迁移提示。 + +- 风险与关注点: + - 改动涉及 `GET /api/v1/tower-models` 列表分页契约,未改变响应字段、CRUD 字段、权限码或图片上传/预览接口。 diff --git a/web/src/app/admin/atp-models/page.tsx b/web/src/app/admin/atp-models/page.tsx index 3940f39..d0c6ae0 100644 --- a/web/src/app/admin/atp-models/page.tsx +++ b/web/src/app/admin/atp-models/page.tsx @@ -23,7 +23,7 @@ import { } from "antd"; 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"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react"; import { AdminPageLoading } from "@/components/admin-page-loading"; import { useAuth } from "@/components/auth-provider"; @@ -34,7 +34,7 @@ import { readApiError } from "@/lib/api"; import { getAtpAssetStatusDisplay } from "@/lib/atp-asset-display"; import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth"; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; type AssetFormValues = { description: string; diff --git a/web/src/app/admin/elevation/page.tsx b/web/src/app/admin/elevation/page.tsx index 4052579..db1c262 100644 --- a/web/src/app/admin/elevation/page.tsx +++ b/web/src/app/admin/elevation/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties, type RefAttributes } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { App, @@ -38,7 +38,7 @@ import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; import { readLinePreparation } from "@/lib/line-preparation"; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; import type { ElevationApplyJobCreateResponse, ElevationApplyJobListResponse, diff --git a/web/src/app/admin/files/page.tsx b/web/src/app/admin/files/page.tsx index 4318769..ce022e6 100644 --- a/web/src/app/admin/files/page.tsx +++ b/web/src/app/admin/files/page.tsx @@ -35,7 +35,7 @@ import { MoreOutlined, } from "@ant-design/icons"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react"; import { useAuth } from "@/components/auth-provider"; import { Button } from "@/components/ui-antd"; @@ -93,7 +93,7 @@ function readXhrError(xhr: XMLHttpRequest): string { const FILES_TABLE_MIN_SCROLL_Y = 180; const FILES_TABLE_VIEWPORT_GAP = 40; const FILES_TABLE_FALLBACK_RESERVE = 220; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; export default function AdminFilesPage() { const queryClient = useQueryClient(); diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index c74936d..d1cc002 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -27,7 +27,7 @@ import { type TableColumnsType, } from "antd"; import { MoreOutlined, EditOutlined } from "@ant-design/icons"; -import type { CSSProperties, ComponentType } from "react"; +import type { CSSProperties, ComponentType, RefAttributes } from "react"; import { useAuth } from "@/components/auth-provider"; import { useToastFeedback } from "@/hooks/use-toast-feedback"; @@ -37,7 +37,7 @@ import { readApiError } from "@/lib/api"; import { normalizeAppRoutePath } from "@/lib/app-route-path"; import type { MenuItem, MenuListResponse } from "@/types/auth"; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; type FilterStatus = "all" | "enabled" | "disabled"; diff --git a/web/src/app/admin/power-lines/page.tsx b/web/src/app/admin/power-lines/page.tsx index b0e77cf..775e861 100644 --- a/web/src/app/admin/power-lines/page.tsx +++ b/web/src/app/admin/power-lines/page.tsx @@ -245,13 +245,6 @@ function parseJsonObjectText(value: string, label: string): Record; } -function formatNumber(value: number | null | undefined, digits = 3): string { - if (value === null || value === undefined || Number.isNaN(value)) { - return "-"; - } - return value.toFixed(digits); -} - type TowerTopologyKind = "single" | "double" | "quad" | "dc"; type TowerCircuitKey = "I" | "II" | "III" | "IV"; type TowerPhaseKey = "upper" | "middle" | "lower"; @@ -2224,7 +2217,7 @@ export default function AdminPowerLinesPage() { 雷电流幅值 {selectedLinePreparation.lightning_current.ready && ( - (a={selectedLinePreparation.lightning_current.values.current_a ?? "-"}, b={selectedLinePreparation.lightning_current.values.current_b ?? "-"}) + (a={String(selectedLinePreparation.lightning_current.values.current_a ?? "-")}, b={String(selectedLinePreparation.lightning_current.values.current_b ?? "-")}) )} @@ -2250,7 +2243,7 @@ export default function AdminPowerLinesPage() { 地闪密度 {selectedLinePreparation.lightning_density.ready && ( - (Ng={selectedLinePreparation.lightning_density.values.ng ?? "-"}) + (Ng={String(selectedLinePreparation.lightning_density.values.ng ?? "-")}) )} diff --git a/web/src/app/admin/roles/page.tsx b/web/src/app/admin/roles/page.tsx index 57bd303..3775f15 100644 --- a/web/src/app/admin/roles/page.tsx +++ b/web/src/app/admin/roles/page.tsx @@ -24,7 +24,7 @@ import { } from "antd"; import { EditOutlined, MoreOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; -import type { CSSProperties, ComponentType } from "react"; +import type { CSSProperties, ComponentType, RefAttributes } from "react"; import { useAuth } from "@/components/auth-provider"; import { useToastFeedback } from "@/hooks/use-toast-feedback"; @@ -33,7 +33,7 @@ import { useMobileDetection } from "@/hooks/use-mobile-detection"; import { readApiError } from "@/lib/api"; import type { MenuItem, RoleItem } from "@/types/auth"; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; type RolesWithMenusResponse = { roles: RoleItem[]; diff --git a/web/src/app/admin/scheduled-tasks/page.tsx b/web/src/app/admin/scheduled-tasks/page.tsx index 7f862fe..05d6816 100644 --- a/web/src/app/admin/scheduled-tasks/page.tsx +++ b/web/src/app/admin/scheduled-tasks/page.tsx @@ -21,7 +21,7 @@ import { type CardProps, type TableColumnsType, } from "antd"; -import type { ComponentType } from "react"; +import type { ComponentType, RefAttributes } from "react"; import { useAuth } from "@/components/auth-provider"; import { useToastFeedback } from "@/hooks/use-toast-feedback"; @@ -67,7 +67,7 @@ const TIMEZONE_OPTIONS = [ { label: "UTC", value: "UTC" }, ] as const; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; const TABLE_MIN_SCROLL_Y = 180; const TABLE_VIEWPORT_GAP = 40; diff --git a/web/src/app/admin/syslog/page.tsx b/web/src/app/admin/syslog/page.tsx index 66555c7..5b82fca 100644 --- a/web/src/app/admin/syslog/page.tsx +++ b/web/src/app/admin/syslog/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Card, Col, Empty, Form, Input, Row, Space, Spin, Table, Tag, Typography, type CardProps } from "antd"; import type { ColumnsType } from "antd/es/table"; -import type { CSSProperties, ComponentType } from "react"; +import type { CSSProperties, ComponentType, RefAttributes } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/components/auth-provider"; @@ -18,7 +18,7 @@ const PAGE_SIZE = 50; const SYSLOG_TABLE_MIN_SCROLL_Y = 180; const SYSLOG_TABLE_VIEWPORT_GAP = 40; const SYSLOG_TABLE_FALLBACK_RESERVE = 220; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; type Filters = { action: string; diff --git a/web/src/app/admin/system-messages/page.tsx b/web/src/app/admin/system-messages/page.tsx index b03ad8d..58fd188 100644 --- a/web/src/app/admin/system-messages/page.tsx +++ b/web/src/app/admin/system-messages/page.tsx @@ -25,7 +25,7 @@ import { } from "antd"; import { MoreOutlined } from "@ant-design/icons"; import Link from "next/link"; -import type { ComponentType, CSSProperties, ReactNode } from "react"; +import type { ComponentType, CSSProperties, ReactNode, RefAttributes } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/components/auth-provider"; @@ -41,7 +41,7 @@ type CreateMessageValues = { target_user_id?: string; }; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; const MESSAGE_TYPE_OPTIONS: Array<{ label: string; value: SystemMessageType }> = [ { label: "通知", value: "info" }, diff --git a/web/src/app/admin/system-params/page.tsx b/web/src/app/admin/system-params/page.tsx index 5900d8e..b639197 100644 --- a/web/src/app/admin/system-params/page.tsx +++ b/web/src/app/admin/system-params/page.tsx @@ -25,7 +25,7 @@ import { type TableColumnsType, } from "antd"; import { EditOutlined, MoreOutlined } from "@ant-design/icons"; -import type { ComponentType } from "react"; +import type { ComponentType, RefAttributes } from "react"; import { useAuth } from "@/components/auth-provider"; import { useToastFeedback } from "@/hooks/use-toast-feedback"; @@ -57,7 +57,7 @@ const PARAM_STATUS_OPTIONS = [ { label: "已禁用", value: "disabled" }, ] as const satisfies ReadonlyArray<{ label: string; value: FormState["status"] }>; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; const PARAM_TABLE_MIN_SCROLL_Y = 180; const PARAM_TABLE_VIEWPORT_GAP = 40; diff --git a/web/src/app/admin/task-monitor/page.tsx b/web/src/app/admin/task-monitor/page.tsx index bf5cd03..8a425b3 100644 --- a/web/src/app/admin/task-monitor/page.tsx +++ b/web/src/app/admin/task-monitor/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import dayjs from "dayjs"; -import type { ComponentType } from "react"; +import type { ComponentType, RefAttributes } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useQuery } from "@tanstack/react-query"; import { @@ -38,7 +38,7 @@ import { } from "@/lib/task-monitor-display"; const { Text } = Typography; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; const DEFAULT_RECENT_LIMIT = 100; const TASK_MONITOR_TABLE_MIN_SCROLL_Y = 180; diff --git a/web/src/app/admin/tower-models/page.tsx b/web/src/app/admin/tower-models/page.tsx index 1e25799..f2885df 100644 --- a/web/src/app/admin/tower-models/page.tsx +++ b/web/src/app/admin/tower-models/page.tsx @@ -3,13 +3,13 @@ import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - App, Button, Card, Col, Dropdown, Empty, Form, + Image as AntImageBase, Input, InputNumber, Modal, @@ -25,7 +25,7 @@ import { } from "antd"; 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"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react"; import { useAuth } from "@/components/auth-provider"; import { useToastFeedback } from "@/hooks/use-toast-feedback"; @@ -40,7 +40,15 @@ import type { TowerModelSummary, } from "@/types/auth"; -const AntCard = Card as unknown as ComponentType; +const AntCard = Card as unknown as ComponentType>; +const AntImage = AntImageBase as unknown as ComponentType<{ + alt?: string; + height?: number; + preview?: boolean; + src?: string; + style?: CSSProperties; + width?: number; +}>; type TowerModelFormValues = { code: string; @@ -58,7 +66,7 @@ const EMPTY_FORM: TowerModelFormValues = { sort_order: 0, }; -const TOWER_MODEL_TABLE_MIN_SCROLL_Y = 220; +const TOWER_MODEL_TABLE_MIN_SCROLL_Y = 180; const TOWER_MODEL_VIEWPORT_GAP = 40; const TOWER_MODEL_FALLBACK_RESERVE = 220; const TOWER_MODEL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; @@ -171,11 +179,12 @@ function TowerModelImageCell({ return ( {imageUrl ? ( - {model.name} ) : ( @@ -205,20 +214,22 @@ function TowerModelImageCell({ export default function AdminTowerModelsPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const queryClient = useQueryClient(); - const { message: messageApi } = App.useApp(); const [form] = Form.useForm(); const fileInputRef = useRef(null); const isMobile = useMobileDetection(); const [keywordInput, setKeywordInput] = useState(""); - const [keyword, setKeyword] = useState(""); - const [enabledFilter, setEnabledFilter] = useState<"all" | "enabled" | "disabled">("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + const [enabledFilter, setEnabledFilter] = useState<"enabled" | "disabled" | undefined>(undefined); const keywordDebounceTimeoutRef = useRef(null); const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editingModel, setEditingModel] = useState(null); const [uploadModel, setUploadModel] = useState(null); const [pagination, setPagination] = useState({ current: 1, pageSize: TOWER_MODEL_DEFAULT_PAGE_SIZE }); const [cardViewPage, setCardViewPage] = useState(1); + const [allLoadedModels, setAllLoadedModels] = useState([]); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [tableScrollY, setTableScrollY] = useState(TOWER_MODEL_TABLE_MIN_SCROLL_Y); const tableScrollAnchorRef = useRef(null); const pageCardRef = useRef(null); @@ -230,18 +241,21 @@ export default function AdminTowerModelsPage() { const canRead = hasPermission("tower_model.read") || hasPermission("tower_model.manage") || hasPermission("tower.read") || hasPermission("tower.manage"); const canManage = hasPermission("tower_model.manage"); + const { current: paginationCurrent, pageSize: paginationPageSize } = pagination; + const trimmedKeyword = searchKeyword.trim(); const listPath = useMemo(() => { const params = new URLSearchParams(); - if (keyword.trim()) { - params.set("keyword", keyword.trim()); + params.set("limit", String(paginationPageSize)); + params.set("offset", String((paginationCurrent - 1) * paginationPageSize)); + if (trimmedKeyword) { + params.set("keyword", trimmedKeyword); } - if (enabledFilter !== "all") { + if (enabledFilter) { params.set("enabled", enabledFilter === "enabled" ? "true" : "false"); } - const query = params.toString(); - return `/api/v1/tower-models${query ? `?${query}` : ""}`; - }, [keyword, enabledFilter]); + return `/api/v1/tower-models?${params.toString()}`; + }, [enabledFilter, paginationCurrent, paginationPageSize, trimmedKeyword]); const mountsQuery = useQuery({ queryKey: ["/api/v1/admin/files?path=/"], @@ -271,18 +285,15 @@ export default function AdminTowerModelsPage() { useToastFeedback({ errorMessage: error || listError, + successMessage: success, clearError: () => setError(""), + clearSuccess: () => setSuccess(""), }); const listData = towerModelsQuery.data; const listItems = useMemo(() => listData?.items ?? [], [listData?.items]); const totalItems = listData?.total ?? listItems.length; - const { current: paginationCurrent, pageSize: paginationPageSize } = pagination; const paginationMaxPage = Math.max(1, Math.ceil(totalItems / paginationPageSize)); const tableCurrentPage = Math.min(paginationCurrent, paginationMaxPage); - const visibleCardModels = useMemo( - () => listItems.slice(0, cardViewPage * paginationPageSize), - [cardViewPage, listItems, paginationPageSize], - ); const refreshList = useCallback(async () => { await queryClient.invalidateQueries({ @@ -297,6 +308,32 @@ export default function AdminTowerModelsPage() { void refreshList(); }, [refreshList])); + useEffect(() => { + if (viewMode !== "card" || towerModelsQuery.isLoading) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + if (cardViewPage === 1) { + setAllLoadedModels(() => listItems); + } else { + setAllLoadedModels((previous) => { + if (listItems.length === 0) { + return previous; + } + const existingIds = new Set(previous.map((item) => item.id)); + const newModels = listItems.filter((item) => !existingIds.has(item.id)); + return [...previous, ...newModels]; + }); + } + setIsLoadingMore(false); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [cardViewPage, listItems, towerModelsQuery.isLoading, viewMode]); + // Handle infinite scroll for card view useEffect(() => { if (viewMode !== "card") return; @@ -308,7 +345,7 @@ export default function AdminTowerModelsPage() { if (!cardBody) return; const handleScroll = () => { - if (towerModelsQuery.isLoading) return; + if (isLoadingMore || towerModelsQuery.isLoading) return; const scrollTop = cardBody.scrollTop; const scrollHeight = cardBody.scrollHeight; @@ -316,17 +353,19 @@ export default function AdminTowerModelsPage() { if (scrollTop + clientHeight >= scrollHeight - 100) { const total = totalItems; - const loadedCount = visibleCardModels.length; + const loadedCount = allLoadedModels.length; if (loadedCount < total) { + setIsLoadingMore(true); setCardViewPage((prev) => prev + 1); + setPagination((prev) => ({ ...prev, current: prev.current + 1 })); } } }; cardBody.addEventListener("scroll", handleScroll); return () => cardBody.removeEventListener("scroll", handleScroll); - }, [viewMode, towerModelsQuery.isLoading, totalItems, visibleCardModels.length]); + }, [viewMode, isLoadingMore, towerModelsQuery.isLoading, totalItems, allLoadedModels.length]); const saveMutation = useMutation({ mutationFn: async (values: TowerModelFormValues) => { @@ -354,8 +393,8 @@ export default function AdminTowerModelsPage() { return "created" as const; }, onSuccess: async (mode) => { + setSuccess(mode === "created" ? "杆塔模型已创建" : "杆塔模型已更新"); setError(""); - messageApi.success(mode === "created" ? "杆塔模型已创建" : "杆塔模型已更新"); setDialogOpen(false); setEditingModel(null); form.resetFields(); @@ -374,8 +413,8 @@ export default function AdminTowerModelsPage() { } }, onSuccess: async () => { + setSuccess("杆塔模型已删除"); setError(""); - messageApi.success("杆塔模型已删除"); await refreshList(); }, onError: (candidate) => { @@ -398,8 +437,8 @@ export default function AdminTowerModelsPage() { return (await response.json()) as TowerModelImageUploadResponse; }, onSuccess: async () => { + setSuccess("模型图片上传成功"); setError(""); - messageApi.success("模型图片上传成功"); setUploadModel(null); await refreshList(); }, @@ -409,16 +448,24 @@ export default function AdminTowerModelsPage() { }); const openCreate = () => { + setError(""); + setSuccess(""); setEditingModel(null); form.setFieldsValue(EMPTY_FORM); setDialogOpen(true); }; const openEdit = useCallback((item: TowerModelSummary) => { + setError(""); + setSuccess(""); setEditingModel(item); form.setFieldsValue(toEditValues(item)); setDialogOpen(true); }, [form]); + const isDeletingModel = deleteMutation.isPending; + const isSavingModel = saveMutation.isPending; + const isUploadingImage = uploadImageMutation.isPending; + const deleteModelAsync = deleteMutation.mutateAsync; const handleKeywordChange = (value: string) => { setKeywordInput(value); @@ -428,9 +475,10 @@ export default function AdminTowerModelsPage() { } keywordDebounceTimeoutRef.current = setTimeout(() => { - setKeyword(value); + setSearchKeyword(value); setPagination((previous) => ({ ...previous, current: 1 })); setCardViewPage(1); + setAllLoadedModels([]); }, 500); }; @@ -481,7 +529,8 @@ export default function AdminTowerModelsPage() { title: "状态", dataIndex: "is_enabled", width: 80, - render: (value: boolean) => {value ? "启用" : "禁用"}, + align: "center", + render: (value: boolean) => {value ? "启用" : "禁用"}, }, { title: "排序", @@ -491,23 +540,27 @@ export default function AdminTowerModelsPage() { { title: "操作", key: "actions", - width: 120, - fixed: "right", + width: 180, render: (_: unknown, row) => { + const rowBusy = isDeletingModel || isSavingModel || isUploadingImage; const moreMenuItems: MenuProps["items"] = [ { key: "delete", label: "删除", danger: true, - disabled: deleteMutation.isPending, + disabled: rowBusy, }, ]; return ( - - {canManage && } + {canManage && ( - + )} + {canManage && ( + )} @@ -524,7 +577,7 @@ export default function AdminTowerModelsPage() { cancelText: "取消", okButtonProps: { danger: true }, onOk: async () => { - await deleteMutation.mutateAsync(row.id); + await deleteModelAsync(row.id); }, }); } @@ -532,7 +585,7 @@ export default function AdminTowerModelsPage() { }} trigger={["click"]} > -