update: data source

This commit is contained in:
tao.chen
2026-08-14 11:59:06 +08:00
parent 225a585499
commit 480e1cc638
15 changed files with 432 additions and 559 deletions
@@ -1,147 +0,0 @@
.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;
}
@@ -1,339 +0,0 @@
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<ResourceItem["visibility"], string> = {
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<string> {
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<ResourceItem[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [resourceName, setResourceName] = useState("");
const [visibility, setVisibility] = useState<ResourceItem["visibility"]>("workspace");
const [description, setDescription] = useState("");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = async () => {
if (!workspaceId) return;
setLoading(true);
try {
const data = await listResources(workspaceId, { keyword });
// Defensive: apiRequest<T> 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<HTMLInputElement>) => {
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 (
<section className="data-resources-page">
<header className="data-resources-page__header">
<div>
<h1></h1>
<p> Jupyter CSV/Excel/JSON/Parquet </p>
</div>
<button
className="primary-button"
type="button"
onClick={() => setModalOpen(true)}
>
<Icon name="upload" size={17} />
</button>
</header>
<div className="data-resources-page__toolbar">
<div className="search-box">
<Icon name="search" size={16} />
<input
type="text"
placeholder="搜索资源名称或文件名"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</div>
</div>
<div className="data-resources-page__table-wrap">
{loading && items.length === 0 ? (
<div className="data-resources-page__empty"></div>
) : items.length === 0 ? (
<div className="data-resources-page__empty">
</div>
) : (
<table className="data-resources-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.resource_id}>
<td className="data-resources-table__name">
{item.resource_name}
</td>
<td>{item.file.file_name}</td>
<td>{formatBytes(item.file.size_bytes)}</td>
<td>
<span
className={`visibility-badge visibility-badge--${item.visibility}`}
>
{VISIBILITY_LABELS[item.visibility]}
</span>
</td>
<td>
<div className="row-actions">
<button
className="text-button"
type="button"
onClick={() => handleCopy(item.jupyter_accessible_path)}
>
</button>
<button
className="text-button"
type="button"
onClick={() => handleCopy(item.absolute_path)}
disabled={!item.absolute_path}
title={item.absolute_path || "绝对路径不可用"}
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modalOpen && (
<div
className="modal-backdrop"
onClick={() => {
if (!uploading) setModalOpen(false);
}}
>
<div
className="modal modal--compact"
onClick={(event) => event.stopPropagation()}
>
<div className="modal__header">
<div>
<span className="modal__eyebrow"></span>
<h2></h2>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-field">
<label htmlFor="data-resource-file"></label>
<input
id="data-resource-file"
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileChange}
required
/>
<small> CSVExcelJSONParquetTSVTXT</small>
</div>
<div className="form-field">
<label htmlFor="data-resource-name"></label>
<input
id="data-resource-name"
type="text"
value={resourceName}
onChange={(event) => setResourceName(event.target.value)}
placeholder="输入资源名称"
required
/>
</div>
<div className="form-field">
<label htmlFor="data-resource-visibility"></label>
<select
id="data-resource-visibility"
value={visibility}
onChange={(event) =>
setVisibility(event.target.value as ResourceItem["visibility"])
}
>
<option value="private"></option>
<option value="workspace"></option>
<option value="public"></option>
</select>
</div>
<div className="form-field">
<label htmlFor="data-resource-description"></label>
<textarea
id="data-resource-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="输入描述"
rows={3}
/>
</div>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={() => setModalOpen(false)}
disabled={uploading}
>
</button>
<button
className="primary-button"
type="submit"
disabled={!file || uploading}
>
{uploading ? "上传中…" : "上传"}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
}
+98 -7
View File
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useMemo, useRef } from "react";
import { useAuth } from "../../context/AuthContext";
import { CreateFolderModal } from "../../components/platform/CreateFolderModal";
import { CreateScriptModal } from "../../components/platform/CreateScriptModal";
import { DataResourceUploadModal } from "../../components/platform/DataResourceUploadModal";
import Icon from "../../components/common/Icon";
import { PublishModal } from "../../components/platform/PublishModal";
import { ScriptExplorer } from "../../components/platform/ScriptExplorer";
@@ -32,6 +33,9 @@ export default function ScriptsPage() {
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
const dataResources = useScriptWorkspaceStore((s) => s.dataResources);
const loadDataResources = useScriptWorkspaceStore((s) => s.loadDataResources);
const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers);
// store actions
@@ -51,6 +55,7 @@ export default function ScriptsPage() {
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
const createScript = useScriptWorkspaceStore((s) => s.createScript);
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
const uploadDataResource = useScriptWorkspaceStore((s) => s.uploadDataResource);
const createFolder = useScriptWorkspaceStore((s) => s.createFolder);
const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript);
const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory);
@@ -74,6 +79,13 @@ export default function ScriptsPage() {
const openCreateDialog = useUiStore((s) => s.openCreateDialog);
const openFolderDialog = useUiStore((s) => s.openFolderDialog);
const chooseUpload = useUiStore((s) => s.chooseUpload);
const dataResourceDialog = useUiStore((s) => s.dataResourceDialog);
const openDataResourceDialog = useUiStore((s) => s.openDataResourceDialog);
const closeDataResourceDialog = useUiStore((s) => s.closeDataResourceDialog);
const setDataResourceName = useUiStore((s) => s.setDataResourceName);
const setDataResourceVisibility = useUiStore((s) => s.setDataResourceVisibility);
const setDataResourceDescription = useUiStore((s) => s.setDataResourceDescription);
const setDataResourceTargetPath = useUiStore((s) => s.setDataResourceTargetPath);
const publish = useUiStore((s) => s.publish);
const setReleaseNote = useUiStore((s) => s.setReleaseNote);
const setPublishVisibility = useUiStore((s) => s.setPublishVisibility);
@@ -87,6 +99,10 @@ export default function ScriptsPage() {
void load();
}, [load]);
useEffect(() => {
void loadDataResources();
}, [loadDataResources, workspaceId]);
useEffect(() => {
reset();
// 刷新前递增版本号,触发已打开标签页的只读内容刷新
@@ -189,12 +205,68 @@ export default function ScriptsPage() {
void closeTab(scriptId);
};
// 5) handlers
// 5) handlers
const SCRIPT_EXTS = [".py", ".ipynb"];
const DATA_EXTS = [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet", ".txt"];
const matchesExt = (name: string, exts: string[]) =>
exts.some((ext) => name.toLowerCase().endsWith(ext));
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0) return;
void uploadScripts(files, upload.parentPath);
const scriptFiles = files.filter((f) => matchesExt(f.name, SCRIPT_EXTS));
const dataFiles = files.filter((f) => matchesExt(f.name, DATA_EXTS));
const rejected = files.filter(
(f) => !matchesExt(f.name, SCRIPT_EXTS) && !matchesExt(f.name, DATA_EXTS),
);
if (rejected.length) {
pushToast({
tone: "error",
message: `不支持的文件类型: ${rejected.map((f) => f.name).join(", ")}`,
});
}
if (scriptFiles.length) {
void uploadScripts(scriptFiles, upload.parentPath);
}
if (dataFiles.length === 1) {
openDataResourceDialog(dataFiles[0], upload.parentPath);
} else if (dataFiles.length > 1) {
pushToast({
tone: "info",
message: "数据资源一次只支持上传一个文件,请分别上传",
});
}
};
const handleDataResourceSubmit = (event: FormEvent) => {
event.preventDefault();
const { file, resourceName, visibility, description, targetPath } = dataResourceDialog;
if (!file || !resourceName.trim()) return;
void uploadDataResource(file, {
resourceName: resourceName.trim(),
visibility,
description: description.trim(),
targetPath: targetPath.trim(),
}).then((resource) => {
if (resource) void loadDataResources();
});
};
const handleCopyResourcePath = async (jupyterPath: string) => {
if (!jupyterPath) return;
const fullPrefix = `${workspaceId ?? ""}/${user?.user_id ?? ""}/`;
let stripped = jupyterPath;
if (fullPrefix !== "/" && stripped.startsWith(fullPrefix)) {
stripped = stripped.slice(fullPrefix.length);
}
try {
await navigator.clipboard.writeText(stripped);
pushToast({ tone: "success", message: `已复制:${stripped}` });
} catch {
pushToast({ tone: "error", message: "复制失败" });
}
};
const handleCreateSubmit = (event: FormEvent) => {
@@ -223,6 +295,7 @@ export default function ScriptsPage() {
scripts={scripts}
filteredScripts={filteredScripts}
directories={directories}
dataResources={dataResources}
user={user}
selectedId={selectedId}
loading={loading}
@@ -238,6 +311,7 @@ export default function ScriptsPage() {
onChooseUpload={(parentPath) => triggerUpload(parentPath)}
onContextMenu={showContextMenu}
onSelect={openTab}
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
uploadInputRef={uploadInputRef}
onHandleUpload={handleUpload}
/>
@@ -344,8 +418,9 @@ export default function ScriptsPage() {
onOpenCreateDialog={(parentPath, scriptType) =>
openCreateDialog(parentPath, scriptType)}
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
onChooseUpload={(parentPath) => chooseUpload(parentPath)}
onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
onRemoveDirectory={(p) => void deleteDirectory(p)}
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
onClose={closeContextMenu}
/>
@@ -360,10 +435,26 @@ export default function ScriptsPage() {
onClose={closePublishDialog}
/>
<VersionReceiptModal
publishedVersion={publish.publishedVersion}
onClose={clearPublishedVersion}
onCopy={(message) => pushToast({ tone: "success", message })}
<VersionReceiptModal
publishedVersion={publish.publishedVersion}
onClose={clearPublishedVersion}
onCopy={(message) => pushToast({ tone: "success", message })}
/>
<DataResourceUploadModal
open={dataResourceDialog.open}
file={dataResourceDialog.file}
resourceName={dataResourceDialog.resourceName}
visibility={dataResourceDialog.visibility}
description={dataResourceDialog.description}
uploading={dataResourceDialog.uploading}
parentPath={dataResourceDialog.parentPath}
targetPath={dataResourceDialog.targetPath}
onNameChange={setDataResourceName}
onVisibilityChange={setDataResourceVisibility}
onDescriptionChange={setDataResourceDescription}
onTargetPathChange={setDataResourceTargetPath}
onSubmit={handleDataResourceSubmit}
onClose={closeDataResourceDialog}
/>
</section>
);
@@ -1,9 +1,10 @@
import { type MouseEvent as ReactMouseEvent, useEffect } from "react";
import { type MouseEvent as ReactMouseEvent, useEffect, useMemo } from "react";
import Icon from "../../components/common/Icon";
import type {
ScriptItem,
WorkspaceDirectory,
ResourceItem,
} from "~/services/api";
export type WorkspaceTreeTarget = {
@@ -23,6 +24,8 @@ type WorkspaceTreeProps = {
target: WorkspaceTreeTarget,
) => void;
readOnly?: boolean;
dataResources?: ResourceItem[];
onCopyResourcePath?: (jupyterPath: string) => void;
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
groupKey: string;
expandedPaths: Set<string>;
@@ -33,6 +36,7 @@ type WorkspaceTreeProps = {
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & {
path: string;
depth: number;
onCopyResourcePath?: (jupyterPath: string) => void;
};
function formatTime(value: string) {
@@ -50,6 +54,11 @@ export function scriptIcon(item: ScriptItem) {
}
function ownedScriptPath(item: ScriptItem) {
// 数据资源的 relative_path 已经是 jupyter 路径(无 ULID 前缀);脚本相对路径形如
// `{ulid}/{ws_id}/{user_id}/...`,需要剥前两层。
if (item.script_id.startsWith("data:")) {
return item.relative_path.replaceAll("\\", "/");
}
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
@@ -67,12 +76,58 @@ export function WorkspaceTreeGroup({
onSelect,
onContextMenu,
readOnly = false,
dataResources,
onCopyResourcePath,
groupKey,
expandedPaths,
onToggle,
loadingChildrenPaths,
}: WorkspaceTreeProps) {
const open = expandedPaths.has(groupKey);
const dataResourceScripts = useMemo(() => {
if (!dataResources) return [];
return dataResources.map((r) => ({
script_id: `data:${r.resource_id}`,
workspace_id: r.workspace_id,
current_object_id: r.storage_object_id,
owner_user_id: r.owner_user_id,
owner_display_name: null,
script_name: r.resource_name,
script_type: "python" as const,
visibility: r.visibility,
status: r.status,
is_locked: false,
// relative_path 直接是 jupyter 路径(与 Python/Notebook 同层渲染,不再带虚拟前缀)
relative_path: r.jupyter_accessible_path,
jupyter_path: r.jupyter_accessible_path,
content_hash: r.file.content_hash ?? "",
size_bytes: r.file.size_bytes,
created_at: r.created_at,
updated_at: r.updated_at,
} as unknown as ScriptItem));
}, [dataResources]);
const dataResourceDirectories = useMemo(() => {
if (!dataResources) return [];
const dirSet = new Set<string>();
for (const r of dataResources) {
const parts = r.jupyter_accessible_path.split("/");
parts.pop();
let acc = "";
for (const p of parts) {
acc = acc ? `${acc}/${p}` : p;
dirSet.add(acc);
}
}
return [...dirSet].map((path) => ({
path,
name: path.split("/").pop() ?? path,
parent_path: path.includes("/")
? path.split("/").slice(0, -1).join("/")
: "",
}));
}, [dataResources]);
// 首次挂载自动展开(保留原本 useState(true) 的默认展开行为)。
// toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。
useEffect(() => {
@@ -103,16 +158,22 @@ export function WorkspaceTreeGroup({
groupKey={groupKey}
path=""
depth={0}
scripts={scripts}
directories={directories}
scripts={[...scripts, ...dataResourceScripts]}
// 数据资源目录与真实/推断目录按 path 去重
directories={[
...new Map(
[...directories, ...dataResourceDirectories].map((d) => [d.path, d]),
).values(),
]}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/>
{scripts.length === 0 && directories.length === 0 && (
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
<p className="tree-group__empty">
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
</p>
@@ -132,6 +193,7 @@ function WorkspaceTreeItems({
selectedId,
onSelect,
onContextMenu,
onCopyResourcePath,
expandedPaths,
onToggle,
loadingChildrenPaths,
@@ -158,6 +220,7 @@ function WorkspaceTreeItems({
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/>
))}
{childScripts.map((item) => (
@@ -168,7 +231,13 @@ function WorkspaceTreeItems({
style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id}
type="button"
onClick={() => onSelect(item.script_id)}
onClick={() => {
if (item.script_id.startsWith("data:") && onCopyResourcePath) {
onCopyResourcePath(item.relative_path);
} else {
onSelect(item.script_id);
}
}}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "file",
@@ -177,8 +246,10 @@ function WorkspaceTreeItems({
})
: undefined}
>
<span className={`file-icon file-icon--${item.script_type}`}>
<Icon name={scriptIcon(item)} size={17} />
<span className={`file-icon file-icon--${
item.script_id.startsWith("data:") ? "data" : item.script_type
}`}>
<Icon name={item.script_id.startsWith("data:") ? "database" : scriptIcon(item)} size={17} />
</span>
<span className="script-row__copy">
<strong title={item.script_name}>{item.script_name}</strong>
@@ -207,6 +278,7 @@ function DirectoryBranch({
selectedId,
onSelect,
onContextMenu,
onCopyResourcePath,
expandedPaths,
onToggle,
loadingChildrenPaths,
@@ -250,6 +322,7 @@ function DirectoryBranch({
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/>
)}
</div>
@@ -6,7 +6,7 @@ import {
useScriptWorkspaceStore,
} from "../state/scriptWorkspaceStore";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
type ActivePage = "home" | "scripts" | "schedules" | "system";
/**
* 必须在 layout 层挂载,不能放在 ScriptsPage。
@@ -4,6 +4,7 @@ import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
ResourceItem,
StableVersion,
Visibility,
WorkspaceBoundApi,
@@ -55,9 +56,11 @@ export const editSessionHandle: { current: ActiveEditSession | null } = {
};
type State = {
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
dataResourcesLoading: boolean;
selectedId: string | null;
openTabIds: string[];
keyword: string;
loading: boolean;
@@ -87,11 +90,12 @@ type State = {
readOnlyRefreshVersion: number;
// actions
setApiOnline: (online: boolean) => void;
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
selectScript: (id: string | null) => void;
setApiOnline: (online: boolean) => void;
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
loadDataResources: () => Promise<void>;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
switchTab: (id: string) => void;
@@ -106,6 +110,12 @@ type State = {
loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
uploadScripts: (files: File[], parentPath: string) => Promise<void>;
uploadDataResource: (file: File, meta: {
resourceName: string;
visibility: Visibility;
description: string;
targetPath: string;
}) => Promise<ResourceItem | null>;
createFolder: (name: string, parentPath: string) => Promise<void>;
deleteScript: (script: ScriptItem) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
@@ -136,6 +146,12 @@ function ownedScriptPath(item: ScriptItem) {
function pushToast(tone: "success" | "error" | "info", message: string) {
useUiStore.getState().pushToast({ tone, message });
}
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
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 const useScriptWorkspaceStore = create<State>((set, get) => {
const setEditSessionState = (
@@ -151,9 +167,11 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
};
return {
scripts: [],
directories: [],
selectedId: null,
scripts: [],
directories: [],
dataResources: [],
dataResourcesLoading: false,
selectedId: null,
openTabIds: [],
keyword: "",
loading: true,
@@ -196,10 +214,12 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
editSessionHandle.current = null;
sessionCache.clear();
_pythonEditorOpeningIds.clear();
set({
scripts: [],
directories: [],
selectedId: null,
set({
scripts: [],
directories: [],
dataResources: [],
dataResourcesLoading: false,
selectedId: null,
openTabIds: [],
editSession: null,
embeddedJupyterUrl: null,
@@ -257,6 +277,19 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadDataResources: async () => {
const api = requireApi();
set({ dataResourcesLoading: true });
try {
const list = await api.listResources();
set({ dataResources: Array.isArray(list) ? list : [] });
} catch {
set({ dataResources: [] });
} finally {
set({ dataResourcesLoading: false });
}
},
loadChildren: async (parentPath) => {
const api = requireApi();
if (get().loadedChildPaths.has(parentPath)) return;
@@ -777,6 +810,47 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
uploadDataResource: async (file, meta) => {
const api = requireApi();
const ui = useUiStore.getState();
ui.setDataResourceUploading(true);
try {
const buffer = await file.arrayBuffer();
const hash = await sha256Hex(buffer);
const { upload_id: uploadId } = await api.createResourceUpload({
file_name: file.name,
content_type: file.type || "application/octet-stream",
expected_size_bytes: file.size,
expected_hash: hash,
target_path: meta.targetPath,
});
await api.uploadResourceBytes(
uploadId,
buffer,
file.type || "application/octet-stream",
);
const resource = await api.bindResourceUpload(uploadId, {
resource_name: meta.resourceName,
description: meta.description,
visibility: meta.visibility,
});
ui.closeDataResourceDialog();
pushToast(
"success",
`数据资源 ${resource.resource_name} 上传成功,路径:${resource.jupyter_accessible_path}`,
);
return resource;
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "数据资源上传失败",
);
return null;
} finally {
ui.setDataResourceUploading(false);
}
},
createFolder: async (name, parentPath) => {
const api = requireApi();
const trimmed = name.trim();
@@ -34,6 +34,16 @@ export type FolderDialogState = {
name: string;
busy: boolean;
};
export type DataResourceDialogState = {
open: boolean;
file: File | null;
parentPath: string;
resourceName: string;
visibility: Visibility;
description: string;
uploading: boolean;
targetPath: string;
};
export type PublishDialogState = {
target: ScriptItem | null;
@@ -67,6 +77,7 @@ type UiState = {
workspaceMenuOpen: boolean;
upload: UploadState;
publish: PublishDialogState;
dataResourceDialog: DataResourceDialogState;
// toast
pushToast: (toast: ToastState) => void;
@@ -107,6 +118,14 @@ type UiState = {
setPublishing: (publishing: boolean) => void;
setPublishedVersion: (version: StableVersion) => void;
clearPublishedVersion: () => void;
// data resource dialog
openDataResourceDialog: (file: File, parentPath?: string) => void;
closeDataResourceDialog: () => void;
setDataResourceName: (name: string) => void;
setDataResourceVisibility: (visibility: Visibility) => void;
setDataResourceDescription: (description: string) => void;
setDataResourceTargetPath: (path: string) => void;
setDataResourceUploading: (uploading: boolean) => void;
};
export const useUiStore = create<UiState>((set) => ({
@@ -135,6 +154,16 @@ export const useUiStore = create<UiState>((set) => ({
publishing: false,
publishedVersion: null,
},
dataResourceDialog: {
open: false,
file: null,
parentPath: "",
resourceName: "",
visibility: "workspace",
description: "",
uploading: false,
targetPath: "",
},
pushToast: (toast) => set({ toast }),
dismissToast: () => set({ toast: null }),
@@ -230,4 +259,33 @@ export const useUiStore = create<UiState>((set) => ({
})),
clearPublishedVersion: () =>
set((state) => ({ publish: { ...state.publish, publishedVersion: null } })),
}));
openDataResourceDialog: (file, parentPath = "") =>
set((state) => ({
contextMenu: null,
dataResourceDialog: {
open: true,
file,
parentPath,
resourceName: file.name.replace(/\.[^/.]+$/, ""),
visibility: "workspace",
description: "",
uploading: false,
// 右键"在此处上传"时,把父目录预填进子目录输入框
targetPath: parentPath,
},
})),
closeDataResourceDialog: () =>
set((state) => ({
dataResourceDialog: { ...state.dataResourceDialog, open: false, uploading: false },
})),
setDataResourceName: (name) =>
set((state) => ({ dataResourceDialog: { ...state.dataResourceDialog, resourceName: name } })),
setDataResourceVisibility: (visibility) =>
set((state) => ({ dataResourceDialog: { ...state.dataResourceDialog, visibility } })),
setDataResourceDescription: (description) =>
set((state) => ({ dataResourceDialog: { ...state.dataResourceDialog, description } })),
setDataResourceTargetPath: (path) =>
set((state) => ({ dataResourceDialog: { ...state.dataResourceDialog, targetPath: path } })),
setDataResourceUploading: (uploading) =>
set((state) => ({ dataResourceDialog: { ...state.dataResourceDialog, uploading } })),
}));