Files
model-platform/frontend/app/features/platform/DataResourcePreviewDialog.tsx
T
2026-08-31 18:48:19 +08:00

328 lines
9.9 KiB
TypeScript

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 "加载预览失败";
}