[feat]:[FL-159][杆塔模型管理页面一致性优化]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 08:36:04 +08:00
parent 495c73cc9e
commit 09835543a2
20 changed files with 264 additions and 85 deletions
+9
View File
@@ -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}` 当前支持:
+4
View File
@@ -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,
)
+4
View File
@@ -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],
+88
View File
@@ -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()
+26
View File
@@ -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` 包装以消除页面级 `<img>` 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 字段、权限码或图片上传/预览接口。
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type AssetFormValues = {
description: string;
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
import type {
ElevationApplyJobCreateResponse,
ElevationApplyJobListResponse,
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
export default function AdminFilesPage() {
const queryClient = useQueryClient();
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type FilterStatus = "all" | "enabled" | "disabled";
+2 -9
View File
@@ -245,13 +245,6 @@ function parseJsonObjectText(value: string, label: string): Record<string, unkno
return parsed as Record<string, unknown>;
}
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() {
<Typography.Text></Typography.Text>
{selectedLinePreparation.lightning_current.ready && (
<Typography.Text type="secondary">
(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 ?? "-")})
</Typography.Text>
)}
<Typography.Text type="secondary">
@@ -2250,7 +2243,7 @@ export default function AdminPowerLinesPage() {
<Typography.Text></Typography.Text>
{selectedLinePreparation.lightning_density.ready && (
<Typography.Text type="secondary">
(Ng={selectedLinePreparation.lightning_density.values.ng ?? "-"})
(Ng={String(selectedLinePreparation.lightning_density.values.ng ?? "-")})
</Typography.Text>
)}
<Typography.Text type="secondary">
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type RolesWithMenusResponse = {
roles: RoleItem[];
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
const TABLE_MIN_SCROLL_Y = 180;
const TABLE_VIEWPORT_GAP = 40;
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type Filters = {
action: string;
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
const MESSAGE_TYPE_OPTIONS: Array<{ label: string; value: SystemMessageType }> = [
{ label: "通知", value: "info" },
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
const PARAM_TABLE_MIN_SCROLL_Y = 180;
const PARAM_TABLE_VIEWPORT_GAP = 40;
+2 -2
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
const DEFAULT_RECENT_LIMIT = 100;
const TASK_MONITOR_TABLE_MIN_SCROLL_Y = 180;
+105 -50
View File
@@ -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<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
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 (
<Space size={8}>
{imageUrl ? (
<img
<AntImage
src={imageUrl}
alt={model.name}
width={56}
height={56}
preview={false}
style={{ objectFit: "cover", borderRadius: 6, border: "1px solid #ddd" }}
/>
) : (
@@ -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<TowerModelFormValues>();
const fileInputRef = useRef<HTMLInputElement | null>(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<NodeJS.Timeout | null>(null);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState<TowerModelSummary | null>(null);
const [uploadModel, setUploadModel] = useState<TowerModelSummary | null>(null);
const [pagination, setPagination] = useState({ current: 1, pageSize: TOWER_MODEL_DEFAULT_PAGE_SIZE });
const [cardViewPage, setCardViewPage] = useState(1);
const [allLoadedModels, setAllLoadedModels] = useState<TowerModelSummary[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [tableScrollY, setTableScrollY] = useState(TOWER_MODEL_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const pageCardRef = useRef<HTMLDivElement | null>(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) => <Tag color={value ? "success" : "default"}>{value ? "启用" : "禁用"}</Tag>,
align: "center",
render: (value: boolean) => <Tag color={value ? "green" : "default"}>{value ? "启用" : "禁用"}</Tag>,
},
{
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 (
<Space size="small" wrap>
{canManage && <Button size="small" onClick={() => openEdit(row)}></Button>}
<Space wrap>
{canManage && (
<Button size="small" onClick={() => setUploadModel(row)}>
<Button size="small" disabled={rowBusy} onClick={() => openEdit(row)}>
</Button>
)}
{canManage && (
<Button size="small" disabled={rowBusy} onClick={() => setUploadModel(row)}>
</Button>
)}
@@ -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"]}
>
<Button size="small" icon={<MoreOutlined />} />
<Button size="small" disabled={rowBusy} icon={<MoreOutlined />} />
</Dropdown>
)}
</Space>
@@ -540,7 +593,7 @@ export default function AdminTowerModelsPage() {
},
},
],
[canManage, deleteMutation, fetchWithAuth, handleImagePreviewError, openEdit],
[canManage, deleteModelAsync, fetchWithAuth, handleImagePreviewError, isDeletingModel, isSavingModel, isUploadingImage, openEdit],
);
const renderDefaultSummary = (row: TowerModelSummary) => (
@@ -552,7 +605,7 @@ export default function AdminTowerModelsPage() {
);
const renderTowerModelCard = (row: TowerModelSummary) => {
const rowBusy = deleteMutation.isPending;
const rowBusy = isDeletingModel || isSavingModel || isUploadingImage;
const moreMenuItems: MenuProps["items"] = [
{
key: "upload-image",
@@ -573,7 +626,7 @@ export default function AdminTowerModelsPage() {
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
await deleteMutation.mutateAsync(row.id);
await deleteModelAsync(row.id);
},
});
},
@@ -590,7 +643,7 @@ export default function AdminTowerModelsPage() {
<Typography.Text strong ellipsis={{ tooltip: row.name }}>
{row.name}
</Typography.Text>
<Tag color={row.is_enabled ? "success" : "default"}>{row.is_enabled ? "启用" : "禁用"}</Tag>
<Tag color={row.is_enabled ? "green" : "default"}>{row.is_enabled ? "启用" : "禁用"}</Tag>
</Space>
}
extra={canManage ? (
@@ -671,7 +724,7 @@ export default function AdminTowerModelsPage() {
return;
}
window.requestAnimationFrame(updateTableScrollY);
}, [error, listError, listItems.length, paginationCurrent, paginationPageSize, towerModelsQuery.isFetching, updateTableScrollY]);
}, [error, listError, listItems.length, paginationCurrent, paginationPageSize, success, towerModelsQuery.isFetching, updateTableScrollY]);
useEffect(() => {
return () => {
@@ -786,10 +839,11 @@ export default function AdminTowerModelsPage() {
/>
</Form.Item>
<Form.Item label="状态" style={{ width: 170 }}>
<Select<"all" | "enabled" | "disabled">
<Select<"enabled" | "disabled">
value={enabledFilter}
allowClear
placeholder="全部"
options={[
{ value: "all", label: "全部" },
{ value: "enabled", label: "已启用" },
{ value: "disabled", label: "已禁用" },
]}
@@ -797,6 +851,7 @@ export default function AdminTowerModelsPage() {
setEnabledFilter(value);
setPagination((previous) => ({ ...previous, current: 1 }));
setCardViewPage(1);
setAllLoadedModels([]);
}}
/>
</Form.Item>
@@ -814,6 +869,7 @@ export default function AdminTowerModelsPage() {
columns={tableColumns}
dataSource={listItems}
loading={towerModelsQuery.isLoading}
tableLayout="fixed"
pagination={{
current: tableCurrentPage,
pageSize: pagination.pageSize,
@@ -827,8 +883,7 @@ export default function AdminTowerModelsPage() {
setPagination({ current: page, pageSize });
},
}}
scroll={{ x: 1450, y: tableScrollY }}
tableLayout="fixed"
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
@@ -841,11 +896,11 @@ export default function AdminTowerModelsPage() {
</div>
) : (
<div className="admin-tower-models-card-view">
{towerModelsQuery.isLoading && visibleCardModels.length === 0 ? (
{towerModelsQuery.isLoading && allLoadedModels.length === 0 ? (
<div className="admin-tower-models-card-view-state">
<Spin tip="加载中..." />
</div>
) : visibleCardModels.length === 0 ? (
) : allLoadedModels.length === 0 ? (
<div className="admin-tower-models-card-view-state">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -855,21 +910,21 @@ export default function AdminTowerModelsPage() {
) : (
<div className="admin-tower-models-card-view-content">
<Row gutter={[12, 12]}>
{visibleCardModels.map((row) => (
{allLoadedModels.map((row) => (
<Col key={row.id} xs={24} sm={24} md={12} lg={8} xl={6}>
{renderTowerModelCard(row)}
</Col>
))}
</Row>
{towerModelsQuery.isFetching && visibleCardModels.length < totalItems && (
{isLoadingMore && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Spin tip="加载更多..." />
</div>
)}
{visibleCardModels.length >= totalItems && visibleCardModels.length > 0 && (
{allLoadedModels.length >= totalItems && allLoadedModels.length > 0 && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Typography.Text type="secondary">
{visibleCardModels.length}
{allLoadedModels.length}
</Typography.Text>
</div>
)}
+2 -2
View File
@@ -24,7 +24,7 @@ import {
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
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 { useToastFeedback } from "@/hooks/use-toast-feedback";
@@ -33,7 +33,7 @@ import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { readApiError } from "@/lib/api";
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
const AntCard = Card as unknown as ComponentType<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type UserRolePayload = {
role_codes: string[];
+2 -2
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import dayjs from "dayjs";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ComponentType } from "react";
import type { CSSProperties, ComponentType, RefAttributes } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
@@ -41,7 +41,7 @@ import {
} from "@/lib/task-monitor-display";
const { Text } = Typography;
const AntCard = Card as unknown as ComponentType<CardProps>;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
const DEFAULT_RECENT_LIMIT = 100;
const WORKERS_TABLE_MIN_SCROLL_Y = 180;
+2 -2
View File
@@ -974,7 +974,7 @@ body {
}
.admin-tower-models-table-anchor .ant-table-body {
min-height: var(--admin-tower-models-table-body-min-height, 220px);
min-height: var(--admin-tower-models-table-body-min-height, 180px);
}
.admin-tower-models-page-card {
@@ -1038,7 +1038,7 @@ body {
.admin-tower-models-model-card-field {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
grid-template-columns: 64px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
}