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-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
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue