diff --git a/web/src/app/admin/dimensions/page.tsx b/web/src/app/admin/dimensions/page.tsx index f480077..480fc2c 100644 --- a/web/src/app/admin/dimensions/page.tsx +++ b/web/src/app/admin/dimensions/page.tsx @@ -4,31 +4,37 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Card, + Col, Dropdown, Empty, Form, Input, Modal, Popconfirm, + Row, Select, Space, Spin, Table, Tag, Typography, + type CardProps, type MenuProps, } from "antd"; -import { MoreOutlined, PlusOutlined } from "@ant-design/icons"; +import { MoreOutlined, EditOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import Link from "next/link"; -import { useCallback, useMemo, useRef, useState } 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"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; +import { useMobileDetection } from "@/hooks/use-mobile-detection"; import { readApiError } from "@/lib/api"; import type { DimensionItem, DimensionItemListResponse } from "@/types/dimension"; +const AntCard = Card as unknown as ComponentType>; + type CreateDimensionValues = { dimension_type: string; code: string; @@ -57,10 +63,13 @@ function statusLabel(enabled: boolean): string { } const DIMENSIONS_TABLE_MIN_SCROLL_Y = 180; +const DIMENSIONS_TABLE_VIEWPORT_GAP = 40; +const DIMENSIONS_TABLE_FALLBACK_RESERVE = 220; export default function AdminDimensionsPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const queryClient = useQueryClient(); + const isMobile = useMobileDetection(); const [createForm] = Form.useForm(); const [editForm] = Form.useForm(); @@ -69,9 +78,14 @@ export default function AdminDimensionsPage() { const [createModalOpen, setCreateModalOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [selectedDimensionType, setSelectedDimensionType] = useState(undefined); - const [pagination, setPagination] = useState({ current: 1, pageSize: 50 }); + const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); const [tableScrollY, setTableScrollY] = useState(DIMENSIONS_TABLE_MIN_SCROLL_Y); const tableScrollAnchorRef = useRef(null); + const viewMode: "table" | "card" = isMobile ? "card" : "table"; + const [cardViewPage, setCardViewPage] = useState(1); + const [allLoadedDimensions, setAllLoadedDimensions] = useState([]); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const pageCardRef = useRef(null); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); @@ -114,6 +128,66 @@ export default function AdminDimensionsPage() { const dimensions = useMemo(() => dimensionsQuery.data?.items ?? [], [dimensionsQuery.data?.items]); + // Update allLoadedDimensions when dimensions data changes in card view + useEffect(() => { + if (viewMode !== "card" || dimensionsQuery.isLoading) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + if (cardViewPage === 1) { + setAllLoadedDimensions(() => dimensions); + } else { + setAllLoadedDimensions((prev) => { + if (dimensions.length === 0) { + return prev; + } + const existingIds = new Set(prev.map(d => d.id)); + const newDimensions = dimensions.filter(d => !existingIds.has(d.id)); + return [...prev, ...newDimensions]; + }); + } + setIsLoadingMore(false); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [dimensions, dimensionsQuery.isLoading, viewMode, cardViewPage]); + + // Handle infinite scroll for card view + 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 || dimensionsQuery.isLoading) return; + + const scrollTop = cardBody.scrollTop; + const scrollHeight = cardBody.scrollHeight; + const clientHeight = cardBody.clientHeight; + + if (scrollTop + clientHeight >= scrollHeight - 100) { + const total = dimensionsQuery.data?.total ?? 0; + const loadedCount = allLoadedDimensions.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, isLoadingMore, dimensionsQuery.isLoading, dimensionsQuery.data?.total, allLoadedDimensions.length]); + const uniqueDimensionTypes = useMemo(() => { const types = new Set(dimensions.map((d) => d.dimension_type)); return Array.from(types).sort(); @@ -263,6 +337,74 @@ export default function AdminDimensionsPage() { clearSuccess: () => setSuccess(""), }); + const updateTableScrollY = useCallback(() => { + if (typeof window === "undefined") { + return; + } + const anchor = tableScrollAnchorRef.current; + if (!anchor) { + return; + } + + const anchorTop = anchor.getBoundingClientRect().top; + const tableWrapper = anchor.querySelector(".ant-table-wrapper"); + const tableBody = anchor.querySelector(".ant-table-body"); + + let nextHeight = Math.floor(window.innerHeight - anchorTop - DIMENSIONS_TABLE_FALLBACK_RESERVE); + if (tableWrapper) { + const wrapperRect = tableWrapper.getBoundingClientRect(); + const bodyHeight = tableBody?.getBoundingClientRect().height ?? DIMENSIONS_TABLE_MIN_SCROLL_Y; + const nonBodyHeight = Math.max(0, wrapperRect.height - bodyHeight); + const topGap = Math.max(0, wrapperRect.top - anchorTop); + nextHeight = Math.floor(window.innerHeight - anchorTop - topGap - nonBodyHeight - DIMENSIONS_TABLE_VIEWPORT_GAP); + } + + const clampedHeight = Math.max(DIMENSIONS_TABLE_MIN_SCROLL_Y, nextHeight); + setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight)); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + window.requestAnimationFrame(updateTableScrollY); + }, [anyError, paginationCurrent, paginationPageSize, dimensions.length, dimensionsQuery.isFetching, updateTableScrollY]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const onViewportChange = () => { + window.requestAnimationFrame(updateTableScrollY); + }; + + window.addEventListener("resize", onViewportChange); + return () => { + window.removeEventListener("resize", onViewportChange); + }; + }, [updateTableScrollY]); + + useEffect(() => { + if (typeof window === "undefined" || typeof ResizeObserver === "undefined") { + return; + } + + const anchor = tableScrollAnchorRef.current; + if (!anchor) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + window.requestAnimationFrame(updateTableScrollY); + }); + resizeObserver.observe(anchor); + + return () => { + resizeObserver.disconnect(); + }; + }, [updateTableScrollY]); + const columns: ColumnsType = [ { title: "维度类型", @@ -372,6 +514,104 @@ export default function AdminDimensionsPage() { }, ]; + const renderDimensionCard = (item: DimensionItem) => { + const deleteLoading = deletingId === item.id; + const rowBusy = deleteLoading; + + const moreMenuItems: MenuProps["items"] = [ + { + key: "delete", + label: "删除", + danger: true, + disabled: rowBusy, + onClick: () => { + Modal.confirm({ + title: `确认删除维度项 ${item.name}(${item.code})?`, + okText: "删除", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: () => deleteDimensionMutation.mutate(item.id), + }); + }, + }, + { + key: "toggle-status", + label: item.is_enabled ? "禁用" : "启用", + disabled: rowBusy, + onClick: () => { + updateDimensionMutation.mutate({ + itemId: item.id, + payload: { + code: item.code, + name: item.name, + parent_id: item.parent_id || undefined, + description: item.description || undefined, + is_enabled: !item.is_enabled, + sort_order: item.sort_order, + }, + }); + }, + }, + ]; + + const parent = item.parent_id ? dimensions.find((d) => d.id === item.parent_id) : null; + + return ( + + {item.name} + {statusLabel(item.is_enabled)} + + } + extra={ + + ) } > -
- - ({ value: type, label: type }))} + onChange={(value) => { + setSelectedDimensionType(value); + setPagination((prev) => ({ ...prev, current: 1 })); + setCardViewPage(1); + setAllLoadedDimensions([]); + }} + /> + +
+ ) : ( +
+ +