优化杆塔模型管理分页与卡片展示布局

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-04 09:59:05 +08:00
parent 8ddaef7115
commit 7851f1bdc6
2 changed files with 286 additions and 81 deletions
+276 -81
View File
@@ -11,6 +11,7 @@ import {
Input,
InputNumber,
Modal,
Pagination,
Popconfirm,
Segmented,
Select,
@@ -21,7 +22,7 @@ import {
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useAuth } from "@/components/auth-provider";
import { Card } from "@/components/ui-antd";
@@ -74,6 +75,13 @@ const EMPTY_FORM: TowerModelFormValues = {
default_risk_level: "",
};
const TOWER_MODEL_MIN_SCROLL_Y = 220;
const TOWER_MODEL_TABLE_VIEWPORT_GAP = 40;
const TOWER_MODEL_TABLE_FALLBACK_RESERVE = 220;
const TOWER_MODEL_CARD_FALLBACK_RESERVE = 300;
const TOWER_MODEL_PAGE_SIZE_OPTIONS = [6, 9, 12, 18, 24];
const TOWER_MODEL_DEFAULT_PAGE_SIZE = 12;
function toEditValues(item: TowerModelSummary): TowerModelFormValues {
return {
code: item.code,
@@ -118,12 +126,14 @@ type TowerModelImageCellProps = {
model: TowerModelSummary;
fetchWithAuth: ReturnType<typeof useAuth>["fetchWithAuth"];
onPreviewError: (message: string) => void;
mode?: "compact" | "hero";
};
function TowerModelImageCell({
model,
fetchWithAuth,
onPreviewError,
mode = "compact",
}: TowerModelImageCellProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -198,6 +208,98 @@ function TowerModelImageCell({
}
}, [fetchWithAuth, model.id, onPreviewError]);
if (mode === "hero") {
return (
<div
style={{
position: "relative",
borderRadius: 10,
overflow: "hidden",
border: "1px solid var(--gray-6, #d9d9d9)",
background: "#f5f5f5",
minHeight: 260,
height: 280,
}}
>
{imageUrl ? (
<img
src={imageUrl}
alt={model.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f0f0f0",
}}
>
<Typography.Text type="secondary">{loading ? "图片加载中..." : "未上传模型图片"}</Typography.Text>
</div>
)}
<Tag
color={model.is_enabled ? "success" : "default"}
style={{ position: "absolute", right: 10, top: 10, marginInlineEnd: 0 }}
>
{model.is_enabled ? "启用" : "禁用"}
</Tag>
<div
style={{
position: "absolute",
inset: "auto 0 0 0",
padding: "10px 12px",
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 8,
background: "linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.72) 100%)",
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
color: "#fff",
fontWeight: 600,
lineHeight: 1.25,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{model.name}
</div>
<div
style={{
color: "rgba(255,255,255,0.86)",
fontSize: 12,
lineHeight: 1.25,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{model.code} · {model.tower_type || "-"}
</div>
</div>
<Button size="small" type="primary" ghost onClick={() => void openPreview()} loading={previewing}>
</Button>
</div>
</div>
);
}
return (
<Space size={8}>
{imageUrl ? (
@@ -251,6 +353,9 @@ export default function AdminTowerModelsPage() {
const [seedUploadOpen, setSeedUploadOpen] = useState(false);
const [seedOverwrite, setSeedOverwrite] = useState(false);
const [viewMode, setViewMode] = useState<TowerModelViewMode>("card");
const [pagination, setPagination] = useState({ current: 1, pageSize: TOWER_MODEL_DEFAULT_PAGE_SIZE });
const [tableScrollY, setTableScrollY] = useState(TOWER_MODEL_MIN_SCROLL_Y);
const viewScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const handleImagePreviewError = useCallback((message: string) => {
setError(message);
@@ -295,6 +400,15 @@ export default function AdminTowerModelsPage() {
},
});
const listError = towerModelsQuery.error instanceof Error ? towerModelsQuery.error.message : "";
const listData = towerModelsQuery.data;
const listItems = listData?.items ?? [];
const totalItems = listData?.total ?? listItems.length;
const pagedItems = useMemo(() => {
const start = (pagination.current - 1) * pagination.pageSize;
return listItems.slice(start, start + pagination.pageSize);
}, [listItems, pagination.current, pagination.pageSize]);
const refreshList = useCallback(async () => {
await queryClient.invalidateQueries({
predicate: (query) =>
@@ -308,6 +422,17 @@ export default function AdminTowerModelsPage() {
void refreshList();
}, [refreshList]));
useEffect(() => {
setPagination((previous) => (previous.current === 1 ? previous : { ...previous, current: 1 }));
}, [keyword, enabledFilter]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(totalItems / pagination.pageSize));
if (pagination.current > maxPage) {
setPagination((previous) => ({ ...previous, current: maxPage }));
}
}, [pagination.current, pagination.pageSize, totalItems]);
const saveMutation = useMutation({
mutationFn: async (values: TowerModelFormValues) => {
const payload = buildPayload(values);
@@ -551,6 +676,80 @@ export default function AdminTowerModelsPage() {
[canManage, deleteMutation, fetchWithAuth, handleImagePreviewError, openEdit],
);
const updateTableScrollY = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const anchor = viewScrollAnchorRef.current;
if (!anchor) {
return;
}
const anchorTop = anchor.getBoundingClientRect().top;
const tableWrapper = anchor.querySelector<HTMLElement>(".ant-table-wrapper");
const tableBody = anchor.querySelector<HTMLElement>(".ant-table-body");
let nextHeight = Math.floor(
window.innerHeight
- anchorTop
- (viewMode === "card" ? TOWER_MODEL_CARD_FALLBACK_RESERVE : TOWER_MODEL_TABLE_FALLBACK_RESERVE),
);
if (tableWrapper) {
const wrapperRect = tableWrapper.getBoundingClientRect();
const bodyHeight = tableBody?.getBoundingClientRect().height ?? TOWER_MODEL_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 - TOWER_MODEL_TABLE_VIEWPORT_GAP);
}
const overflow = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
if (overflow > 0) {
nextHeight -= Math.ceil(overflow + 8);
}
const clampedHeight = Math.max(TOWER_MODEL_MIN_SCROLL_Y, nextHeight);
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
}, [viewMode]);
useEffect(() => {
updateTableScrollY();
}, [error, listError, pagination.current, pagination.pageSize, totalItems, towerModelsQuery.isFetching, updateTableScrollY, viewMode]);
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 = viewScrollAnchorRef.current;
if (!anchor) {
return;
}
const resizeObserver = new ResizeObserver(() => {
window.requestAnimationFrame(updateTableScrollY);
});
resizeObserver.observe(anchor);
return () => {
resizeObserver.disconnect();
};
}, [updateTableScrollY]);
const mounts = mountsQuery.data?.mounts ?? [];
if (initializing || towerModelsQuery.isLoading) {
@@ -579,9 +778,6 @@ export default function AdminTowerModelsPage() {
);
}
const listError = towerModelsQuery.error instanceof Error ? towerModelsQuery.error.message : "";
const listData = towerModelsQuery.data;
return (
<Space direction="vertical" size={16} className="w-full">
{(error || listError) && (
@@ -637,85 +833,84 @@ export default function AdminTowerModelsPage() {
onChange={(value) => setViewMode(value === "list" ? "list" : "card")}
/>
</Space>
{listData && listData.items.length === 0 ? (
{totalItems === 0 ? (
<Empty description="暂无杆塔模型数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(listData?.items ?? []).map((row) => (
<Card key={row.id} size="2">
<Space direction="vertical" size={10} className="w-full">
<Space size={8} align="start" className="w-full justify-between">
<Space direction="vertical" size={0}>
<Typography.Text strong>{row.name}</Typography.Text>
<Typography.Text code>{row.code}</Typography.Text>
</Space>
<Tag color={row.is_enabled ? "success" : "default"}>
{row.is_enabled ? "启用" : "禁用"}
</Tag>
</Space>
<Space direction="vertical" size={4}>
<Typography.Text type="secondary">
{row.tower_type || "-"}
</Typography.Text>
<Typography.Text type="secondary">
{row.sort_order}
</Typography.Text>
</Space>
<Space size={[8, 4]} wrap>
<Tag> {row.default_ground_resistance_ohm ?? "-"}Ω</Tag>
<Tag> {row.default_lightning_density ?? "-"}</Tag>
<Tag> {row.default_span_small_m ?? "-"} / {row.default_span_large_m ?? "-"}</Tag>
<Tag> {row.default_slope_1 ?? "-"} / {row.default_slope_2 ?? "-"}</Tag>
</Space>
{row.image_path ? (
<TowerModelImageCell
model={row}
fetchWithAuth={fetchWithAuth}
onPreviewError={handleImagePreviewError}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{canManage && (
<Space size={8} wrap>
<Button size="small" onClick={() => openEdit(row)}>
</Button>
<Button size="small" onClick={() => setUploadModel(row)}>
</Button>
<Popconfirm
title="删除杆塔模型"
description={`确认删除模型 ${row.code} 吗?`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={async () => {
await deleteMutation.mutateAsync(row.id);
}}
>
<Button size="small" danger loading={deleteMutation.isPending}>
</Button>
</Popconfirm>
</Space>
)}
</Space>
</Card>
))}
</div>
) : (
<Table<TowerModelSummary>
rowKey={(row) => row.id}
columns={tableColumns}
dataSource={listData?.items ?? []}
pagination={{ pageSize: 20, showSizeChanger: true }}
scroll={{ x: 1450 }}
/>
<>
<div ref={viewScrollAnchorRef} className="mt-4">
{viewMode === "card" ? (
<div
className="admin-tower-models-card-anchor"
style={{ "--admin-tower-models-card-body-height": `${tableScrollY}px` } as CSSProperties}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{pagedItems.map((row) => (
<Card key={row.id} size="2">
<Space direction="vertical" size={10} className="w-full">
<TowerModelImageCell
model={row}
fetchWithAuth={fetchWithAuth}
onPreviewError={handleImagePreviewError}
mode="hero"
/>
{canManage && (
<Space size={8} wrap>
<Button size="small" onClick={() => openEdit(row)}>
</Button>
<Button size="small" onClick={() => setUploadModel(row)}>
</Button>
<Popconfirm
title="删除杆塔模型"
description={`确认删除模型 ${row.code} 吗?`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={async () => {
await deleteMutation.mutateAsync(row.id);
}}
>
<Button size="small" danger loading={deleteMutation.isPending}>
</Button>
</Popconfirm>
</Space>
)}
</Space>
</Card>
))}
</div>
</div>
) : (
<div
className="admin-tower-models-table-anchor"
style={{ "--admin-tower-models-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<TowerModelSummary>
rowKey={(row) => row.id}
columns={tableColumns}
dataSource={pagedItems}
pagination={false}
scroll={{ x: 1450, y: tableScrollY }}
/>
</div>
)}
</div>
<div className="mt-4 flex justify-end">
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={totalItems}
showSizeChanger
pageSizeOptions={TOWER_MODEL_PAGE_SIZE_OPTIONS.map((value) => String(value))}
showTotal={(total) => `${total}`}
onChange={(page, pageSize) => {
setPagination({ current: page, pageSize });
}}
/>
</div>
</>
)}
</Space>
</Card>
+10
View File
@@ -232,6 +232,16 @@ body {
min-height: var(--admin-files-table-body-min-height, 180px);
}
.admin-tower-models-table-anchor .ant-table-body {
min-height: var(--admin-tower-models-table-body-min-height, 220px);
}
.admin-tower-models-card-anchor {
max-height: var(--admin-tower-models-card-body-height, 320px);
overflow: auto;
padding-right: 2px;
}
.fquiz-row-selected > td {
background: var(--fquiz-theme-bg-active) !important;
}