feat(components): 添加Excel和Word文件预览组件

2.0
LiuJiaNan 2026-07-03 10:25:38 +08:00
parent 8983cf0b0f
commit 1cb87fcf5c
10 changed files with 709 additions and 1 deletions

View File

@ -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<string, any>` | 否 | `{}` | 内容区域样式,会合并到高度为 `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<string, any>` | 否 | `{}` | 内容区域样式,会合并到默认高度 `72vh`、背景 `#f5f5f5`、内边距 `24px` 的外层容器。 |
### Video
引入:

View File

@ -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",

22
src/components/Excel/index.d.ts vendored Normal file
View File

@ -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<string, any>;
}
declare const Excel: FC<ExcelProps>;
export default Excel;

View File

@ -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 = () => (
<Spin spinning={loading}>
<div
style={{ height: "72vh", overflow: "hidden", ...style }}
>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
</div>
</Spin>
);
if (inline)
return renderExcelContent();
const onDownloadFile = () => {
downloadFile({
url: file,
name,
});
};
return (
<div ref={fullscreenRef}>
<Modal
style={{ top: 100, maxWidth: "calc(100vw - 32px)", paddingBottom: 24 }}
open={visible}
mask={{ closable: false }}
width={1000}
title={title}
onCancel={() => {
onCancel();
}}
getContainer={false}
footer={[
<Button
key="cancel"
onClick={() => {
onCancel();
}}
>
关闭
</Button>,
<Button key="download" type="primary" onClick={onDownloadFile}>下载</Button>,
]}
>
{renderExcelContent()}
</Modal>
</div>
);
}
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;

18
src/components/PreviewExcel/index.d.ts vendored Normal file
View File

@ -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<PreviewExcelProps>;
export default PreviewExcel;

View File

@ -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 (
<>
<Space>
<span>{name}</span>
<Button type="primary" size="small" onClick={() => previewExcel(url, name)}>
预览
</Button>
</Space>
<Excel
visible={visible}
file={currentSrc}
name={currentName}
onCancel={onCancel}
/>
</>
);
}
// 多文件预览模式
if (files.length > 0 && !name && !url) {
return (
<>
{files.map(item => (
<div key={extractFileUrl(item)} style={{ marginTop: 5 }}>
<Space>
<span>{extractFileName(item)}</span>
<Button
type="primary"
size="small"
onClick={() => previewExcel(extractFileUrl(item), extractFileName(item))}
>
预览
</Button>
</Space>
</div>
))}
<Excel
visible={visible}
file={currentSrc}
name={currentName}
onCancel={onCancel}
/>
</>
);
}
return null;
};
PreviewExcel.displayName = "PreviewExcel";
export default PreviewExcel;

18
src/components/PreviewWord/index.d.ts vendored Normal file
View File

@ -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<PreviewWordProps>;
export default PreviewWord;

View File

@ -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 (
<>
<Space>
<span>{name}</span>
<Button type="primary" size="small" onClick={() => previewWord(url, name)}>
预览
</Button>
</Space>
<Word
visible={visible}
file={currentSrc}
name={currentName}
onCancel={onCancel}
/>
</>
);
}
// 多文件预览模式
if (files.length > 0 && !name && !url) {
return (
<>
{files.map(item => (
<div key={extractFileUrl(item)} style={{ marginTop: 5 }}>
<Space>
<span>{extractFileName(item)}</span>
<Button
type="primary"
size="small"
onClick={() => previewWord(extractFileUrl(item), extractFileName(item))}
>
预览
</Button>
</Space>
</div>
))}
<Word
visible={visible}
file={currentSrc}
name={currentName}
onCancel={onCancel}
/>
</>
);
}
return null;
};
PreviewWord.displayName = "PreviewWord";
export default PreviewWord;

22
src/components/Word/index.d.ts vendored Normal file
View File

@ -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<string, any>;
}
declare const Word: FC<WordProps>;
export default Word;

View File

@ -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 = () => (
<Spin spinning={loading}>
<div
style={{ height: "72vh", overflowY: "auto", padding: "24px", background: "#f5f5f5", ...style }}
>
<div ref={contentRef} />
</div>
</Spin>
);
if (inline)
return renderWordContent();
const onDownloadFile = () => {
downloadFile({
url: file,
name,
});
};
return (
<div ref={fullscreenRef}>
<Modal
style={{ top: 100, maxWidth: "calc(100vw - 32px)", paddingBottom: 24 }}
open={visible}
mask={{ closable: false }}
width={900}
title={title}
onCancel={() => {
onCancel();
}}
getContainer={false}
footer={[
<Button
key="cancel"
onClick={() => {
onCancel();
}}
>
关闭
</Button>,
<Button key="download" type="primary" onClick={onDownloadFile}>下载</Button>,
]}
>
{renderWordContent()}
</Modal>
</div>
);
}
Word.displayName = "Word";
export default Word;