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

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ import {
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import * as XLSX from "xlsx"; import ExcelJS from "exceljs";
import { import {
Artifact, Artifact,
@ -52,6 +52,98 @@ const POST_MESSAGE_TYPES = {
FULLSCREEN: "fullscreen", FULLSCREEN: "fullscreen",
} as const; } 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 { function sendToParent(message: unknown): void {
if (window.parent !== window) { if (window.parent !== window) {
window.parent.postMessage(message, "*"); window.parent.postMessage(message, "*");
@ -1004,11 +1096,14 @@ function ArtifactOfficePreview({
}) { }) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [xlsxHtml, setXlsxHtml] = useState<string>("");
const [sheetNames, setSheetNames] = useState<string[]>([]); const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState<string>(""); const [activeSheet, setActiveSheet] = useState<string>("");
const [xlsxColumns, setXlsxColumns] = useState<RevoGridColumn[]>([]);
const [xlsxRows, setXlsxRows] = useState<RevoGridRow[]>([]);
const docxContainerRef = useRef<HTMLDivElement | null>(null); 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 canRenderDocx = kind === "docx";
const canRenderXlsx = kind === "xlsx"; const canRenderXlsx = kind === "xlsx";
@ -1067,6 +1162,10 @@ function ArtifactOfficePreview({
} }
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setSheetNames([]);
setActiveSheet("");
setXlsxColumns([]);
setXlsxRows([]);
workbookRef.current = null; workbookRef.current = null;
try { try {
const response = await fetch(artifactUrl); const response = await fetch(artifactUrl);
@ -1074,21 +1173,16 @@ function ArtifactOfficePreview({
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
const bytes = await response.arrayBuffer(); 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; workbookRef.current = workbook;
const names = workbook.SheetNames ?? []; const names = workbook.worksheets.map((sheet) => sheet.name);
if (names.length === 0) { if (names.length === 0) {
throw new Error("Empty workbook"); throw new Error("Empty workbook");
} }
if (disposed) return; if (disposed) return;
setSheetNames(names); setSheetNames(names);
const first = names[0] ?? ""; setActiveSheet(names[0] ?? "");
setActiveSheet(first);
const sheet = workbook.Sheets[first];
const html = sheet
? XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" })
: "";
setXlsxHtml(html);
} catch (err) { } catch (err) {
console.error("Failed to render xlsx preview:", err); console.error("Failed to render xlsx preview:", err);
if (!disposed) { if (!disposed) {
@ -1112,14 +1206,58 @@ function ArtifactOfficePreview({
return; return;
} }
try { try {
const sheet = workbookRef.current.Sheets[activeSheet]; const sheet = workbookRef.current.getWorksheet(activeSheet);
if (!sheet) return; 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) { } catch (err) {
console.error("Failed to switch xlsx sheet:", err); console.error("Failed to switch xlsx sheet:", err);
setError("切换工作表失败。"); setError("切换工作表失败。");
} }
}, [activeSheet, canRenderXlsx]); }, [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(() => { useEffect(() => {
if (!canRenderPptx) { if (!canRenderPptx) {
return; return;
@ -1137,10 +1275,10 @@ function ArtifactOfficePreview({
key={sheetName} key={sheetName}
type="button" type="button"
className={cn( className={cn(
"rounded px-2 py-1 text-xs whitespace-nowrap", "rounded px-4 py-3 text-xs whitespace-nowrap",
activeSheet === sheetName activeSheet === sheetName
? "bg-primary text-primary-foreground" ? "bg-[#1500331a] text-[#000000]"
: "bg-muted text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
onClick={() => setActiveSheet(sheetName)} onClick={() => setActiveSheet(sheetName)}
> >
@ -1157,10 +1295,10 @@ function ArtifactOfficePreview({
className="docx-preview-wrap mx-auto max-w-5xl" className="docx-preview-wrap mx-auto max-w-5xl"
/> />
)} )}
{canRenderXlsx && xlsxHtml && ( {canRenderXlsx && (
<div <div
className="artifact-xlsx-preview overflow-auto" ref={xlsxGridContainerRef}
dangerouslySetInnerHTML={{ __html: xlsxHtml }} className="h-full min-h-[320px] overflow-hidden rounded border"
/> />
)} )}
</div> </div>