2026-06-11 22:39:48 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import {
|
|
|
|
|
Button,
|
2026-06-12 21:06:00 +08:00
|
|
|
Card,
|
2026-06-11 23:45:57 +08:00
|
|
|
Col,
|
2026-06-19 17:04:52 +08:00
|
|
|
Empty,
|
2026-06-11 22:39:48 +08:00
|
|
|
Form,
|
|
|
|
|
Input,
|
2026-06-19 17:05:06 +08:00
|
|
|
Modal,
|
2026-06-11 22:39:48 +08:00
|
|
|
Popconfirm,
|
2026-06-11 23:45:57 +08:00
|
|
|
Row,
|
2026-06-11 22:39:48 +08:00
|
|
|
Space,
|
2026-06-12 21:06:00 +08:00
|
|
|
Spin,
|
2026-06-11 22:39:48 +08:00
|
|
|
Table,
|
|
|
|
|
Typography,
|
2026-06-28 09:50:09 +08:00
|
|
|
Upload,
|
|
|
|
|
message,
|
2026-06-12 21:06:00 +08:00
|
|
|
type CardProps,
|
2026-06-11 22:39:48 +08:00
|
|
|
} from "antd";
|
2026-06-28 09:50:09 +08:00
|
|
|
import { UploadOutlined, InboxOutlined } from "@ant-design/icons";
|
2026-06-11 22:39:48 +08:00
|
|
|
import type { ColumnsType } from "antd/es/table";
|
2026-06-20 08:36:04 +08:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
2026-06-11 22:39:48 +08:00
|
|
|
|
|
|
|
|
import { AdminPageLoading } from "@/components/admin-page-loading";
|
|
|
|
|
import { useAuth } from "@/components/auth-provider";
|
2026-06-11 23:45:57 +08:00
|
|
|
import { CreatableSingleSelect } from "@/components/creatable-single-select";
|
2026-06-19 16:45:06 +08:00
|
|
|
import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
2026-06-20 08:01:54 +08:00
|
|
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
2026-06-11 22:39:48 +08:00
|
|
|
import { readApiError } from "@/lib/api";
|
2026-06-12 07:57:31 +08:00
|
|
|
import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
|
2026-06-11 22:39:48 +08:00
|
|
|
|
2026-06-20 08:36:04 +08:00
|
|
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
2026-06-12 21:06:00 +08:00
|
|
|
|
2026-06-11 22:39:48 +08:00
|
|
|
type AssetFormValues = {
|
|
|
|
|
description: string;
|
|
|
|
|
voltage_level: string;
|
|
|
|
|
tower_type: string;
|
|
|
|
|
scene_type: string;
|
2026-06-12 12:48:33 +08:00
|
|
|
arrester_config: string;
|
2026-06-28 09:50:09 +08:00
|
|
|
files: File[];
|
2026-06-11 22:39:48 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const EMPTY_FORM: AssetFormValues = {
|
|
|
|
|
description: "",
|
|
|
|
|
voltage_level: "",
|
|
|
|
|
tower_type: "",
|
|
|
|
|
scene_type: "",
|
2026-06-12 12:48:33 +08:00
|
|
|
arrester_config: "",
|
2026-06-28 09:50:09 +08:00
|
|
|
files: [],
|
2026-06-11 22:39:48 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function formatDateTime(value: string | null | undefined): string {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return "-";
|
|
|
|
|
}
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
return date.toLocaleString("zh-CN", { hour12: false });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 22:32:53 +08:00
|
|
|
function generateName(values: AssetFormValues): string {
|
|
|
|
|
const parts = [
|
|
|
|
|
values.voltage_level,
|
|
|
|
|
values.tower_type,
|
|
|
|
|
values.scene_type,
|
|
|
|
|
values.arrester_config,
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
return parts.join("-");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateCode(): string {
|
|
|
|
|
return `atp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 18:04:14 +08:00
|
|
|
const DEFAULT_VOLTAGE_LEVELS = [
|
|
|
|
|
{ label: "35kV", value: "35" },
|
|
|
|
|
{ label: "66kV", value: "66" },
|
|
|
|
|
{ label: "110kV", value: "110" },
|
|
|
|
|
{ label: "220kV", value: "220" },
|
|
|
|
|
{ label: "330kV", value: "330" },
|
|
|
|
|
{ label: "500kV", value: "500" },
|
|
|
|
|
{ label: "750kV", value: "750" },
|
|
|
|
|
{ label: "800kV", value: "800" },
|
|
|
|
|
{ label: "1000kV", value: "1000" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TOWER_TYPES = [
|
|
|
|
|
{ label: "干字塔", value: "ganzi" },
|
|
|
|
|
{ label: "鼓型塔", value: "guxing" },
|
|
|
|
|
{ label: "鼓型双回路塔", value: "guxingd" },
|
|
|
|
|
{ label: "酒杯塔", value: "jiubei" },
|
|
|
|
|
{ label: "猫头塔", value: "maotou" },
|
|
|
|
|
{ label: "上字塔", value: "shangzi" },
|
|
|
|
|
{ label: "四回路塔", value: "sihuita" },
|
|
|
|
|
{ label: "直流V型塔", value: "vzhiliu" },
|
|
|
|
|
{ label: "直流塔", value: "zhiliu" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DEFAULT_SCENE_TYPES = [
|
|
|
|
|
{ label: "反击", value: "fanji" },
|
|
|
|
|
{ label: "绕击1", value: "raoji1" },
|
|
|
|
|
{ label: "绕击2", value: "raoji2" },
|
|
|
|
|
{ label: "绕击3", value: "raoji3" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DEFAULT_ARRESTER_CONFIGS = [
|
|
|
|
|
{ label: "M1", value: "M1" },
|
|
|
|
|
{ label: "M2", value: "M2" },
|
|
|
|
|
{ label: "M3", value: "M3" },
|
|
|
|
|
{ label: "M12", value: "M12" },
|
|
|
|
|
{ label: "M13", value: "M13" },
|
|
|
|
|
{ label: "M23", value: "M23" },
|
|
|
|
|
{ label: "M123", value: "M123" },
|
|
|
|
|
{ label: "noM", value: "noM" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function buildDimensionOptions(items: AtpAssetSummary[], picker: (item: AtpAssetSummary) => string | null, defaults: Array<{ label: string; value: string }>): Array<{ label: string; value: string }> {
|
2026-06-11 23:45:57 +08:00
|
|
|
const values = new Set<string>();
|
2026-06-12 18:04:14 +08:00
|
|
|
const optionsMap = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
for (const defaultOption of defaults) {
|
|
|
|
|
values.add(defaultOption.value);
|
|
|
|
|
optionsMap.set(defaultOption.value, defaultOption.label);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 23:45:57 +08:00
|
|
|
for (const item of items) {
|
|
|
|
|
const value = picker(item)?.trim();
|
|
|
|
|
if (!value) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
values.add(value);
|
2026-06-12 18:04:14 +08:00
|
|
|
if (!optionsMap.has(value)) {
|
|
|
|
|
optionsMap.set(value, value);
|
|
|
|
|
}
|
2026-06-11 23:45:57 +08:00
|
|
|
}
|
2026-06-12 18:04:14 +08:00
|
|
|
|
2026-06-11 23:45:57 +08:00
|
|
|
return Array.from(values)
|
|
|
|
|
.sort((left, right) => left.localeCompare(right, "zh-CN"))
|
2026-06-12 18:04:14 +08:00
|
|
|
.map((value) => ({ label: optionsMap.get(value) || value, value }));
|
2026-06-11 23:45:57 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 21:06:00 +08:00
|
|
|
const ATP_TABLE_MIN_SCROLL_Y = 180;
|
|
|
|
|
const ATP_TABLE_VIEWPORT_GAP = 40;
|
|
|
|
|
const ATP_TABLE_FALLBACK_RESERVE = 220;
|
|
|
|
|
|
2026-06-09 00:11:06 +08:00
|
|
|
export default function AtpModelsPage() {
|
2026-06-11 22:39:48 +08:00
|
|
|
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const [form] = Form.useForm<AssetFormValues>();
|
2026-06-19 16:45:06 +08:00
|
|
|
const isMobile = useMobileDetection();
|
2026-06-11 22:39:48 +08:00
|
|
|
|
|
|
|
|
const [keywordInput, setKeywordInput] = useState("");
|
2026-06-20 08:01:54 +08:00
|
|
|
const [searchKeyword, setSearchKeyword] = useState("");
|
2026-06-19 17:13:20 +08:00
|
|
|
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
2026-06-11 22:39:48 +08:00
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
2026-06-28 09:50:09 +08:00
|
|
|
const [fileList, setFileList] = useState<File[]>([]);
|
2026-06-12 21:06:00 +08:00
|
|
|
const [tableScrollY, setTableScrollY] = useState(ATP_TABLE_MIN_SCROLL_Y);
|
|
|
|
|
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
|
2026-06-19 16:45:06 +08:00
|
|
|
const viewMode: "table" | "card" = isMobile ? "card" : "table";
|
2026-06-20 08:01:54 +08:00
|
|
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
|
|
|
|
const [cardViewPage, setCardViewPage] = useState(1);
|
|
|
|
|
const [allLoadedAssets, setAllLoadedAssets] = useState<AtpAssetSummary[]>([]);
|
|
|
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
|
|
|
const pageCardRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
const [success, setSuccess] = useState("");
|
2026-06-11 22:39:48 +08:00
|
|
|
|
|
|
|
|
const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage");
|
|
|
|
|
const canManage = hasPermission("atp.manage");
|
2026-06-20 08:01:54 +08:00
|
|
|
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
|
|
|
|
|
|
|
|
|
|
const trimmedKeyword = searchKeyword.trim();
|
|
|
|
|
const assetsQueryParams = useMemo(() => {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
params.set("limit", String(paginationPageSize));
|
|
|
|
|
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
|
|
|
|
|
if (trimmedKeyword) {
|
|
|
|
|
params.set("keyword", trimmedKeyword);
|
|
|
|
|
}
|
|
|
|
|
return params.toString();
|
|
|
|
|
}, [paginationCurrent, paginationPageSize, trimmedKeyword]);
|
2026-06-11 22:39:48 +08:00
|
|
|
|
|
|
|
|
const assetsQuery = useQuery({
|
2026-06-20 08:01:54 +08:00
|
|
|
queryKey: ["atp-assets", assetsQueryParams],
|
2026-06-11 22:39:48 +08:00
|
|
|
enabled: Boolean(user && canRead),
|
|
|
|
|
queryFn: async () => {
|
2026-06-20 08:01:54 +08:00
|
|
|
const response = await fetchWithAuth(`/api/v1/atp/assets?${assetsQueryParams}`);
|
2026-06-11 22:39:48 +08:00
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readApiError(response));
|
|
|
|
|
}
|
|
|
|
|
return (await response.json()) as AtpAssetListResponse;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 09:50:09 +08:00
|
|
|
const createAssetMutation = useMutation({
|
2026-06-11 22:39:48 +08:00
|
|
|
mutationFn: async (values: AssetFormValues) => {
|
2026-06-28 09:50:09 +08:00
|
|
|
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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-11 22:39:48 +08:00
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readApiError(response));
|
|
|
|
|
}
|
2026-06-28 09:50:09 +08:00
|
|
|
|
|
|
|
|
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;
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
2026-06-28 09:50:09 +08:00
|
|
|
setSuccess("模型已创建并上传");
|
2026-06-20 08:01:54 +08:00
|
|
|
setError("");
|
2026-06-11 22:39:48 +08:00
|
|
|
setModalOpen(false);
|
2026-06-28 09:50:09 +08:00
|
|
|
setFileList([]);
|
2026-06-11 22:39:48 +08:00
|
|
|
form.resetFields();
|
|
|
|
|
},
|
|
|
|
|
onError: (candidate) => {
|
2026-06-20 08:01:54 +08:00
|
|
|
setSuccess("");
|
2026-06-28 09:50:09 +08:00
|
|
|
setError(candidate instanceof Error ? candidate.message : "创建模型失败");
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const deleteMutation = useMutation({
|
|
|
|
|
mutationFn: async (assetId: string) => {
|
|
|
|
|
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}`, { method: "DELETE" });
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readApiError(response));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
2026-06-20 08:01:54 +08:00
|
|
|
setSuccess("模型已删除");
|
|
|
|
|
setError("");
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
|
|
|
|
onError: (candidate) => {
|
2026-06-20 08:01:54 +08:00
|
|
|
setSuccess("");
|
|
|
|
|
setError(candidate instanceof Error ? candidate.message : "删除模型失败");
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-20 08:01:54 +08:00
|
|
|
const openCreateModal = useCallback(() => {
|
|
|
|
|
setError("");
|
|
|
|
|
setSuccess("");
|
2026-06-28 09:50:09 +08:00
|
|
|
setFileList([]);
|
2026-06-20 08:01:54 +08:00
|
|
|
form.setFieldsValue(EMPTY_FORM);
|
|
|
|
|
setModalOpen(true);
|
|
|
|
|
}, [form]);
|
|
|
|
|
|
|
|
|
|
const closeModal = () => {
|
2026-06-28 09:50:09 +08:00
|
|
|
if (createAssetMutation.isPending) return;
|
2026-06-20 08:01:54 +08:00
|
|
|
setModalOpen(false);
|
2026-06-28 09:50:09 +08:00
|
|
|
setFileList([]);
|
2026-06-20 08:01:54 +08:00
|
|
|
form.resetFields();
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-19 17:13:20 +08:00
|
|
|
const handleKeywordChange = (value: string) => {
|
|
|
|
|
setKeywordInput(value);
|
|
|
|
|
|
|
|
|
|
if (keywordDebounceTimeoutRef.current) {
|
|
|
|
|
clearTimeout(keywordDebounceTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
keywordDebounceTimeoutRef.current = setTimeout(() => {
|
2026-06-20 08:01:54 +08:00
|
|
|
setSearchKeyword(value);
|
|
|
|
|
setPagination((previous) => ({ ...previous, current: 1 }));
|
|
|
|
|
setCardViewPage(1);
|
|
|
|
|
setAllLoadedAssets([]);
|
2026-06-19 17:13:20 +08:00
|
|
|
}, 500);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (keywordDebounceTimeoutRef.current) {
|
|
|
|
|
clearTimeout(keywordDebounceTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-20 08:01:54 +08:00
|
|
|
const assetItems = useMemo(() => assetsQuery.data?.items ?? [], [assetsQuery.data?.items]);
|
2026-06-12 18:04:14 +08:00
|
|
|
const voltageLevelOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.voltage_level, DEFAULT_VOLTAGE_LEVELS), [assetItems]);
|
|
|
|
|
const towerTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.tower_type, DEFAULT_TOWER_TYPES), [assetItems]);
|
|
|
|
|
const sceneTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.scene_type, DEFAULT_SCENE_TYPES), [assetItems]);
|
|
|
|
|
const arresterConfigOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.arrester_config, DEFAULT_ARRESTER_CONFIGS), [assetItems]);
|
2026-06-20 08:01:54 +08:00
|
|
|
const assetTotal = assetsQuery.data?.total ?? 0;
|
|
|
|
|
const queryError = assetsQuery.error instanceof Error ? assetsQuery.error.message : "";
|
|
|
|
|
const anyError = error || queryError;
|
|
|
|
|
|
|
|
|
|
useToastFeedback({
|
|
|
|
|
errorMessage: anyError,
|
|
|
|
|
successMessage: success,
|
|
|
|
|
clearError: () => setError(""),
|
|
|
|
|
clearSuccess: () => setSuccess(""),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (viewMode !== "card" || assetsQuery.isLoading) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const frameId = window.requestAnimationFrame(() => {
|
|
|
|
|
if (cardViewPage === 1) {
|
|
|
|
|
setAllLoadedAssets(() => assetItems);
|
|
|
|
|
} else {
|
|
|
|
|
setAllLoadedAssets((previous) => {
|
|
|
|
|
if (assetItems.length === 0) {
|
|
|
|
|
return previous;
|
|
|
|
|
}
|
|
|
|
|
const existingIds = new Set(previous.map((item) => item.id));
|
|
|
|
|
const newAssets = assetItems.filter((item) => !existingIds.has(item.id));
|
|
|
|
|
return [...previous, ...newAssets];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
setIsLoadingMore(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.cancelAnimationFrame(frameId);
|
|
|
|
|
};
|
|
|
|
|
}, [assetItems, assetsQuery.isLoading, viewMode, cardViewPage]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (viewMode !== "card") return;
|
|
|
|
|
|
|
|
|
|
const pageCard = pageCardRef.current;
|
|
|
|
|
if (!pageCard) return;
|
|
|
|
|
|
|
|
|
|
const cardBody = pageCard.querySelector<HTMLElement>(".ant-card-body");
|
|
|
|
|
if (!cardBody) return;
|
|
|
|
|
|
|
|
|
|
const handleScroll = () => {
|
|
|
|
|
if (isLoadingMore || assetsQuery.isLoading) return;
|
|
|
|
|
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = cardBody;
|
|
|
|
|
|
|
|
|
|
if (scrollTop + clientHeight >= scrollHeight - 100 && allLoadedAssets.length < assetTotal) {
|
|
|
|
|
setIsLoadingMore(true);
|
|
|
|
|
setCardViewPage((previous) => previous + 1);
|
|
|
|
|
setPagination((previous) => ({ ...previous, current: previous.current + 1 }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
cardBody.addEventListener("scroll", handleScroll);
|
|
|
|
|
return () => cardBody.removeEventListener("scroll", handleScroll);
|
|
|
|
|
}, [allLoadedAssets.length, assetTotal, assetsQuery.isLoading, isLoadingMore, viewMode]);
|
2026-06-11 22:39:48 +08:00
|
|
|
|
2026-06-12 21:06:00 +08:00
|
|
|
const updateTableScrollY = useCallback(() => {
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const anchor = tableScrollAnchorRef.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 - ATP_TABLE_FALLBACK_RESERVE);
|
|
|
|
|
if (tableWrapper) {
|
|
|
|
|
const wrapperRect = tableWrapper.getBoundingClientRect();
|
|
|
|
|
const bodyHeight = tableBody?.getBoundingClientRect().height ?? ATP_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 - ATP_TABLE_VIEWPORT_GAP);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const clampedHeight = Math.max(ATP_TABLE_MIN_SCROLL_Y, nextHeight);
|
|
|
|
|
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
window.requestAnimationFrame(updateTableScrollY);
|
2026-06-20 08:01:54 +08:00
|
|
|
}, [anyError, paginationCurrent, paginationPageSize, assetItems.length, assetsQuery.isFetching, updateTableScrollY]);
|
2026-06-12 21:06:00 +08:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-06-11 22:39:48 +08:00
|
|
|
const columns = useMemo<ColumnsType<AtpAssetSummary>>(
|
|
|
|
|
() => [
|
|
|
|
|
{
|
2026-06-28 09:50:09 +08:00
|
|
|
title: "电压等级",
|
|
|
|
|
dataIndex: "voltage_level",
|
|
|
|
|
width: 120,
|
|
|
|
|
render: (value: string | null) => value || "-",
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
2026-06-20 08:01:54 +08:00
|
|
|
{
|
2026-06-28 09:50:09 +08:00
|
|
|
title: "塔型",
|
|
|
|
|
dataIndex: "tower_type",
|
|
|
|
|
width: 120,
|
|
|
|
|
render: (value: string | null) => value || "-",
|
2026-06-20 08:01:54 +08:00
|
|
|
},
|
2026-06-11 22:39:48 +08:00
|
|
|
{
|
2026-06-28 09:50:09 +08:00
|
|
|
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 || "-",
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "更新时间",
|
|
|
|
|
dataIndex: "update_date",
|
2026-06-28 09:50:09 +08:00
|
|
|
width: 170,
|
2026-06-11 22:39:48 +08:00
|
|
|
render: (value: string) => formatDateTime(value),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: "操作",
|
|
|
|
|
key: "actions",
|
2026-06-28 09:50:09 +08:00
|
|
|
width: 100,
|
2026-06-13 22:56:09 +08:00
|
|
|
render: (_, item) => {
|
2026-06-19 16:09:35 +08:00
|
|
|
const deleteLoading = deleteMutation.isPending;
|
2026-06-19 17:09:55 +08:00
|
|
|
const rowBusy = deleteLoading;
|
|
|
|
|
|
2026-06-13 22:56:09 +08:00
|
|
|
return (
|
2026-06-28 09:50:09 +08:00
|
|
|
<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}>
|
|
|
|
|
删除
|
2026-06-11 22:39:48 +08:00
|
|
|
</Button>
|
2026-06-28 09:50:09 +08:00
|
|
|
</Popconfirm>
|
2026-06-13 22:56:09 +08:00
|
|
|
);
|
|
|
|
|
},
|
2026-06-11 22:39:48 +08:00
|
|
|
},
|
|
|
|
|
],
|
2026-06-28 09:50:09 +08:00
|
|
|
[canManage, deleteMutation],
|
2026-06-11 22:39:48 +08:00
|
|
|
);
|
|
|
|
|
|
2026-06-19 16:45:06 +08:00
|
|
|
const renderAtpModelCard = (item: AtpAssetSummary) => {
|
|
|
|
|
const deleteLoading = deleteMutation.isPending;
|
2026-06-19 17:09:55 +08:00
|
|
|
const rowBusy = deleteLoading;
|
2026-06-19 16:45:06 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AntCard
|
|
|
|
|
key={item.id}
|
|
|
|
|
className="admin-atp-models-model-card"
|
|
|
|
|
size="small"
|
|
|
|
|
title={
|
2026-06-28 09:50:09 +08:00
|
|
|
<Typography.Text strong ellipsis={{ tooltip: item.name }}>
|
|
|
|
|
{item.name}
|
|
|
|
|
</Typography.Text>
|
2026-06-20 08:01:54 +08:00
|
|
|
}
|
|
|
|
|
extra={
|
2026-06-28 09:50:09 +08:00
|
|
|
<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>
|
2026-06-19 16:45:06 +08:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Space direction="vertical" size={10} style={{ width: "100%" }}>
|
2026-06-20 08:01:54 +08:00
|
|
|
<div className="admin-atp-models-model-card-field">
|
2026-06-28 09:50:09 +08:00
|
|
|
<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>
|
|
|
|
|
<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>
|
2026-06-20 08:01:54 +08:00
|
|
|
</div>
|
|
|
|
|
<div className="admin-atp-models-model-card-field">
|
2026-06-28 09:50:09 +08:00
|
|
|
<Typography.Text type="secondary">描述</Typography.Text>
|
|
|
|
|
<Typography.Text ellipsis={{ tooltip: item.description }}>
|
|
|
|
|
{item.description || "-"}
|
|
|
|
|
</Typography.Text>
|
2026-06-19 16:45:06 +08:00
|
|
|
</div>
|
2026-06-20 08:01:54 +08:00
|
|
|
<div className="admin-atp-models-model-card-field">
|
|
|
|
|
<Typography.Text type="secondary">更新时间</Typography.Text>
|
|
|
|
|
<Typography.Text>{formatDateTime(item.update_date)}</Typography.Text>
|
2026-06-19 16:45:06 +08:00
|
|
|
</div>
|
|
|
|
|
</Space>
|
|
|
|
|
</AntCard>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-11 22:39:48 +08:00
|
|
|
if (initializing) {
|
2026-06-11 23:45:57 +08:00
|
|
|
return <AdminPageLoading tip="加载 ATP 模型中..." minHeightClassName="min-h-[280px]" />;
|
2026-06-11 22:39:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!user || !canRead) {
|
|
|
|
|
return (
|
2026-06-12 21:06:00 +08:00
|
|
|
<AntCard title="ATP 模型管理">
|
2026-06-11 22:39:48 +08:00
|
|
|
<Typography.Text type="secondary">
|
2026-06-11 23:45:57 +08:00
|
|
|
{!user ? "请先登录后再查看 ATP 模型管理。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
2026-06-11 22:39:48 +08:00
|
|
|
</Typography.Text>
|
2026-06-12 21:06:00 +08:00
|
|
|
</AntCard>
|
2026-06-11 22:39:48 +08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-20 08:01:54 +08:00
|
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
2026-06-12 21:06:00 +08:00
|
|
|
<AntCard
|
2026-06-20 08:01:54 +08:00
|
|
|
ref={pageCardRef}
|
2026-06-19 16:06:35 +08:00
|
|
|
className="admin-atp-models-page-card"
|
2026-06-11 23:45:57 +08:00
|
|
|
title="ATP 模型管理"
|
2026-06-28 09:50:09 +08:00
|
|
|
extra={
|
2026-06-12 21:06:00 +08:00
|
|
|
<Space>
|
|
|
|
|
{assetsQuery.isFetching && <Spin size="small" />}
|
|
|
|
|
<Button
|
|
|
|
|
type="primary"
|
|
|
|
|
disabled={!canManage}
|
2026-06-20 08:01:54 +08:00
|
|
|
onClick={openCreateModal}
|
2026-06-12 21:06:00 +08:00
|
|
|
>
|
|
|
|
|
新建模型
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
2026-06-28 09:50:09 +08:00
|
|
|
}
|
2026-06-11 22:39:48 +08:00
|
|
|
>
|
2026-06-20 08:01:54 +08:00
|
|
|
{viewMode === "card" ? (
|
|
|
|
|
<Form layout="vertical" style={{ marginBottom: 16 }}>
|
|
|
|
|
<Form.Item style={{ marginBottom: 0 }}>
|
|
|
|
|
<Input
|
|
|
|
|
allowClear
|
|
|
|
|
value={keywordInput}
|
|
|
|
|
onChange={(event) => handleKeywordChange(event.target.value)}
|
|
|
|
|
placeholder="按编码/名称/描述搜索"
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
) : (
|
|
|
|
|
<Form layout="inline" style={{ rowGap: 12 }}>
|
|
|
|
|
<Form.Item label="关键词" style={{ width: 260 }}>
|
|
|
|
|
<Input
|
|
|
|
|
allowClear
|
|
|
|
|
value={keywordInput}
|
|
|
|
|
onChange={(event) => handleKeywordChange(event.target.value)}
|
|
|
|
|
placeholder="按编码/名称/描述搜索"
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
)}
|
2026-06-12 21:06:00 +08:00
|
|
|
|
2026-06-19 16:45:06 +08:00
|
|
|
{viewMode === "table" ? (
|
|
|
|
|
<div
|
|
|
|
|
ref={tableScrollAnchorRef}
|
|
|
|
|
className="admin-atp-models-table-anchor mt-4"
|
|
|
|
|
style={{ "--admin-atp-models-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
|
|
|
|
|
>
|
|
|
|
|
<Table<AtpAssetSummary>
|
|
|
|
|
rowKey="id"
|
|
|
|
|
loading={assetsQuery.isLoading}
|
|
|
|
|
columns={columns}
|
|
|
|
|
dataSource={assetItems}
|
2026-06-20 08:01:54 +08:00
|
|
|
tableLayout="fixed"
|
2026-06-19 17:04:52 +08:00
|
|
|
locale={{
|
|
|
|
|
emptyText: (
|
|
|
|
|
<Empty
|
|
|
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
2026-06-20 08:01:54 +08:00
|
|
|
description="未找到符合筛选条件的 ATP 模型。"
|
2026-06-19 17:04:52 +08:00
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
}}
|
2026-06-20 08:01:54 +08:00
|
|
|
pagination={{
|
|
|
|
|
current: pagination.current,
|
|
|
|
|
pageSize: pagination.pageSize,
|
|
|
|
|
total: Math.max(assetTotal, 1),
|
|
|
|
|
showSizeChanger: true,
|
|
|
|
|
pageSizeOptions: [10, 20, 50, 100],
|
|
|
|
|
showTotal: () => `共 ${assetTotal} 条`,
|
|
|
|
|
hideOnSinglePage: false,
|
|
|
|
|
style: { marginBottom: 0 },
|
|
|
|
|
onChange: (page, pageSize) => {
|
|
|
|
|
setPagination({ current: page, pageSize });
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
scroll={{ y: tableScrollY }}
|
2026-06-19 16:45:06 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-06-20 08:01:54 +08:00
|
|
|
<div className="admin-atp-models-card-view">
|
|
|
|
|
{assetsQuery.isLoading && allLoadedAssets.length === 0 ? (
|
2026-06-19 17:04:52 +08:00
|
|
|
<div className="admin-atp-models-card-view-state">
|
2026-06-19 16:45:06 +08:00
|
|
|
<Spin tip="加载中..." />
|
|
|
|
|
</div>
|
2026-06-20 08:01:54 +08:00
|
|
|
) : allLoadedAssets.length === 0 ? (
|
2026-06-19 17:04:52 +08:00
|
|
|
<div className="admin-atp-models-card-view-state">
|
|
|
|
|
<Empty
|
|
|
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
2026-06-20 08:01:54 +08:00
|
|
|
description="未找到符合筛选条件的 ATP 模型。"
|
2026-06-19 17:04:52 +08:00
|
|
|
/>
|
2026-06-19 16:45:06 +08:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-06-20 08:01:54 +08:00
|
|
|
<div className="admin-atp-models-card-view-content">
|
|
|
|
|
<Row gutter={[12, 12]}>
|
|
|
|
|
{allLoadedAssets.map((item) => (
|
|
|
|
|
<Col key={item.id} xs={24} sm={24} md={12} lg={8} xl={6}>
|
|
|
|
|
{renderAtpModelCard(item)}
|
|
|
|
|
</Col>
|
|
|
|
|
))}
|
|
|
|
|
</Row>
|
|
|
|
|
{isLoadingMore && (
|
|
|
|
|
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
|
|
|
|
<Spin tip="加载更多..." />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{allLoadedAssets.length >= assetTotal && allLoadedAssets.length > 0 && (
|
|
|
|
|
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
|
|
|
|
<Typography.Text type="secondary">
|
|
|
|
|
已加载全部 {allLoadedAssets.length} 条数据
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-19 16:45:06 +08:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-12 21:06:00 +08:00
|
|
|
</AntCard>
|
2026-06-11 22:39:48 +08:00
|
|
|
|
|
|
|
|
<Modal
|
2026-06-28 09:50:09 +08:00
|
|
|
title="新建 ATP 模型"
|
2026-06-11 22:39:48 +08:00
|
|
|
open={modalOpen}
|
2026-06-20 08:01:54 +08:00
|
|
|
onCancel={closeModal}
|
2026-06-11 22:39:48 +08:00
|
|
|
onOk={() => void form.submit()}
|
2026-06-28 09:50:09 +08:00
|
|
|
confirmLoading={createAssetMutation.isPending}
|
2026-06-11 22:39:48 +08:00
|
|
|
destroyOnClose
|
2026-06-28 09:50:09 +08:00
|
|
|
okText={createAssetMutation.isPending ? "提交中..." : "创建模型"}
|
2026-06-20 08:01:54 +08:00
|
|
|
cancelText="取消"
|
2026-06-11 22:39:48 +08:00
|
|
|
>
|
2026-06-28 09:50:09 +08:00
|
|
|
<Form
|
2026-06-11 22:39:48 +08:00
|
|
|
form={form}
|
|
|
|
|
layout="vertical"
|
|
|
|
|
initialValues={EMPTY_FORM}
|
2026-06-28 09:50:09 +08:00
|
|
|
onFinish={(values) => {
|
|
|
|
|
values.files = fileList;
|
|
|
|
|
void createAssetMutation.mutateAsync(values);
|
|
|
|
|
}}
|
2026-06-20 08:01:54 +08:00
|
|
|
autoComplete="off"
|
2026-06-11 22:39:48 +08:00
|
|
|
>
|
2026-06-28 09:50:09 +08:00
|
|
|
<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>
|
2026-06-11 22:39:48 +08:00
|
|
|
</Form>
|
|
|
|
|
</Modal>
|
2026-06-20 08:01:54 +08:00
|
|
|
</div>
|
2026-06-11 22:39:48 +08:00
|
|
|
);
|
2026-06-09 00:11:06 +08:00
|
|
|
}
|