328 lines
9.9 KiB
TypeScript
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 "加载预览失败";
|
|
}
|