Files
deerflow-code/frontend-web/src/components/workspace/artifacts/spreadsheet-preview.tsx
T

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>
);
}