diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 69007f1..6048ba5 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -55,15 +55,16 @@ def resource_payload( resource: DataResources, storage_object: StorageObjects, ) -> dict[str, Any]: - # ``object_key`` is stored as ``workspace/{ws_id}/{user_id}/.resources/{file_name}``. - # Two Jupyter-facing path views: + # ``object_key`` is stored as ``{ws_id}/{user_id}/.resources/{file_name}`` + # (see ``backend/services/storage.py:create_upload_record``). Two + # Jupyter-facing path views: # - ``jupyter_accessible_path``: per-user Jupyter-relative path. The - # Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so we - # only return the tail ``.resources/{file_name}`` for copy-paste use. + # Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so + # we return the tail ``.resources/{file_name}`` for copy-paste use. # - ``absolute_path``: full filesystem path inside the Jupyter # container (the rclone mount root). Shape: # ``{workspaces_root}/{ws_id}/{user_id}/.resources/{file_name}``. - workspace_prefix = f"workspace/{resource.workspace_id}/" + workspace_prefix = f"{resource.workspace_id}/" user_prefix = f"{resource.owner_user_id}/" jupyter_accessible_path = "" absolute_path = "" diff --git a/frontend/app/components/common/Sidebar.tsx b/frontend/app/components/common/Sidebar.tsx index 98a5189..6ae1544 100644 --- a/frontend/app/components/common/Sidebar.tsx +++ b/frontend/app/components/common/Sidebar.tsx @@ -1,6 +1,6 @@ import Icon from "./Icon"; -type ActivePage = "home" | "scripts" | "schedules" | "system"; +type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system"; type SidebarProps = { activePage: ActivePage; @@ -15,7 +15,7 @@ type SidebarProps = { type NavigationItem = { label: string; - icon: "home" | "script" | "schedule" | "settings"; + icon: "home" | "script" | "database" | "schedule" | "settings"; page: ActivePage; }; @@ -44,6 +44,7 @@ export function Sidebar({ const navigation: NavigationItem[] = [ { label: "工作台", icon: "home", page: "home" }, { label: "构建脚本", icon: "script", page: "scripts" }, + { label: "数据资源", icon: "database", page: "data-resources" }, { label: "调度配置", icon: "schedule", page: "schedules" }, ]; diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index 22e7dcd..f19c29a 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -28,6 +28,7 @@ export function Topbar({ home: "工作台", scripts: "构建脚本", schedules: "调度配置", + "data-resources": "数据资源", system: "系统管理", }; diff --git a/frontend/app/features/platform/DataResourcesPage.css b/frontend/app/features/platform/DataResourcesPage.css new file mode 100644 index 0000000..c60828d --- /dev/null +++ b/frontend/app/features/platform/DataResourcesPage.css @@ -0,0 +1,147 @@ +.data-resources-page { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + padding: 22px; + overflow-y: auto; +} + +.data-resources-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} + +.data-resources-page__header h1 { + margin: 0 0 5px; + color: #1c2d42; + font-size: 20px; +} + +.data-resources-page__header p { + margin: 0; + color: #7a8999; + font-size: 12px; +} + +.data-resources-page__toolbar { + display: flex; + margin-bottom: 16px; +} + +.data-resources-page__toolbar .search-box { + width: 320px; + margin: 0; +} + +.data-resources-page__table-wrap { + flex: 1; + min-height: 0; + border: 1px solid #dce4eb; + border-radius: 8px; + background: #fff; + overflow: auto; +} + +.data-resources-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.data-resources-table th, +.data-resources-table td { + padding: 12px 14px; + border-bottom: 1px solid #e8edf2; + text-align: left; + vertical-align: middle; +} + +.data-resources-table th { + position: sticky; + top: 0; + color: #5c6d7e; + background: #f7f9fb; + font-size: 11px; + font-weight: 650; +} + +.data-resources-table tbody tr:hover { + background: #f4f8fc; +} + +.data-resources-table td { + color: #33465a; +} + +.data-resources-table__name { + font-weight: 600; +} + +.data-resources-table code.jupyter-path { + display: inline-block; + max-width: 260px; + overflow: hidden; + padding: 3px 7px; + border-radius: 4px; + background: #f1f5f9; + color: #2a4d6b; + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.visibility-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; +} + +.visibility-badge--private { + color: #7a6a3f; + background: #f9f5e6; +} + +.visibility-badge--workspace { + color: #1a5e9c; + background: #eaf4fd; +} + +.visibility-badge--public { + color: #1f7a54; + background: #eaf9f3; +} + +.data-resources-page__empty { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: #8b99a8; + font-size: 13px; +} + +.data-resources-page .form-field input[type="file"] { + height: auto; + padding: 8px 11px; + font-size: 12px; +} + +.data-resources-table td .row-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} + +.data-resources-table td .text-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} diff --git a/frontend/app/features/platform/DataResourcesPage.tsx b/frontend/app/features/platform/DataResourcesPage.tsx new file mode 100644 index 0000000..1c48782 --- /dev/null +++ b/frontend/app/features/platform/DataResourcesPage.tsx @@ -0,0 +1,339 @@ +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 + /> +
+
+ + +
+
+ +