152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { parseXlsx, type ParsedWorksheet } from "@/core/utils/xlsx";
|
|
import { desensitize } from "@/lib/desensitize";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Renders an .xlsx ArrayBuffer as sheet tabs + an HTML table, replacing the
|
|
* old binary-blob iframe preview that showed the raw ZIP bytes as mojibake.
|
|
*/
|
|
export function SpreadsheetPreview({
|
|
data,
|
|
className,
|
|
}: {
|
|
data: ArrayBuffer;
|
|
className?: string;
|
|
}) {
|
|
const [sheets, setSheets] = useState<ParsedWorksheet[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setSheets(null);
|
|
setError(null);
|
|
setActiveSheetIndex(0);
|
|
parseXlsx(data)
|
|
.then((parsed) => {
|
|
if (!cancelled) {
|
|
setSheets(parsed);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("xlsx preview parse failed:", err);
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : "解析失败");
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [data]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"text-muted-foreground flex size-full flex-col items-center justify-center gap-2 p-6 text-sm",
|
|
className,
|
|
)}
|
|
>
|
|
<TriangleAlertIcon className="size-5" />
|
|
<div>无法预览此表格文件:{error}</div>
|
|
<div className="text-xs">可点击右上角下载后用本地软件打开。</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!sheets) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"text-muted-foreground flex size-full items-center justify-center gap-2 text-sm",
|
|
className,
|
|
)}
|
|
>
|
|
<LoaderIcon className="size-5 animate-spin" />
|
|
正在解析表格…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activeSheet = sheets[activeSheetIndex] ?? sheets[0]!;
|
|
const [headerRow, ...bodyRows] = activeSheet.rows;
|
|
const columnCount = Math.max(
|
|
headerRow?.length ?? 0,
|
|
...activeSheet.rows.map((row) => row.length),
|
|
1,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
data-artifact-preview-scroll
|
|
className={cn("flex size-full flex-col overflow-hidden", className)}
|
|
>
|
|
{sheets.length > 1 && (
|
|
<div className="border-border/70 flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2">
|
|
{sheets.map((sheet, index) => (
|
|
<button
|
|
key={`${sheet.name}-${index}`}
|
|
type="button"
|
|
onClick={() => setActiveSheetIndex(index)}
|
|
className={cn(
|
|
"shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors",
|
|
index === activeSheetIndex
|
|
? "bg-primary/10 text-primary font-medium"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
)}
|
|
>
|
|
{desensitize(sheet.name)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
|
{activeSheet.rows.length === 0 ? (
|
|
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
|
(空工作表)
|
|
</div>
|
|
) : (
|
|
<table className="border-border w-max min-w-full border-collapse text-sm">
|
|
<thead>
|
|
<tr>
|
|
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
|
<th
|
|
key={columnIndex}
|
|
className="border-border bg-muted/60 text-foreground border px-3 py-1.5 text-left font-semibold whitespace-pre-wrap"
|
|
>
|
|
{desensitize(headerRow?.[columnIndex] ?? "")}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{bodyRows.map((row, rowIndex) => (
|
|
<tr key={rowIndex} className="even:bg-muted/30">
|
|
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
|
<td
|
|
key={columnIndex}
|
|
className="border-border border px-3 py-1.5 align-top whitespace-pre-wrap"
|
|
>
|
|
{desensitize(row[columnIndex] ?? "")}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
{activeSheet.truncated && (
|
|
<div className="text-muted-foreground py-2 text-center text-xs">
|
|
表格过大,仅预览部分内容;完整数据请下载文件查看。
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|