Merge origin/develop into feature/a-card-operations
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import FileViewer from "@file-viewer/react";
|
||||
import officePreset from "@file-viewer/preset-office";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { ApiRequestError, type ResourcePreviewPayload } from "~/services/api";
|
||||
import { useApi, useAuth } from "~/context/AuthContext";
|
||||
import {
|
||||
EXCEL_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
monacoLanguageFromFileName,
|
||||
resourceDisplayName,
|
||||
type DataResourcePreviewTarget,
|
||||
} from "./dataResourcePreview";
|
||||
|
||||
const EYEBROW: Record<DataResourcePreviewTarget["kind"], string> = {
|
||||
excel: "EXCEL PREVIEW",
|
||||
text: "TEXT PREVIEW",
|
||||
table: "TABLE PREVIEW",
|
||||
};
|
||||
|
||||
export function DataResourcePreviewDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: DataResourcePreviewTarget | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const title = target
|
||||
? resourceDisplayName(target.resourceName, target.fileExtension)
|
||||
: "";
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/35 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预览 ${title}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-[min(90vh,880px)] w-[min(96vw,1100px)] flex-col overflow-hidden rounded-[11px] border border-[#dce4eb] bg-white shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#edf1f5] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] font-extrabold tracking-[0.12em] text-[#2d82d4]">
|
||||
{EYEBROW[target.kind]}
|
||||
</div>
|
||||
<h3 className="mt-0.5 truncate text-[16px] font-medium text-[#1c2d42]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button grid size-8 place-items-center rounded-md text-[#66788a] hover:bg-[#f3f6f9]"
|
||||
onClick={onClose}
|
||||
aria-label="关闭预览"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#f7f9fb]">
|
||||
{target.kind === "excel" && (
|
||||
<ExcelPreviewBody target={target} title={title} />
|
||||
)}
|
||||
{target.kind === "text" && (
|
||||
<TextPreviewBody target={target} title={title} />
|
||||
)}
|
||||
{target.kind === "table" && <TablePreviewBody target={target} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusOverlay({
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="absolute inset-0 grid place-items-center text-[13px] text-[#7a8b9c]">
|
||||
正在加载预览…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<p className="absolute inset-0 grid place-items-center px-6 text-center text-[13px] text-[#c74848]">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ExcelPreviewBody({
|
||||
target,
|
||||
title,
|
||||
}: {
|
||||
target: DataResourcePreviewTarget;
|
||||
title: string;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { currentWorkspace } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace?.workspace_id) return;
|
||||
if (target.sizeBytes > EXCEL_PREVIEW_MAX_BYTES) {
|
||||
setFile(null);
|
||||
setError(
|
||||
`文件过大(${Math.ceil(target.sizeBytes / (1024 * 1024))} MB),暂不支持在线预览`,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setFile(null);
|
||||
void api
|
||||
.fetchResourceContentFile(target.resourceId, title, controller.signal)
|
||||
.then((loaded) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFile(loaded);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFile(null);
|
||||
setLoading(false);
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [api, currentWorkspace?.workspace_id, target, title]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusOverlay loading={loading} error={error} />
|
||||
{!loading && !error && file && (
|
||||
<FileViewer
|
||||
className="h-full w-full"
|
||||
file={file}
|
||||
filename={title}
|
||||
options={{
|
||||
preset: officePreset,
|
||||
theme: "light",
|
||||
toolbar: { position: "bottom-right" },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextPreviewBody({
|
||||
target,
|
||||
title,
|
||||
}: {
|
||||
target: DataResourcePreviewTarget;
|
||||
title: string;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { currentWorkspace } = useAuth();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace?.workspace_id) return;
|
||||
if (target.sizeBytes > TEXT_PREVIEW_MAX_BYTES) {
|
||||
setContent(null);
|
||||
setError(
|
||||
`文件过大(${Math.ceil(target.sizeBytes / (1024 * 1024))} MB),暂不支持文本预览`,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setContent(null);
|
||||
void api
|
||||
.fetchResourceContentFile(target.resourceId, title, controller.signal)
|
||||
.then(async (loaded) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const text = await loaded.text();
|
||||
if (controller.signal.aborted) return;
|
||||
setContent(text);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setContent(null);
|
||||
setLoading(false);
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [api, currentWorkspace?.workspace_id, target, title]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusOverlay loading={loading} error={error} />
|
||||
{!loading && !error && content !== null && (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguageFromFileName(title)}
|
||||
theme="vs"
|
||||
value={content}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: content.length > 5000 },
|
||||
wordWrap: "on",
|
||||
fontSize: 13,
|
||||
automaticLayout: true,
|
||||
renderLineHighlight: "gutter",
|
||||
contextmenu: false,
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TablePreviewBody({ target }: { target: DataResourcePreviewTarget }) {
|
||||
const api = useApi();
|
||||
const { currentWorkspace } = useAuth();
|
||||
const [payload, setPayload] = useState<ResourcePreviewPayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace?.workspace_id) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPayload(null);
|
||||
void api
|
||||
.fetchResourcePreview(target.resourceId, { limit: 100 }, controller.signal)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPayload(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPayload(null);
|
||||
setLoading(false);
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [api, currentWorkspace?.workspace_id, target.resourceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusOverlay loading={loading} error={error} />
|
||||
{!loading && !error && payload && (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<p className="shrink-0 border-b border-[#edf1f5] bg-white px-4 py-2 text-[11px] text-[#7a8b9c]">
|
||||
{payload.truncated
|
||||
? `仅预览前 ${payload.row_count} 行(已截断)`
|
||||
: `共 ${payload.row_count} 行`}
|
||||
{payload.delimiter === "\t" ? " · TSV" : " · CSV"}
|
||||
</p>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-max min-w-full border-collapse text-left text-[12px] text-[#34475d]">
|
||||
<thead className="sticky top-0 bg-[#f5f8fb]">
|
||||
<tr>
|
||||
<th className="border-b border-[#edf1f5] px-3 py-2 font-semibold text-[#8a9aab]">
|
||||
#
|
||||
</th>
|
||||
{payload.columns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
className="border-b border-[#edf1f5] px-3 py-2 font-semibold whitespace-nowrap"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payload.rows.map((row, index) => (
|
||||
<tr key={index} className="odd:bg-white even:bg-[#fbfcfd]">
|
||||
<td className="border-b border-[#f0f3f6] px-3 py-1.5 text-[#9ba8b7]">
|
||||
{index + 1}
|
||||
</td>
|
||||
{payload.columns.map((_, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="border-b border-[#f0f3f6] px-3 py-1.5 whitespace-nowrap"
|
||||
>
|
||||
{row[colIndex] ?? ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{payload.rows.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-[13px] text-[#8a9aab]">
|
||||
文件没有可预览的数据行
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof ApiRequestError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return "加载预览失败";
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
dialogSecondaryButtonClass,
|
||||
} from "~/components/common/AppFormDialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { fileVisualFromName } from "./WorkspaceTree";
|
||||
import {
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
@@ -52,6 +53,8 @@ export function DataResourceUploadModal({
|
||||
const finalPath = finalTarget
|
||||
? `${finalTarget}/${file?.name ?? ""}`
|
||||
: file?.name ?? "";
|
||||
const fileVisual = fileVisualFromName(file?.name ?? resourceName);
|
||||
const FileIcon = fileVisual.Icon;
|
||||
|
||||
return (
|
||||
<AppFormDialog
|
||||
@@ -65,8 +68,13 @@ export function DataResourceUploadModal({
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<label className={formFieldClass}>
|
||||
<span>已选文件</span>
|
||||
<div className="font-normal text-[#283a4e]">
|
||||
{file ? file.name : "未选择文件"}
|
||||
<div className="flex items-center gap-2.5 font-normal text-[#283a4e]">
|
||||
<span
|
||||
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileVisual.tone}`}
|
||||
>
|
||||
<FileIcon size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{file ? file.name : "未选择文件"}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
@@ -75,7 +83,7 @@ export function DataResourceUploadModal({
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="例如:训练数据"
|
||||
placeholder="例如:训练数据.csv"
|
||||
value={resourceName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
required
|
||||
|
||||
@@ -29,6 +29,7 @@ type ScriptExplorerProps = {
|
||||
) => void;
|
||||
onSelect: (scriptId: string) => void;
|
||||
onCopyResourcePath: (jupyterPath: string) => void;
|
||||
onPreviewResource: (script: ScriptItem) => void;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
@@ -53,6 +54,7 @@ export function ScriptExplorer({
|
||||
onContextMenu,
|
||||
onSelect,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
uploadInputRef,
|
||||
onHandleUpload,
|
||||
}: ScriptExplorerProps) {
|
||||
@@ -251,6 +253,7 @@ export function ScriptExplorer({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { type FormEvent, useEffect, useMemo, useRef } from "react";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import type { ScriptItem } from "../../services/api";
|
||||
import { CreateFolderModal } from "./CreateFolderModal";
|
||||
import { CreateScriptModal } from "./CreateScriptModal";
|
||||
import { DataResourcePreviewDialog } from "./DataResourcePreviewDialog";
|
||||
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
||||
import {
|
||||
previewKindFromFileName,
|
||||
type DataResourcePreviewTarget,
|
||||
} from "./dataResourcePreview";
|
||||
import { WelcomePanel } from "./WelcomePanel";
|
||||
import { PublishModal } from "./PublishModal";
|
||||
import { ScriptExplorer } from "./ScriptExplorer";
|
||||
import { ScriptsPendingConfirmDialog } from "./ScriptsPendingConfirm";
|
||||
import { TreeContextMenu } from "./TreeContextMenu";
|
||||
import { VersionReceiptModal } from "./VersionReceiptModal";
|
||||
|
||||
import { useScriptsPendingConfirm } from "./useScriptsPendingConfirm";
|
||||
import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||
import { useUiStore } from "./state/uiStore";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,6 +26,8 @@ import { copyToClipboard } from "../../lib/clipboard";
|
||||
export default function ScriptsPage() {
|
||||
const { currentWorkspace, user } = useAuth();
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [resourcePreview, setResourcePreview] =
|
||||
useState<DataResourcePreviewTarget | null>(null);
|
||||
|
||||
// store state
|
||||
const scripts = useScriptWorkspaceStore((s) => s.scripts);
|
||||
@@ -67,6 +76,18 @@ export default function ScriptsPage() {
|
||||
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
|
||||
const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent);
|
||||
|
||||
const {
|
||||
pendingConfirm,
|
||||
setPendingConfirm,
|
||||
requestCloseTab,
|
||||
handleConfirm,
|
||||
} = useScriptsPendingConfirm(scripts, {
|
||||
deleteScript,
|
||||
deleteDataResource,
|
||||
deleteDirectory,
|
||||
closeTab,
|
||||
});
|
||||
|
||||
// ui store
|
||||
const pushToast = (notice: { tone: "success" | "error" | "info"; message: string }) => {
|
||||
if (notice.tone === "error") toast.error(notice.message);
|
||||
@@ -198,27 +219,11 @@ export default function ScriptsPage() {
|
||||
};
|
||||
}, [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
|
||||
// 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 = "";
|
||||
@@ -256,8 +261,6 @@ export default function ScriptsPage() {
|
||||
visibility,
|
||||
description: description.trim(),
|
||||
targetPath: targetPath.trim(),
|
||||
}).then((resource) => {
|
||||
if (resource) void loadDataResources();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -276,6 +279,20 @@ export default function ScriptsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openResourcePreview = (script: ScriptItem): void => {
|
||||
const kind = previewKindFromFileName(script.script_name);
|
||||
if (!kind) return;
|
||||
const resourceId = script.script_id.replace(/^data:/, "");
|
||||
const resource = dataResources.find((item) => item.resource_id === resourceId);
|
||||
setResourcePreview({
|
||||
resourceId,
|
||||
resourceName: resource?.resource_name ?? script.script_name,
|
||||
fileExtension: resource?.file.file_extension ?? null,
|
||||
sizeBytes: resource?.file.size_bytes ?? script.size_bytes,
|
||||
kind,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void createScript(createDialog.form);
|
||||
@@ -319,6 +336,7 @@ export default function ScriptsPage() {
|
||||
onContextMenu={showContextMenu}
|
||||
onSelect={openTab}
|
||||
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
||||
onPreviewResource={openResourcePreview}
|
||||
uploadInputRef={uploadInputRef}
|
||||
onHandleUpload={handleUpload}
|
||||
/>
|
||||
@@ -360,17 +378,17 @@ export default function ScriptsPage() {
|
||||
void endEditing();
|
||||
}
|
||||
}}
|
||||
onClose={(scriptId, event) => void closeTab(scriptId, event)}
|
||||
onClose={(scriptId, event) => void requestCloseTab(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}
|
||||
onSetPythonEditorContent={setPythonEditorContent}
|
||||
onSavePythonEditor={(scriptId) => void savePythonEditor(scriptId)}
|
||||
onExitPythonEditor={exitPythonEditor}
|
||||
onClosePythonTab={(scriptId) => void requestCloseTab(scriptId)}
|
||||
onInfo={(t) => pushToast(t)}
|
||||
/>
|
||||
) : (
|
||||
@@ -408,18 +426,40 @@ export default function ScriptsPage() {
|
||||
selectScript(scriptId);
|
||||
closeContextMenu();
|
||||
}}
|
||||
onRemoveScript={(s) => void deleteScript(s)}
|
||||
onRemoveScript={(s) => setPendingConfirm({ kind: "script", script: 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)}
|
||||
onRemoveDirectory={(p) =>
|
||||
setPendingConfirm({ kind: "directory", path: p })}
|
||||
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
||||
onRemoveResource={(id) => void deleteDataResource(id)}
|
||||
onPreviewResource={openResourcePreview}
|
||||
onRemoveResource={(id) => {
|
||||
const resource = dataResources.find((item) => item.resource_id === id);
|
||||
setPendingConfirm({
|
||||
kind: "resource",
|
||||
id,
|
||||
name: resource?.resource_name ?? id,
|
||||
});
|
||||
}}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
|
||||
<ScriptsPendingConfirmDialog
|
||||
pending={pendingConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingConfirm(null);
|
||||
}}
|
||||
onConfirm={() => void handleConfirm()}
|
||||
/>
|
||||
|
||||
<DataResourcePreviewDialog
|
||||
target={resourcePreview}
|
||||
onClose={() => setResourcePreview(null)}
|
||||
/>
|
||||
|
||||
<PublishModal
|
||||
publishTarget={publish.target}
|
||||
releaseNote={publish.releaseNote}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
|
||||
import type { ScriptItem } from "~/services/api";
|
||||
|
||||
export type ScriptsPendingConfirm =
|
||||
| { kind: "script"; script: ScriptItem }
|
||||
| { kind: "resource"; id: string; name: string }
|
||||
| { kind: "directory"; path: string }
|
||||
| { kind: "close-tab"; id: string; name: string };
|
||||
|
||||
type ScriptsPendingConfirmDialogProps = {
|
||||
pending: ScriptsPendingConfirm | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function copyFor(pending: ScriptsPendingConfirm) {
|
||||
switch (pending.kind) {
|
||||
case "script":
|
||||
return {
|
||||
title: "确定删除文件?",
|
||||
description: `确定删除文件"${pending.script.script_name}"吗?稳定版本会保留。`,
|
||||
confirmLabel: "删除",
|
||||
destructive: true,
|
||||
};
|
||||
case "resource":
|
||||
return {
|
||||
title: "确定删除数据资源?",
|
||||
description: `确定删除数据资源"${pending.name}"吗?稳定版本会保留。`,
|
||||
confirmLabel: "删除",
|
||||
destructive: true,
|
||||
};
|
||||
case "directory":
|
||||
return {
|
||||
title: "确定删除文件夹?",
|
||||
description: `确定递归删除文件夹"${pending.path}"及其内容吗?稳定版本会保留。`,
|
||||
confirmLabel: "删除",
|
||||
destructive: true,
|
||||
};
|
||||
case "close-tab":
|
||||
return {
|
||||
title: "确定关闭标签?",
|
||||
description: `当前脚本有未保存修改,确定关闭 "${pending.name}" 吗?`,
|
||||
confirmLabel: "关闭",
|
||||
destructive: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ScriptsPendingConfirmDialog({
|
||||
pending,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ScriptsPendingConfirmDialogProps) {
|
||||
const copy = pending ? copyFor(pending) : null;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={pending !== null}
|
||||
onOpenChange={onOpenChange}
|
||||
title={copy?.title ?? ""}
|
||||
description={copy?.description ?? ""}
|
||||
confirmLabel={copy?.confirmLabel}
|
||||
destructive={copy?.destructive}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Database,
|
||||
Eye,
|
||||
FileCode,
|
||||
FileText,
|
||||
Folder,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ScriptItem, ScriptType } from "../../services/api";
|
||||
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||
|
||||
type ContextMenuState = {
|
||||
x: number;
|
||||
@@ -29,6 +31,7 @@ type TreeContextMenuProps = {
|
||||
onChooseUpload: (parentPath: string) => void;
|
||||
onRemoveDirectory: (path: string) => void;
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
onPreviewResource?: (script: ScriptItem) => void;
|
||||
onRemoveResource?: (resourceId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -49,12 +52,15 @@ export function TreeContextMenu({
|
||||
onChooseUpload,
|
||||
onRemoveDirectory,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
onRemoveResource,
|
||||
onClose,
|
||||
}: TreeContextMenuProps) {
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
||||
const canPreview =
|
||||
isDataResource && canPreviewDataResource(contextMenu.script?.script_name);
|
||||
const LockToggleIcon = contextMenu.script?.is_locked ? Unlock : Lock;
|
||||
|
||||
return (
|
||||
@@ -71,6 +77,20 @@ export function TreeContextMenu({
|
||||
<>
|
||||
{isDataResource ? (
|
||||
<>
|
||||
{canPreview && onPreviewResource && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onPreviewResource(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Eye size={16} />
|
||||
预览
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -131,7 +151,10 @@ export function TreeContextMenu({
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveScript(contextMenu.script!)}
|
||||
onClick={() => {
|
||||
onRemoveScript(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
删除文件
|
||||
@@ -184,7 +207,10 @@ export function TreeContextMenu({
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveDirectory(contextMenu.path)}
|
||||
onClick={() => {
|
||||
onRemoveDirectory(contextMenu.path);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
删除文件夹
|
||||
|
||||
@@ -5,8 +5,13 @@ import {
|
||||
ChevronRight,
|
||||
Database,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
Folder,
|
||||
Lock,
|
||||
Table2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
@@ -14,6 +19,7 @@ import type {
|
||||
WorkspaceDirectory,
|
||||
ResourceItem,
|
||||
} from "~/services/api";
|
||||
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||
|
||||
export type WorkspaceTreeTarget = {
|
||||
kind: "root" | "directory" | "file";
|
||||
@@ -34,6 +40,7 @@ type WorkspaceTreeProps = {
|
||||
readOnly?: boolean;
|
||||
dataResources?: ResourceItem[];
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
onPreviewResource?: (script: ScriptItem) => void;
|
||||
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
|
||||
groupKey: string;
|
||||
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
|
||||
@@ -50,6 +57,11 @@ type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> &
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
};
|
||||
|
||||
export type FileVisual = {
|
||||
Icon: LucideIcon;
|
||||
tone: string;
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
@@ -64,6 +76,36 @@ export function scriptIcon(item: Pick<ScriptItem, "script_type">): LucideIcon {
|
||||
return item.script_type === "notebook" ? BookOpen : FileCode;
|
||||
}
|
||||
|
||||
/** 按文件名后缀返回树节点图标与配色(脚本 + 数据资源共用)。 */
|
||||
export function fileVisualFromName(
|
||||
name: string,
|
||||
scriptType?: ScriptItem["script_type"],
|
||||
): FileVisual {
|
||||
const lower = name.toLowerCase();
|
||||
if (scriptType === "notebook" || lower.endsWith(".ipynb")) {
|
||||
return { Icon: BookOpen, tone: "text-[#e15e50] bg-[#fff0ed]" };
|
||||
}
|
||||
if (scriptType === "python" || lower.endsWith(".py")) {
|
||||
return { Icon: FileCode, tone: "text-[#2e73c6] bg-[#eaf3ff]" };
|
||||
}
|
||||
if (lower.endsWith(".csv") || lower.endsWith(".tsv")) {
|
||||
return { Icon: Table2, tone: "text-[#2f8f7b] bg-[#eaf8f4]" };
|
||||
}
|
||||
if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) {
|
||||
return { Icon: FileSpreadsheet, tone: "text-[#3d8f5a] bg-[#eef8f1]" };
|
||||
}
|
||||
if (lower.endsWith(".json")) {
|
||||
return { Icon: FileJson, tone: "text-[#b07a1a] bg-[#fff8e8]" };
|
||||
}
|
||||
if (lower.endsWith(".parquet")) {
|
||||
return { Icon: Database, tone: "text-[#4a6fa5] bg-[#eef3fa]" };
|
||||
}
|
||||
if (lower.endsWith(".txt")) {
|
||||
return { Icon: FileText, tone: "text-[#6b7c8f] bg-[#f2f5f8]" };
|
||||
}
|
||||
return { Icon: FileType, tone: "text-[#5a8f6a] bg-[#eef8f1]" };
|
||||
}
|
||||
|
||||
function ownedScriptPath(item: ScriptItem) {
|
||||
// 数据资源的 relative_path 已经是 jupyter 路径(无 ULID 前缀);脚本相对路径形如
|
||||
// `{ulid}/{ws_id}/{user_id}/...`,需要剥前两层。
|
||||
@@ -89,6 +131,7 @@ export function WorkspaceTreeGroup({
|
||||
readOnly = false,
|
||||
dataResources,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
groupKey,
|
||||
ownerUserId,
|
||||
expandedPaths,
|
||||
@@ -98,25 +141,33 @@ export function WorkspaceTreeGroup({
|
||||
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));
|
||||
return dataResources.map((r) => {
|
||||
const ext = r.file.file_extension;
|
||||
const name = r.resource_name;
|
||||
const displayName =
|
||||
ext && !name.toLowerCase().endsWith(ext.toLowerCase())
|
||||
? `${name}${ext}`
|
||||
: name;
|
||||
return {
|
||||
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: displayName,
|
||||
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(() => {
|
||||
@@ -194,6 +245,7 @@ export function WorkspaceTreeGroup({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
|
||||
<p className="mb-[7px] ml-[35px] mt-0.5 text-[11px] text-[#a5b0bc]">
|
||||
@@ -217,6 +269,7 @@ function WorkspaceTreeItems({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -245,17 +298,16 @@ function WorkspaceTreeItems({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
))}
|
||||
{childScripts.map((item) => {
|
||||
const isActive = selectedId === item.script_id;
|
||||
const isData = item.script_id.startsWith("data:");
|
||||
const ItemIcon = isData ? Database : scriptIcon(item);
|
||||
const iconTone = isData
|
||||
? "text-[#5a8f6a] bg-[#eef8f1]"
|
||||
: item.script_type === "notebook"
|
||||
? "text-[#e15e50] bg-[#fff0ed]"
|
||||
: "text-[#2e73c6] bg-[#eaf3ff]";
|
||||
const { Icon: ItemIcon, tone: iconTone } = fileVisualFromName(
|
||||
item.script_name,
|
||||
isData ? undefined : item.script_type,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className={`flex h-[47px] w-full cursor-pointer items-center gap-2 rounded-md border pr-[9px] text-left ${
|
||||
@@ -267,8 +319,12 @@ function WorkspaceTreeItems({
|
||||
key={item.script_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isData && onCopyResourcePath) {
|
||||
onCopyResourcePath(item.relative_path);
|
||||
if (isData) {
|
||||
if (canPreviewDataResource(item.script_name) && onPreviewResource) {
|
||||
onPreviewResource(item);
|
||||
} else if (onCopyResourcePath) {
|
||||
onCopyResourcePath(item.relative_path);
|
||||
}
|
||||
} else {
|
||||
onSelect(item.script_id);
|
||||
}
|
||||
@@ -324,6 +380,7 @@ function DirectoryBranch({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -382,6 +439,7 @@ function DirectoryBranch({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** 数据资源预览:扩展名分流与展示名。 */
|
||||
|
||||
export type DataResourcePreviewKind = "excel" | "text" | "table";
|
||||
|
||||
export const EXCEL_EXTENSIONS = [".xlsx", ".xls"] as const;
|
||||
export const TEXT_EXTENSIONS = [".txt", ".json"] as const;
|
||||
export const TABLE_EXTENSIONS = [".csv", ".tsv"] as const;
|
||||
|
||||
/** Excel 整文件拉取上限。 */
|
||||
export const EXCEL_PREVIEW_MAX_BYTES = 80 * 1024 * 1024;
|
||||
/** 文本预览拉取上限。 */
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export type DataResourcePreviewTarget = {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
fileExtension: string | null;
|
||||
sizeBytes: number;
|
||||
kind: DataResourcePreviewKind;
|
||||
};
|
||||
|
||||
function lowerName(name: string | null | undefined): string {
|
||||
return (name ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
function matchesExt(name: string, exts: readonly string[]): boolean {
|
||||
return exts.some((ext) => name.endsWith(ext));
|
||||
}
|
||||
|
||||
export function isExcelFileName(name: string | null | undefined): boolean {
|
||||
return matchesExt(lowerName(name), EXCEL_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isTextPreviewFileName(name: string | null | undefined): boolean {
|
||||
return matchesExt(lowerName(name), TEXT_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isTablePreviewFileName(name: string | null | undefined): boolean {
|
||||
return matchesExt(lowerName(name), TABLE_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function previewKindFromFileName(
|
||||
name: string | null | undefined,
|
||||
): DataResourcePreviewKind | null {
|
||||
const lower = lowerName(name);
|
||||
if (matchesExt(lower, EXCEL_EXTENSIONS)) return "excel";
|
||||
if (matchesExt(lower, TEXT_EXTENSIONS)) return "text";
|
||||
if (matchesExt(lower, TABLE_EXTENSIONS)) return "table";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canPreviewDataResource(name: string | null | undefined): boolean {
|
||||
return previewKindFromFileName(name) !== null;
|
||||
}
|
||||
|
||||
export function resourceDisplayName(
|
||||
resourceName: string,
|
||||
fileExtension: string | null | undefined,
|
||||
): string {
|
||||
if (!fileExtension) return resourceName;
|
||||
const ext = fileExtension.startsWith(".")
|
||||
? fileExtension
|
||||
: `.${fileExtension}`;
|
||||
if (resourceName.toLowerCase().endsWith(ext.toLowerCase())) {
|
||||
return resourceName;
|
||||
}
|
||||
return `${resourceName}${ext}`;
|
||||
}
|
||||
|
||||
/** @deprecated use resourceDisplayName */
|
||||
export const excelDisplayName = resourceDisplayName;
|
||||
|
||||
export function monacoLanguageFromFileName(name: string): string {
|
||||
const lower = lowerName(name);
|
||||
if (lower.endsWith(".json")) return "json";
|
||||
return "plaintext";
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export const createEditSessionSlice: StateCreator<
|
||||
const scriptId = active?.script_id;
|
||||
if (!active) {
|
||||
if (closeTabFlag && scriptId) {
|
||||
await get().closeTab(scriptId);
|
||||
await get().closeTab(scriptId, undefined, { discardDirty: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export const createEditSessionSlice: StateCreator<
|
||||
applyEditSessionState((p) => set(p), null, null);
|
||||
if (scriptId) sessionCache.delete(scriptId);
|
||||
if (closeTabFlag && scriptId) {
|
||||
await get().closeTab(scriptId);
|
||||
await get().closeTab(scriptId, undefined, { discardDirty: true });
|
||||
}
|
||||
if (showToast) {
|
||||
pushToast("success", `${active.script_name} 的编辑锁已释放`);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice)
|
||||
// - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) +
|
||||
// 调 load (scriptsSlice)
|
||||
// - uploadDataResource 不写 store state (只走 uiStore)
|
||||
// - uploadDataResource 写 dataResources (scriptsSlice) + 失效对应路径缓存
|
||||
// - createFolder 写 loadedChildPaths/directories/expandedPaths (treeSlice) +
|
||||
// 调 loadChildren / load (treeSlice/scriptsSlice)
|
||||
// - deleteScript 写 openTabIds/selectedId (selectionSlice) + 调
|
||||
@@ -175,6 +175,26 @@ export const createMutationsSlice: StateCreator<
|
||||
description: meta.description,
|
||||
visibility: meta.visibility,
|
||||
});
|
||||
// 与 uploadScripts 一致:直接写入 store。loadDataResources 有路径缓存,
|
||||
// 上传后再调会命中已加载路径直接 return,列表不会更新。
|
||||
const parentPath = parentPathOfResource(resource.jupyter_accessible_path);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedDataResourcePaths);
|
||||
nextLoaded.delete(ownerCacheKey(resource.owner_user_id, parentPath));
|
||||
// targetPath 与 jupyter 父路径不一致时一并失效(例如带前缀差异)。
|
||||
if (meta.targetPath !== parentPath) {
|
||||
nextLoaded.delete(
|
||||
ownerCacheKey(resource.owner_user_id, meta.targetPath),
|
||||
);
|
||||
}
|
||||
const withoutDup = state.dataResources.filter(
|
||||
(r) => r.resource_id !== resource.resource_id,
|
||||
);
|
||||
return {
|
||||
dataResources: [resource, ...withoutDup],
|
||||
loadedDataResourcePaths: nextLoaded,
|
||||
};
|
||||
});
|
||||
ui.closeDataResourceDialog();
|
||||
pushToast(
|
||||
"success",
|
||||
@@ -195,9 +215,6 @@ export const createMutationsSlice: StateCreator<
|
||||
deleteDataResource: async (resourceId) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteResource(resourceId);
|
||||
set((state) => ({
|
||||
@@ -267,11 +284,6 @@ export const createMutationsSlice: StateCreator<
|
||||
deleteScript: async (script: ScriptItem) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (
|
||||
!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const editSession = getEditSession();
|
||||
if (editSession?.script_id === script.script_id) {
|
||||
await get().endEditing(false, false);
|
||||
@@ -303,11 +315,6 @@ export const createMutationsSlice: StateCreator<
|
||||
deleteDirectory: async (path) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (
|
||||
!window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const activeScript = get().scripts.find(
|
||||
(item) => item.script_id === getEditSession()?.script_id,
|
||||
);
|
||||
@@ -426,4 +433,10 @@ export const createMutationsSlice: StateCreator<
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function parentPathOfResource(path: string): string {
|
||||
const parts = path.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
@@ -283,28 +283,41 @@ export const createScriptsSlice: StateCreator<
|
||||
|
||||
loadDataResources: async (parentPath = "", ownerUserId) => {
|
||||
const api = requireApi();
|
||||
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
|
||||
// 命中缓存:listResources 是非递归的,同一 (owner, parent_path) 拉过的
|
||||
// 内容不会自己变化;省去 toggleExpanded 重复展开同一目录时的网络往返。
|
||||
if (get().loadedDataResourcePaths.has(cacheKey)) return;
|
||||
set({ dataResourcesLoading: true });
|
||||
try {
|
||||
const list = await api.listResources(parentPath, { ownerUserId });
|
||||
const fresh = Array.isArray(list) ? list : [];
|
||||
set((state) => {
|
||||
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 fresh(fresh 覆盖
|
||||
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
|
||||
// 按 (owner, parent_path) 局部替换:丢掉该 owner 在 parentPath 下的
|
||||
// 旧条目,保留该 owner 在其它路径下的条目,再并入 fresh。
|
||||
// 这样 toggleExpanded 在子目录展开时按需拉取不会把根已加载的数据
|
||||
// 资源擦掉(修"根加载后子目录展开丢数据 / 子目录数据本来不显示")。
|
||||
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
|
||||
const kept = state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== targetOwner,
|
||||
);
|
||||
const kept = state.dataResources.filter((r) => {
|
||||
if (r.owner_user_id !== targetOwner) return true;
|
||||
return parentPathOf(r.jupyter_accessible_path) !== parentPath;
|
||||
});
|
||||
const byId = new Map(kept.map((r) => [r.resource_id, r]));
|
||||
for (const item of fresh) byId.set(item.resource_id, item);
|
||||
return { dataResources: Array.from(byId.values()) };
|
||||
const nextLoaded = new Set(state.loadedDataResourcePaths);
|
||||
nextLoaded.add(cacheKey);
|
||||
return {
|
||||
dataResources: Array.from(byId.values()),
|
||||
loadedDataResourcePaths: nextLoaded,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set((state) => {
|
||||
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
|
||||
return {
|
||||
dataResources: state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== targetOwner,
|
||||
),
|
||||
dataResources: state.dataResources.filter((r) => {
|
||||
if (r.owner_user_id !== targetOwner) return true;
|
||||
return parentPathOf(r.jupyter_accessible_path) !== parentPath;
|
||||
}),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
@@ -336,4 +349,13 @@ export const createScriptsSlice: StateCreator<
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// 提取 jupyter-accessible 路径的父目录;用于 `loadDataResources` 局部替换时
|
||||
// 判断一条缓存资源是否落在目标 parent_path 下(list-resources 按 parent_path
|
||||
// 精确匹配,非递归)。
|
||||
function parentPathOf(path: string): string {
|
||||
const parts = path.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
@@ -62,14 +62,11 @@ export const createSelectionSlice: StateCreator<
|
||||
}));
|
||||
},
|
||||
|
||||
closeTab: async (id, event) => {
|
||||
closeTab: async (id, event, options) => {
|
||||
event?.stopPropagation();
|
||||
const buffer = get().pythonEditorBuffers[id];
|
||||
if (buffer?.dirty && !buffer.saving) {
|
||||
const name =
|
||||
get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本";
|
||||
const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`);
|
||||
if (!ok) return;
|
||||
if (buffer?.dirty && !buffer.saving && !options?.discardDirty) {
|
||||
return false;
|
||||
}
|
||||
if (buffer) {
|
||||
get().exitPythonEditor(id);
|
||||
@@ -91,6 +88,7 @@ export const createSelectionSlice: StateCreator<
|
||||
}
|
||||
return { openTabIds: newTabs, selectedId: nextSelected };
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
switchTab: (id) => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// ---- treeSlice ----
|
||||
//
|
||||
// 拥有 expandedPaths / loadingChildrenPaths / loadedChildPaths /
|
||||
// loadedScriptPaths / loadingScriptPaths (5 个 directory-tree 缓存集合)。
|
||||
// 负责 toggleExpanded 和 loadChildren。
|
||||
// loadedScriptPaths / loadingScriptPaths / loadedDataResourcePaths
|
||||
// (6 个 directory-tree 缓存集合)。负责 toggleExpanded 和 loadChildren。
|
||||
//
|
||||
// 注意:
|
||||
// - loadedScriptPaths/loadingScriptPaths 也由 scriptsSlice 写 (loadScripts /
|
||||
// loadOwnerGroup),但 ownership 在 treeSlice 里 (因为是 cache set,不是数据)
|
||||
// - scriptsSlice.load 也会写这俩,所以这里只保留这两个 setter (toggleExpanded
|
||||
// 也要写 expandedPaths)。
|
||||
// - loadedDataResourcePaths 由 scriptsSlice.loadDataResources 写(同样的 cache
|
||||
// 不放数据原则),toggleExpanded 在真实目录分支按需触发。
|
||||
// - scriptsSlice.load 也会写 cached script paths,所以这里只保留 toggleExpanded
|
||||
// (写 expandedPaths) 和 loadChildren (写目录缓存)。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
|
||||
@@ -34,6 +36,7 @@ export const createTreeSlice: StateCreator<
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
loadedDataResourcePaths: new Set<string>(),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -110,15 +113,24 @@ export const createTreeSlice: StateCreator<
|
||||
}
|
||||
} else {
|
||||
// 真实目录展开:loadChildren(owner 限定的显式目录行)+ loadScripts
|
||||
// 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样
|
||||
// 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现
|
||||
// (list_scripts 非递归,只能看到直接子脚本)。
|
||||
// + loadDataResources 并行。三者都 idempotent + 缓存;ownerUserId
|
||||
// 缺省=我。他人目录同样调 loadChildren(owner) 拉取其目录结构,否则
|
||||
// 嵌套子目录无法被发现(list_scripts 非递归,只能看到直接子脚本)。
|
||||
// 数据资源也是非递归的——不按需拉取,子目录里的 csv/xlsx/json 等
|
||||
// 都不会出现(修"目录树子目录里的数据文件不显示")。
|
||||
if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadChildren(loadPath, ownerUserId);
|
||||
}
|
||||
if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadScripts(loadPath, ownerUserId);
|
||||
}
|
||||
if (
|
||||
!state.loadedDataResourcePaths.has(
|
||||
ownerCacheKey(ownerUserId, loadPath),
|
||||
)
|
||||
) {
|
||||
void get().loadDataResources(loadPath, ownerUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
set({ expandedPaths: next });
|
||||
|
||||
@@ -62,6 +62,10 @@ export type TreeSliceState = {
|
||||
// `${owner_user_id}:${parent_path}` (see ownerCacheKey).
|
||||
loadedScriptPaths: Set<string>;
|
||||
loadingScriptPaths: Set<string>;
|
||||
// 与 loadedScriptPaths 同形:按 (owner, parent_path) 缓存已拉取的数据资源,
|
||||
// 让 toggleExpanded 在子目录展开时也按需请求 listDataResources,而不是
|
||||
// 只在根加载一次(修"子目录下的数据文件不展示")。
|
||||
loadedDataResourcePaths: Set<string>;
|
||||
};
|
||||
|
||||
export type TreeSliceActions = {
|
||||
@@ -88,7 +92,8 @@ export type SelectionSliceActions = {
|
||||
closeTab: (
|
||||
id: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
) => Promise<void>;
|
||||
options?: { discardDirty?: boolean },
|
||||
) => Promise<boolean>;
|
||||
switchTab: (id: string) => void;
|
||||
openPublishDialog: (script: ScriptItem) => void;
|
||||
};
|
||||
|
||||
@@ -251,7 +251,7 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
open: true,
|
||||
file,
|
||||
parentPath,
|
||||
resourceName: file.name.replace(/\.[^/.]+$/, ""),
|
||||
resourceName: file.name,
|
||||
visibility: "workspace",
|
||||
description: "",
|
||||
uploading: false,
|
||||
|
||||
@@ -112,6 +112,7 @@ const INITIAL: ScriptWorkspaceState = {
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
loadedDataResourcePaths: new Set<string>(),
|
||||
// selectionSlice
|
||||
selectedId: null,
|
||||
openTabIds: [],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import type { ScriptItem } from "~/services/api";
|
||||
import type { ScriptsPendingConfirm } from "./ScriptsPendingConfirm";
|
||||
|
||||
type DeleteActions = {
|
||||
deleteScript: (script: ScriptItem) => Promise<void>;
|
||||
deleteDataResource: (resourceId: string) => Promise<void>;
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
closeTab: (
|
||||
id: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
options?: { discardDirty?: boolean },
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useScriptsPendingConfirm(
|
||||
scripts: ScriptItem[],
|
||||
actions: DeleteActions,
|
||||
) {
|
||||
const [pendingConfirm, setPendingConfirm] =
|
||||
useState<ScriptsPendingConfirm | null>(null);
|
||||
|
||||
const requestCloseTab = async (
|
||||
scriptId: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
) => {
|
||||
const closed = await actions.closeTab(scriptId, event);
|
||||
if (closed) return;
|
||||
const name =
|
||||
scripts.find((s) => s.script_id === scriptId)?.script_name ?? "该脚本";
|
||||
setPendingConfirm({ kind: "close-tab", id: scriptId, name });
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pendingConfirm) return;
|
||||
const target = pendingConfirm;
|
||||
setPendingConfirm(null);
|
||||
if (target.kind === "script") await actions.deleteScript(target.script);
|
||||
else if (target.kind === "resource") {
|
||||
await actions.deleteDataResource(target.id);
|
||||
} else if (target.kind === "directory") {
|
||||
await actions.deleteDirectory(target.path);
|
||||
} else {
|
||||
await actions.closeTab(target.id, undefined, { discardDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
pendingConfirm,
|
||||
setPendingConfirm,
|
||||
requestCloseTab,
|
||||
handleConfirm,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user