update: data source
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
|
||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
|
||||
type SidebarProps = {
|
||||
activePage: ActivePage;
|
||||
@@ -15,7 +15,7 @@ type SidebarProps = {
|
||||
|
||||
type NavigationItem = {
|
||||
label: string;
|
||||
icon: "home" | "script" | "database" | "schedule" | "settings";
|
||||
icon: "home" | "script" | "schedule" | "settings";
|
||||
page: ActivePage;
|
||||
};
|
||||
|
||||
@@ -44,7 +44,6 @@ 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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export function Topbar({
|
||||
home: "工作台",
|
||||
scripts: "构建脚本",
|
||||
schedules: "调度配置",
|
||||
"data-resources": "数据资源",
|
||||
system: "系统管理",
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
|
||||
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
|
||||
import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
|
||||
import { type AuthUser } from "~/context/AuthContext";
|
||||
import type { ResourceItem } from "~/services/api";
|
||||
|
||||
type ScriptExplorerProps = {
|
||||
scripts: ScriptItem[];
|
||||
filteredScripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
dataResources: ResourceItem[];
|
||||
user: AuthUser | null;
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
@@ -26,6 +28,7 @@ type ScriptExplorerProps = {
|
||||
target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem }
|
||||
) => void;
|
||||
onSelect: (scriptId: string) => void;
|
||||
onCopyResourcePath: (jupyterPath: string) => void;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
@@ -34,6 +37,7 @@ export function ScriptExplorer({
|
||||
scripts,
|
||||
filteredScripts,
|
||||
directories,
|
||||
dataResources,
|
||||
user,
|
||||
selectedId,
|
||||
loading,
|
||||
@@ -48,6 +52,7 @@ export function ScriptExplorer({
|
||||
onChooseUpload,
|
||||
onContextMenu,
|
||||
onSelect,
|
||||
onCopyResourcePath,
|
||||
uploadInputRef,
|
||||
onHandleUpload,
|
||||
}: ScriptExplorerProps) {
|
||||
@@ -57,6 +62,16 @@ export function ScriptExplorer({
|
||||
);
|
||||
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
|
||||
|
||||
const dataByOwner = useMemo(() => {
|
||||
const map = new Map<string, ResourceItem[]>();
|
||||
for (const r of dataResources) {
|
||||
const list = map.get(r.owner_user_id) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.owner_user_id, list);
|
||||
}
|
||||
return map;
|
||||
}, [dataResources]);
|
||||
|
||||
const memberScriptGroups = useMemo(() => {
|
||||
const visibleScripts =
|
||||
user?.is_system_admin === true
|
||||
@@ -83,6 +98,7 @@ export function ScriptExplorer({
|
||||
user: AuthUser | null;
|
||||
scripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
dataResources: ResourceItem[];
|
||||
}[] = [];
|
||||
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
|
||||
const displayName =
|
||||
@@ -109,6 +125,7 @@ export function ScriptExplorer({
|
||||
ownerUserId === user?.user_id
|
||||
? mergeDirectories(directories, inferred)
|
||||
: inferred,
|
||||
dataResources: dataByOwner.get(groupUser.user_id) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,7 +136,7 @@ export function ScriptExplorer({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredScripts, directories, user]);
|
||||
}, [filteredScripts, directories, user, dataByOwner]);
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
@@ -142,7 +159,7 @@ export function ScriptExplorer({
|
||||
ref={uploadInputRef}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
accept=".py,.ipynb"
|
||||
accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"
|
||||
multiple
|
||||
onChange={onHandleUpload}
|
||||
/>
|
||||
@@ -191,6 +208,7 @@ export function ScriptExplorer({
|
||||
title={`${group.user?.display_name}`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
dataResources={group.dataResources}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={
|
||||
@@ -202,6 +220,7 @@ export function ScriptExplorer({
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,6 +18,7 @@ type TreeContextMenuProps = {
|
||||
onOpenFolderDialog: (parentPath: string) => void;
|
||||
onChooseUpload: (parentPath: string) => void;
|
||||
onRemoveDirectory: (path: string) => void;
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -30,12 +31,14 @@ export function TreeContextMenu({
|
||||
onOpenFolderDialog,
|
||||
onChooseUpload,
|
||||
onRemoveDirectory,
|
||||
onCopyResourcePath,
|
||||
onClose,
|
||||
}: TreeContextMenuProps) {
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const width = 188;
|
||||
const height = contextMenu.kind === "file" ? 124 : 190;
|
||||
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -51,37 +54,58 @@ export function TreeContextMenu({
|
||||
>
|
||||
{contextMenu.kind === "file" && contextMenu.script ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenScript(contextMenu.script!.script_id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="script" size={16} />
|
||||
打开文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onToggleLock(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name={contextMenu.script.is_locked ? "unlock" : "lock"} size={16} />
|
||||
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
|
||||
</button>
|
||||
<button
|
||||
className="is-danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveScript(contextMenu.script!)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
删除文件
|
||||
</button>
|
||||
{isDataResource ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
if (onCopyResourcePath) {
|
||||
onCopyResourcePath(contextMenu.script!.relative_path);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="database" size={16} />
|
||||
复制 Jupyter 路径
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenScript(contextMenu.script!.script_id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="script" size={16} />
|
||||
打开文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onToggleLock(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={contextMenu.script.is_locked ? "unlock" : "lock"}
|
||||
size={16}
|
||||
/>
|
||||
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
|
||||
</button>
|
||||
<button
|
||||
className="is-danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveScript(contextMenu.script!)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
删除文件
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -221,10 +221,17 @@ export function useApi(): WorkspaceBoundApi {
|
||||
const workspaceId = currentWorkspace?.workspace_id ?? "";
|
||||
|
||||
return useMemo<WorkspaceBoundApi>(() => ({
|
||||
listScripts: () => rawApi.listScripts(workspaceId),
|
||||
createScript: (input) => rawApi.createScript(workspaceId, input),
|
||||
listScripts: () => rawApi.listScripts(workspaceId),
|
||||
listResources: (opts) => rawApi.listResources(workspaceId, opts),
|
||||
createScript: (input) => rawApi.createScript(workspaceId, input),
|
||||
uploadScript: (file, parentPath, visibility) =>
|
||||
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
|
||||
createResourceUpload: (body) =>
|
||||
rawApi.createResourceUpload(workspaceId, body),
|
||||
uploadResourceBytes: (uploadId, fileBytes, contentType) =>
|
||||
rawApi.uploadResourceBytes(workspaceId, uploadId, fileBytes, contentType),
|
||||
bindResourceUpload: (uploadId, body) =>
|
||||
rawApi.bindResourceUpload(workspaceId, uploadId, body),
|
||||
updateScript: (scriptId, input) =>
|
||||
rawApi.updateScript(workspaceId, scriptId, input),
|
||||
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||
|
||||
@@ -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>支持 CSV、Excel、JSON、Parquet、TSV、TXT</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>
|
||||
);
|
||||
}
|
||||
@@ -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 } })),
|
||||
}));
|
||||
@@ -6,7 +6,6 @@ export default [
|
||||
index("features/platform/RootIndex.tsx"),
|
||||
route("workbench", "features/platform/DashboardRoute.tsx"),
|
||||
route("scripts", "features/platform/ScriptsPage.tsx"),
|
||||
route("data-resources", "features/platform/DataResourcesPage.tsx"),
|
||||
route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
|
||||
route("system", "features/admin/SystemAdminRoute.tsx"),
|
||||
]),
|
||||
|
||||
@@ -18,11 +18,11 @@ import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
|
||||
|
||||
import "../styles/platform.css";
|
||||
|
||||
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system";
|
||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
|
||||
function pageFromPath(pathname: string): ActivePage {
|
||||
const page = pathname.replace(/^\/+|\/+$/g, "");
|
||||
return ["scripts", "data-resources", "schedules", "system"].includes(page)
|
||||
return ["scripts", "schedules", "system"].includes(page)
|
||||
? (page as ActivePage)
|
||||
: "home";
|
||||
}
|
||||
|
||||
@@ -435,6 +435,7 @@ export async function createResourceUpload(
|
||||
content_type: string;
|
||||
expected_size_bytes: number;
|
||||
expected_hash: string | null;
|
||||
target_path?: string;
|
||||
},
|
||||
): Promise<{ upload_id: string; upload_path: string }> {
|
||||
return apiRequest<{ upload_id: string; upload_path: string }>(
|
||||
@@ -1438,6 +1439,9 @@ export async function getScheduleNodeRunArtifacts(
|
||||
// ----------------------------------------------------------------------------
|
||||
export type WorkspaceBoundApi = {
|
||||
listScripts: () => Promise<ScriptItem[]>;
|
||||
listResources: (
|
||||
opts?: Parameters<typeof listResources>[1],
|
||||
) => Promise<ResourceItem[]>;
|
||||
createScript: (
|
||||
input: Parameters<typeof createScript>[1],
|
||||
) => Promise<ScriptItem>;
|
||||
@@ -1446,6 +1450,18 @@ export type WorkspaceBoundApi = {
|
||||
parentPath?: string,
|
||||
visibility?: Visibility,
|
||||
) => Promise<ScriptItem>;
|
||||
createResourceUpload: (
|
||||
body: Parameters<typeof createResourceUpload>[1],
|
||||
) => Promise<{ upload_id: string; upload_path: string }>;
|
||||
uploadResourceBytes: (
|
||||
uploadId: string,
|
||||
fileBytes: ArrayBuffer | Blob,
|
||||
contentType: string,
|
||||
) => Promise<{ storage_object_id: string }>;
|
||||
bindResourceUpload: (
|
||||
uploadId: string,
|
||||
body: Parameters<typeof bindResourceUpload>[2],
|
||||
) => Promise<ResourceItem>;
|
||||
updateScript: (
|
||||
scriptId: string,
|
||||
input: Parameters<typeof updateScript>[2],
|
||||
|
||||
Reference in New Issue
Block a user