feat:脚本文件预览
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
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,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<HTMLInputElement | null>(null);
|
||||
const [resourcePreview, setResourcePreview] =
|
||||
useState<DataResourcePreviewTarget | null>(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}
|
||||
/>
|
||||
|
||||
<DataResourcePreviewDialog
|
||||
target={resourcePreview}
|
||||
onClose={() => setResourcePreview(null)}
|
||||
/>
|
||||
|
||||
<PublishModal
|
||||
publishTarget={publish.target}
|
||||
releaseNote={publish.releaseNote}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
WorkspaceDirectory,
|
||||
ResourceItem,
|
||||
} from "~/services/api";
|
||||
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||
|
||||
export type WorkspaceTreeTarget = {
|
||||
kind: "root" | "directory" | "file";
|
||||
@@ -39,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,使他人
|
||||
@@ -129,6 +131,7 @@ export function WorkspaceTreeGroup({
|
||||
readOnly = false,
|
||||
dataResources,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
groupKey,
|
||||
ownerUserId,
|
||||
expandedPaths,
|
||||
@@ -242,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]">
|
||||
@@ -265,6 +269,7 @@ function WorkspaceTreeItems({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -293,6 +298,7 @@ function WorkspaceTreeItems({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
))}
|
||||
{childScripts.map((item) => {
|
||||
@@ -313,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);
|
||||
}
|
||||
@@ -370,6 +380,7 @@ function DirectoryBranch({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -428,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";
|
||||
}
|
||||
@@ -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(
|
||||
workspaceId: string,
|
||||
body: {
|
||||
@@ -1667,10 +1770,20 @@ export type WorkspaceBoundApi = {
|
||||
uploadId: string,
|
||||
body: Parameters<typeof bindResourceUpload>[2],
|
||||
) => Promise<ResourceItem>;
|
||||
deleteResource: (
|
||||
resourceId: string,
|
||||
) => Promise<{ resource_id: string; status: string }>;
|
||||
updateScript: (
|
||||
deleteResource: (
|
||||
resourceId: 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: (
|
||||
scriptId: string,
|
||||
input: Parameters<typeof updateScript>[2],
|
||||
) => Promise<ScriptItem>;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@react-router/node": "^8",
|
||||
@@ -34,6 +36,7 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@file-viewer/vite-plugin": "^3.0.0",
|
||||
"@react-router/dev": "^8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@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 { fileViewerRenderers } from "@file-viewer/vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [reactRouter(), tailwindcss()],
|
||||
plugins: [
|
||||
reactRouter(),
|
||||
tailwindcss(),
|
||||
fileViewerRenderers({
|
||||
preset: "office",
|
||||
copyAssets: { mode: "both", baseDir: "file-viewer" },
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./app"),
|
||||
|
||||
Reference in New Issue
Block a user