feat:脚本文件预览
This commit is contained in:
@@ -9,3 +9,6 @@
|
|||||||
# monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时
|
# monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时
|
||||||
# 从 node_modules/monaco-editor/min/vs 复制生成,不要提交)
|
# 从 node_modules/monaco-editor/min/vs 复制生成,不要提交)
|
||||||
/public/monaco/vs/
|
/public/monaco/vs/
|
||||||
|
|
||||||
|
# @file-viewer/vite-plugin copyAssets 生成的 office 预览 vendor,不要提交
|
||||||
|
/public/file-viewer/
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ export function useApi(): WorkspaceBoundApi {
|
|||||||
rawApi.bindResourceUpload(workspaceId, uploadId, body),
|
rawApi.bindResourceUpload(workspaceId, uploadId, body),
|
||||||
deleteResource: (resourceId) =>
|
deleteResource: (resourceId) =>
|
||||||
rawApi.deleteResource(workspaceId, 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) =>
|
updateScript: (scriptId, input) =>
|
||||||
rawApi.updateScript(workspaceId, scriptId, input),
|
rawApi.updateScript(workspaceId, scriptId, input),
|
||||||
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||||
|
|||||||
@@ -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 "加载预览失败";
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type ScriptExplorerProps = {
|
|||||||
) => void;
|
) => void;
|
||||||
onSelect: (scriptId: string) => void;
|
onSelect: (scriptId: string) => void;
|
||||||
onCopyResourcePath: (jupyterPath: string) => void;
|
onCopyResourcePath: (jupyterPath: string) => void;
|
||||||
|
onPreviewResource: (script: ScriptItem) => void;
|
||||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||||
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ export function ScriptExplorer({
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCopyResourcePath,
|
onCopyResourcePath,
|
||||||
|
onPreviewResource,
|
||||||
uploadInputRef,
|
uploadInputRef,
|
||||||
onHandleUpload,
|
onHandleUpload,
|
||||||
}: ScriptExplorerProps) {
|
}: ScriptExplorerProps) {
|
||||||
@@ -251,6 +253,7 @@ export function ScriptExplorer({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
loadingChildrenPaths={loadingChildrenPaths}
|
loadingChildrenPaths={loadingChildrenPaths}
|
||||||
onCopyResourcePath={onCopyResourcePath}
|
onCopyResourcePath={onCopyResourcePath}
|
||||||
|
onPreviewResource={onPreviewResource}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 { useAuth } from "../../context/AuthContext";
|
||||||
|
import type { ScriptItem } from "../../services/api";
|
||||||
import { CreateFolderModal } from "./CreateFolderModal";
|
import { CreateFolderModal } from "./CreateFolderModal";
|
||||||
import { CreateScriptModal } from "./CreateScriptModal";
|
import { CreateScriptModal } from "./CreateScriptModal";
|
||||||
|
import { DataResourcePreviewDialog } from "./DataResourcePreviewDialog";
|
||||||
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
||||||
|
import {
|
||||||
|
previewKindFromFileName,
|
||||||
|
type DataResourcePreviewTarget,
|
||||||
|
} from "./dataResourcePreview";
|
||||||
import { WelcomePanel } from "./WelcomePanel";
|
import { WelcomePanel } from "./WelcomePanel";
|
||||||
import { PublishModal } from "./PublishModal";
|
import { PublishModal } from "./PublishModal";
|
||||||
import { ScriptExplorer } from "./ScriptExplorer";
|
import { ScriptExplorer } from "./ScriptExplorer";
|
||||||
@@ -19,6 +25,8 @@ import { copyToClipboard } from "../../lib/clipboard";
|
|||||||
export default function ScriptsPage() {
|
export default function ScriptsPage() {
|
||||||
const { currentWorkspace, user } = useAuth();
|
const { currentWorkspace, user } = useAuth();
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [resourcePreview, setResourcePreview] =
|
||||||
|
useState<DataResourcePreviewTarget | null>(null);
|
||||||
|
|
||||||
// store state
|
// store state
|
||||||
const scripts = useScriptWorkspaceStore((s) => s.scripts);
|
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) => {
|
const handleCreateSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void createScript(createDialog.form);
|
void createScript(createDialog.form);
|
||||||
@@ -319,6 +341,7 @@ export default function ScriptsPage() {
|
|||||||
onContextMenu={showContextMenu}
|
onContextMenu={showContextMenu}
|
||||||
onSelect={openTab}
|
onSelect={openTab}
|
||||||
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
||||||
|
onPreviewResource={openResourcePreview}
|
||||||
uploadInputRef={uploadInputRef}
|
uploadInputRef={uploadInputRef}
|
||||||
onHandleUpload={handleUpload}
|
onHandleUpload={handleUpload}
|
||||||
/>
|
/>
|
||||||
@@ -416,10 +439,16 @@ export default function ScriptsPage() {
|
|||||||
onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
|
onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
|
||||||
onRemoveDirectory={(p) => void deleteDirectory(p)}
|
onRemoveDirectory={(p) => void deleteDirectory(p)}
|
||||||
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
||||||
|
onPreviewResource={openResourcePreview}
|
||||||
onRemoveResource={(id) => void deleteDataResource(id)}
|
onRemoveResource={(id) => void deleteDataResource(id)}
|
||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DataResourcePreviewDialog
|
||||||
|
target={resourcePreview}
|
||||||
|
onClose={() => setResourcePreview(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<PublishModal
|
<PublishModal
|
||||||
publishTarget={publish.target}
|
publishTarget={publish.target}
|
||||||
releaseNote={publish.releaseNote}
|
releaseNote={publish.releaseNote}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Database,
|
Database,
|
||||||
|
Eye,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ScriptItem, ScriptType } from "../../services/api";
|
import type { ScriptItem, ScriptType } from "../../services/api";
|
||||||
|
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||||
|
|
||||||
type ContextMenuState = {
|
type ContextMenuState = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -29,6 +31,7 @@ type TreeContextMenuProps = {
|
|||||||
onChooseUpload: (parentPath: string) => void;
|
onChooseUpload: (parentPath: string) => void;
|
||||||
onRemoveDirectory: (path: string) => void;
|
onRemoveDirectory: (path: string) => void;
|
||||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||||
|
onPreviewResource?: (script: ScriptItem) => void;
|
||||||
onRemoveResource?: (resourceId: string) => void;
|
onRemoveResource?: (resourceId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -49,12 +52,15 @@ export function TreeContextMenu({
|
|||||||
onChooseUpload,
|
onChooseUpload,
|
||||||
onRemoveDirectory,
|
onRemoveDirectory,
|
||||||
onCopyResourcePath,
|
onCopyResourcePath,
|
||||||
|
onPreviewResource,
|
||||||
onRemoveResource,
|
onRemoveResource,
|
||||||
onClose,
|
onClose,
|
||||||
}: TreeContextMenuProps) {
|
}: TreeContextMenuProps) {
|
||||||
if (!contextMenu) return null;
|
if (!contextMenu) return null;
|
||||||
|
|
||||||
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
||||||
|
const canPreview =
|
||||||
|
isDataResource && canPreviewDataResource(contextMenu.script?.script_name);
|
||||||
const LockToggleIcon = contextMenu.script?.is_locked ? Unlock : Lock;
|
const LockToggleIcon = contextMenu.script?.is_locked ? Unlock : Lock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +77,20 @@ export function TreeContextMenu({
|
|||||||
<>
|
<>
|
||||||
{isDataResource ? (
|
{isDataResource ? (
|
||||||
<>
|
<>
|
||||||
|
{canPreview && onPreviewResource && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={menuItemClass}
|
||||||
|
onClick={() => {
|
||||||
|
onPreviewResource(contextMenu.script!);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
WorkspaceDirectory,
|
WorkspaceDirectory,
|
||||||
ResourceItem,
|
ResourceItem,
|
||||||
} from "~/services/api";
|
} from "~/services/api";
|
||||||
|
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||||
|
|
||||||
export type WorkspaceTreeTarget = {
|
export type WorkspaceTreeTarget = {
|
||||||
kind: "root" | "directory" | "file";
|
kind: "root" | "directory" | "file";
|
||||||
@@ -39,6 +40,7 @@ type WorkspaceTreeProps = {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
dataResources?: ResourceItem[];
|
dataResources?: ResourceItem[];
|
||||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||||
|
onPreviewResource?: (script: ScriptItem) => void;
|
||||||
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
|
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
|
||||||
groupKey: string;
|
groupKey: string;
|
||||||
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
|
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
|
||||||
@@ -129,6 +131,7 @@ export function WorkspaceTreeGroup({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
dataResources,
|
dataResources,
|
||||||
onCopyResourcePath,
|
onCopyResourcePath,
|
||||||
|
onPreviewResource,
|
||||||
groupKey,
|
groupKey,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
@@ -242,6 +245,7 @@ export function WorkspaceTreeGroup({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
loadingChildrenPaths={loadingChildrenPaths}
|
loadingChildrenPaths={loadingChildrenPaths}
|
||||||
onCopyResourcePath={onCopyResourcePath}
|
onCopyResourcePath={onCopyResourcePath}
|
||||||
|
onPreviewResource={onPreviewResource}
|
||||||
/>
|
/>
|
||||||
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
|
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
|
||||||
<p className="mb-[7px] ml-[35px] mt-0.5 text-[11px] text-[#a5b0bc]">
|
<p className="mb-[7px] ml-[35px] mt-0.5 text-[11px] text-[#a5b0bc]">
|
||||||
@@ -265,6 +269,7 @@ function WorkspaceTreeItems({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onCopyResourcePath,
|
onCopyResourcePath,
|
||||||
|
onPreviewResource,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onToggle,
|
onToggle,
|
||||||
loadingChildrenPaths,
|
loadingChildrenPaths,
|
||||||
@@ -293,6 +298,7 @@ function WorkspaceTreeItems({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
loadingChildrenPaths={loadingChildrenPaths}
|
loadingChildrenPaths={loadingChildrenPaths}
|
||||||
onCopyResourcePath={onCopyResourcePath}
|
onCopyResourcePath={onCopyResourcePath}
|
||||||
|
onPreviewResource={onPreviewResource}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{childScripts.map((item) => {
|
{childScripts.map((item) => {
|
||||||
@@ -313,8 +319,12 @@ function WorkspaceTreeItems({
|
|||||||
key={item.script_id}
|
key={item.script_id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isData && onCopyResourcePath) {
|
if (isData) {
|
||||||
|
if (canPreviewDataResource(item.script_name) && onPreviewResource) {
|
||||||
|
onPreviewResource(item);
|
||||||
|
} else if (onCopyResourcePath) {
|
||||||
onCopyResourcePath(item.relative_path);
|
onCopyResourcePath(item.relative_path);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onSelect(item.script_id);
|
onSelect(item.script_id);
|
||||||
}
|
}
|
||||||
@@ -370,6 +380,7 @@ function DirectoryBranch({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onCopyResourcePath,
|
onCopyResourcePath,
|
||||||
|
onPreviewResource,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onToggle,
|
onToggle,
|
||||||
loadingChildrenPaths,
|
loadingChildrenPaths,
|
||||||
@@ -428,6 +439,7 @@ function DirectoryBranch({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
loadingChildrenPaths={loadingChildrenPaths}
|
loadingChildrenPaths={loadingChildrenPaths}
|
||||||
onCopyResourcePath={onCopyResourcePath}
|
onCopyResourcePath={onCopyResourcePath}
|
||||||
|
onPreviewResource={onPreviewResource}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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";
|
||||||
|
}
|
||||||
@@ -556,6 +556,109 @@ export async function deleteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同源流式下载数据资源字节,供 Excel 等预览器使用。 */
|
||||||
|
export async function fetchResourceContentFile(
|
||||||
|
workspaceId: string,
|
||||||
|
resourceId: string,
|
||||||
|
fileName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<File> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/data-resources/${encodeURIComponent(resourceId)}/content?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||||
|
{
|
||||||
|
credentials: "same-origin",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"X-Request-ID": createUuid().replaceAll("-", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
|
const here = window.location.pathname;
|
||||||
|
if (here !== "/login") {
|
||||||
|
window.location.assign("/login");
|
||||||
|
}
|
||||||
|
throw new ApiRequestError("未登录或登录已过期", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `请求失败(HTTP ${response.status})`;
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as ApiErrorEnvelope;
|
||||||
|
const detailMessage =
|
||||||
|
typeof payload.detail === "string"
|
||||||
|
? payload.detail
|
||||||
|
: payload.detail?.message;
|
||||||
|
if (detailMessage) message = detailMessage;
|
||||||
|
} catch {
|
||||||
|
/* ignore non-JSON error bodies */
|
||||||
|
}
|
||||||
|
throw new ApiRequestError(message, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], fileName, {
|
||||||
|
type: blob.type || "application/octet-stream",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResourcePreviewPayload = {
|
||||||
|
kind: "table";
|
||||||
|
columns: string[];
|
||||||
|
rows: string[][];
|
||||||
|
row_count: number;
|
||||||
|
truncated: boolean;
|
||||||
|
delimiter: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 表格类数据资源抽样预览(csv / tsv)。 */
|
||||||
|
export async function fetchResourcePreview(
|
||||||
|
workspaceId: string,
|
||||||
|
resourceId: string,
|
||||||
|
input: { limit?: number } = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ResourcePreviewPayload> {
|
||||||
|
const parameters = new URLSearchParams();
|
||||||
|
parameters.set("workspace_id", workspaceId);
|
||||||
|
if (input.limit != null) parameters.set("limit", String(input.limit));
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/data-resources/${encodeURIComponent(resourceId)}/preview?${parameters.toString()}`,
|
||||||
|
{
|
||||||
|
credentials: "same-origin",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"X-Request-ID": createUuid().replaceAll("-", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
|
const here = window.location.pathname;
|
||||||
|
if (here !== "/login") {
|
||||||
|
window.location.assign("/login");
|
||||||
|
}
|
||||||
|
throw new ApiRequestError("未登录或登录已过期", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = payload as ApiErrorEnvelope;
|
||||||
|
const detailMessage =
|
||||||
|
typeof error.detail === "string" ? error.detail : error.detail?.message;
|
||||||
|
throw new ApiRequestError(
|
||||||
|
detailMessage ?? `请求失败(HTTP ${response.status})`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (payload as { data: ResourcePreviewPayload }).data;
|
||||||
|
if (!data || data.kind !== "table" || !Array.isArray(data.columns)) {
|
||||||
|
throw new ApiRequestError("响应数据格式错误", response.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createResourceUpload(
|
export async function createResourceUpload(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
body: {
|
body: {
|
||||||
@@ -1670,6 +1773,16 @@ export type WorkspaceBoundApi = {
|
|||||||
deleteResource: (
|
deleteResource: (
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
) => Promise<{ resource_id: string; status: string }>;
|
) => Promise<{ resource_id: string; status: string }>;
|
||||||
|
fetchResourceContentFile: (
|
||||||
|
resourceId: string,
|
||||||
|
fileName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => Promise<File>;
|
||||||
|
fetchResourcePreview: (
|
||||||
|
resourceId: string,
|
||||||
|
input?: { limit?: number },
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => Promise<ResourcePreviewPayload>;
|
||||||
updateScript: (
|
updateScript: (
|
||||||
scriptId: string,
|
scriptId: string,
|
||||||
input: Parameters<typeof updateScript>[2],
|
input: Parameters<typeof updateScript>[2],
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
|
"@file-viewer/preset-office": "^3.0.0",
|
||||||
|
"@file-viewer/react": "^3.0.0",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@react-router/node": "^8",
|
"@react-router/node": "^8",
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@file-viewer/vite-plugin": "^3.0.0",
|
||||||
"@react-router/dev": "^8",
|
"@react-router/dev": "^8",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
|||||||
Generated
+531
-57
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,19 @@
|
|||||||
import { reactRouter } from "@react-router/dev/vite";
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
|
import { fileViewerRenderers } from "@file-viewer/vite-plugin";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/',
|
base: '/',
|
||||||
plugins: [reactRouter(), tailwindcss()],
|
plugins: [
|
||||||
|
reactRouter(),
|
||||||
|
tailwindcss(),
|
||||||
|
fileViewerRenderers({
|
||||||
|
preset: "office",
|
||||||
|
copyAssets: { mode: "both", baseDir: "file-viewer" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"~": path.resolve(__dirname, "./app"),
|
"~": path.resolve(__dirname, "./app"),
|
||||||
|
|||||||
Reference in New Issue
Block a user