From 0aca4934f798c6c4eba711ea65ba3f452d3ec91c Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Mon, 31 Aug 2026 18:48:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=84=9A=E6=9C=AC=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.gitignore | 3 + frontend/app/context/AuthContext.tsx | 4 + .../platform/DataResourcePreviewDialog.tsx | 327 ++++++++++ .../app/features/platform/ScriptExplorer.tsx | 3 + .../app/features/platform/ScriptsPage.tsx | 31 +- .../app/features/platform/TreeContextMenu.tsx | 20 + .../app/features/platform/WorkspaceTree.tsx | 16 +- .../features/platform/dataResourcePreview.ts | 77 +++ frontend/app/services/api.ts | 121 +++- frontend/package.json | 3 + frontend/pnpm-lock.yaml | 588 ++++++++++++++++-- frontend/vite.config.ts | 10 +- 12 files changed, 1138 insertions(+), 65 deletions(-) create mode 100644 frontend/app/features/platform/DataResourcePreviewDialog.tsx create mode 100644 frontend/app/features/platform/dataResourcePreview.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index 271afdb..b4ac379 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -9,3 +9,6 @@ # monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时 # 从 node_modules/monaco-editor/min/vs 复制生成,不要提交) /public/monaco/vs/ + +# @file-viewer/vite-plugin copyAssets 生成的 office 预览 vendor,不要提交 +/public/file-viewer/ diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index 49cffe8..c79a2dd 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -238,6 +238,10 @@ export function useApi(): WorkspaceBoundApi { rawApi.bindResourceUpload(workspaceId, uploadId, body), deleteResource: (resourceId) => rawApi.deleteResource(workspaceId, resourceId), + fetchResourceContentFile: (resourceId, fileName, signal) => + rawApi.fetchResourceContentFile(workspaceId, resourceId, fileName, signal), + fetchResourcePreview: (resourceId, input, signal) => + rawApi.fetchResourcePreview(workspaceId, resourceId, input, signal), updateScript: (scriptId, input) => rawApi.updateScript(workspaceId, scriptId, input), deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId), diff --git a/frontend/app/features/platform/DataResourcePreviewDialog.tsx b/frontend/app/features/platform/DataResourcePreviewDialog.tsx new file mode 100644 index 0000000..5d8bf87 --- /dev/null +++ b/frontend/app/features/platform/DataResourcePreviewDialog.tsx @@ -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 = { + 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 ( +
+
event.stopPropagation()} + > +
+
+
+ {EYEBROW[target.kind]} +
+

+ {title} +

+
+ +
+
+ {target.kind === "excel" && ( + + )} + {target.kind === "text" && ( + + )} + {target.kind === "table" && } +
+
+
+ ); +} + +function StatusOverlay({ + loading, + error, +}: { + loading: boolean; + error: string | null; +}) { + if (loading) { + return ( +

+ 正在加载预览… +

+ ); + } + if (error) { + return ( +

+ {error} +

+ ); + } + return null; +} + +function ExcelPreviewBody({ + target, + title, +}: { + target: DataResourcePreviewTarget; + title: string; +}) { + const api = useApi(); + const { currentWorkspace } = useAuth(); + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + {!loading && !error && file && ( + + )} + + ); +} + +function TextPreviewBody({ + target, + title, +}: { + target: DataResourcePreviewTarget; + title: string; +}) { + const api = useApi(); + const { currentWorkspace } = useAuth(); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + {!loading && !error && content !== null && ( + 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + + {!loading && !error && payload && ( +
+

+ {payload.truncated + ? `仅预览前 ${payload.row_count} 行(已截断)` + : `共 ${payload.row_count} 行`} + {payload.delimiter === "\t" ? " · TSV" : " · CSV"} +

+
+ + + + + {payload.columns.map((column) => ( + + ))} + + + + {payload.rows.map((row, index) => ( + + + {payload.columns.map((_, colIndex) => ( + + ))} + + ))} + +
+ # + + {column} +
+ {index + 1} + + {row[colIndex] ?? ""} +
+ {payload.rows.length === 0 && ( +

+ 文件没有可预览的数据行 +

+ )} +
+
+ )} + + ); +} + +function errorMessage(err: unknown): string { + if (err instanceof ApiRequestError) return err.message; + if (err instanceof Error) return err.message; + return "加载预览失败"; +} diff --git a/frontend/app/features/platform/ScriptExplorer.tsx b/frontend/app/features/platform/ScriptExplorer.tsx index 046e0e6..8710096 100644 --- a/frontend/app/features/platform/ScriptExplorer.tsx +++ b/frontend/app/features/platform/ScriptExplorer.tsx @@ -29,6 +29,7 @@ type ScriptExplorerProps = { ) => void; onSelect: (scriptId: string) => void; onCopyResourcePath: (jupyterPath: string) => void; + onPreviewResource: (script: ScriptItem) => void; uploadInputRef: RefObject; onHandleUpload: (event: ChangeEvent) => 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} /> ); })} diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx index cafe625..3d8166c 100644 --- a/frontend/app/features/platform/ScriptsPage.tsx +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -1,9 +1,15 @@ -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"; @@ -19,6 +25,8 @@ import { copyToClipboard } from "../../lib/clipboard"; export default function ScriptsPage() { const { currentWorkspace, user } = useAuth(); const uploadInputRef = useRef(null); + const [resourcePreview, setResourcePreview] = + useState(null); // store state const scripts = useScriptWorkspaceStore((s) => s.scripts); @@ -276,6 +284,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 +341,7 @@ export default function ScriptsPage() { onContextMenu={showContextMenu} onSelect={openTab} onCopyResourcePath={(p) => void handleCopyResourcePath(p)} + onPreviewResource={openResourcePreview} uploadInputRef={uploadInputRef} onHandleUpload={handleUpload} /> @@ -416,10 +439,16 @@ export default function ScriptsPage() { onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")} onRemoveDirectory={(p) => void deleteDirectory(p)} onCopyResourcePath={(p) => void handleCopyResourcePath(p)} + onPreviewResource={openResourcePreview} onRemoveResource={(id) => void deleteDataResource(id)} onClose={closeContextMenu} /> + setResourcePreview(null)} + /> + 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 && ( + + )}