import { useEffect, useRef, useState } from "react"; import { useAuth } from "../../context/AuthContext"; import Icon from "../../components/common/Icon"; import { useUiStore } from "./state/uiStore"; import { type ResourceItem, listResources, createResourceUpload, uploadResourceBytes, bindResourceUpload, } from "../../services/api"; import "./DataResourcesPage.css"; const ACCEPTED_TYPES = ".csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"; const VISIBILITY_LABELS: Record = { private: "私有", workspace: "工作区", public: "公开", }; function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB"]; const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const value = bytes / Math.pow(1024, index); return `${value.toFixed(index === 0 ? 0 : 2)} ${units[index]}`; } async function sha256Hex(file: File): Promise { const buffer = await file.arrayBuffer(); const digest = await crypto.subtle.digest("SHA-256", buffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } export default function DataResourcesPage() { const { currentWorkspace, user } = useAuth(); const workspaceId = currentWorkspace?.workspace_id ?? ""; const ownerUserId = user?.user_id ?? ""; const pushToast = useUiStore((state) => state.pushToast); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [keyword, setKeyword] = useState(""); const [modalOpen, setModalOpen] = useState(false); const [file, setFile] = useState(null); const [resourceName, setResourceName] = useState(""); const [visibility, setVisibility] = useState("workspace"); const [description, setDescription] = useState(""); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const load = async () => { if (!workspaceId) return; setLoading(true); try { const data = await listResources(workspaceId, { keyword }); // Defensive: apiRequest returns T, but treat non-array payloads // as empty so a malformed response never crashes the render. setItems(Array.isArray(data) ? data : []); } catch (error) { pushToast({ tone: "error", message: error instanceof Error ? error.message : "加载数据资源失败", }); setItems([]); } finally { setLoading(false); } }; useEffect(() => { void load(); }, [workspaceId, keyword]); const handleFileChange = (event: React.ChangeEvent) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected && !resourceName.trim()) { const baseName = selected.name.replace(/\.[^/.]+$/, ""); setResourceName(baseName); } }; const resetForm = () => { setFile(null); setResourceName(""); setVisibility("workspace"); setDescription(""); if (fileInputRef.current) fileInputRef.current.value = ""; }; const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!file || !workspaceId) return; const name = resourceName.trim() || file.name; setUploading(true); try { const expectedHash = await sha256Hex(file); const { upload_id: uploadId } = await createResourceUpload(workspaceId, { file_name: file.name, content_type: file.type || "application/octet-stream", expected_size_bytes: file.size, expected_hash: expectedHash, }); await uploadResourceBytes( workspaceId, uploadId, await file.arrayBuffer(), file.type || "application/octet-stream", ); await bindResourceUpload(workspaceId, uploadId, { resource_name: name, description: description.trim(), visibility, }); pushToast({ tone: "success", message: "数据资源上传成功" }); setModalOpen(false); resetForm(); await load(); } catch (error) { pushToast({ tone: "error", message: error instanceof Error ? error.message : "上传失败", }); } finally { setUploading(false); } }; // 把 {workspace_id}/{user_id}/ 前缀从路径里去掉,方便用户粘贴到 notebook。 // 后端目前返回的 jupyter_accessible_path 偶尔会带这层前缀(legacy 数据 + // workspace_prefix 配错),所以前端再做一次防御性剥除。 const stripOwnerPrefix = (path: string): string => { if (!path) return path; const fullPrefix = `${workspaceId}/${ownerUserId}/`; if (fullPrefix !== "/" && path.startsWith(fullPrefix)) { return path.slice(fullPrefix.length); } const ownerPrefix = `${ownerUserId}/`; if (ownerPrefix !== "/" && path.startsWith(ownerPrefix)) { return path.slice(ownerPrefix.length); } return path; }; const handleCopy = async (path: string) => { try { const stripped = stripOwnerPrefix(path); await navigator.clipboard.writeText(stripped); pushToast({ tone: "success", message: "已复制 Jupyter 路径" }); } catch { pushToast({ tone: "error", message: "复制失败" }); } }; return (

数据资源

管理可在 Jupyter 中读取的 CSV/Excel/JSON/Parquet 文件

setKeyword(event.target.value)} />
{loading && items.length === 0 ? (
加载中…
) : items.length === 0 ? (
暂无数据资源,点击右上角上传。
) : ( {items.map((item) => ( ))}
资源名称 文件名 大小 可见性 操作
{item.resource_name} {item.file.file_name} {formatBytes(item.file.size_bytes)} {VISIBILITY_LABELS[item.visibility]}
)}
{modalOpen && (
{ if (!uploading) setModalOpen(false); }} >
event.stopPropagation()} >
数据资源

上传数据文件

支持 CSV、Excel、JSON、Parquet、TSV、TXT
setResourceName(event.target.value)} placeholder="输入资源名称" required />