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
+2 -3
View File
@@ -1,6 +1,6 @@
import Icon from "./Icon"; import Icon from "./Icon";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system"; type ActivePage = "home" | "scripts" | "schedules" | "system";
type SidebarProps = { type SidebarProps = {
activePage: ActivePage; activePage: ActivePage;
@@ -15,7 +15,7 @@ type SidebarProps = {
type NavigationItem = { type NavigationItem = {
label: string; label: string;
icon: "home" | "script" | "database" | "schedule" | "settings"; icon: "home" | "script" | "schedule" | "settings";
page: ActivePage; page: ActivePage;
}; };
@@ -44,7 +44,6 @@ export function Sidebar({
const navigation: NavigationItem[] = [ const navigation: NavigationItem[] = [
{ label: "工作台", icon: "home", page: "home" }, { label: "工作台", icon: "home", page: "home" },
{ label: "构建脚本", icon: "script", page: "scripts" }, { label: "构建脚本", icon: "script", page: "scripts" },
{ label: "数据资源", icon: "database", page: "data-resources" },
{ label: "调度配置", icon: "schedule", page: "schedules" }, { label: "调度配置", icon: "schedule", page: "schedules" },
]; ];
@@ -28,7 +28,6 @@ export function Topbar({
home: "工作台", home: "工作台",
scripts: "构建脚本", scripts: "构建脚本",
schedules: "调度配置", schedules: "调度配置",
"data-resources": "数据资源",
system: "系统管理", system: "系统管理",
}; };
@@ -4,11 +4,13 @@ import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore"; import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
import type { ScriptItem, WorkspaceDirectory } from "~/services/api"; import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
import { type AuthUser } from "~/context/AuthContext"; import { type AuthUser } from "~/context/AuthContext";
import type { ResourceItem } from "~/services/api";
type ScriptExplorerProps = { type ScriptExplorerProps = {
scripts: ScriptItem[]; scripts: ScriptItem[];
filteredScripts: ScriptItem[]; filteredScripts: ScriptItem[];
directories: WorkspaceDirectory[]; directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
user: AuthUser | null; user: AuthUser | null;
selectedId: string | null; selectedId: string | null;
loading: boolean; loading: boolean;
@@ -26,6 +28,7 @@ type ScriptExplorerProps = {
target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem } target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem }
) => void; ) => void;
onSelect: (scriptId: string) => void; onSelect: (scriptId: string) => void;
onCopyResourcePath: (jupyterPath: string) => void;
uploadInputRef: RefObject<HTMLInputElement | null>; uploadInputRef: RefObject<HTMLInputElement | null>;
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void; onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
}; };
@@ -34,6 +37,7 @@ export function ScriptExplorer({
scripts, scripts,
filteredScripts, filteredScripts,
directories, directories,
dataResources,
user, user,
selectedId, selectedId,
loading, loading,
@@ -48,6 +52,7 @@ export function ScriptExplorer({
onChooseUpload, onChooseUpload,
onContextMenu, onContextMenu,
onSelect, onSelect,
onCopyResourcePath,
uploadInputRef, uploadInputRef,
onHandleUpload, onHandleUpload,
}: ScriptExplorerProps) { }: ScriptExplorerProps) {
@@ -57,6 +62,16 @@ export function ScriptExplorer({
); );
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded); 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 memberScriptGroups = useMemo(() => {
const visibleScripts = const visibleScripts =
user?.is_system_admin === true user?.is_system_admin === true
@@ -83,6 +98,7 @@ export function ScriptExplorer({
user: AuthUser | null; user: AuthUser | null;
scripts: ScriptItem[]; scripts: ScriptItem[];
directories: WorkspaceDirectory[]; directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
}[] = []; }[] = [];
for (const [ownerUserId, groupScripts] of byOwner.entries()) { for (const [ownerUserId, groupScripts] of byOwner.entries()) {
const displayName = const displayName =
@@ -109,6 +125,7 @@ export function ScriptExplorer({
ownerUserId === user?.user_id ownerUserId === user?.user_id
? mergeDirectories(directories, inferred) ? mergeDirectories(directories, inferred)
: inferred, : inferred,
dataResources: dataByOwner.get(groupUser.user_id) ?? [],
}); });
} }
@@ -119,7 +136,7 @@ export function ScriptExplorer({
}); });
return groups; return groups;
}, [filteredScripts, directories, user]); }, [filteredScripts, directories, user, dataByOwner]);
return ( return (
<aside className="explorer"> <aside className="explorer">
@@ -142,7 +159,7 @@ export function ScriptExplorer({
ref={uploadInputRef} ref={uploadInputRef}
className="visually-hidden" className="visually-hidden"
type="file" type="file"
accept=".py,.ipynb" accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"
multiple multiple
onChange={onHandleUpload} onChange={onHandleUpload}
/> />
@@ -191,6 +208,7 @@ export function ScriptExplorer({
title={`${group.user?.display_name}`} title={`${group.user?.display_name}`}
scripts={group.scripts} scripts={group.scripts}
directories={group.directories} directories={group.directories}
dataResources={group.dataResources}
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onContextMenu={ onContextMenu={
@@ -202,6 +220,7 @@ export function ScriptExplorer({
expandedPaths={expandedPaths} expandedPaths={expandedPaths}
onToggle={onToggle} onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths} loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/> />
); );
})} })}
@@ -18,6 +18,7 @@ type TreeContextMenuProps = {
onOpenFolderDialog: (parentPath: string) => void; onOpenFolderDialog: (parentPath: string) => void;
onChooseUpload: (parentPath: string) => void; onChooseUpload: (parentPath: string) => void;
onRemoveDirectory: (path: string) => void; onRemoveDirectory: (path: string) => void;
onCopyResourcePath?: (jupyterPath: string) => void;
onClose: () => void; onClose: () => void;
}; };
@@ -30,12 +31,14 @@ export function TreeContextMenu({
onOpenFolderDialog, onOpenFolderDialog,
onChooseUpload, onChooseUpload,
onRemoveDirectory, onRemoveDirectory,
onCopyResourcePath,
onClose, onClose,
}: TreeContextMenuProps) { }: TreeContextMenuProps) {
if (!contextMenu) return null; if (!contextMenu) return null;
const width = 188; const width = 188;
const height = contextMenu.kind === "file" ? 124 : 190; const height = contextMenu.kind === "file" ? 124 : 190;
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
return ( return (
<div <div
@@ -50,6 +53,22 @@ export function TreeContextMenu({
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
{contextMenu.kind === "file" && contextMenu.script ? ( {contextMenu.kind === "file" && contextMenu.script ? (
<>
{isDataResource ? (
<button
type="button"
role="menuitem"
onClick={() => {
if (onCopyResourcePath) {
onCopyResourcePath(contextMenu.script!.relative_path);
}
onClose();
}}
>
<Icon name="database" size={16} />
Jupyter
</button>
) : (
<> <>
<button <button
type="button" type="button"
@@ -70,7 +89,10 @@ export function TreeContextMenu({
onClose(); onClose();
}} }}
> >
<Icon name={contextMenu.script.is_locked ? "unlock" : "lock"} size={16} /> <Icon
name={contextMenu.script.is_locked ? "unlock" : "lock"}
size={16}
/>
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"} {contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
</button> </button>
<button <button
@@ -83,6 +105,8 @@ export function TreeContextMenu({
</button> </button>
</> </>
)}
</>
) : ( ) : (
<> <>
<button <button
+7
View File
@@ -222,9 +222,16 @@ export function useApi(): WorkspaceBoundApi {
return useMemo<WorkspaceBoundApi>(() => ({ return useMemo<WorkspaceBoundApi>(() => ({
listScripts: () => rawApi.listScripts(workspaceId), listScripts: () => rawApi.listScripts(workspaceId),
listResources: (opts) => rawApi.listResources(workspaceId, opts),
createScript: (input) => rawApi.createScript(workspaceId, input), createScript: (input) => rawApi.createScript(workspaceId, input),
uploadScript: (file, parentPath, visibility) => uploadScript: (file, parentPath, visibility) =>
rawApi.uploadScript(workspaceId, 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) => updateScript: (scriptId, input) =>
rawApi.updateScript(workspaceId, scriptId, input), rawApi.updateScript(workspaceId, scriptId, input),
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId), 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> 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>
);
}
+93 -2
View File
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useMemo, useRef } from "react";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { CreateFolderModal } from "../../components/platform/CreateFolderModal"; import { CreateFolderModal } from "../../components/platform/CreateFolderModal";
import { CreateScriptModal } from "../../components/platform/CreateScriptModal"; import { CreateScriptModal } from "../../components/platform/CreateScriptModal";
import { DataResourceUploadModal } from "../../components/platform/DataResourceUploadModal";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import { PublishModal } from "../../components/platform/PublishModal"; import { PublishModal } from "../../components/platform/PublishModal";
import { ScriptExplorer } from "../../components/platform/ScriptExplorer"; import { ScriptExplorer } from "../../components/platform/ScriptExplorer";
@@ -32,6 +33,9 @@ export default function ScriptsPage() {
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion); const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading); const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
const dataResources = useScriptWorkspaceStore((s) => s.dataResources);
const loadDataResources = useScriptWorkspaceStore((s) => s.loadDataResources);
const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers); const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers);
// store actions // store actions
@@ -51,6 +55,7 @@ export default function ScriptsPage() {
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion); const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
const createScript = useScriptWorkspaceStore((s) => s.createScript); const createScript = useScriptWorkspaceStore((s) => s.createScript);
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts); const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
const uploadDataResource = useScriptWorkspaceStore((s) => s.uploadDataResource);
const createFolder = useScriptWorkspaceStore((s) => s.createFolder); const createFolder = useScriptWorkspaceStore((s) => s.createFolder);
const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript); const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript);
const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory); const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory);
@@ -74,6 +79,13 @@ export default function ScriptsPage() {
const openCreateDialog = useUiStore((s) => s.openCreateDialog); const openCreateDialog = useUiStore((s) => s.openCreateDialog);
const openFolderDialog = useUiStore((s) => s.openFolderDialog); const openFolderDialog = useUiStore((s) => s.openFolderDialog);
const chooseUpload = useUiStore((s) => s.chooseUpload); 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 publish = useUiStore((s) => s.publish);
const setReleaseNote = useUiStore((s) => s.setReleaseNote); const setReleaseNote = useUiStore((s) => s.setReleaseNote);
const setPublishVisibility = useUiStore((s) => s.setPublishVisibility); const setPublishVisibility = useUiStore((s) => s.setPublishVisibility);
@@ -87,6 +99,10 @@ export default function ScriptsPage() {
void load(); void load();
}, [load]); }, [load]);
useEffect(() => {
void loadDataResources();
}, [loadDataResources, workspaceId]);
useEffect(() => { useEffect(() => {
reset(); reset();
// 刷新前递增版本号,触发已打开标签页的只读内容刷新 // 刷新前递增版本号,触发已打开标签页的只读内容刷新
@@ -190,11 +206,67 @@ export default function ScriptsPage() {
}; };
// 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 handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []); const files = Array.from(event.target.files ?? []);
event.target.value = ""; event.target.value = "";
if (files.length === 0) return; 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) => { const handleCreateSubmit = (event: FormEvent) => {
@@ -223,6 +295,7 @@ export default function ScriptsPage() {
scripts={scripts} scripts={scripts}
filteredScripts={filteredScripts} filteredScripts={filteredScripts}
directories={directories} directories={directories}
dataResources={dataResources}
user={user} user={user}
selectedId={selectedId} selectedId={selectedId}
loading={loading} loading={loading}
@@ -238,6 +311,7 @@ export default function ScriptsPage() {
onChooseUpload={(parentPath) => triggerUpload(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath)}
onContextMenu={showContextMenu} onContextMenu={showContextMenu}
onSelect={openTab} onSelect={openTab}
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
uploadInputRef={uploadInputRef} uploadInputRef={uploadInputRef}
onHandleUpload={handleUpload} onHandleUpload={handleUpload}
/> />
@@ -344,8 +418,9 @@ export default function ScriptsPage() {
onOpenCreateDialog={(parentPath, scriptType) => onOpenCreateDialog={(parentPath, scriptType) =>
openCreateDialog(parentPath, scriptType)} openCreateDialog(parentPath, scriptType)}
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
onChooseUpload={(parentPath) => chooseUpload(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
onRemoveDirectory={(p) => void deleteDirectory(p)} onRemoveDirectory={(p) => void deleteDirectory(p)}
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
onClose={closeContextMenu} onClose={closeContextMenu}
/> />
@@ -365,6 +440,22 @@ export default function ScriptsPage() {
onClose={clearPublishedVersion} onClose={clearPublishedVersion}
onCopy={(message) => pushToast({ tone: "success", message })} 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> </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 Icon from "../../components/common/Icon";
import type { import type {
ScriptItem, ScriptItem,
WorkspaceDirectory, WorkspaceDirectory,
ResourceItem,
} from "~/services/api"; } from "~/services/api";
export type WorkspaceTreeTarget = { export type WorkspaceTreeTarget = {
@@ -23,6 +24,8 @@ type WorkspaceTreeProps = {
target: WorkspaceTreeTarget, target: WorkspaceTreeTarget,
) => void; ) => void;
readOnly?: boolean; readOnly?: boolean;
dataResources?: ResourceItem[];
onCopyResourcePath?: (jupyterPath: string) => void;
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。 // 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
groupKey: string; groupKey: string;
expandedPaths: Set<string>; expandedPaths: Set<string>;
@@ -33,6 +36,7 @@ type WorkspaceTreeProps = {
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & { type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & {
path: string; path: string;
depth: number; depth: number;
onCopyResourcePath?: (jupyterPath: string) => void;
}; };
function formatTime(value: string) { function formatTime(value: string) {
@@ -50,6 +54,11 @@ export function scriptIcon(item: ScriptItem) {
} }
function ownedScriptPath(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("/"); return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
} }
@@ -67,12 +76,58 @@ export function WorkspaceTreeGroup({
onSelect, onSelect,
onContextMenu, onContextMenu,
readOnly = false, readOnly = false,
dataResources,
onCopyResourcePath,
groupKey, groupKey,
expandedPaths, expandedPaths,
onToggle, onToggle,
loadingChildrenPaths, loadingChildrenPaths,
}: WorkspaceTreeProps) { }: WorkspaceTreeProps) {
const open = expandedPaths.has(groupKey); 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) 的默认展开行为)。 // 首次挂载自动展开(保留原本 useState(true) 的默认展开行为)。
// toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。 // toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。
useEffect(() => { useEffect(() => {
@@ -103,16 +158,22 @@ export function WorkspaceTreeGroup({
groupKey={groupKey} groupKey={groupKey}
path="" path=""
depth={0} depth={0}
scripts={scripts} scripts={[...scripts, ...dataResourceScripts]}
directories={directories} // 数据资源目录与真实/推断目录按 path 去重
directories={[
...new Map(
[...directories, ...dataResourceDirectories].map((d) => [d.path, d]),
).values(),
]}
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
expandedPaths={expandedPaths} expandedPaths={expandedPaths}
onToggle={onToggle} onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths} loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/> />
{scripts.length === 0 && directories.length === 0 && ( {scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
<p className="tree-group__empty"> <p className="tree-group__empty">
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"} {readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
</p> </p>
@@ -132,6 +193,7 @@ function WorkspaceTreeItems({
selectedId, selectedId,
onSelect, onSelect,
onContextMenu, onContextMenu,
onCopyResourcePath,
expandedPaths, expandedPaths,
onToggle, onToggle,
loadingChildrenPaths, loadingChildrenPaths,
@@ -158,6 +220,7 @@ function WorkspaceTreeItems({
expandedPaths={expandedPaths} expandedPaths={expandedPaths}
onToggle={onToggle} onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths} loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/> />
))} ))}
{childScripts.map((item) => ( {childScripts.map((item) => (
@@ -168,7 +231,13 @@ function WorkspaceTreeItems({
style={{ paddingLeft: 20 + depth * 16 }} style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id} key={item.script_id}
type="button" 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 onContextMenu={onContextMenu
? (event) => onContextMenu(event, { ? (event) => onContextMenu(event, {
kind: "file", kind: "file",
@@ -177,8 +246,10 @@ function WorkspaceTreeItems({
}) })
: undefined} : undefined}
> >
<span className={`file-icon file-icon--${item.script_type}`}> <span className={`file-icon file-icon--${
<Icon name={scriptIcon(item)} size={17} /> item.script_id.startsWith("data:") ? "data" : item.script_type
}`}>
<Icon name={item.script_id.startsWith("data:") ? "database" : scriptIcon(item)} size={17} />
</span> </span>
<span className="script-row__copy"> <span className="script-row__copy">
<strong title={item.script_name}>{item.script_name}</strong> <strong title={item.script_name}>{item.script_name}</strong>
@@ -207,6 +278,7 @@ function DirectoryBranch({
selectedId, selectedId,
onSelect, onSelect,
onContextMenu, onContextMenu,
onCopyResourcePath,
expandedPaths, expandedPaths,
onToggle, onToggle,
loadingChildrenPaths, loadingChildrenPaths,
@@ -250,6 +322,7 @@ function DirectoryBranch({
expandedPaths={expandedPaths} expandedPaths={expandedPaths}
onToggle={onToggle} onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths} loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/> />
)} )}
</div> </div>
@@ -6,7 +6,7 @@ import {
useScriptWorkspaceStore, useScriptWorkspaceStore,
} from "../state/scriptWorkspaceStore"; } from "../state/scriptWorkspaceStore";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system"; type ActivePage = "home" | "scripts" | "schedules" | "system";
/** /**
* 必须在 layout 层挂载,不能放在 ScriptsPage。 * 必须在 layout 层挂载,不能放在 ScriptsPage。
@@ -4,6 +4,7 @@ import type {
ActiveEditSession, ActiveEditSession,
LatestVersion, LatestVersion,
ScriptItem, ScriptItem,
ResourceItem,
StableVersion, StableVersion,
Visibility, Visibility,
WorkspaceBoundApi, WorkspaceBoundApi,
@@ -57,6 +58,8 @@ export const editSessionHandle: { current: ActiveEditSession | null } = {
type State = { type State = {
scripts: ScriptItem[]; scripts: ScriptItem[];
directories: WorkspaceDirectory[]; directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
dataResourcesLoading: boolean;
selectedId: string | null; selectedId: string | null;
openTabIds: string[]; openTabIds: string[];
keyword: string; keyword: string;
@@ -91,6 +94,7 @@ type State = {
setKeyword: (keyword: string) => void; setKeyword: (keyword: string) => void;
reset: () => void; reset: () => void;
load: (silent?: boolean) => Promise<void>; load: (silent?: boolean) => Promise<void>;
loadDataResources: () => Promise<void>;
selectScript: (id: string | null) => void; selectScript: (id: string | null) => void;
openTab: (id: string) => void; openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>; closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
@@ -106,6 +110,12 @@ type State = {
loadPreview: (workspaceId: string, filePath: string) => Promise<void>; loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>; createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
uploadScripts: (files: File[], parentPath: string) => Promise<void>; 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>; createFolder: (name: string, parentPath: string) => Promise<void>;
deleteScript: (script: ScriptItem) => Promise<void>; deleteScript: (script: ScriptItem) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>; deleteDirectory: (path: string) => Promise<void>;
@@ -136,6 +146,12 @@ function ownedScriptPath(item: ScriptItem) {
function pushToast(tone: "success" | "error" | "info", message: string) { function pushToast(tone: "success" | "error" | "info", message: string) {
useUiStore.getState().pushToast({ tone, message }); 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) => { export const useScriptWorkspaceStore = create<State>((set, get) => {
const setEditSessionState = ( const setEditSessionState = (
@@ -153,6 +169,8 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
return { return {
scripts: [], scripts: [],
directories: [], directories: [],
dataResources: [],
dataResourcesLoading: false,
selectedId: null, selectedId: null,
openTabIds: [], openTabIds: [],
keyword: "", keyword: "",
@@ -199,6 +217,8 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
set({ set({
scripts: [], scripts: [],
directories: [], directories: [],
dataResources: [],
dataResourcesLoading: false,
selectedId: null, selectedId: null,
openTabIds: [], openTabIds: [],
editSession: null, editSession: 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) => { loadChildren: async (parentPath) => {
const api = requireApi(); const api = requireApi();
if (get().loadedChildPaths.has(parentPath)) return; 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) => { createFolder: async (name, parentPath) => {
const api = requireApi(); const api = requireApi();
const trimmed = name.trim(); const trimmed = name.trim();
@@ -34,6 +34,16 @@ export type FolderDialogState = {
name: string; name: string;
busy: boolean; 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 = { export type PublishDialogState = {
target: ScriptItem | null; target: ScriptItem | null;
@@ -67,6 +77,7 @@ type UiState = {
workspaceMenuOpen: boolean; workspaceMenuOpen: boolean;
upload: UploadState; upload: UploadState;
publish: PublishDialogState; publish: PublishDialogState;
dataResourceDialog: DataResourceDialogState;
// toast // toast
pushToast: (toast: ToastState) => void; pushToast: (toast: ToastState) => void;
@@ -107,6 +118,14 @@ type UiState = {
setPublishing: (publishing: boolean) => void; setPublishing: (publishing: boolean) => void;
setPublishedVersion: (version: StableVersion) => void; setPublishedVersion: (version: StableVersion) => void;
clearPublishedVersion: () => 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) => ({ export const useUiStore = create<UiState>((set) => ({
@@ -135,6 +154,16 @@ export const useUiStore = create<UiState>((set) => ({
publishing: false, publishing: false,
publishedVersion: null, publishedVersion: null,
}, },
dataResourceDialog: {
open: false,
file: null,
parentPath: "",
resourceName: "",
visibility: "workspace",
description: "",
uploading: false,
targetPath: "",
},
pushToast: (toast) => set({ toast }), pushToast: (toast) => set({ toast }),
dismissToast: () => set({ toast: null }), dismissToast: () => set({ toast: null }),
@@ -230,4 +259,33 @@ export const useUiStore = create<UiState>((set) => ({
})), })),
clearPublishedVersion: () => clearPublishedVersion: () =>
set((state) => ({ publish: { ...state.publish, publishedVersion: null } })), 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 } })),
})); }));
-1
View File
@@ -6,7 +6,6 @@ export default [
index("features/platform/RootIndex.tsx"), index("features/platform/RootIndex.tsx"),
route("workbench", "features/platform/DashboardRoute.tsx"), route("workbench", "features/platform/DashboardRoute.tsx"),
route("scripts", "features/platform/ScriptsPage.tsx"), route("scripts", "features/platform/ScriptsPage.tsx"),
route("data-resources", "features/platform/DataResourcesPage.tsx"),
route("schedules", "features/schedules/SchedulesPageRoute.tsx"), route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
route("system", "features/admin/SystemAdminRoute.tsx"), route("system", "features/admin/SystemAdminRoute.tsx"),
]), ]),
+2 -2
View File
@@ -18,11 +18,11 @@ import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
import "../styles/platform.css"; import "../styles/platform.css";
type ActivePage = "home" | "scripts" | "data-resources" | "schedules" | "system"; type ActivePage = "home" | "scripts" | "schedules" | "system";
function pageFromPath(pathname: string): ActivePage { function pageFromPath(pathname: string): ActivePage {
const page = pathname.replace(/^\/+|\/+$/g, ""); const page = pathname.replace(/^\/+|\/+$/g, "");
return ["scripts", "data-resources", "schedules", "system"].includes(page) return ["scripts", "schedules", "system"].includes(page)
? (page as ActivePage) ? (page as ActivePage)
: "home"; : "home";
} }
+16
View File
@@ -435,6 +435,7 @@ export async function createResourceUpload(
content_type: string; content_type: string;
expected_size_bytes: number; expected_size_bytes: number;
expected_hash: string | null; expected_hash: string | null;
target_path?: string;
}, },
): Promise<{ upload_id: string; upload_path: string }> { ): Promise<{ upload_id: string; upload_path: string }> {
return apiRequest<{ 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 = { export type WorkspaceBoundApi = {
listScripts: () => Promise<ScriptItem[]>; listScripts: () => Promise<ScriptItem[]>;
listResources: (
opts?: Parameters<typeof listResources>[1],
) => Promise<ResourceItem[]>;
createScript: ( createScript: (
input: Parameters<typeof createScript>[1], input: Parameters<typeof createScript>[1],
) => Promise<ScriptItem>; ) => Promise<ScriptItem>;
@@ -1446,6 +1450,18 @@ export type WorkspaceBoundApi = {
parentPath?: string, parentPath?: string,
visibility?: Visibility, visibility?: Visibility,
) => Promise<ScriptItem>; ) => 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: ( updateScript: (
scriptId: string, scriptId: string,
input: Parameters<typeof updateScript>[2], input: Parameters<typeof updateScript>[2],