From 1cb87fcf5c1336bc5c6346aa9aa595993d6e80cf Mon Sep 17 00:00:00 2001 From: LiuJiaNan <15703339975@163.com> Date: Fri, 3 Jul 2026 10:25:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(components):=20=E6=B7=BB=E5=8A=A0Excel?= =?UTF-8?q?=E5=92=8CWord=E6=96=87=E4=BB=B6=E9=A2=84=E8=A7=88=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 80 +++++++++ package.json | 5 +- src/components/Excel/index.d.ts | 22 +++ src/components/Excel/index.js | 226 +++++++++++++++++++++++++ src/components/PreviewExcel/index.d.ts | 18 ++ src/components/PreviewExcel/index.js | 96 +++++++++++ src/components/PreviewWord/index.d.ts | 18 ++ src/components/PreviewWord/index.js | 96 +++++++++++ src/components/Word/index.d.ts | 22 +++ src/components/Word/index.js | 127 ++++++++++++++ 10 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 src/components/Excel/index.d.ts create mode 100644 src/components/Excel/index.js create mode 100644 src/components/PreviewExcel/index.d.ts create mode 100644 src/components/PreviewExcel/index.js create mode 100644 src/components/PreviewWord/index.d.ts create mode 100644 src/components/PreviewWord/index.js create mode 100644 src/components/Word/index.d.ts create mode 100644 src/components/Word/index.js diff --git a/README.md b/README.md index bb3f9bc..18433b0 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,10 @@ import { FORM_ITEM_RENDER_ENUM } from "zy-react-library/enum/formItemRender"; | `PreviewImg` | `files=[]`、`fileUrlKey="filePath"`;图片固定 `width=100`、`height=100`,图片间距 `marginRight=10`、`marginBottom=10`。 | | `PreviewPdf` | `files=[]`、`nameKey=""`、`urlKey=""`、`name=""`、`url=""`。 | | `Pdf` | `visible=false`、`inline=false`、`title="PDF预览"`、`style={}`。 | +| `PreviewExcel` | `files=[]`、`nameKey=""`、`urlKey=""`、`name=""`、`url=""`。 | +| `Excel` | `visible=false`、`inline=false`、`title="Excel预览"`、`style={}`;弹窗宽度 `1000`,内容高度 `72vh`。 | +| `PreviewWord` | `files=[]`、`nameKey=""`、`urlKey=""`、`name=""`、`url=""`。 | +| `Word` | `visible=false`、`inline=false`、`title="Word预览"`、`style={}`;弹窗宽度 `900`,内容高度 `72vh`,默认背景 `#f5f5f5`、内边距 `24px`。 | | `Video` | `source=""`、`vid=""`、`playAuth=""`、`cover=""`、`autoplay=true`、`showProgress=true`、`playTime=0`、`inline=false`、`isLive=false`、`width="100%"`、`height="600px"`、`title="视频"`、`visible=false`、`aliPlayerProps={}`;弹窗宽度 `800`。 | | `AliPlayer` | `source=""`、`vid=""`、`playAuth=""`、`cover=""`、`width="100%"`、`height="600px"`、`autoplay=true`、`showProgress=true`、`isLive=false`、`playTime=0`。 | | `Signature` | `width=752`、`height=300`、`url=""`;签名弹窗宽度 `800`,画笔颜色 `black`,画布背景 `rgb(255,255,255)`。 | @@ -497,6 +501,82 @@ PDF 展示组件。 | `inline` | `boolean` | 否 | `false` | 是否内联展示。 | | `style` | `CSSProperties` | 否 | `{}` | 外层样式。 | +### PreviewExcel + +引入: + +```tsx +import PreviewExcel from "zy-react-library/components/PreviewExcel"; +``` + +Excel 预览入口组件,使用方式和 `PreviewPdf` 一致,负责展示文件名和预览按钮。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `files` | `{ filePath?: string; fileName?: string; name?: string; url?: string; [key: string]: string }[]` | 否 | `[]` | Excel 文件对象数组;和 `name`、`url` 单文件模式互斥。 | +| `nameKey` | `string` | 否 | `""` | 文件名字段名;不传时优先取 `name`、`fileName`。 | +| `urlKey` | `string` | 否 | `""` | 文件地址字段名;不传时优先取 `filePath`、`url`。 | +| `name` | `string` | 否 | `""` | 单文件文件名;和 `files` 互斥。 | +| `url` | `string` | 否 | `""` | 单文件地址;和 `files` 互斥。 | + +### Excel + +引入: + +```tsx +import Excel from "zy-react-library/components/Excel"; +``` + +Excel 展示组件,使用 `exceljs` 解析 `xlsx`,再通过 `x-data-spreadsheet` 渲染表格预览。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `visible` | `boolean` | 否 | `false` | 是否以弹窗形式显示。 | +| `onCancel` | `() => void` | 否 | - | 关闭弹窗回调。 | +| `file` | `string` | 否 | - | `xlsx` 文件地址;非完整文件服务地址时会自动拼接 `getFileUrl()`。 | +| `name` | `string` | 否 | - | 文件名,用于下载。 | +| `inline` | `boolean` | 否 | `false` | 是否内联展示。 | +| `title` | `string` | 否 | `"Excel预览"` | 弹窗标题。 | +| `style` | `Record` | 否 | `{}` | 内容区域样式,会合并到高度为 `72vh` 的外层容器。 | + +### PreviewWord + +引入: + +```tsx +import PreviewWord from "zy-react-library/components/PreviewWord"; +``` + +Word 预览入口组件,使用方式和 `PreviewPdf` 一致,负责展示文件名和预览按钮。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `files` | `{ filePath?: string; fileName?: string; name?: string; url?: string; [key: string]: string }[]` | 否 | `[]` | Word 文件对象数组;和 `name`、`url` 单文件模式互斥。 | +| `nameKey` | `string` | 否 | `""` | 文件名字段名;不传时优先取 `name`、`fileName`。 | +| `urlKey` | `string` | 否 | `""` | 文件地址字段名;不传时优先取 `filePath`、`url`。 | +| `name` | `string` | 否 | `""` | 单文件文件名;和 `files` 互斥。 | +| `url` | `string` | 否 | `""` | 单文件地址;和 `files` 互斥。 | + +### Word + +引入: + +```tsx +import Word from "zy-react-library/components/Word"; +``` + +Word 展示组件,使用 `docx-preview` 渲染 `docx` 文件。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `visible` | `boolean` | 否 | `false` | 是否以弹窗形式显示。 | +| `onCancel` | `() => void` | 否 | - | 关闭弹窗回调。 | +| `file` | `string` | 否 | - | `docx` 文件地址;非完整文件服务地址时会自动拼接 `getFileUrl()`。 | +| `name` | `string` | 否 | - | 文件名,用于下载。 | +| `inline` | `boolean` | 否 | `false` | 是否内联展示。 | +| `title` | `string` | 否 | `"Word预览"` | 弹窗标题。 | +| `style` | `Record` | 否 | `{}` | 内容区域样式,会合并到默认高度 `72vh`、背景 `#f5f5f5`、内边距 `24px` 的外层容器。 | + ### Video 引入: diff --git a/package.json b/package.json index 366a19f..af318fb 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,15 @@ "ahooks": "^3.9.5", "antd": "^6.4.4", "dayjs": "^1.11.18", + "docx-preview": "^0.3.7", + "exceljs": "^4.4.0", "lodash-es": "^4.17.21", "react": "^18.3.1", "react-pdf": "^10.2.0", "react-signature-canvas": "^1.1.0-alpha.2", "throttle-debounce": "^5.0.2", - "use-antd-resizable-header": "^3.2.2" + "use-antd-resizable-header": "^3.2.2", + "x-data-spreadsheet": "^1.1.9" }, "devDependencies": { "@antfu/eslint-config": "^5.4.1", diff --git a/src/components/Excel/index.d.ts b/src/components/Excel/index.d.ts new file mode 100644 index 0000000..cbf0ba7 --- /dev/null +++ b/src/components/Excel/index.d.ts @@ -0,0 +1,22 @@ +import type { FC } from "react"; + +export interface ExcelProps { + /** 是否显示弹窗 */ + visible?: boolean; + /** 关闭回调 */ + onCancel?: () => void; + /** xlsx 文件地址 */ + file?: string; + /** xlsx 文件名 */ + name?: string; + /** 是否内联展示 */ + inline?: boolean; + /** 弹窗标题 */ + title?: string; + /** 内容样式 */ + style?: Record; +} + +declare const Excel: FC; + +export default Excel; diff --git a/src/components/Excel/index.js b/src/components/Excel/index.js new file mode 100644 index 0000000..8a76d52 --- /dev/null +++ b/src/components/Excel/index.js @@ -0,0 +1,226 @@ +import { Button, message, Modal, Spin } from "antd"; +import ExcelJS from "exceljs"; +import { useEffect, useRef, useState } from "react"; +import Spreadsheet from "x-data-spreadsheet"; +import useDownloadFile from "../../hooks/useDownloadFile"; +import { getFileUrl } from "../../utils"; +import "x-data-spreadsheet/dist/xspreadsheet.css"; + +/** + * Excel 查看组件。 + * 使用 exceljs 解析 xlsx,再通过 x-data-spreadsheet 渲染表格预览。 + */ +function Excel(props) { + const { + visible = false, + onCancel, + file, + name, + inline = false, + title = "Excel预览", + style = {}, + } = props; + + const containerRef = useRef(null); + const fullscreenRef = useRef(null); + const unmountedRef = useRef(false); + const fileUrl = getFileUrl(); + const [loading, setLoading] = useState(false); + const { downloadFile } = useDownloadFile(); + + const renderExcel = async () => { + setLoading(true); + containerRef.current.innerHTML = ""; + try { + const response = await fetch(!file.includes(fileUrl) ? fileUrl + file : file); + if (!response.ok) + throw new Error("加载 Excel 文件失败"); + const data = await response.arrayBuffer(); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(data); + if (unmountedRef.current) + return; + new Spreadsheet(containerRef.current, { + mode: "read", + showToolbar: false, + showContextmenu: false, + view: { + height: () => containerRef.current?.clientHeight || 520, + width: () => containerRef.current?.clientWidth || 800, + }, + }).loadData(convertWorkbookToSpreadsheet(workbook)); + } + catch { + message.error("加载 Excel 文件失败"); + if (!inline && onCancel) + onCancel(); + } + finally { + if (!unmountedRef.current) + setLoading(false); + } + }; + + useEffect(() => { + if ((!visible && !inline) || !file) + return; + + unmountedRef.current = false; + const initTimer = setTimeout(() => { + renderExcel(); + }, 100); + + return () => { + unmountedRef.current = true; + if (containerRef.current) + containerRef.current.innerHTML = ""; + if (initTimer) + clearTimeout(initTimer); + }; + }, [visible, inline, file]); + + const renderExcelContent = () => ( + +
+
+
+ + ); + + if (inline) + return renderExcelContent(); + + const onDownloadFile = () => { + downloadFile({ + url: file, + name, + }); + }; + + return ( +
+ { + onCancel(); + }} + getContainer={false} + footer={[ + , + , + ]} + > + {renderExcelContent()} + +
+ ); +} + +function convertWorkbookToSpreadsheet(workbook) { + const sheets = []; + workbook.worksheets.forEach((worksheet) => { + sheets.push(convertWorksheetToSheetData(worksheet)); + }); + return sheets.length > 0 ? sheets : [{ name: "Sheet1", rows: {} }]; +} + +function convertWorksheetToSheetData(worksheet) { + const sheetData = { + name: worksheet.name, + rows: {}, + cols: { len: worksheet.columnCount || 26 }, + merges: worksheet.model?.merges || [], + }; + + worksheet.columns.forEach((column, index) => { + if (column.width) + sheetData.cols[index] = { width: Math.round(column.width * 8) }; + }); + + // x-data-spreadsheet 使用 0 起始下标,exceljs 使用 1 起始下标,这里统一转换。 + worksheet.eachRow({ includeEmpty: true }, (row, rowIndex) => { + const rowData = { cells: {} }; + if (row.height) + rowData.height = Math.round(row.height * 1.4); + row.eachCell({ includeEmpty: true }, (cell, colIndex) => { + rowData.cells[colIndex - 1] = { + text: getCellText(cell), + }; + }); + sheetData.rows[rowIndex - 1] = rowData; + }); + + (sheetData.merges || []).forEach((merge) => { + const range = parseExcelRange(merge); + const cell = sheetData.rows[range.sri]?.cells?.[range.sci]; + if (cell) + cell.merge = [range.eri - range.sri, range.eci - range.sci]; + }); + + return sheetData; +} + +function getCellText(cell) { + if (cell.value === null || cell.value === undefined) + return ""; + if (cell.formula) + return cell.result === null || cell.result === undefined ? `=${cell.formula}` : String(cell.result); + if (cell.text) + return cell.text; + if (cell.value instanceof Date) + return cell.value.toLocaleDateString(); + if (typeof cell.value === "object") { + if (cell.value.text) + return cell.value.text; + if (cell.value.richText) + return cell.value.richText.map(item => item.text).join(""); + if (cell.value.result !== undefined) + return String(cell.value.result); + } + return String(cell.value); +} + +function parseExcelRange(range) { + const [start, end = start] = range.split(":"); + const startCell = parseExcelCell(start); + const endCell = parseExcelCell(end); + return { + sri: startCell.rowIndex, + sci: startCell.colIndex, + eri: endCell.rowIndex, + eci: endCell.colIndex, + }; +} + +function parseExcelCell(cell) { + const match = /^([A-Z]+)(\d+)$/i.exec(cell); + if (!match) + return { rowIndex: 0, colIndex: 0 }; + return { + rowIndex: Number(match[2]) - 1, + colIndex: getColumnIndex(match[1]), + }; +} + +function getColumnIndex(columnName) { + return columnName.toUpperCase().split("").reduce((total, char) => { + return total * 26 + char.charCodeAt(0) - 64; + }, 0) - 1; +} + +Excel.displayName = "Excel"; + +export default Excel; diff --git a/src/components/PreviewExcel/index.d.ts b/src/components/PreviewExcel/index.d.ts new file mode 100644 index 0000000..1f362b1 --- /dev/null +++ b/src/components/PreviewExcel/index.d.ts @@ -0,0 +1,18 @@ +import type { FC } from "react"; + +export interface PreviewExcelProps { + /** 文件列表,和 name、url 冲突 */ + files?: { filePath?: string; fileName?: string; name?: string; url?: string; [key: string]: string }[]; + /** 文件名字段名,传入 files 时会优先查找是否存在 name、fileName */ + nameKey?: string; + /** 文件路径字段名,传入 files 时会优先查找是否存在 filePath、url */ + urlKey?: string; + /** 单个文件名,和 files 冲突 */ + name?: string; + /** 单个文件路径,和 files 冲突 */ + url?: string; +} + +declare const PreviewExcel: FC; + +export default PreviewExcel; diff --git a/src/components/PreviewExcel/index.js b/src/components/PreviewExcel/index.js new file mode 100644 index 0000000..4903b8b --- /dev/null +++ b/src/components/PreviewExcel/index.js @@ -0,0 +1,96 @@ +import { Button, Space } from "antd"; +import { useState } from "react"; +import { getFileName } from "../../utils"; +import Excel from "../Excel"; + +/** + * 在查看页面中 Excel 查看组件。 + * 使用方式和 PreviewPdf 一致,负责展示文件名和预览按钮。 + */ +const PreviewExcel = (props) => { + const { + files = [], + nameKey = "", + urlKey = "", + name = "", + url = "", + } = props; + + const [visible, setVisible] = useState(false); + const [currentSrc, setCurrentSrc] = useState(""); + const [currentName, setCurrentName] = useState(""); + + const previewExcel = (src, name) => { + setVisible(true); + setCurrentSrc(src); + setCurrentName(name); + }; + + const onCancel = () => { + setVisible(false); + setCurrentSrc(""); + setCurrentName(""); + }; + + const extractFileName = (item) => { + return item.name || item.fileName || item[nameKey] || getFileName(item.filePath || item.url || item[urlKey]); + }; + + const extractFileUrl = (item) => { + return item.filePath || item.url || item[urlKey]; + }; + + // 单个文件预览模式 + if (files.length === 0 && name && url) { + return ( + <> + + {name} + + + + + ); + } + + // 多文件预览模式 + if (files.length > 0 && !name && !url) { + return ( + <> + {files.map(item => ( +
+ + {extractFileName(item)} + + +
+ ))} + + + ); + } + + return null; +}; + +PreviewExcel.displayName = "PreviewExcel"; + +export default PreviewExcel; diff --git a/src/components/PreviewWord/index.d.ts b/src/components/PreviewWord/index.d.ts new file mode 100644 index 0000000..a8aa607 --- /dev/null +++ b/src/components/PreviewWord/index.d.ts @@ -0,0 +1,18 @@ +import type { FC } from "react"; + +export interface PreviewWordProps { + /** 文件列表,和 name、url 冲突 */ + files?: { filePath?: string; fileName?: string; name?: string; url?: string; [key: string]: string }[]; + /** 文件名字段名,传入 files 时会优先查找是否存在 name、fileName */ + nameKey?: string; + /** 文件路径字段名,传入 files 时会优先查找是否存在 filePath、url */ + urlKey?: string; + /** 单个文件名,和 files 冲突 */ + name?: string; + /** 单个文件路径,和 files 冲突 */ + url?: string; +} + +declare const PreviewWord: FC; + +export default PreviewWord; diff --git a/src/components/PreviewWord/index.js b/src/components/PreviewWord/index.js new file mode 100644 index 0000000..b5aa9ee --- /dev/null +++ b/src/components/PreviewWord/index.js @@ -0,0 +1,96 @@ +import { Button, Space } from "antd"; +import { useState } from "react"; +import { getFileName } from "../../utils"; +import Word from "../Word"; + +/** + * 在查看页面中 Word 查看组件。 + * 使用方式和 PreviewPdf 一致,负责展示文件名和预览按钮。 + */ +const PreviewWord = (props) => { + const { + files = [], + nameKey = "", + urlKey = "", + name = "", + url = "", + } = props; + + const [visible, setVisible] = useState(false); + const [currentSrc, setCurrentSrc] = useState(""); + const [currentName, setCurrentName] = useState(""); + + const previewWord = (src, name) => { + setVisible(true); + setCurrentSrc(src); + setCurrentName(name); + }; + + const onCancel = () => { + setVisible(false); + setCurrentSrc(""); + setCurrentName(""); + }; + + const extractFileName = (item) => { + return item.name || item.fileName || item[nameKey] || getFileName(item.filePath || item.url || item[urlKey]); + }; + + const extractFileUrl = (item) => { + return item.filePath || item.url || item[urlKey]; + }; + + // 单个文件预览模式 + if (files.length === 0 && name && url) { + return ( + <> + + {name} + + + + + ); + } + + // 多文件预览模式 + if (files.length > 0 && !name && !url) { + return ( + <> + {files.map(item => ( +
+ + {extractFileName(item)} + + +
+ ))} + + + ); + } + + return null; +}; + +PreviewWord.displayName = "PreviewWord"; + +export default PreviewWord; diff --git a/src/components/Word/index.d.ts b/src/components/Word/index.d.ts new file mode 100644 index 0000000..008ed4e --- /dev/null +++ b/src/components/Word/index.d.ts @@ -0,0 +1,22 @@ +import type { FC } from "react"; + +export interface WordProps { + /** 是否显示弹窗 */ + visible?: boolean; + /** 关闭回调 */ + onCancel?: () => void; + /** docx 文件地址 */ + file?: string; + /** docx 文件名 */ + name?: string; + /** 是否内联展示 */ + inline?: boolean; + /** 弹窗标题 */ + title?: string; + /** 内容样式 */ + style?: Record; +} + +declare const Word: FC; + +export default Word; diff --git a/src/components/Word/index.js b/src/components/Word/index.js new file mode 100644 index 0000000..ef85414 --- /dev/null +++ b/src/components/Word/index.js @@ -0,0 +1,127 @@ +import { Button, message, Modal, Spin } from "antd"; +import { renderAsync } from "docx-preview"; +import { useEffect, useRef, useState } from "react"; +import useDownloadFile from "../../hooks/useDownloadFile"; +import { getFileUrl } from "../../utils"; + +/** + * Word 查看组件。 + */ +function Word(props) { + const { + visible = false, + onCancel, + file, + name, + inline = false, + title = "Word预览", + style = {}, + } = props; + + const contentRef = useRef(null); + const fullscreenRef = useRef(null); + const unmountedRef = useRef(false); + const fileUrl = getFileUrl(); + const [loading, setLoading] = useState(false); + const { downloadFile } = useDownloadFile(); + + const renderWord = async () => { + setLoading(true); + contentRef.current.innerHTML = ""; + try { + const response = await fetch(!file.includes(fileUrl) ? fileUrl + file : file); + if (!response.ok) + throw new Error("加载 Word 文件失败"); + const data = await response.arrayBuffer(); + if (unmountedRef.current) + return; + await renderAsync(data, contentRef.current, null, { + className: "docx-preview", + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + breakPages: true, + }); + } + catch { + message.error("加载 Word 文件失败"); + if (!inline && onCancel) + onCancel(); + } + finally { + if (!unmountedRef.current) + setLoading(false); + } + }; + + useEffect(() => { + if ((!visible && !inline) || !file) + return; + + unmountedRef.current = false; + const initTimer = setTimeout(() => { + renderWord(); + }, 100); + + return () => { + unmountedRef.current = true; + if (contentRef.current) + contentRef.current.innerHTML = ""; + if (initTimer) + clearTimeout(initTimer); + }; + }, [visible, inline, file]); + + const renderWordContent = () => ( + +
+
+
+ + ); + + if (inline) + return renderWordContent(); + + const onDownloadFile = () => { + downloadFile({ + url: file, + name, + }); + }; + + return ( +
+ { + onCancel(); + }} + getContainer={false} + footer={[ + , + , + ]} + > + {renderWordContent()} + +
+ ); +} + +Word.displayName = "Word"; + +export default Word;