feat(artifacts): 使用 RevoGrid+ExcelJS 预览 Excel

- Excel 预览从 sheet_to_html 切换为 RevoGrid 网格渲染

- 使用 ExcelJS 解析工作簿并支持工作表切换

- 更新前端依赖:新增 @revolist/revogrid、exceljs;移除 nuxt-og-image、pptx-preview、xlsx
This commit is contained in:
肖应宇 2026-04-11 17:33:22 +08:00 committed by Titan
parent 2ab49325da
commit 6a243220a8
3 changed files with 617 additions and 1746 deletions

View File

@ -46,6 +46,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@revolist/revogrid": "^4.21.3",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.90.17",
"@tombcato/smart-ticker": "^1.2.4",
@ -67,6 +68,7 @@
"docx-preview": "^0.3.7",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"html2pdf.js": "^0.14.0",
@ -80,10 +82,8 @@
"next-themes": "^0.4.6",
"nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1",
"nuxt-og-image": "^5.1.13",
"ogl": "^1.0.11",
"pdfjs-dist": "^5.6.205",
"pptx-preview": "^1.0.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1",
@ -99,7 +99,6 @@
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^3.24.2"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ import {
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import * as XLSX from "xlsx";
import ExcelJS from "exceljs";
import {
Artifact,
@ -52,6 +52,98 @@ const POST_MESSAGE_TYPES = {
FULLSCREEN: "fullscreen",
} as const;
type RevoGridColumn = { prop: string; name: string };
type RevoGridRow = Record<string, string>;
type RevoGridSheetData = {
columns: RevoGridColumn[];
rows: RevoGridRow[];
};
type RevoGridElement = HTMLElement & {
columns: RevoGridColumn[];
source: RevoGridRow[];
readonly: boolean;
resize: boolean;
rowHeaders: boolean;
theme: string;
};
let revoGridLoaderPromise: Promise<void> | null = null;
function ensureRevoGridDefined() {
if (typeof window === "undefined") return Promise.resolve();
if (window.customElements.get("revo-grid")) return Promise.resolve();
if (!revoGridLoaderPromise) {
revoGridLoaderPromise = import("@revolist/revogrid/loader").then(
({ defineCustomElements }) => {
defineCustomElements(window);
},
);
}
return revoGridLoaderPromise;
}
function toExcelColumnLabel(index: number): string {
let n = index;
let label = "";
while (n > 0) {
const remainder = (n - 1) % 26;
label = String.fromCharCode(65 + remainder) + label;
n = Math.floor((n - 1) / 26);
}
return label || "A";
}
function toGridCellText(cell: ExcelJS.Cell): string {
if (cell.text) return cell.text;
const value = cell.value;
if (value == null) return "";
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") {
if ("result" in value && value.result != null) {
return String(value.result);
}
if ("text" in value && value.text) {
return String(value.text);
}
if ("hyperlink" in value && value.hyperlink) {
return String(value.hyperlink);
}
}
return String(value);
}
function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData {
const maxColumns = Math.max(worksheet.columnCount, 1);
const headerRow = worksheet.getRow(1);
const columns = Array.from({ length: maxColumns }, (_, idx) => {
const columnIndex = idx + 1;
const key = `c${columnIndex}`;
const header = toGridCellText(headerRow.getCell(columnIndex)).trim();
return {
prop: key,
name: header || toExcelColumnLabel(columnIndex),
};
});
const rows: RevoGridRow[] = [];
const lastRow = Math.max(worksheet.actualRowCount, worksheet.rowCount);
for (let rowIndex = 2; rowIndex <= lastRow; rowIndex += 1) {
const row = worksheet.getRow(rowIndex);
const rowData: RevoGridRow = {};
let hasContent = false;
for (let columnIndex = 1; columnIndex <= maxColumns; columnIndex += 1) {
const value = toGridCellText(row.getCell(columnIndex));
rowData[`c${columnIndex}`] = value;
if (!hasContent && value !== "") hasContent = true;
}
if (hasContent) rows.push(rowData);
}
return { columns, rows };
}
function sendToParent(message: unknown): void {
if (window.parent !== window) {
window.parent.postMessage(message, "*");
@ -1004,11 +1096,14 @@ function ArtifactOfficePreview({
}) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [xlsxHtml, setXlsxHtml] = useState<string>("");
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState<string>("");
const [xlsxColumns, setXlsxColumns] = useState<RevoGridColumn[]>([]);
const [xlsxRows, setXlsxRows] = useState<RevoGridRow[]>([]);
const docxContainerRef = useRef<HTMLDivElement | null>(null);
const workbookRef = useRef<XLSX.WorkBook | null>(null);
const xlsxGridContainerRef = useRef<HTMLDivElement | null>(null);
const xlsxGridRef = useRef<RevoGridElement | null>(null);
const workbookRef = useRef<ExcelJS.Workbook | null>(null);
const canRenderDocx = kind === "docx";
const canRenderXlsx = kind === "xlsx";
@ -1067,6 +1162,10 @@ function ArtifactOfficePreview({
}
setIsLoading(true);
setError(null);
setSheetNames([]);
setActiveSheet("");
setXlsxColumns([]);
setXlsxRows([]);
workbookRef.current = null;
try {
const response = await fetch(artifactUrl);
@ -1074,21 +1173,16 @@ function ArtifactOfficePreview({
throw new Error(`HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
const workbook = XLSX.read(bytes, { type: "array" });
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bytes);
workbookRef.current = workbook;
const names = workbook.SheetNames ?? [];
const names = workbook.worksheets.map((sheet) => sheet.name);
if (names.length === 0) {
throw new Error("Empty workbook");
}
if (disposed) return;
setSheetNames(names);
const first = names[0] ?? "";
setActiveSheet(first);
const sheet = workbook.Sheets[first];
const html = sheet
? XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" })
: "";
setXlsxHtml(html);
setActiveSheet(names[0] ?? "");
} catch (err) {
console.error("Failed to render xlsx preview:", err);
if (!disposed) {
@ -1112,14 +1206,58 @@ function ArtifactOfficePreview({
return;
}
try {
const sheet = workbookRef.current.Sheets[activeSheet];
const sheet = workbookRef.current.getWorksheet(activeSheet);
if (!sheet) return;
setXlsxHtml(XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" }));
const { columns, rows } = toRevoGridSheetData(sheet);
setXlsxColumns(columns);
setXlsxRows(rows);
} catch (err) {
console.error("Failed to switch xlsx sheet:", err);
setError("切换工作表失败。");
}
}, [activeSheet, canRenderXlsx]);
useEffect(() => {
if (!canRenderXlsx || !xlsxGridContainerRef.current) {
return;
}
let disposed = false;
async function renderGrid() {
try {
await ensureRevoGridDefined();
if (disposed || !xlsxGridContainerRef.current) return;
let grid = xlsxGridRef.current;
if (!grid) {
grid = document.createElement("revo-grid") as RevoGridElement;
grid.style.width = "100%";
grid.style.height = "100%";
grid.readonly = true;
grid.resize = true;
grid.rowHeaders = true;
grid.theme = "default";
xlsxGridContainerRef.current.innerHTML = "";
xlsxGridContainerRef.current.appendChild(grid);
xlsxGridRef.current = grid;
}
grid.columns = xlsxColumns;
grid.source = xlsxRows;
} catch (err) {
console.error("Failed to render RevoGrid preview:", err);
if (!disposed) {
setError("无法渲染 Excel 网格预览。");
}
}
}
void renderGrid();
return () => {
disposed = true;
};
}, [canRenderXlsx, xlsxColumns, xlsxRows]);
useEffect(() => {
if (!canRenderPptx) {
return;
@ -1137,10 +1275,10 @@ function ArtifactOfficePreview({
key={sheetName}
type="button"
className={cn(
"rounded px-2 py-1 text-xs whitespace-nowrap",
"rounded px-4 py-3 text-xs whitespace-nowrap",
activeSheet === sheetName
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
? "bg-[#1500331a] text-[#000000]"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveSheet(sheetName)}
>
@ -1157,10 +1295,10 @@ function ArtifactOfficePreview({
className="docx-preview-wrap mx-auto max-w-5xl"
/>
)}
{canRenderXlsx && xlsxHtml && (
{canRenderXlsx && (
<div
className="artifact-xlsx-preview overflow-auto"
dangerouslySetInnerHTML={{ __html: xlsxHtml }}
ref={xlsxGridContainerRef}
className="h-full min-h-[320px] overflow-hidden rounded border"
/>
)}
</div>