update: data source
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>支持 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 { 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 } })),
|
||||||
}));
|
}));
|
||||||
@@ -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"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
Reference in New Issue
Block a user