import { App, Button, Input, Modal, Select } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Download, Eye, Search, Trash2 } from "lucide-react"; import { saveAs } from "file-saver"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import { PaginationBar } from "@/components/layout/workspace-page"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { adminResourceFileUrl, deleteAdminResources, downloadAdminResource, getAdminStorageStats, listAdminResources, type AdminStorageResource, type AdminStorageStats, } from "@/services/api/admin-storage"; import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; const pageSizes = [20, 50, 100]; export default function StorageResourcesPanel() { const { message, modal } = App.useApp(); const [searchParams, setSearchParams] = useSearchParams(); const keyword = searchParams.get("filter") || ""; const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]); const status = normalizeOption(searchParams.get("status"), ["pending", "ready", "failed", "deleted"]); const provider = normalizeOption(searchParams.get("provider"), ["local", "aliyun", "tencent", "qiniu", "s3"]); const userId = searchParams.get("userId") || ""; const page = positiveInt(searchParams.get("page"), 1); const pageSize = normalizePageSize(searchParams.get("pageSize")); const debouncedKeyword = useDebouncedValue(keyword); const debouncedUserId = useDebouncedValue(userId); const [resources, setResources] = useState([]); const [stats, setStats] = useState(null); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [previewing, setPreviewing] = useState(null); const [downloadingId, setDownloadingId] = useState(""); const [selectedIds, setSelectedIds] = useState([]); const [deleting, setDeleting] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const requestSequence = useRef(0); const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all"); const updateUrl = (patch: Record, replace = false) => { const next = new URLSearchParams(searchParams); Object.entries(patch).forEach(([key, value]) => { const isDefault = (key === "filter" || key === "userId") && value === "" || ["kind", "status", "provider"].includes(key) && value === "all" || key === "page" && value === 1 || key === "pageSize" && value === 20; if (isDefault) next.delete(key); else next.set(key, String(value)); }); setSearchParams(next, { replace }); }; useEffect(() => { const controller = new AbortController(); void getAdminStorageStats(controller.signal) .then(setStats) .catch((error) => { if (error instanceof DOMException && error.name === "AbortError") return; message.error(error instanceof Error ? error.message : "读取存储统计失败"); }); return () => controller.abort(); }, [refreshKey]); useEffect(() => { const sequence = ++requestSequence.current; const controller = new AbortController(); setLoading(true); void listAdminResources({ keyword: debouncedKeyword || undefined, kind: kind === "all" ? undefined : kind, status: status === "all" ? undefined : status, provider: provider === "all" ? undefined : provider, userId: debouncedUserId || undefined, page, limit: pageSize, }, controller.signal) .then((result) => { if (sequence !== requestSequence.current) return; setResources(result.items); setTotal(result.total); if (result.total > 0 && result.items.length === 0 && page > 1) updateUrl({ page: 1 }, true); }) .catch((error) => { if (error instanceof DOMException && error.name === "AbortError") return; if (sequence === requestSequence.current) message.error(error instanceof Error ? error.message : "读取资源列表失败"); }) .finally(() => sequence === requestSequence.current && setLoading(false)); return () => controller.abort(); }, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize, refreshKey]); const columns = useMemo>(() => [ { title: "资源", width: 260, render: (_, resource) =>
{fileName(resource.objectKey) || resource.id}
{resource.id}
}, { title: "用户", width: 170, render: (_, resource) =>
{resource.userName || resource.userId}
{resource.userId}
}, { title: "类型", dataIndex: "kind", width: 92, render: (value) => kindLabel(value) }, { title: "状态", dataIndex: "status", width: 104, render: (value) => }, { title: "存储", dataIndex: "provider", width: 110, render: (value) => providerLabel(value) }, { title: "大小", dataIndex: "size", width: 110, render: (value) => {formatBytes(value)} }, { title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) }, { title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime }, { title: "操作", width: 220, align: "right", fixed: "right", render: (_, resource) =>
}, ], [downloadingId, deleting]); const download = async (resource: AdminStorageResource) => { setDownloadingId(resource.id); try { const blob = await downloadAdminResource(resource); saveAs(blob, fileName(resource.objectKey) || resource.id); } catch (error) { message.error(error instanceof Error ? error.message : "下载资源失败"); } finally { setDownloadingId(""); } }; const confirmDelete = (resourceIds: string[]) => { const uniqueIds = Array.from(new Set(resourceIds)); modal.confirm({ title: uniqueIds.length > 1 ? `删除选中的 ${uniqueIds.length} 个资源?` : "删除这个资源?", content: "系统会先批量检查公告、素材、画布、项目、工作流和镜头产物引用。仍被引用的资源会保留;无引用资源的记录、清理任务和审计事件会在同一事务提交。", okText: uniqueIds.length > 1 ? "检查并删除" : "确认删除", cancelText: "取消", okButtonProps: { danger: true }, onOk: async () => { setDeleting(true); try { const result = await deleteAdminResources(uniqueIds); setSelectedIds([]); setRefreshKey((value) => value + 1); if (result.deleted.length > 0) message.success(`已删除 ${result.deleted.length} 个资源`); if (result.blocked.length > 0) { modal.warning({ title: result.deleted.length > 0 ? "部分资源未删除" : "资源未删除", content: , okText: "知道了", }); } } catch (error) { message.error(error instanceof Error ? error.message : "删除资源失败"); throw error; } finally { setDeleting(false); } }, }); }; return (
} value={keyword} placeholder="搜索资源 ID 或对象路径" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} /> updateUrl({ userId: event.target.value, page: 1 }, true)} />} toolbarFilters={<> updateUrl({ status: value, page: 1 })} options={statusOptions} />