Merge origin/develop into feature/a-card-operations

This commit is contained in:
郑龙捷
2026-09-02 10:33:00 +08:00
73 changed files with 7177 additions and 3280 deletions
@@ -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}
/>
);
})}
+69 -29
View File
@@ -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 {
// 真实目录展开:loadChildrenowner 限定的显式目录行)+ 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,
};
}