458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
import { type FormEvent, useEffect, useMemo, useRef } from "react";
|
|
|
|
import { useAuth } from "../../context/AuthContext";
|
|
import { CreateFolderModal } from "./CreateFolderModal";
|
|
import { CreateScriptModal } from "./CreateScriptModal";
|
|
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
|
import { WelcomePanel } from "./WelcomePanel";
|
|
import { PublishModal } from "./PublishModal";
|
|
import { ScriptExplorer } from "./ScriptExplorer";
|
|
import { TreeContextMenu } from "./TreeContextMenu";
|
|
import { VersionReceiptModal } from "./VersionReceiptModal";
|
|
|
|
import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
|
import { useUiStore } from "./state/uiStore";
|
|
import { toast } from "sonner";
|
|
import { ScriptWorkspace } from "./ScriptWorkspace";
|
|
import { copyToClipboard } from "../../lib/clipboard";
|
|
|
|
export default function ScriptsPage() {
|
|
const { currentWorkspace, user } = useAuth();
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// store state
|
|
const scripts = useScriptWorkspaceStore((s) => s.scripts);
|
|
const directories = useScriptWorkspaceStore((s) => s.directories);
|
|
const selectedId = useScriptWorkspaceStore((s) => s.selectedId);
|
|
const openTabIds = useScriptWorkspaceStore((s) => s.openTabIds);
|
|
const keyword = useScriptWorkspaceStore((s) => s.keyword);
|
|
const loading = useScriptWorkspaceStore((s) => s.loading);
|
|
const refreshing = useScriptWorkspaceStore((s) => s.refreshing);
|
|
const editSession = useScriptWorkspaceStore((s) => s.editSession);
|
|
const embeddedJupyterUrl = useScriptWorkspaceStore((s) => s.embeddedJupyterUrl);
|
|
const editBusy = useScriptWorkspaceStore((s) => s.editBusy);
|
|
const editorOpenError = useScriptWorkspaceStore((s) => s.editorOpenError);
|
|
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
|
|
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
|
|
|
|
const dataResources = useScriptWorkspaceStore((s) => s.dataResources);
|
|
const loadDataResources = useScriptWorkspaceStore((s) => s.loadDataResources);
|
|
|
|
const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers);
|
|
|
|
// store actions
|
|
const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword);
|
|
const load = useScriptWorkspaceStore((s) => s.load);
|
|
const reset = useScriptWorkspaceStore((s) => s.reset);
|
|
const selectScript = useScriptWorkspaceStore((s) => s.selectScript);
|
|
const openTab = useScriptWorkspaceStore((s) => s.openTab);
|
|
const closeTab = useScriptWorkspaceStore((s) => s.closeTab);
|
|
const switchTab = useScriptWorkspaceStore((s) => s.switchTab);
|
|
const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor);
|
|
const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
|
|
const openPythonEditor = useScriptWorkspaceStore((s) => s.openPythonEditor);
|
|
const setPythonEditorContent = useScriptWorkspaceStore((s) => s.setPythonEditorContent);
|
|
const savePythonEditor = useScriptWorkspaceStore((s) => s.savePythonEditor);
|
|
const exitPythonEditor = useScriptWorkspaceStore((s) => s.exitPythonEditor);
|
|
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
|
|
const createScript = useScriptWorkspaceStore((s) => s.createScript);
|
|
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
|
|
const uploadDataResource = useScriptWorkspaceStore((s) => s.uploadDataResource);
|
|
const createFolder = useScriptWorkspaceStore((s) => s.createFolder);
|
|
const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript);
|
|
const deleteDataResource = useScriptWorkspaceStore((s) => s.deleteDataResource);
|
|
const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory);
|
|
const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock);
|
|
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
|
|
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
|
|
const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent);
|
|
|
|
// ui store
|
|
const pushToast = (notice: { tone: "success" | "error" | "info"; message: string }) => {
|
|
if (notice.tone === "error") toast.error(notice.message);
|
|
else if (notice.tone === "info") toast.info(notice.message);
|
|
else toast.success(notice.message);
|
|
};
|
|
const createDialog = useUiStore((s) => s.createDialog);
|
|
const setCreateForm = useUiStore((s) => s.setCreateForm);
|
|
const closeCreateDialog = useUiStore((s) => s.closeCreateDialog);
|
|
const folderDialog = useUiStore((s) => s.folderDialog);
|
|
const setFolderName = useUiStore((s) => s.setFolderName);
|
|
const closeFolderDialog = useUiStore((s) => s.closeFolderDialog);
|
|
const contextMenu = useUiStore((s) => s.contextMenu);
|
|
const closeContextMenu = useUiStore((s) => s.closeContextMenu);
|
|
const showContextMenu = useUiStore((s) => s.showContextMenu);
|
|
const upload = useUiStore((s) => s.upload);
|
|
const openCreateDialog = useUiStore((s) => s.openCreateDialog);
|
|
const openFolderDialog = useUiStore((s) => s.openFolderDialog);
|
|
const chooseUpload = useUiStore((s) => s.chooseUpload);
|
|
const dataResourceDialog = useUiStore((s) => s.dataResourceDialog);
|
|
const openDataResourceDialog = useUiStore((s) => s.openDataResourceDialog);
|
|
const closeDataResourceDialog = useUiStore((s) => s.closeDataResourceDialog);
|
|
const setDataResourceName = useUiStore((s) => s.setDataResourceName);
|
|
const setDataResourceVisibility = useUiStore((s) => s.setDataResourceVisibility);
|
|
const setDataResourceDescription = useUiStore((s) => s.setDataResourceDescription);
|
|
const setDataResourceTargetPath = useUiStore((s) => s.setDataResourceTargetPath);
|
|
const publish = useUiStore((s) => s.publish);
|
|
const setReleaseNote = useUiStore((s) => s.setReleaseNote);
|
|
const setPublishVisibility = useUiStore((s) => s.setPublishVisibility);
|
|
const closePublishDialog = useUiStore((s) => s.closePublishDialog);
|
|
const clearPublishedVersion = useUiStore((s) => s.clearPublishedVersion);
|
|
|
|
const workspaceId = currentWorkspace?.workspace_id;
|
|
|
|
// 1) 初始加载 + workspace 切换时重置
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
void loadDataResources();
|
|
}, [loadDataResources, workspaceId]);
|
|
|
|
useEffect(() => {
|
|
reset();
|
|
// 刷新前递增版本号,触发已打开标签页的只读内容刷新
|
|
refreshReadOnlyContent();
|
|
void load();
|
|
}, [reset, load, workspaceId, refreshReadOnlyContent]);
|
|
|
|
// 2) 选中文件变更时加载最新版本
|
|
useEffect(() => {
|
|
if (!selectedId) {
|
|
useScriptWorkspaceStore.setState({ latestVersion: null });
|
|
return;
|
|
}
|
|
let ignore = false;
|
|
useScriptWorkspaceStore.setState({ latestVersionLoading: true });
|
|
void loadLatestVersion(selectedId).then(() => {
|
|
if (!ignore) {
|
|
useScriptWorkspaceStore.setState({ latestVersionLoading: false });
|
|
}
|
|
});
|
|
return () => {
|
|
ignore = true;
|
|
};
|
|
}, [selectedId, loadLatestVersion]);
|
|
|
|
const selected = useMemo(
|
|
() => scripts.find((item) => item.script_id === selectedId) ?? null,
|
|
[scripts, selectedId],
|
|
);
|
|
|
|
const filteredScripts = useMemo(() => {
|
|
const normalized = keyword.trim().toLocaleLowerCase();
|
|
if (!normalized) return scripts;
|
|
return scripts.filter((item) =>
|
|
item.script_name.toLocaleLowerCase().includes(normalized),
|
|
);
|
|
}, [keyword, scripts]);
|
|
|
|
// 3) 自动打开 notebook 编辑器
|
|
useEffect(() => {
|
|
if (
|
|
!selected
|
|
|| selected.script_type !== "notebook"
|
|
|| editBusy
|
|
|| (editSession?.script_id === selected.script_id && embeddedJupyterUrl)
|
|
|| editorOpenError?.scriptId === selected.script_id
|
|
) {
|
|
return;
|
|
}
|
|
const cached = getSessionCache().get(selected.script_id);
|
|
if (cached) {
|
|
useScriptWorkspaceStore.setState({
|
|
editSession: cached.session,
|
|
embeddedJupyterUrl: cached.jupyterUrl,
|
|
});
|
|
return;
|
|
}
|
|
void openScriptEditor(selected, false);
|
|
}, [
|
|
selected,
|
|
editBusy,
|
|
editSession?.script_id,
|
|
embeddedJupyterUrl,
|
|
editorOpenError?.scriptId,
|
|
openScriptEditor,
|
|
]);
|
|
|
|
// 4) context menu 外部点击关闭
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
const close = () => closeContextMenu();
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") close();
|
|
};
|
|
window.addEventListener("pointerdown", close);
|
|
window.addEventListener("blur", close);
|
|
window.addEventListener("resize", close);
|
|
window.addEventListener("scroll", close, true);
|
|
window.addEventListener("keydown", closeOnEscape);
|
|
return () => {
|
|
window.removeEventListener("pointerdown", close);
|
|
window.removeEventListener("blur", close);
|
|
window.removeEventListener("resize", close);
|
|
window.removeEventListener("scroll", close, true);
|
|
window.removeEventListener("keydown", closeOnEscape);
|
|
};
|
|
}, [contextMenu, closeContextMenu]);
|
|
|
|
// python editor handlers with scriptId forwarding
|
|
const handleSetPythonEditorContent = (scriptId: string, value: string) => {
|
|
setPythonEditorContent(scriptId, value);
|
|
};
|
|
const handleSavePythonEditor = (scriptId: string) => {
|
|
void savePythonEditor(scriptId);
|
|
};
|
|
const handleExitPythonEditor = (scriptId: string) => {
|
|
exitPythonEditor(scriptId);
|
|
};
|
|
const handleClosePythonTab = (scriptId: string) => {
|
|
void closeTab(scriptId);
|
|
};
|
|
|
|
// 5) handlers
|
|
const SCRIPT_EXTS = [".py", ".ipynb"];
|
|
const DATA_EXTS = [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet", ".txt"];
|
|
|
|
const matchesExt = (name: string, exts: string[]) =>
|
|
exts.some((ext) => name.toLowerCase().endsWith(ext));
|
|
|
|
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(event.target.files ?? []);
|
|
event.target.value = "";
|
|
if (files.length === 0) return;
|
|
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 copyToClipboard(stripped);
|
|
pushToast({ tone: "success", message: `已复制:${stripped}` });
|
|
} catch {
|
|
pushToast({ tone: "error", message: "复制失败" });
|
|
}
|
|
};
|
|
|
|
const handleCreateSubmit = (event: FormEvent) => {
|
|
event.preventDefault();
|
|
void createScript(createDialog.form);
|
|
};
|
|
|
|
const handleFolderSubmit = (event: FormEvent) => {
|
|
event.preventDefault();
|
|
void createFolder(folderDialog.name, folderDialog.parentPath);
|
|
};
|
|
|
|
const handlePublishSubmit = (event: FormEvent) => {
|
|
event.preventDefault();
|
|
void submitPublish(publish.releaseNote, publish.visibility);
|
|
};
|
|
|
|
const triggerUpload = (parentPath?: string) => {
|
|
chooseUpload(parentPath);
|
|
uploadInputRef.current?.click();
|
|
};
|
|
|
|
return (
|
|
<section className="grid min-h-0 flex-1 grid-cols-[310px_minmax(0,1fr)] gap-2.5 p-2.5 max-xl:grid-cols-[285px_minmax(0,1fr)]">
|
|
<ScriptExplorer
|
|
scripts={scripts}
|
|
filteredScripts={filteredScripts}
|
|
directories={directories}
|
|
dataResources={dataResources}
|
|
user={user}
|
|
selectedId={selectedId}
|
|
loading={loading}
|
|
refreshing={refreshing}
|
|
uploading={upload.uploading}
|
|
keyword={keyword}
|
|
onKeywordChange={setKeyword}
|
|
onRefresh={() => void load(true)}
|
|
onUpload={() => triggerUpload("")}
|
|
onOpenCreateDialog={(parentPath, scriptType) =>
|
|
openCreateDialog(parentPath, scriptType)}
|
|
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
|
|
onChooseUpload={(parentPath) => triggerUpload(parentPath)}
|
|
onContextMenu={showContextMenu}
|
|
onSelect={openTab}
|
|
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
|
uploadInputRef={uploadInputRef}
|
|
onHandleUpload={handleUpload}
|
|
/>
|
|
|
|
<section className="flex min-h-0 flex-col overflow-hidden rounded-[7px] border border-[#dce4ec] bg-white shadow-[0_2px_7px_rgb(25_46_73/3%)]">
|
|
{selected ? (
|
|
<ScriptWorkspace
|
|
script={selected}
|
|
sessionCache={getSessionCache()}
|
|
editSession={editSession}
|
|
jupyterUrl={embeddedJupyterUrl}
|
|
editBusy={editBusy}
|
|
openError={
|
|
editorOpenError?.scriptId === selected.script_id
|
|
? editorOpenError.message
|
|
: null
|
|
}
|
|
latestVersion={latestVersion}
|
|
versionsLoading={latestVersionLoading}
|
|
openTabs={openTabIds.map((id) => {
|
|
const s = scripts.find((item) => item.script_id === id);
|
|
return {
|
|
scriptId: id,
|
|
scriptName: s?.script_name ?? "未知",
|
|
scriptType: s?.script_type ?? "notebook",
|
|
};
|
|
})}
|
|
onOpenEditor={() => {
|
|
if (selected.script_type === "python") {
|
|
void openPythonEditor(selected);
|
|
} else {
|
|
void openScriptEditor(selected);
|
|
}
|
|
}}
|
|
onEndEditing={() => {
|
|
if (selected && pythonEditorBuffers[selected.script_id]) {
|
|
exitPythonEditor(selected.script_id);
|
|
} else {
|
|
void endEditing();
|
|
}
|
|
}}
|
|
onClose={(scriptId, event) => void closeTab(scriptId, event)}
|
|
onSwitchTab={switchTab}
|
|
onNewTab={() => openCreateDialog("")}
|
|
onPublish={() => openPublishDialog(selected)}
|
|
scripts={scripts}
|
|
pythonEditorBuffers={pythonEditorBuffers}
|
|
onOpenPythonEditor={() => void openPythonEditor(selected)}
|
|
onSetPythonEditorContent={handleSetPythonEditorContent}
|
|
onSavePythonEditor={handleSavePythonEditor}
|
|
onExitPythonEditor={handleExitPythonEditor}
|
|
onClosePythonTab={handleClosePythonTab}
|
|
onInfo={(t) => pushToast(t)}
|
|
/>
|
|
) : (
|
|
<WelcomePanel onCreateScript={() => openCreateDialog("")} />
|
|
)}
|
|
</section>
|
|
|
|
<CreateScriptModal
|
|
open={createDialog.open}
|
|
creating={createDialog.creating}
|
|
form={createDialog.form}
|
|
scripts={scripts.map((s) => ({
|
|
script_type: s.script_type,
|
|
script_name: s.script_name,
|
|
relative_path: s.relative_path,
|
|
}))}
|
|
onFormChange={setCreateForm}
|
|
onSubmit={handleCreateSubmit}
|
|
onClose={closeCreateDialog}
|
|
/>
|
|
|
|
<CreateFolderModal
|
|
open={folderDialog.open}
|
|
parentPath={folderDialog.parentPath}
|
|
name={folderDialog.name}
|
|
busy={folderDialog.busy}
|
|
onNameChange={setFolderName}
|
|
onSubmit={handleFolderSubmit}
|
|
onClose={closeFolderDialog}
|
|
/>
|
|
|
|
<TreeContextMenu
|
|
contextMenu={contextMenu}
|
|
onOpenScript={(scriptId) => {
|
|
selectScript(scriptId);
|
|
closeContextMenu();
|
|
}}
|
|
onRemoveScript={(s) => void deleteScript(s)}
|
|
onToggleLock={(s) => void toggleScriptLock(s)}
|
|
onOpenCreateDialog={(parentPath, scriptType) =>
|
|
openCreateDialog(parentPath, scriptType)}
|
|
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
|
|
onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
|
|
onRemoveDirectory={(p) => void deleteDirectory(p)}
|
|
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
|
onRemoveResource={(id) => void deleteDataResource(id)}
|
|
onClose={closeContextMenu}
|
|
/>
|
|
|
|
<PublishModal
|
|
publishTarget={publish.target}
|
|
releaseNote={publish.releaseNote}
|
|
publishVisibility={publish.visibility}
|
|
publishing={publish.publishing}
|
|
onReleaseNoteChange={setReleaseNote}
|
|
onPublishVisibilityChange={setPublishVisibility}
|
|
onSubmit={handlePublishSubmit}
|
|
onClose={closePublishDialog}
|
|
/>
|
|
|
|
<VersionReceiptModal
|
|
publishedVersion={publish.publishedVersion}
|
|
onClose={clearPublishedVersion}
|
|
onCopy={(message) => pushToast({ tone: "success", message })}
|
|
/>
|
|
<DataResourceUploadModal
|
|
open={dataResourceDialog.open}
|
|
file={dataResourceDialog.file}
|
|
resourceName={dataResourceDialog.resourceName}
|
|
visibility={dataResourceDialog.visibility}
|
|
description={dataResourceDialog.description}
|
|
uploading={dataResourceDialog.uploading}
|
|
parentPath={dataResourceDialog.parentPath}
|
|
targetPath={dataResourceDialog.targetPath}
|
|
onNameChange={setDataResourceName}
|
|
onVisibilityChange={setDataResourceVisibility}
|
|
onDescriptionChange={setDataResourceDescription}
|
|
onTargetPathChange={setDataResourceTargetPath}
|
|
onSubmit={handleDataResourceSubmit}
|
|
onClose={closeDataResourceDialog}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|