Modal中使用表单
Tree 不展示浏览器title属性
通过titleRender属性,自定义标题,在子组件内返回元素最外层设置title=""
// 代码示意
<Tree
titleRender={(nodeData) => (<TreeTitle nodeData={nodeData} />)}
/>
const TreeTitle = ({ nodeData }) => <div title="">{nodeData.title}</div>
Form表单使用JSON Editor
// JSONEditor.tsx
import { useEffect, useRef } from 'react';
import { JSONEditor } from 'vanilla-jsoneditor';
const SvelteJSONEditor = (props) => {
const refContainer = useRef(null);
const refEditor = useRef(null);
useEffect(() => {
// create editor
refEditor.current = new JSONEditor({
target: refContainer.current,
props: {
content: props.value,
},
});
return () => {
// destroy editor
if (refEditor.current) {
refEditor.current.destroy();
refEditor.current = null;
}
};
}, []);
// update props
useEffect(() => {
if (refEditor.current) {
refEditor.current.updateProps({ ...props });
}
}, [props]);
return <div className="vanilla-jsoneditor-react" ref={refContainer}></div>;
};
export default SvelteJSONEditor;
// form中配置
<Form
labelCol={{ span: 4 }}
initialValues={{
settings: settings ? { json: settings, text: undefined } : undefined,
}}
form={dashBoardForm}
>
<Form.Item
label="前端配置项"
name="settings"
rules={[{ required: true, message: '请输入配置项' }]}
>
<JSONEditor />
</Form.Item>
</Form>
// 取值
const { settings } = await dashBoardForm.validateFields();
// 统一转换为JSON格式
const value = settings?.text ? JSON.parse(settings?.text) : settings.json;
Upload导入表格并解析表头
import * as XLSX from 'xlsx';
const onUploadChange: UploadProps['onChange'] = ({ file }) => {
const { name, status } = file;
if (status === 'error') {
message.error("上传失败!");
}
if (status === 'done') {
const fileReader = new FileReader();
fileReader.onload = () => {
const workbook = XLSX.read(fileReader.result, { type: 'binary' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const dataSource: string[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
setMatchFields(dataSource?.[0] || []);
};
fileReader.readAsArrayBuffer(file.originFileObj);
}
};
<Upload
accept=".xls,.xlsx"
action="/app/platform/resource/attachments"
onChange={onUploadChange}
>
<Button icon={<UploadOutlined />}>上传文件</Button>
</Upload>
前端处理表格文件并获取表头
import { ErrorCode, useDropzone } from 'react-dropzone';
const acceptUploadTypes = ['.xls', '.xlsx'];
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
multiple: false,
accept: { 'application/octet-stream': acceptUploadTypes },
validator(file) {
const isAcceptFile = acceptUploadTypes.some((type) => file?.name?.endsWith(type));
return isAcceptFile
? null
: {
message: `仅支持上传 ${acceptUploadTypes.join('、')} 等类型文件`,
code: ErrorCode.FileInvalidType,
};
},
onDropRejected([{ errors }]) {
message.error(errors[0].message);
},
onDrop: async (files) => {
files.forEach((file) => {
if (file) {
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
const workbook = XLSX.read(reader.result, { type: 'binary' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const dataSource: string[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
setMatchFields(dataSource?.[0] || []);
}
};
reader.readAsArrayBuffer(file);
}
});
},
});
<div {...getRootProps()}>
<input {...getInputProps()} />
<Button icon={<UploadOutlined />}>上传文件</Button>
</div>
{!!acceptedFiles.length && (
<Space>
<PaperClipOutlined />
{acceptedFiles.map((i) => (
<Typography.Paragraph type="success" key={i.name}>
{i.name}
</Typography.Paragraph>
))}
</Space>
)}
Upload自定义文件上传并根据上传进度提示
// 上传物料表格文件
export const uploadFile = (warehouseId: number, data, onUploadProgress) =>
post('/xx', {
data,
headers: { 'content-type': 'multipart/form-data' },
onUploadProgress,
params: { warehouseId },
});
// 根据progressTimes判断是否需要提示
const onFileUpload: UploadProps["customRequest"] = async (options) => {
const { onSuccess, onError, file } = options;
const fmData = new FormData();
fmData.append("file", file);
try {
const res = await api.inventory.uploadFile(storeId, fmData, (event) => {
if (event.progress !== 1 && progressTimes < maxProgressTimes) {
uploadRef.current = setTimeout(() => {
setProgressTimes((times) => times + 1);
}, 300);
}
});
message.success("上传成功");
setProgressTimes(0);
onSuccess?.();
} catch (error) {
setProgressTimes(0);
onError?.(error);
}
};
<Upload customRequest={onFileUpload} accept=".xls,.xlsx" maxCount={1}>
<Button icon={<UploadOutlined />}>
上传文件
</Button>
</Upload>
Upload上传PDF文件获取文件页数
import { PDFDocument } from 'pdf-lib';
const arrayBufferToString = (buffer) => {
// 创建一个Uint8Array来操作ArrayBuffer
const uintArray = new Uint8Array(buffer);
// 创建一个TextDecoder对象
const decoder = new TextDecoder();
// 使用TextDecoder的decode方法将Uint8Array转为字符串
const result = decoder.decode(uintArray);
return result;
};
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsArrayBuffer(file); // 拿到的file
reader.onloadend = async () => {
const arrayBuffer = reader.result;
let count = 0;
try {
const pdf = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
});
count = pdf.getPageCount();
} catch (e) {
count =
arrayBufferToString(arrayBuffer)?.match(/\/Type[\s]*\/Page[^s]/g)
?.length || 0;
}
if (count > PDF_LIMITS.pages) {
message.error(
formatMessage(
{
defaultMessage: "文件页数超过 {maxSize} 页,请重新上传",
id: "hyIgXE",
},
{ maxSize: PDF_LIMITS.pages }
)
);
reject();
}
const blob = file.slice(0, file.size, file.type);
const encodedFileName = encodeURIComponent(file.name);
const newFile = new File([blob], encodedFileName, { type: file.type });
resolve(newFile);
};
reader.onerror = (error) => reject(error);
});
PDFJS+React+antd改造
/* eslint-disable no-underscore-dangle */
import {
CloseOutlined,
DownloadOutlined,
LeftOutlined,
MinusOutlined,
PlusOutlined,
RightOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { ProCard } from '@ant-design/pro-components';
import { API, api, StatusCode } from '@yanyin/service';
import { Content, Layout } from '@yanyin/ui-basic';
import { useDebounceFn, useRequest } from 'ahooks';
import type { InputNumberProps, LayoutProps, SelectProps } from 'antd';
import {
Button,
Checkbox,
Flex,
Input,
InputNumber,
Popover,
Select,
Space,
Spin,
Typography,
} from 'antd';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import * as pdfjs from 'pdfjs-dist';
import { getPdfFilenameFromUrl } from 'pdfjs-dist';
import {
DownloadManager,
EventBus,
PDFFindController,
PDFLinkService,
PDFViewer,
} from 'pdfjs-dist/web/pdf_viewer.mjs';
import React, { useEffect, useRef, useState, type FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import './pdf_viewer.css';
interface PdfPreviewProps {
url?: string;
fileId?: number;
attachmentId?: number;
fileName?: string;
searchAble?: boolean;
scaleAble?: boolean;
downloadAble?: boolean;
extra?: React.ReactNode;
toolbarProps?: LayoutProps;
}
const DEFAULT_SCALE_VALUE = 'auto';
const PAGE_FIT = 'page-fit';
const MIN_SCALE = 0.1;
const MAX_SCALE = 10.0;
enum FindState {
FOUND = 0,
NOT_FOUND = 1,
WRAPPED = 2,
PENDING = 3,
}
const PdfPreview: FC<PdfPreviewProps> = ({
url,
fileId,
attachmentId,
fileName,
searchAble = true,
scaleAble = true,
downloadAble = false,
extra,
toolbarProps = {},
}) => {
const navigator = useNavigate();
const { formatMessage } = useIntl();
const [pageNum, setPageNum] = useState(0);
const [open, setOpen] = useState(false);
const [scale, setScale] = useState<number | string>(DEFAULT_SCALE_VALUE);
const [currentPage, setCurrentPage] = useState<number>(0);
const [pdfLoading, setPdfLoading] = useState(true);
const [searchLoading, setSearchLoading] = useState(false);
const [matches, setMatches] = useState<{ current: number; total: number }>({
total: 0,
current: 0,
});
const [searcher, setSearcher] = useState({
query: '',
highlightAll: true, // 全部高亮显示
caseSensitive: false, // 大小写敏感
entireWord: false, // 全词匹配
matchDiacritics: false, // 匹配变音符号
});
const mainContainerRef = useRef<HTMLDivElement>(null);
const viewerContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const windowAbortControllerRef = useRef(new AbortController());
const eventBusRef = useRef<EventBus>();
const downloadManagerRef = useRef<DownloadManager>();
const pdfLinkServiceRef = useRef<PDFLinkService>();
const pdfDocumentRef = useRef<PDFDocumentProxy>();
const pdfViewerRef = useRef<PDFViewer>();
const options = [
{
label: formatMessage({ defaultMessage: '自动缩放', id: 'Xvrkin' }),
value: DEFAULT_SCALE_VALUE,
},
{
label: formatMessage({ defaultMessage: '实际大小', id: 'KgIFHV' }),
value: 'page-actual',
},
{
label: formatMessage({ defaultMessage: '适合页面', id: '45oOyT' }),
value: PAGE_FIT,
},
{
label: formatMessage({ defaultMessage: '适合页宽', id: 'fi+lRl' }),
value: 'page-width',
},
{
label: formatMessage({ defaultMessage: '50%', id: '+iy0zp' }),
value: 0.5,
},
{
label: formatMessage({ defaultMessage: '75%', id: 'nkl3al' }),
value: 0.75,
},
{
label: formatMessage({ defaultMessage: '100%', id: '8ZVfG8' }),
value: 1,
},
{
label: formatMessage({ defaultMessage: '125%', id: 'wcyEbJ' }),
value: 1.25,
},
{
label: formatMessage({ defaultMessage: '150%', id: 'j02hp4' }),
value: 1.5,
},
{
label: formatMessage({ defaultMessage: '200%', id: 'IfpyNV' }),
value: 2,
},
{
label: formatMessage({ defaultMessage: '300%', id: 'RzcXh4' }),
value: 3,
},
{
label: formatMessage({ defaultMessage: '400%', id: 'ezsVYF' }),
value: 4,
},
];
const onNext = () => {
pdfViewerRef.current?.nextPage();
};
const onPrev = () => {
pdfViewerRef.current?.previousPage();
};
const goToPage: InputNumberProps['onChange'] = (value) => {
if (value) {
pdfLinkServiceRef.current?.goToPage(value);
}
};
const zoomIn = () => {
pdfViewerRef.current?.increaseScale();
};
const zoomOut = () => {
pdfViewerRef.current?.decreaseScale();
};
const onScaleChange: SelectProps['onChange'] = (value) => {
pdfViewerRef.current!.currentScaleValue = value;
};
const onSearcherChange = (
param: Record<string, any> = {},
type = 'again',
findPrevious = false,
) => {
const newSearcher = { ...searcher, ...param };
setSearcher(newSearcher);
eventBusRef?.current?.dispatch('find', {
type,
findPrevious,
...newSearcher,
});
};
const { run: onChange } = useDebounceFn(
(...args) => {
onSearcherChange(...args);
},
{ wait: 100 },
);
const onKeyDown = (evt) => {
const cmd =
(evt.ctrlKey ? 1 : 0) ||
(evt.altKey ? 2 : 0) ||
(evt.shiftKey ? 4 : 0) ||
(evt.metaKey ? 8 : 0);
// 缩放设置为适合页面时,会监听上下按键事件
if (cmd === 0) {
let turnPage = 0;
let turnOnlyIfPageFit = false;
switch (evt.keyCode) {
case 40:
case 34:
turnOnlyIfPageFit = true;
turnPage = 1;
break;
case 38:
case 33:
turnOnlyIfPageFit = true;
turnPage = -1;
break;
default:
break;
}
if (
turnPage !== 0 &&
(!turnOnlyIfPageFit || pdfViewerRef.current!.currentScaleValue === PAGE_FIT)
) {
if (turnPage > 0) {
pdfViewerRef.current?.nextPage();
} else {
pdfViewerRef.current?.previousPage();
}
evt.preventDefault();
}
}
};
const bindWindowEvents = () => {
window.addEventListener('resize', () =>
eventBusRef?.current?.dispatch('resize', { source: window }),
);
window.addEventListener('keydown', onKeyDown);
};
const onLoad = (url: string) => {
const linkService = new PDFLinkService();
const eventBus = new EventBus();
const downloadManager = new DownloadManager();
const findController = new PDFFindController({
eventBus,
linkService,
});
const pdfViewer = new PDFViewer({
container: mainContainerRef.current,
viewer: viewerContainerRef.current,
linkService,
eventBus,
findController,
downloadManager,
});
linkService.setViewer(pdfViewer);
const loadingTask = pdfjs.getDocument({
url,
});
loadingTask.onProgress = ({ loaded, total }: { loaded: number; total: number }) => {
setPdfLoading(loaded !== total);
};
loadingTask.promise.then(async (pdfDocument) => {
if (pdfDocument) {
const pageLayoutPromise = pdfDocument.getPageLayout().catch(() => {});
const pageModePromise = pdfDocument.getPageMode().catch(() => {});
const openActionPromise = pdfDocument.getOpenAction().catch(() => {});
const animationStarted = new Promise((resolve) => {
window.requestAnimationFrame(resolve);
});
const num = pdfDocument.numPages;
setPageNum(num);
setCurrentPage(1);
pdfViewer.setDocument(pdfDocument);
linkService.setDocument(pdfDocument);
downloadManagerRef.current = downloadManager;
pdfDocumentRef.current = pdfDocument;
pdfLinkServiceRef.current = linkService;
const { firstPagePromise, pagesPromise } = pdfViewer;
firstPagePromise.then(() => {
Promise.all([
animationStarted,
pageLayoutPromise,
pageModePromise,
openActionPromise,
]).then(async () => {
await Promise.race([
pagesPromise,
new Promise((resolve) => {
setTimeout(resolve, 1000);
}),
]);
pdfViewer.currentScaleValue = DEFAULT_SCALE_VALUE;
pdfViewerRef.current = pdfViewer;
// 缩放监听函数
eventBus?._on('resize', () => {
if (!pdfDocument) return;
const { currentScaleValue = 1 } = pdfViewer;
if (
currentScaleValue === DEFAULT_SCALE_VALUE ||
currentScaleValue === PAGE_FIT ||
currentScaleValue === 'page-width'
) {
pdfViewer!.currentScaleValue = currentScaleValue;
}
pdfViewer?.update();
});
// 翻页监听函数
eventBus?._on('pagechanging', (evt: { pageNumber: number }) => {
setCurrentPage(evt.pageNumber);
});
// 首次匹配监听函数
eventBus?._on(
'updatefindmatchescount',
(evt: { matchesCount: { total: number; current: number } }) => {
setMatches(evt.matchesCount);
},
);
// 全文匹配监听函数
eventBus?._on(
'updatefindcontrolstate',
(evt: { matchesCount: { total: number; current: number }; state: number }) => {
if (evt.state === FindState.PENDING) {
setSearchLoading(true);
} else {
setSearchLoading(false);
setMatches(evt.matchesCount);
}
},
);
// 缩放监听函数
eventBus?._on(
'scalechanging',
(evt: { scale: number | string; presetValue?: string }) => {
setScale(
evt?.presetValue ||
(options.map((i) => i.value).includes(evt.scale)
? evt.scale
: `${Math.floor((evt.scale || 1) * 100)}%`),
);
},
);
eventBusRef.current = eventBus;
bindWindowEvents();
});
});
}
});
};
const { data: pdfUrl = '' } = useRequest(
async () => {
if (url) return url;
let curAttachmentId = attachmentId;
if (fileId) {
const fileInfo = await api.file.getFileInfo(API.File.FileTypeEnum.PDF, fileId);
curAttachmentId = fileInfo.fileContent?.attachmentId;
}
const binaryData = await api.file.downloadAttachment(curAttachmentId!);
const blob = new Blob([binaryData]);
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
resolve(e.target.result);
};
// readAsDataURL
fileReader.readAsDataURL(blob);
fileReader.onerror = () => {
reject(new Error('blobToBase64 error'));
};
});
},
{
ready: !!fileId || !!attachmentId || !!url,
onSuccess: (url: string) => {
onLoad(url);
},
onError: (e) => {
if (e?.code === StatusCode.UNAUTHORIZED) {
navigator('/login');
}
},
},
);
const onDownload = async () => {
const data = await pdfDocumentRef.current?.getData();
const { contentDispositionFilename } = (await pdfDocumentRef.current?.getMetadata()) as any;
downloadManagerRef.current?.download(
data,
pdfUrl,
fileName || contentDispositionFilename || getPdfFilenameFromUrl(pdfUrl),
);
};
useEffect(() => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
return () => {
windowAbortControllerRef.current.abort();
};
}, []);
useEffect(() => {
if (open) {
inputRef?.current?.focus();
}
}, [open]);
const { className, ...restToolbarProps } = toolbarProps;
return (
<Spin spinning={pdfLoading} size="large">
<Layout className="h-screen">
<Layout.Header
className={`h-12 bg-white px-4 leading-[48px] ${className}`}
{...restToolbarProps}
>
<Flex justify="space-between" align="center" className="h-full">
{extra}
<Space size={8}>
{searchAble && (
<Popover
arrow={false}
open={open}
trigger="click"
placement="bottomRight"
content={
<ProCard
className="w-[320px]"
bodyStyle={{ paddingInline: 0, paddingBlockEnd: 0 }}
headStyle={{ padding: 0 }}
title={formatMessage({ defaultMessage: '检索', id: 'Uzdp3n' })}
extra={
<CloseOutlined
onClick={() => {
setOpen(false);
onChange({ query: '' });
}}
/>
}
>
<Flex gap={16} vertical>
<Input
prefix={<SearchOutlined />}
placeholder={formatMessage({ defaultMessage: '检索全文', id: 'liP7lJ' })}
onChange={(e) => onChange({ query: e.target.value })}
ref={inputRef}
/>
<Flex vertical>
<Checkbox
checked={searcher.highlightAll}
onChange={(e) =>
onChange({ highlightAll: e.target.checked }, 'highlightallchange')
}
>
<FormattedMessage defaultMessage="全部高亮显示" id="OKjoI+" />
</Checkbox>
<Checkbox
checked={searcher.caseSensitive}
onChange={(e) =>
onChange({ caseSensitive: e.target.checked }, 'casesensitivitychange')
}
>
<FormattedMessage defaultMessage="区分大小写" id="9ovhff" />
</Checkbox>
<Checkbox
checked={searcher.matchDiacritics}
onChange={(e) =>
onChange(
{ matchDiacritics: e.target.checked },
'diacriticmatchingchange',
)
}
>
<FormattedMessage defaultMessage="匹配变音符号" id="DUnzL9" />
</Checkbox>
<Checkbox
checked={searcher.entireWord}
onChange={(e) =>
onChange({ entireWord: e.target.checked }, 'entirewordchange')
}
>
<FormattedMessage defaultMessage="全词匹配" id="08ZjOK" />
</Checkbox>
</Flex>
<Flex justify="space-between" align="center">
<Typography.Text type="success">{`${matches.current} / ${matches?.total}`}</Typography.Text>
<Space size={16}>
<Button
onClick={() => onChange({}, 'again', true)}
disabled={searchLoading}
>
<FormattedMessage defaultMessage="上一个" id="U2X/B+" />
</Button>
<Button onClick={() => onChange()} disabled={searchLoading}>
<FormattedMessage defaultMessage="下一个" id="XjKOCc" />
</Button>
</Space>
</Flex>
</Flex>
</ProCard>
}
>
<Button
icon={<SearchOutlined />}
onClick={() => setOpen(!open)}
className={
open ? '[&_.ant-btn-icon]:text-primary border-transparent bg-[#e6f7eb]' : ''
}
/>
</Popover>
)}
{scaleAble && (
<Space.Compact>
<Button
className="p-0"
icon={<MinusOutlined />}
onClick={zoomOut}
disabled={+scale <= MIN_SCALE}
/>
<Select
onChange={onScaleChange}
value={scale}
options={options}
className="min-w-24"
/>
<Button
className="p-0"
icon={<PlusOutlined />}
onClick={zoomIn}
disabled={+scale >= MAX_SCALE}
/>
</Space.Compact>
)}
</Space>
<Space>
<Button
className="p-0"
icon={<LeftOutlined />}
onClick={onPrev}
size="small"
type="link"
disabled={currentPage <= 1}
/>
<InputNumber
className={pageNum < 10 ? 'w-8' : pageNum < 100 ? 'w-10' : 'w-12'}
value={currentPage}
onChange={goToPage}
size="small"
controls={false}
/>
<span className="mx-1">/</span>
<span>{pageNum}</span>
<Button
className="p-0"
icon={<RightOutlined />}
onClick={onNext}
size="small"
type="link"
disabled={currentPage >= pageNum}
/>
</Space>
{downloadAble && (
<Button icon={<DownloadOutlined />} onClick={onDownload}>
<FormattedMessage defaultMessage="文件下载" id="6fYWQx" />
</Button>
)}
</Flex>
</Layout.Header>
<Content autoScroll showBackground showPadding={false}>
<div id="viewerContainer" ref={mainContainerRef}>
<div id="viewer" className="pdfViewer" ref={viewerContainerRef} />
</div>
</Content>
</Layout>
</Spin>
);
};
export default PdfPreview;