fix:[FL-207][修改为文件视图模式-逐级下钻导航]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-28 14:37:00 +08:00
parent 148ce356ee
commit 6c1cead3d9
+215 -98
View File
@@ -16,15 +16,14 @@ import {
Space, Space,
Spin, Spin,
Table, Table,
Tree, Breadcrumb,
Typography, Typography,
Upload, Upload,
message, message,
type CardProps, type CardProps,
} from "antd"; } from "antd";
import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, ApartmentOutlined } from "@ant-design/icons"; import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import type { DataNode } from "antd/es/tree";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
import { AdminPageLoading } from "@/components/admin-page-loading"; import { AdminPageLoading } from "@/components/admin-page-loading";
@@ -168,8 +167,8 @@ export default function AtpModelsPage() {
const [tableScrollY, setTableScrollY] = useState(ATP_TABLE_MIN_SCROLL_Y); const [tableScrollY, setTableScrollY] = useState(ATP_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null); const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const viewMode: "table" | "card" = isMobile ? "card" : "table"; const viewMode: "table" | "card" = isMobile ? "card" : "table";
const [displayMode, setDisplayMode] = useState<"list" | "tree">("list"); const [displayMode, setDisplayMode] = useState<"list" | "file">("list");
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]); const [fileViewPath, setFileViewPath] = useState<string[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const [cardViewPage, setCardViewPage] = useState(1); const [cardViewPage, setCardViewPage] = useState(1);
const [allLoadedAssets, setAllLoadedAssets] = useState<AtpAssetSummary[]>([]); const [allLoadedAssets, setAllLoadedAssets] = useState<AtpAssetSummary[]>([]);
@@ -523,101 +522,150 @@ export default function AtpModelsPage() {
[canManage, deleteMutation], [canManage, deleteMutation],
); );
const buildTreeData = useCallback(() => { type FileViewItem = {
const treeMap = new Map<string, DataNode>(); type: "folder" | "file";
name: string;
displayName: string;
value: string;
item?: AtpAssetSummary;
};
const getFileViewItems = useCallback((): FileViewItem[] => {
const currentLevel = fileViewPath.length;
if (currentLevel === 0) {
const voltageSet = new Set<string>();
assetItems.forEach((item) => { assetItems.forEach((item) => {
const voltage = item.voltage_level || "未分类"; const voltage = item.voltage_level || "未分类";
voltageSet.add(voltage);
});
return Array.from(voltageSet)
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((voltage) => ({
type: "folder" as const,
name: voltage,
displayName: formatDimensionValue(voltage, DEFAULT_VOLTAGE_LEVELS),
value: voltage,
}));
}
if (currentLevel === 1) {
const voltage = fileViewPath[0];
const towerSet = new Set<string>();
assetItems
.filter((item) => (item.voltage_level || "未分类") === voltage)
.forEach((item) => {
const tower = item.tower_type || "未分类"; const tower = item.tower_type || "未分类";
towerSet.add(tower);
});
return Array.from(towerSet)
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((tower) => ({
type: "folder" as const,
name: tower,
displayName: formatDimensionValue(tower, DEFAULT_TOWER_TYPES),
value: tower,
}));
}
if (currentLevel === 2) {
const voltage = fileViewPath[0];
const tower = fileViewPath[1];
const sceneSet = new Set<string>();
assetItems
.filter(
(item) =>
(item.voltage_level || "未分类") === voltage &&
(item.tower_type || "未分类") === tower
)
.forEach((item) => {
const scene = item.scene_type || "未分类"; const scene = item.scene_type || "未分类";
sceneSet.add(scene);
});
return Array.from(sceneSet)
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((scene) => ({
type: "folder" as const,
name: scene,
displayName: formatDimensionValue(scene, DEFAULT_SCENE_TYPES),
value: scene,
}));
}
if (currentLevel === 3) {
const voltage = fileViewPath[0];
const tower = fileViewPath[1];
const scene = fileViewPath[2];
const arresterSet = new Set<string>();
assetItems
.filter(
(item) =>
(item.voltage_level || "未分类") === voltage &&
(item.tower_type || "未分类") === tower &&
(item.scene_type || "未分类") === scene
)
.forEach((item) => {
const arrester = item.arrester_config || "未分类"; const arrester = item.arrester_config || "未分类";
arresterSet.add(arrester);
const voltageKey = `voltage-${voltage}`;
const towerKey = `${voltageKey}-tower-${tower}`;
const sceneKey = `${towerKey}-scene-${scene}`;
const arresterKey = `${sceneKey}-arrester-${arrester}`;
const itemKey = `${arresterKey}-item-${item.id}`;
if (!treeMap.has(voltageKey)) {
treeMap.set(voltageKey, {
key: voltageKey,
title: formatDimensionValue(voltage, DEFAULT_VOLTAGE_LEVELS),
icon: <FolderOutlined />,
children: [],
}); });
return Array.from(arresterSet)
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((arrester) => ({
type: "folder" as const,
name: arrester,
displayName: formatDimensionValue(arrester, DEFAULT_ARRESTER_CONFIGS),
value: arrester,
}));
} }
const voltageNode = treeMap.get(voltageKey)!; if (currentLevel === 4) {
let towerNode = voltageNode.children?.find((n) => n.key === towerKey); const voltage = fileViewPath[0];
if (!towerNode) { const tower = fileViewPath[1];
towerNode = { const scene = fileViewPath[2];
key: towerKey, const arrester = fileViewPath[3];
title: formatDimensionValue(tower, DEFAULT_TOWER_TYPES), return assetItems
icon: <FolderOutlined />, .filter(
children: [], (item) =>
}; (item.voltage_level || "未分类") === voltage &&
voltageNode.children = [...(voltageNode.children || []), towerNode]; (item.tower_type || "未分类") === tower &&
(item.scene_type || "未分类") === scene &&
(item.arrester_config || "未分类") === arrester
)
.map((item) => ({
type: "file" as const,
name: item.name,
displayName: item.name,
value: item.id,
item,
}));
} }
let sceneNode = towerNode.children?.find((n) => n.key === sceneKey); return [];
if (!sceneNode) { }, [assetItems, fileViewPath]);
sceneNode = {
key: sceneKey, const fileViewItems = useMemo(() => getFileViewItems(), [getFileViewItems]);
title: formatDimensionValue(scene, DEFAULT_SCENE_TYPES),
icon: <FolderOutlined />, const handleFileViewItemClick = (item: FileViewItem) => {
children: [], if (item.type === "folder") {
}; setFileViewPath([...fileViewPath, item.value]);
towerNode.children = [...(towerNode.children || []), sceneNode];
} }
let arresterNode = sceneNode.children?.find((n) => n.key === arresterKey);
if (!arresterNode) {
arresterNode = {
key: arresterKey,
title: formatDimensionValue(arrester, DEFAULT_ARRESTER_CONFIGS),
icon: <FolderOutlined />,
children: [],
}; };
sceneNode.children = [...(sceneNode.children || []), arresterNode];
const handleBreadcrumbClick = (index: number) => {
if (index === -1) {
setFileViewPath([]);
} else {
setFileViewPath(fileViewPath.slice(0, index + 1));
} }
const deleteLoading = deleteMutation.isPending;
const itemNode: DataNode = {
key: itemKey,
title: (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span>{item.name}</span>
<Space size="small">
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
{formatDateTime(item.update_date)}
</Typography.Text>
<Popconfirm
title="删除模型"
description="这会同时删除其版本与运行记录。"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deleteLoading }}
onConfirm={() => deleteMutation.mutate(item.id)}
disabled={!canManage || deleteLoading}
>
<Button danger size="small" loading={deleteLoading} disabled={!canManage || deleteLoading} onClick={(e) => e.stopPropagation()}>
</Button>
</Popconfirm>
</Space>
</div>
),
icon: <FileOutlined />,
isLeaf: true,
}; };
arresterNode.children = [...(arresterNode.children || []), itemNode]; const getBreadcrumbLabel = (index: number): string => {
}); if (index === 0) return formatDimensionValue(fileViewPath[0], DEFAULT_VOLTAGE_LEVELS);
if (index === 1) return formatDimensionValue(fileViewPath[1], DEFAULT_TOWER_TYPES);
return Array.from(treeMap.values()); if (index === 2) return formatDimensionValue(fileViewPath[2], DEFAULT_SCENE_TYPES);
}, [assetItems, canManage, deleteMutation]); if (index === 3) return formatDimensionValue(fileViewPath[3], DEFAULT_ARRESTER_CONFIGS);
return "";
const treeData = useMemo(() => buildTreeData(), [buildTreeData]); };
const renderAtpModelCard = (item: AtpAssetSummary) => { const renderAtpModelCard = (item: AtpAssetSummary) => {
const deleteLoading = deleteMutation.isPending; const deleteLoading = deleteMutation.isPending;
@@ -705,10 +753,15 @@ export default function AtpModelsPage() {
{!isMobile && ( {!isMobile && (
<Segmented <Segmented
value={displayMode} value={displayMode}
onChange={(value) => setDisplayMode(value as "list" | "tree")} onChange={(value) => {
setDisplayMode(value as "list" | "file");
if (value === "list") {
setFileViewPath([]);
}
}}
options={[ options={[
{ label: "列表视图", value: "list", icon: <TableOutlined /> }, { label: "列表视图", value: "list", icon: <TableOutlined /> },
{ label: "树形视图", value: "tree", icon: <ApartmentOutlined /> }, { label: "文件视图", value: "file", icon: <FolderOpenOutlined /> },
]} ]}
/> />
)} )}
@@ -746,28 +799,92 @@ export default function AtpModelsPage() {
</Form> </Form>
)} )}
{displayMode === "tree" && viewMode === "table" ? ( {displayMode === "file" && viewMode === "table" ? (
<div className="admin-atp-models-tree-view mt-4" style={{ maxHeight: `${tableScrollY}px`, overflow: "auto" }}> <div className="admin-atp-models-file-view mt-4">
<div style={{ marginBottom: 16 }}>
<Breadcrumb
items={[
{
title: (
<a onClick={() => handleBreadcrumbClick(-1)}>
<HomeOutlined /> ATP模型
</a>
),
},
...fileViewPath.map((_, index) => ({
title: (
<a onClick={() => handleBreadcrumbClick(index)}>
{getBreadcrumbLabel(index)}
</a>
),
})),
]}
/>
</div>
<div style={{ maxHeight: `${tableScrollY}px`, overflow: "auto" }}>
{assetsQuery.isLoading || assetsQuery.isFetching ? ( {assetsQuery.isLoading || assetsQuery.isFetching ? (
<div style={{ textAlign: "center", padding: "40px 0" }}> <div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin tip="加载中..." /> <Spin tip="加载中..." />
</div> </div>
) : treeData.length === 0 ? ( ) : fileViewItems.length === 0 ? (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的 ATP 模型。" description="当前目录为空"
/> />
) : ( ) : (
<Tree <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: "16px" }}>
showIcon {fileViewItems.map((item, index) =>
expandedKeys={expandedKeys} item.type === "folder" ? (
onExpand={(keys) => setExpandedKeys(keys)} <Card
treeData={treeData} key={index}
blockNode hoverable
style={{ backgroundColor: "#fff" }} onClick={() => handleFileViewItemClick(item)}
/> style={{ cursor: "pointer" }}
bodyStyle={{ padding: "16px" }}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<FolderOutlined style={{ fontSize: "48px", color: "#faad14" }} />
<Typography.Text ellipsis={{ tooltip: item.displayName }} style={{ width: "100%", textAlign: "center" }}>
{item.displayName}
</Typography.Text>
</div>
</Card>
) : (
<Card
key={index}
bodyStyle={{ padding: "16px" }}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<FileOutlined style={{ fontSize: "48px", color: "#1890ff" }} />
<Typography.Text ellipsis={{ tooltip: item.displayName }} style={{ width: "100%", textAlign: "center" }}>
{item.displayName}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
{item.item && formatDateTime(item.item.update_date)}
</Typography.Text>
{item.item && (
<Popconfirm
title="删除模型"
description="这会同时删除其版本与运行记录。"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => item.item && deleteMutation.mutate(item.item.id)}
disabled={!canManage}
>
<Button danger size="small" disabled={!canManage}>
</Button>
</Popconfirm>
)} )}
</div> </div>
</Card>
)
)}
</div>
)}
</div>
</div>
) : viewMode === "table" ? ( ) : viewMode === "table" ? (
<div <div
ref={tableScrollAnchorRef} ref={tableScrollAnchorRef}