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:
parent
2ab49325da
commit
6a243220a8
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue