跳到主要内容

Ant Design

全家桶

如何使用主题变量

定制主题

ConfigProvider 中传入 theme 属性,可以配置主题

修改主题变量

通过 theme 中的 token 属性,可以修改主题变量

import { Button, ConfigProvider } from 'antd';

const App = () => {
return (
<>
<ConfigProvider
theme={{
token: {
colorPrimary: '#00b96b',
borderRadius: 2
}
}}
>
<Button type="primary">btn</Button>
</ConfigProvider>
</>
);
};

export default App;

修改组件变量

除了整体的 Design Token,各个组件也会开放自己的 Component Token 来实现针对组件的样式定制能力。

例如修改表格组件的默认样式:

import { Table, ConfigProvider } from 'antd';

const App = () => {
return (
<>
<ConfigProvider
theme={{
components: {
Table: {
headerBg: 'transport',
headerColor: '#fff',
borderColor: '#004670',
rowHoverBg: '#00b96b'
}
}
}}
>
<Table bordered columns={columns} dataSource={Data} />
</ConfigProvider>
</>
);
};

export default App;

Button 组件的 hidden 属性

在做按钮权限时,发现 Button 组件有一个 hideen 属性可以控制显示隐藏,这个属性没有在文档上体现

<Button
type="primary"
key="new"
hidden={!access.hasPerms('system:user:new')}
onClick={() => {
history.push('/client/new');
}}
>
<PlusOutlined /> <FormattedMessage id="pages.searchTable.new" />
</Button>

在控制台调试按钮元素发现有一个 display: none 样式,但是并未生效,被覆盖了

解决方法:在app.tsx引入 import 'antd/dist/reset.css';

参考地址:https://github.com/ant-design/ant-design/issues/38732

Tree 树形组件

默认展开全部

如果直接设置defaultExpandAll为 true,并不会出现默认展开全部的效果。这是因为数据一般是由服务端获取的,初始时节点还没生成,需要等数据回来再设置

选中回显

使用checkedKeys属性,在 useEffect 里获取需要回显的数据

defaultCheckedKeys一般用于直接给定的数据,实际上很多情况都是服务端返回的数据

配合 onCheck 事件设置选中的值。注意checkStrictly属性表示父子节点是否关联,在 onCheck 里获取数据的形式不一样

import { useIntl } from '@umijs/max';
import { Modal, Tree } from 'antd';
import type { TreeProps } from 'antd/es/tree';
import { useEffect, useState } from 'react';
import { getOrgTree } from '@/services/system/organization';
import { getOrgIdsByAppId } from '@/services/api/appStore';

export type RoleTypeFormData = Record<string, unknown> & Partial<System.RoleTypeListItem>;

export type RoleTypeFormProps = {
onCancel: (flag?: boolean, formVals?: RoleTypeFormData) => void;
onSubmit: (values: number[]) => Promise<void>;
open: boolean;
values: Partial<System.RoleTypeListItem>;
lastUpdatedBy: string;
};

const AuthOrg = (props: RoleTypeFormProps) => {
const intl = useIntl();
const [treeData, setTreeData] = useState<any[]>([]);
const [checkedKeyList, setCheckedKeyList] = useState<any[]>([]);

const init = async () => {
try {
const tree = await getOrgTree();
setTreeData(tree.data);
const orgs = await getOrgIdsByAppId(props.values.appId);
setCheckedKeyList(orgs.data);
} catch (error) {
console.log(error);
}
};

useEffect(() => {
if (!props.values.appId) return;
if (props.lastUpdatedBy === 'org') {
init();
}
}, [props.values.appId, props.lastUpdatedBy]);

const handleOk = () => {
props.onSubmit(checkedKeyList);
};
const handleCancel = () => {
props.onCancel();
};

const onCheck: TreeProps['onCheck'] = checkedKeys => {
if (!Array.isArray(checkedKeys)) {
setCheckedKeyList(checkedKeys.checked);
}
};

return (
<Modal
width={600}
title={intl.formatMessage({ id: 'application.authOrg' })}
open={props.open}
destroyOnClose
onOk={handleOk}
onCancel={handleCancel}
>
{treeData.length > 0 && (
<Tree
checkable
blockNode
multiple={true}
checkStrictly={true}
defaultExpandAll={true}
fieldNames={{ title: 'orgName', key: 'orgId' }}
treeData={treeData}
onCheck={onCheck}
checkedKeys={checkedKeyList}
/>
)}
</Modal>
);
};

export default AuthOrg;

获取所有父节点

需求是在无用户交互的情况下,根据服务端返回的叶子节点的 id 数组来获取所有父级节点的 id。需要遍历这个数组,针对每个叶子节点,找到其对应的所有父节点

import { useEffect } from 'react';
import { Tree } from 'antd';

const MyComponent = () => {
// 树的数据
const treeData = [];

// 假设是服务端返回的叶子节点的 id 数组
const leafNodeIds = ['leafNodeId1', 'leafNodeId2', 'leafNodeId3'];

const filterParentNodes = () => {
const findParentNodes = (nodes, targetId) => {
let parentNodes = [];
for (let node of nodes) {
if (node.children) {
const childNodes = findParentNodes(node.children, targetId);
if (childNodes.length > 0) {
parentNodes = [...parentNodes, node.key, ...childNodes];
}
}
if (targetId.includes(node.key)) {
parentNodes.push(node.key);
}
}
return parentNodes;
};

let parentNodes = [];
for (let leafNodeId of leafNodeIds) {
parentNodes = [...parentNodes, ...findParentNodes(treeData, leafNodeId)];
}
parentNodes = Array.from(new Set(parentNodes)); // 去重

// 过滤掉 leafNodeIds 中的叶子节点
// parentNodes = parentNodes.filter((nodeId) => !leafNodeIds.includes(nodeId));

console.log('父节点的 id:', parentNodes);
};

useEffect(() => {
filterParentNodes();
}, [treeData, leafNodeIds]);

return (
<div>
<Tree checkable treeData={treeData} />
</div>
);
};

export default MyComponent;

自定义面包屑

业务场景:

多层级表格,点击下一级按钮进入下一级页面,但是表头不变,只是内容变化了。这个时候就需要显示层级路径,如:a / a-1 / a-2 / a-3,点击每一个面包屑节点还需要切换表格数据,并改变面包屑

import { useState, useRef } from 'react';

const TableList = () => {
const actionRef = useRef<ActionType>();
const [parentId, setParentId] = useState<number>(0);
const [breadcrumbData, setBreadcrumbData] = useState<any[]>([]);

const onClickBreadcrumb = (pid: number) => {
const lastNode = breadcrumbData[breadcrumbData.length - 1];

if (pid === 0) {
setBreadcrumbData([]);
} else {
if (lastNode.key === pid) return;
setBreadcrumbData(sb => {
let key = sb.findIndex((idx: any) => idx.key === pid);
let res = sb.slice(0, key + 1);
return res;
});
}

setParentId(pid);
actionRef.current?.reloadAndRest?.();
};

// 列配置
const columns = [
{
title: '操作列',
dataIndex: 'option',
width: 90,
fixed: 'right',
align: 'center',
valueType: 'option',
render: (_, record) => [
<a
key="cluster"
onClick={async () => {
const hasTop = breadcrumbData.find(b => b.key === 0);
if (hasTop) {
setBreadcrumbData((item: any) => [
...item,
{
title: record.name,
key: record.id,
onClick: () => onClickBreadcrumb(record.id as number)
}
]);
} else {
setBreadcrumbData([
{
title: '全部',
key: 0,
onClick: () => onClickBreadcrumb(0)
},
{
title: record.name,
key: record.id,
onClick: () => onClickBreadcrumb(record.id as number)
}
]);
}

setParentId(record.id as number);
actionRef.current?.reloadAndRest?.();
}}
>
下级管理
</a>
]
}
];

const BreadcrumbLink = () => {
return (
<>
{breadcrumbData.map((item, index) => (
<>
<span
style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: '14px', cursor: 'pointer' }}
key={item.key}
onClick={() => onClickBreadcrumb(item.key as number)}
>
{item.title}
</span>
{index !== breadcrumbData.length - 1 && (
<span style={{ marginInline: '8px', color: 'rgba(0, 0, 0, 0.45)' }}>/</span>
)}
</>
))}
</>
);
};

return (
<PageContainer>
<ProTable headerTitle={<BreadcrumbLink />} actionRef={actionRef} />
</PageContainer>
);
};

两个独立的日期时间选择框限制范围

import { useState } from 'react';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';

function DateRangePicker() {
const defaultStartDate = dayjs('2023-09-05 14:58:20', 'YYYY-MM-DD HH:mm:ss');
const defaultEndDate = dayjs('2023-09-06 18:28:10', 'YYYY-MM-DD HH:mm:ss');

const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);

const disabledStartDate = current => {
// 限制只能选择当前月份
// const minDate = dayjs('2023-09-01', 'YYYY-MM-DD');
// const maxDate = dayjs('2023-09-30', 'YYYY-MM-DD');
// return current && (current.isBefore(minDate) || current.isAfter(maxDate) || current.isAfter(endDate));
return current && current.isAfter(endDate);
};

const disabledEndDate = current => {
// const minDate = dayjs('2023-09-01', 'YYYY-MM-DD');
// const maxDate = dayjs('2023-09-30', 'YYYY-MM-DD');
// return current && (current.isBefore(minDate) || current.isAfter(maxDate) || current.isBefore(startDate));
return current && current.isBefore(startDate);
};

const disabledStartTime = (_, type) => {
if (type === 'start') {
return {
disabledHours: () => [],
disabledMinutes: () => [],
disabledSeconds: () => []
};
}
return {};
};

const disabledEndTime = (_, type) => {
if (type === 'end') {
return {
disabledHours: () => [],
disabledMinutes: () => [],
disabledSeconds: () => []
};
}
return {};
};

const handleStartDateChange = date => {
setStartDate(date);
if (date && endDate && date.isAfter(endDate)) {
setEndDate(date);
}
};

const handleEndDateChange = date => {
setEndDate(date);
if (date && startDate && date.isBefore(startDate)) {
setStartDate(date);
}
};

return (
<div>
<DatePicker
format="YYYY-MM-DD HH:mm:ss"
showTime={{ format: 'HH:mm:ss' }}
value={startDate}
onChange={handleStartDateChange}
disabledDate={disabledStartDate}
disabledTime={disabledStartTime}
/>
<DatePicker
format="YYYY-MM-DD HH:mm:ss"
showTime={{ format: 'HH:mm:ss' }}
value={endDate}
onChange={handleEndDateChange}
disabledDate={disabledEndDate}
disabledTime={disabledEndTime}
/>
</div>
);
}

export default DateRangePicker;

走马灯自定义箭头,点击预览

走马灯增加前进、后退的箭头,点击图片后可以放大预览

<Carousel arrows prevArrow={<LeftOutlined />} nextArrow={<RightOutlined />}>
{listData.map((e: any, i: number) => (
<div className="w-full h-[500px]" key={i}>
<Image style={{ width: '100%', height: '100%' }} src={e.url} preview={{ src: e.url, mask: '' }} />
</div>
))}
</Carousel>
.ant-image {
width: 100%;
height: 100%;
}

.ant-carousel .slick-prev,
.ant-carousel .slick-prev:hover,
.ant-carousel .slick-prev:focus {
font-size: 30px;
left: 10px;
z-index: 2;
color: #fff;
}

.ant-carousel .slick-next,
.ant-carousel .slick-next:hover,
.ant-carousel .slick-next:focus {
font-size: 30px;
right: 20px;
z-index: 2;
color: #fff;
}

Upload

如果使用了itemRender自定义上传列表项,会导致默认的上传进度条不会显示,需要自己处理。如果需要处理点击文件链接或预览图标时的逻辑,可以使用onPreview

使用 onChange 时,如果出现 status 一直是uploading,可以参考这里。主要就是受控组件的问题,需要手动控制上传状态。不管状态是done或者uploading,都需要更新文件列表:

setFileList([...fileList]);

Upload 组件可以使用onSuccessonError回调,文档上没有,慎重使用。

如果使用自定义上传 customRequest,传递的参数如下:

interface UploadProps {
action: string;
data: any;
file: any;
filename: string;
headers: any;
method: string;
onError: (err: any, ret?: any) => void;
onProgress: (event: any) => void;
onSuccess: (ret: any, xhr?: any) => void;
withCredentials: boolean;
}

在上传的过程中(例如在 XMLHttpRequest 的 progress 事件中)需要调用 onProgress({ percent: 100 }); 去更新进度条。

const xhr = new XMLHttpRequest();
xhr.upload.onprogress = event => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress({ percent });
}
};

完整代码:

index.tsx
import { useState } from 'react';
import { Upload, Button, message } from 'antd';
import { UploadOutlined, DeleteOutlined, PaperClipOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import { getAccessToken } from '@/utils/accessToken';
import { deleteFile } from '@/services/api/file';
import chunkUpload from './chunkupload';
import styles from './style.less';

type uploadItemProps = {
fileUUid: string;
name: string;
url: string;
[x: string]: any;
};

export interface UploadProps {
params: {
businessId: string;
type?: 'file' | 'image';
action?: 'many' | 'single';
fileSize?: number;
accept?: string;
[key: string]: any;
};
fileList: uploadItemProps[];
fileListChange: (key: any) => void;
[x: string]: any;
}

const uploadUrl = serverURL.apiServer + '/manage/file/upload';
const defaultAccept = '.zip,.rar,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx';

function BigFileUpload(props: UploadProps) {
const { accept = defaultAccept, type = 'file', pro = false, action = 'many', fileSize = 8192 } = props.params;
const { initialState } = useModel('@@initialState');
const { currentUser } = initialState || {};
const userId = currentUser?.accountId;

const beforeUpload = (file: any) => {
if (fileList.length > 0 && action === 'single') {
message.error(`该部分只能上传一个文件!`);
return false;
}
const type = file.name.substring(file.name.lastIndexOf('.') + 1);
const extension = accept.includes(type);
if (!extension) {
message.error(`上传文件只能是 ${accept} 格式!`);
}
const isLt2M = file.size / 1024 / 1024 < fileSize;
if (!isLt2M) {
message.error(`上传文件大小不能超过 ${fileSize} M!`);
}
const isEmpty = file.size > 0;
if (!isEmpty) {
message.error(`上传文件无内容,请重新上传!`);
}
return extension && isLt2M && isEmpty;
};

const onChange = (info: any) => {
const { status, response, percent } = info.file;

if (status === 'done') {
const data = response.data;
const updatedFileList = info.fileList.map((file: any) => {
if (file.uid === info.file.uid) {
return {
...file,
fileUUid: data.fileList[0].fileUUid,
name: data.fileList[0].fileName,
url: data.serverUrl + data.fileList[0].fileUrl
};
}
return file;
});
props.fileListChange(updatedFileList);
} else if (status === 'uploading') {
// 更新文件列表中的进度
const updatedFileList = info.fileList.map((file: any) => {
if (file.uid === info.file.uid) {
return { ...file, percent };
}
return file;
});
props.fileListChange(updatedFileList);
} else if (status === 'error') {
message.error('文件上传失败,请重试');
}

// 也可以在这里更新 fileList,注意要克隆 fileList,逻辑自处理
// props.fileListChange([...fileList]);
};

const onRemove = (file: any) => {
if (file.fileUUid) {
deleteFile(file.fileUUid, type).then((res: { code: number; msg: string; data: object }) => {
if (res.code === 0) {
message.success(res.msg);
let uploadList = [...props.fileList];
const arr = uploadList.filter(item => item.fileUUid !== file.fileUUid);
props.fileListChange(arr);
} else {
message.error(res.msg);
return false;
}
});
} else {
if (file.status === 'ready') {
return true;
}
return false;
}
};

return (
<>
<Upload
{...props}
name="file"
accept={accept}
action={uploadUrl}
data={{ ...props.params, userId }}
headers={{ Authorization: 'Bearer ' + getAccessToken() }}
customRequest={chunkUpload}
beforeUpload={beforeUpload}
onChange={onChange}
onRemove={onRemove}
>
<Button icon={<UploadOutlined />}>点击上传</Button>
<div style={{ marginTop: '6px' }}>
只能上传
<b>{accept}</b>
文件,且不超过
{fileSize % 1024 === 0 ? <b>{fileSize / 1024} G</b> : <b>{fileSize}M</b>}
</div>
</Upload>
</>
);
}

export default BigFileUpload;
Demo.tsx
import { useState } from 'react';
import BigFileUpload from './index';

interface fileListProps {
fileUUid: string;
name: string;
url: string;
[key: string]: any;
}

const Demo = () => {
const [fileList, setFileList] = useState<fileListProps[]>([
{
fileUUid: 'c39daa73-2897-453f-b7db-ef3c90df8859',
name: '1.zip',
url: 'https://xxx.com/upload/c81d1083-a38e-464e-974a-77f38034afd8.zip'
}
]);

const fileListChange = (e: any) => {
setFileList(e);
};

return (
<div>
<BigFileUpload params={{ businessId: '122133' }} fileList={fileList} fileListChange={fileListChange} />
</div>
);
};

export default Demo;