[feat]:[FL-202][ATP模型管理改造]
1. 表格列调整:只展示电压等级、塔型、场景、避雷器组合、描述、更新时间 2. 创建时支持上传目录,保留原始目录结构(使用jszip打包后上传) 3. 去掉编辑和详情查看功能 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import {
|
||||
getAtpAssetStatusDisplay,
|
||||
getAtpReleaseStatusDisplay,
|
||||
getAtpRunnerKindLabel,
|
||||
} from "@/lib/atp-asset-display";
|
||||
import type {
|
||||
AtpAssetFileEntry,
|
||||
AtpAssetFileListResponse,
|
||||
AtpAssetReleaseDetail,
|
||||
AtpAssetReleaseListResponse,
|
||||
AtpAssetSummary,
|
||||
} from "@/types/auth";
|
||||
|
||||
export default function AtpAssetDetailPage() {
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const params = useParams<{ id: string }>();
|
||||
const assetId = typeof params?.id === "string" ? params.id : "";
|
||||
|
||||
const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
|
||||
const refreshAtpData = useCallback(() => {
|
||||
// This function is called by WebSocket updates - kept for compatibility
|
||||
}, []);
|
||||
|
||||
useTopicSubscription(
|
||||
"admin.atp-assets",
|
||||
useCallback(() => {
|
||||
void refreshAtpData();
|
||||
}, [refreshAtpData]),
|
||||
);
|
||||
|
||||
const assetQuery = useQuery({
|
||||
queryKey: ["atp-asset-detail", assetId],
|
||||
enabled: Boolean(user && canRead && assetId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetSummary;
|
||||
},
|
||||
});
|
||||
|
||||
const releasesQuery = useQuery({
|
||||
queryKey: ["atp-asset-releases", assetId],
|
||||
enabled: Boolean(user && canRead && assetId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}/releases`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetReleaseListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const releases = releasesQuery.data?.items ?? [];
|
||||
const selectedReleaseId = releases.find((item) => item.is_active)?.id ?? releases[0]?.id ?? "";
|
||||
const selectedRelease = releases.find((item) => item.id === selectedReleaseId) ?? null;
|
||||
|
||||
const releaseDetailQuery = useQuery({
|
||||
queryKey: ["atp-release-detail", selectedReleaseId],
|
||||
enabled: Boolean(user && canRead && selectedReleaseId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetReleaseDetail;
|
||||
},
|
||||
});
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ["atp-release-files", selectedReleaseId],
|
||||
enabled: Boolean(user && canRead && selectedReleaseId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseId}/files`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetFileListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const fileColumns = useMemo<ColumnsType<AtpAssetFileEntry>>(
|
||||
() => [
|
||||
{
|
||||
title: "路径",
|
||||
dataIndex: "relative_path",
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "file_role",
|
||||
render: (value: string | null) => (value ? <Tag>{value}</Tag> : "-"),
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "size",
|
||||
render: (value: number, item) => (item.is_dir ? "-" : `${value} B`),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (initializing) {
|
||||
return <AdminPageLoading tip="加载 ATP 模型详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card title="ATP 模型详情">
|
||||
<Typography.Text type="secondary">
|
||||
{!user ? "请先登录后再查看 ATP 模型详情。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetQuery.isLoading) {
|
||||
return <AdminPageLoading tip="加载 ATP 模型详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (assetQuery.error instanceof Error) {
|
||||
return (
|
||||
<Card title="ATP 模型详情">
|
||||
<Alert type="error" showIcon message="模型详情加载失败" description={assetQuery.error.message} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const asset = assetQuery.data;
|
||||
if (!asset) {
|
||||
return (
|
||||
<Card title="ATP 模型详情">
|
||||
<Empty description="未找到对应模型" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const assetStatusDisplay = getAtpAssetStatusDisplay(asset.status);
|
||||
const releaseDetail = releaseDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card
|
||||
title={asset.name}
|
||||
extra={
|
||||
<Link href="/admin/atp-models">
|
||||
<Button>返回列表</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="编码">{asset.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={assetStatusDisplay.color}>{assetStatusDisplay.label}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电压等级">{asset.voltage_level || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="塔型">{asset.tower_type || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{asset.scene_type || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="避雷器装设组合">{asset.arrester_config || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="说明" span={2}>
|
||||
{asset.description || "-"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={selectedRelease ? `当前版本:${selectedRelease.release_tag || `r${selectedRelease.release_no}`}` : "当前版本"}
|
||||
>
|
||||
{!selectedRelease ? (
|
||||
<Empty description="请选择一个版本" />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{releaseDetailQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="版本详情加载失败" description={releaseDetailQuery.error.message} />
|
||||
) : null}
|
||||
|
||||
{releaseDetail ? (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={getAtpReleaseStatusDisplay(releaseDetail.status).color}>
|
||||
{getAtpReleaseStatusDisplay(releaseDetail.status).label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行类型">{getAtpRunnerKindLabel(releaseDetail.runner_kind)}</Descriptions.Item>
|
||||
<Descriptions.Item label="存储挂载">{releaseDetail.storage_mount_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="存储目录">
|
||||
<Typography.Text code>{releaseDetail.storage_root_path}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="入口文件">{releaseDetail.entry_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="结果文件">{releaseDetail.result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM 目录">{releaseDetail.egm_subdir || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM 结果">{releaseDetail.egm_result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="预处理脚本">{releaseDetail.preprocess_script || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="后处理脚本">{releaseDetail.postprocess_script || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Typography.Text strong>Manifest</Typography.Text>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{JSON.stringify(releaseDetail.manifest_json, null, 2)}
|
||||
</pre>
|
||||
<Typography.Text strong>Validation</Typography.Text>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{JSON.stringify(releaseDetail.validation_json, null, 2)}
|
||||
</pre>
|
||||
</Space>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="目录文件清单">
|
||||
{filesQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="文件清单加载失败" description={filesQuery.error.message} />
|
||||
) : (
|
||||
<Table<AtpAssetFileEntry>
|
||||
rowKey="relative_path"
|
||||
loading={filesQuery.isLoading}
|
||||
columns={fileColumns}
|
||||
dataSource={filesQuery.data?.items ?? []}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前版本暂无文件" : "请先选择版本" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 980, y: 320 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
@@ -16,12 +14,12 @@ import {
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
type CardProps,
|
||||
type MenuProps,
|
||||
} from "antd";
|
||||
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
|
||||
import { UploadOutlined, InboxOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
||||
|
||||
@@ -31,7 +29,6 @@ import { CreatableSingleSelect } from "@/components/creatable-single-select";
|
||||
import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||
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 & RefAttributes<HTMLDivElement>>;
|
||||
@@ -42,6 +39,7 @@ type AssetFormValues = {
|
||||
tower_type: string;
|
||||
scene_type: string;
|
||||
arrester_config: string;
|
||||
files: File[];
|
||||
};
|
||||
|
||||
const EMPTY_FORM: AssetFormValues = {
|
||||
@@ -50,6 +48,7 @@ const EMPTY_FORM: AssetFormValues = {
|
||||
tower_type: "",
|
||||
scene_type: "",
|
||||
arrester_config: "",
|
||||
files: [],
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
@@ -63,16 +62,6 @@ function formatDateTime(value: string | null | undefined): string {
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function toFormValues(item: AtpAssetSummary): AssetFormValues {
|
||||
return {
|
||||
description: item.description,
|
||||
voltage_level: item.voltage_level ?? "",
|
||||
tower_type: item.tower_type ?? "",
|
||||
scene_type: item.scene_type ?? "",
|
||||
arrester_config: item.arrester_config ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function generateName(values: AssetFormValues): string {
|
||||
const parts = [
|
||||
values.voltage_level,
|
||||
@@ -87,18 +76,6 @@ function generateCode(): string {
|
||||
return `atp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
function buildPayload(values: AssetFormValues) {
|
||||
return {
|
||||
code: generateCode(),
|
||||
name: generateName(values),
|
||||
description: values.description.trim(),
|
||||
voltage_level: values.voltage_level.trim() || null,
|
||||
tower_type: values.tower_type.trim() || null,
|
||||
scene_type: values.scene_type.trim() || null,
|
||||
arrester_config: values.arrester_config.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_VOLTAGE_LEVELS = [
|
||||
{ label: "35kV", value: "35" },
|
||||
{ label: "66kV", value: "66" },
|
||||
@@ -179,8 +156,8 @@ export default function AtpModelsPage() {
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [editingAsset, setEditingAsset] = useState<AtpAssetSummary | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [fileList, setFileList] = useState<File[]>([]);
|
||||
const [tableScrollY, setTableScrollY] = useState(ATP_TABLE_MIN_SCROLL_Y);
|
||||
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewMode: "table" | "card" = isMobile ? "card" : "table";
|
||||
@@ -219,36 +196,69 @@ export default function AtpModelsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
const createAssetMutation = useMutation({
|
||||
mutationFn: async (values: AssetFormValues) => {
|
||||
const payload = buildPayload(values);
|
||||
const response = editingAsset
|
||||
? await fetchWithAuth(`/api/v1/atp/assets/${editingAsset.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: await fetchWithAuth("/api/v1/atp/assets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const payload = {
|
||||
code: generateCode(),
|
||||
name: generateName(values),
|
||||
description: values.description.trim(),
|
||||
voltage_level: values.voltage_level.trim() || null,
|
||||
tower_type: values.tower_type.trim() || null,
|
||||
scene_type: values.scene_type.trim() || null,
|
||||
arrester_config: values.arrester_config.trim() || null,
|
||||
};
|
||||
|
||||
const response = await fetchWithAuth("/api/v1/atp/assets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return await response.json();
|
||||
|
||||
const createdAsset = await response.json();
|
||||
|
||||
if (values.files.length > 0) {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of values.files) {
|
||||
const path = (file as any).webkitRelativePath || file.name;
|
||||
zip.file(path, file);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
const formData = new FormData();
|
||||
formData.append("archive", zipBlob, "model.zip");
|
||||
|
||||
const uploadResponse = await fetchWithAuth(
|
||||
`/api/v1/atp/assets/${createdAsset.id}/releases/upload`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(await readApiError(uploadResponse));
|
||||
}
|
||||
}
|
||||
|
||||
return createdAsset;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
||||
setSuccess(editingAsset ? "模型已更新" : "模型已创建");
|
||||
setSuccess("模型已创建并上传");
|
||||
setError("");
|
||||
setModalOpen(false);
|
||||
setEditingAsset(null);
|
||||
setFileList([]);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setSuccess("");
|
||||
setError(candidate instanceof Error ? candidate.message : "保存模型失败");
|
||||
setError(candidate instanceof Error ? candidate.message : "创建模型失败");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -273,23 +283,15 @@ export default function AtpModelsPage() {
|
||||
const openCreateModal = useCallback(() => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setEditingAsset(null);
|
||||
setFileList([]);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
}, [form]);
|
||||
|
||||
const openEditModal = useCallback((item: AtpAssetSummary) => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setEditingAsset(item);
|
||||
form.setFieldsValue(toFormValues(item));
|
||||
setModalOpen(true);
|
||||
}, [form]);
|
||||
|
||||
const closeModal = () => {
|
||||
if (saveMutation.isPending) return;
|
||||
if (createAssetMutation.isPending) return;
|
||||
setModalOpen(false);
|
||||
setEditingAsset(null);
|
||||
setFileList([]);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
@@ -454,123 +456,73 @@ export default function AtpModelsPage() {
|
||||
const columns = useMemo<ColumnsType<AtpAssetSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "模型",
|
||||
key: "asset",
|
||||
width: 200,
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" code>
|
||||
{item.code}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
title: "电压等级",
|
||||
dataIndex: "voltage_level",
|
||||
width: 120,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 96,
|
||||
align: "center",
|
||||
render: (value: string) => {
|
||||
const display = getAtpAssetStatusDisplay(value);
|
||||
return <Tag color={display.color}>{display.label}</Tag>;
|
||||
},
|
||||
title: "塔型",
|
||||
dataIndex: "tower_type",
|
||||
width: 120,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "业务维度",
|
||||
key: "dimensions",
|
||||
width: 220,
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{item.voltage_level || "未设置电压等级"}</Tag>
|
||||
<Tag>{item.tower_type || "未设置塔型"}</Tag>
|
||||
<Tag>{item.scene_type || "未设置场景"}</Tag>
|
||||
<Tag>{item.arrester_config || "未设置避雷器"}</Tag>
|
||||
</Space>
|
||||
),
|
||||
title: "场景",
|
||||
dataIndex: "scene_type",
|
||||
width: 120,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "避雷器组合",
|
||||
dataIndex: "arrester_config",
|
||||
width: 120,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
dataIndex: "description",
|
||||
ellipsis: true,
|
||||
render: (value: string) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
key: "update_date",
|
||||
width: 170,
|
||||
dataIndex: "update_date",
|
||||
width: 170,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 180,
|
||||
width: 100,
|
||||
render: (_, item) => {
|
||||
const deleteLoading = deleteMutation.isPending;
|
||||
const rowBusy = deleteLoading;
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Link href={`/admin/atp-models/${item.id}`}>
|
||||
<Button size="small" type="primary">
|
||||
详情
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canManage || rowBusy}
|
||||
onClick={() => openEditModal(item)}
|
||||
>
|
||||
编辑
|
||||
<Popconfirm
|
||||
title="删除模型"
|
||||
description="这会同时删除其版本与运行记录。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: deleteLoading }}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={!canManage || rowBusy}
|
||||
>
|
||||
<Button danger size="small" loading={deleteLoading} disabled={!canManage || rowBusy}>
|
||||
删除
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除模型"
|
||||
description="这会同时删除其版本与运行记录。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: deleteLoading }}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={!canManage || rowBusy}
|
||||
>
|
||||
<Button danger size="small" loading={deleteLoading} disabled={!canManage || rowBusy}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Popconfirm>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[canManage, deleteMutation, openEditModal],
|
||||
[canManage, deleteMutation],
|
||||
);
|
||||
|
||||
const renderAtpModelCard = (item: AtpAssetSummary) => {
|
||||
const deleteLoading = deleteMutation.isPending;
|
||||
const rowBusy = deleteLoading;
|
||||
const statusDisplay = getAtpAssetStatusDisplay(item.status);
|
||||
|
||||
const moreMenuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "detail",
|
||||
label: (
|
||||
<Link href={`/admin/atp-models/${item.id}`}>
|
||||
详情
|
||||
</Link>
|
||||
),
|
||||
disabled: rowBusy,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
disabled: !canManage || rowBusy,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: "删除模型",
|
||||
content: "这会同时删除其版本与运行记录。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => deleteMutation.mutate(item.id),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AntCard
|
||||
@@ -578,43 +530,53 @@ export default function AtpModelsPage() {
|
||||
className="admin-atp-models-model-card"
|
||||
size="small"
|
||||
title={
|
||||
<Space className="min-w-0" size={8}>
|
||||
<Typography.Text strong ellipsis={{ tooltip: item.name }}>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
<Tag color={statusDisplay.color}>{statusDisplay.label}</Tag>
|
||||
</Space>
|
||||
<Typography.Text strong ellipsis={{ tooltip: item.name }}>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!canManage || rowBusy}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(item)}
|
||||
/>
|
||||
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
|
||||
<Button type="text" size="small" disabled={rowBusy} icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
disabled={!canManage || rowBusy}
|
||||
loading={deleteLoading}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: "删除模型",
|
||||
content: "这会同时删除其版本与运行记录。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => deleteMutation.mutate(item.id),
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: "100%" }}>
|
||||
<div className="admin-atp-models-model-card-field">
|
||||
<Typography.Text type="secondary">模型编码</Typography.Text>
|
||||
<Typography.Text code ellipsis={{ tooltip: item.code }}>
|
||||
{item.code}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">电压等级</Typography.Text>
|
||||
<Typography.Text>{item.voltage_level || "-"}</Typography.Text>
|
||||
</div>
|
||||
<div className="admin-atp-models-model-card-field">
|
||||
<Typography.Text type="secondary">业务维度</Typography.Text>
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{item.voltage_level || "未设置电压等级"}</Tag>
|
||||
<Tag>{item.tower_type || "未设置塔型"}</Tag>
|
||||
<Tag>{item.scene_type || "未设置场景"}</Tag>
|
||||
<Tag>{item.arrester_config || "未设置避雷器"}</Tag>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">塔型</Typography.Text>
|
||||
<Typography.Text>{item.tower_type || "-"}</Typography.Text>
|
||||
</div>
|
||||
<div className="admin-atp-models-model-card-field">
|
||||
<Typography.Text type="secondary">场景</Typography.Text>
|
||||
<Typography.Text>{item.scene_type || "-"}</Typography.Text>
|
||||
</div>
|
||||
<div className="admin-atp-models-model-card-field">
|
||||
<Typography.Text type="secondary">避雷器组合</Typography.Text>
|
||||
<Typography.Text>{item.arrester_config || "-"}</Typography.Text>
|
||||
</div>
|
||||
<div className="admin-atp-models-model-card-field">
|
||||
<Typography.Text type="secondary">描述</Typography.Text>
|
||||
<Typography.Text ellipsis={{ tooltip: item.description }}>
|
||||
{item.description || "-"}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="admin-atp-models-model-card-field">
|
||||
<Typography.Text type="secondary">更新时间</Typography.Text>
|
||||
@@ -645,7 +607,7 @@ export default function AtpModelsPage() {
|
||||
ref={pageCardRef}
|
||||
className="admin-atp-models-page-card"
|
||||
title="ATP 模型管理"
|
||||
extra={(
|
||||
extra={
|
||||
<Space>
|
||||
{assetsQuery.isFetching && <Spin size="small" />}
|
||||
<Button
|
||||
@@ -656,7 +618,7 @@ export default function AtpModelsPage() {
|
||||
新建模型
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{viewMode === "card" ? (
|
||||
<Form layout="vertical" style={{ marginBottom: 16 }}>
|
||||
@@ -759,50 +721,67 @@ export default function AtpModelsPage() {
|
||||
</AntCard>
|
||||
|
||||
<Modal
|
||||
title={editingAsset ? "编辑 ATP 模型" : "新建 ATP 模型"}
|
||||
title="新建 ATP 模型"
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
onOk={() => void form.submit()}
|
||||
confirmLoading={saveMutation.isPending}
|
||||
confirmLoading={createAssetMutation.isPending}
|
||||
destroyOnClose
|
||||
width={760}
|
||||
okText={saveMutation.isPending ? "提交中..." : editingAsset ? "保存修改" : "创建模型"}
|
||||
okText={createAssetMutation.isPending ? "提交中..." : "创建模型"}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form<AssetFormValues>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={EMPTY_FORM}
|
||||
onFinish={(values) => void saveMutation.mutateAsync(values)}
|
||||
onFinish={(values) => {
|
||||
values.files = fileList;
|
||||
void createAssetMutation.mutateAsync(values);
|
||||
}}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="voltage_level" label="电压等级" rules={[{ required: true, message: "请选择或新建电压等级" }]}>
|
||||
<CreatableSingleSelect options={voltageLevelOptions} placeholder="请选择或新建电压等级" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="tower_type" label="塔型" rules={[{ required: true, message: "请选择或新建塔型" }]}>
|
||||
<CreatableSingleSelect options={towerTypeOptions} placeholder="请选择或新建塔型" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="scene_type" label="场景" rules={[{ required: true, message: "请选择或新建场景" }]}>
|
||||
<CreatableSingleSelect options={sceneTypeOptions} placeholder="请选择或新建场景" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="arrester_config" label="避雷器装设组合" rules={[{ required: true, message: "请选择或新建避雷器装设组合" }]}>
|
||||
<CreatableSingleSelect options={arresterConfigOptions} placeholder="请选择或新建避雷器装设组合" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="voltage_level" label="电压等级" rules={[{ required: true, message: "请选择或新建电压等级" }]}>
|
||||
<CreatableSingleSelect options={voltageLevelOptions} placeholder="请选择或新建电压等级" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型" rules={[{ required: true, message: "请选择或新建塔型" }]}>
|
||||
<CreatableSingleSelect options={towerTypeOptions} placeholder="请选择或新建塔型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene_type" label="场景" rules={[{ required: true, message: "请选择或新建场景" }]}>
|
||||
<CreatableSingleSelect options={sceneTypeOptions} placeholder="请选择或新建场景" />
|
||||
</Form.Item>
|
||||
<Form.Item name="arrester_config" label="避雷器装设组合" rules={[{ required: true, message: "请选择或新建避雷器装设组合" }]}>
|
||||
<CreatableSingleSelect options={arresterConfigOptions} placeholder="请选择或新建避雷器装设组合" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item label="上传模型文件" required>
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
setFileList((prev) => [...prev, file]);
|
||||
return false;
|
||||
}}
|
||||
onRemove={(uploadFile) => {
|
||||
setFileList((prev) => prev.filter((f) => f.name !== uploadFile.name));
|
||||
}}
|
||||
fileList={fileList.map((file) => ({
|
||||
uid: file.name,
|
||||
name: (file as any).webkitRelativePath || file.name,
|
||||
status: "done" as const,
|
||||
}))}
|
||||
directory
|
||||
multiple
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件夹到此处上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持上传整个目录,将保留原始目录结构
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user