[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:
chengkai3
2026-06-28 09:50:09 +08:00
parent 463a128e68
commit f020bdc3c9
4 changed files with 289 additions and 467 deletions
+1
View File
@@ -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",
-258
View File
@@ -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>
);
}
+188 -209
View File
@@ -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>