前端初学者的Ant Design Pro V6总结(上)
前端初学者的Ant Design Pro V6总结(下)
@umi 请求相关
一个能用的请求配置
Antd Pro的默认的请求配置太复杂了,我写了个简单的,能用,有需要可以做进一步拓展。
import { message } from 'antd';
import { history } from '@umijs/max';
import type { RequestOptions } from '@@/plugin-request/request';
import { RequestConfig } from '@@/plugin-request/request';
import { LOGIN_URL } from '@/***mon/constant';
export const httpCodeDispose = async (code: string | number) => {
if (code.toString().startsWith('4')) {
message.error({ content: `请求错误` });
if (code === 401) {
message.error({ content: `登录已过期,请重新登录` });
history.replace({ pathname: LOGIN_URL });
}
if (code === 403) {
message.error({ content: `登录已过期,请重新登录` });
localStorage.removeItem('UserInfo');
history.replace({ pathname: LOGIN_URL });
}
}
// 500状态码
if (code.toString().startsWith('5')) {
message.error({ content: `服务器错误,请稍后再试` });
}
};
// 运行时配置
export const errorConfig: RequestConfig = {
// 统一的请求设定
timeout: 20000,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
// 错误处理: umi@3 的错误处理方案。
errorConfig: {
/**
* 错误接收及处理,主要返回状态码非200,Axios错误的情况
* @param error 错误类型
* @param opts 请求参数,请求方法
*/
errorHandler: async (error: any, opts: any) => {
if (opts?.skipErrorHandler) throw error;
// 我们的 errorThrower 抛出的错误。
if (error.response) {
// Axios 的错误
// 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
if ((error.message as string).includes('timeout')) {
message.error('请求错误,请检查网络');
}
await httpCodeDispose(error.response.status);
} else if (error.request) {
// 请求已经成功发起,但没有收到响应
// \`error.request\` 在浏览器中是 XMLHttpRequest 的实例,
// 而在node.js中是 http.ClientRequest 的实例
// message.error('无服务器相应,请重试');
} else {
// 发送请求时出了点问题
message.error('请求错误,请重试');
}
},
},
// 请求拦截器
requestInterceptors: [
(config: RequestOptions) => {
// 拦截请求配置,进行个性化处理。
const userInfo = JSON.parse(localStorage.getItem('UserInfo') ?? '{}');
const token = userInfo.token ?? '';
const headers = {
...config.headers,
'Content-Type': 'application/json',
Whiteverse: token,
// Authorization: {
// key: 'Whiteverse',
// value: `Bearer ${token}`
// },
};
return { ...config, headers };
},
],
/**
* 响应拦截器,主要处理服务器返回200,但是实际请求异常的问题
*/
responseInterceptors: [
(response: any) => response,
(error: any) => {
const code = error.data.code;
if (!code.toString().startsWith('2')) {
httpCodeDispose(code);
return Promise.reject(error);
}
return error;
},
],
};
Service层 TS 类型规范
目前团队采用 [name].d.ts 的方式定义公用类型
- src > - types >
service.d.ts
env.d.ts
module.d.ts
服务层命名 nameplace 要求全部大写
type SortOrder = 'descend' | 'ascend' | null;
/**
* 通用API
*/
declare namespace API {
type Response<T> = {
message: string;
code: number;
data: T;
};
type QuerySort<T = any> = Record<string | keyof T, SortOrder>;
}
declare namespace ***MON {
interface Select {
value: string;
label: string;
}
}
/**
* 分页相关
*/
declare namespace PAGINATE {
type Data<T> = { total: number; data: T };
type Query = { current?: number; pageSize?: number };
}
/**
* 用户服务相关
*/
declare namespace USER {
/**
* 用户
*/
interface User {
id: string;
/**
* 头像
*/
avatar: string;
/**
* 昵称
*/
nickname: string;
}
/**
* 用户基本信息
*/
type UserInfo = Omit<User, 'roleIds' | 'updatedAt'>;
type UsersQuery = PAGINATE.Query & {
sort?: API.QuerySort;
nickname?: string;
mobile?: string;
roleId?: string;
};
/**
* 创建用户
*/
type Create = Omit<User, 'id'>;
/**
* 登录信息
*/
interface Login {
Mobile: string;
VerificationCode: string;
}
/**
* 管理员登录参数
*/
interface ALoginParam {
Mobile: string;
VerificationCode: string;
}
/**
* 验证码
*/
interface Captcha {
base64: string;
id: string;
}
}
Service层 函数定义
- 为了与普通的函数做区别,方法名全部大写
- 使用 PREFIX_URL 请求前缀,方便后期维护
src -> services -> activity -> index.ts
export async function GetActivityList(
body: ACTIVITY.ActivitiesQuery,
options?: { [key: string]: any },
) {
return request<API.Response<PAGINATE.Data<ACTIVITY.Activity[]>>>(`${PREFIX_URL}/activity/list`, {
method: 'POST',
data: body,
...(options || {}),
});
}
@umi 请求代理 Proxy
在开发阶段,如果后端服务的端口经常发生变化,可以使用umi 请求代理 替换原有的请求前缀,转发请求。
/**
* @name 代理的配置
* @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置
* -------------------------------
* The agent cannot take effect in the production environment
* so there is no configuration of the production environment
* For details, please see
* https://pro.ant.design/docs/deploy
*
* @doc https://umijs.org/docs/guides/proxy
*/
export default {
// 如果需要自定义本地开发服务器 请取消注释按需调整
dev: {
'/api-mock/': {
// 要代理的地址
target: 'http://127.0.0.1:4523/m1/3280694-0-default',
// 配置了这个可以从 http 代理到 https
// 依赖 origin 的功能可能需要这个,比如 cookie
changeOrigin: true,
pathRewrite: { '^/api-mock': '' },
},
'/api-sys/': {
// 要代理的地址
target: 'http://192.168.50.131:8021',
// 配置了这个可以从 http 代理到 https
// 依赖 origin 的功能可能需要这个,比如 cookie
changeOrigin: true,
pathRewrite: { '^/api-sys': '' },
},
'/api-user/': {
// 要代理的地址
target: 'http://192.168.50.131:8020',
// 配置了这个可以从 http 代理到 https
// 依赖 origin 的功能可能需要这个,比如 cookie
changeOrigin: true,
pathRewrite: { '^/api-user': '' },
},
},
/**
* @name 详细的代理配置
* @doc https://github.***/chimurai/http-proxy-middleware
*/
test: {
// localhost:8000/api/** -> https://preview.pro.ant.design/api/**
'/api/': {
target: 'https://proapi.azurewebsites.***',
changeOrigin: true,
pathRewrite: { '^': '' },
},
},
pre: {
'/api/': {
target: 'your pre url',
changeOrigin: true,
pathRewrite: { '^': '' },
},
},
};
@umi/max 简易数据流
useModel 没有类型提示?
还原 tsconfig.json 为默认配置
{
"extends": "./src/.umi/tsconfig.json"
}
useModel 书写规范
定义Model仓库时,推荐使用匿名默认导出语法
export default () => {}
如果为页面绑定Model,注意页面的层级不要过深,页面组件的名称尽量短
- 文件名定义
- pages
- Activity
- ***ponents
- ActivityList.tsx
- models
- ActivityModels.ts
- 使用Model
const { getActivityData } = useModel('Activity.ActivityModels', (models) => ({
getActivityData: models.getActivityData,
}));
带有分页查询的 Model
带有loading,query,分页
可使用Ahooks 的 useRequest 或 自定封装 useRequest
注意Ahooks的 usePagination函数 对Service层的参数有要求
-
service
的第一个参数为{ current: number, pageSize: number }
-
service
返回的数据结构为{ total: number, list: Item[] }
- 具体看Ahooks文档,不推荐使用或二封分页Hook.
import { useEffect, useState } from 'react';
import { useSetState } from 'ahooks';
import to from 'await-to-js';
import { GetActivityList } from '@/services/activity';
export default () => {
const initialParam = { current: 1, pageSize: 20 };
const [query, queryChange] = useSetState<ACTIVITY.ActivitiesQuery>(initialParam);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>();
const [activityData, setActivityData] = useState<ACTIVITY.Activity[]>();
const [total, setTotal] = useState<number>(0);
const getActivityData = async (_param: ACTIVITY.ActivitiesQuery) => {
// 请求前
if (loading) await Promise.reject();
// 请求中
setLoading(true);
const [err, res] = await to(GetActivityList(_param));
setLoading(false);
// 请求结束
if (!err && res.code === 200) {
setActivityData(res.data.data);
setTotal(res.data.total);
return res.data;
} else {
setError(err);
return await Promise.reject();
}
};
useEffect(() => {
if (!activityData) getActivityData(query);
}, []);
return {
// 状态
loading,
setLoading,
error,
setError,
query,
queryChange,
total,
setTotal,
activityData,
setActivityData,
// 方法
getActivityData,
};
};
ProForm 复杂表单
当外部数据发生变化,ProForm不更新?
解决方案一:
// 监测外部值的变化,更新表单内的数据
useEffect(() => formRef.current && formRef.current.setFieldsValue(selectedNode), [selectedNode]);
解决方案二:
<ProForm<SysRole.Role>
request={async (params) => {
formRef.current?.resetFields();
const res = await GetRole({id: params.id});
return res.data
}}
>
// ...
</ProForm>
ProForm onFinish中请求错误,提交按钮一直Loading
onFinish 方法需要返回一个Promise.resolve(boolean),reject时,会一直loading
一个综合案例
const handleAddActivity = async (fields: ACTIVITY.Create) => {
const hide = message.loading('正在创建活动');
try {
const response = await CreateActivity({ ...fields });
hide();
message.su***ess('活动创建成功!');
return response;
} catch (error) {
hide();
message.error('添加失败,请重试!');
return Promise.reject(false);
}
};
<StepsForm.StepForm<ACTIVITY.Create>
title={"创建活动"}
stepProps={{
description: "请输入活动信息",
}}
onFinish={async (formData: ACTIVITY.Create & { ActivityTime?: string[] }) => {
try {
const requestBody = { ...formData };
requestBody.StartTime = formData.ActivityTime![0];
requestBody.EndTime = formData.ActivityTime![1]!;
delete requestBody["ActivityTime"];
const response = await handleAddActivity(requestBody);
const ActivityId = response.data;
uploadFormsRef.current?.setFieldValue("ActivityId", ActivityId);
return Promise.resolve(true);
} catch (e) {
return Promise.resolve(true);
}
}}
/>
更加优雅的办法是给onFinish 提交的数据添加一个convertValues
const convertValues = useMemo((values: FormColumn) => {
return { ...values };
}, []);
注意:
ProForm中的transform和convertValue属性,仅能操作本字段内容,这个特性在某种情况下会出现一些问题
例如:
<ProFormDateTimeRangePicker
name="ActivityTime"
label="投放时间"
width={'lg'}
rules={[{required: true, message: '请选择活动投放时间!'}]}
dataFormat={FORMAT_DATE_TIME_***}
/>
时间范围组件返回的数据格式是
ActivityTime: string[] // 如果不给dataFormat,就是 Dayjs[]
如果后端接口的数据格式是
{startTime: string, endTime: string}
这个时候如果使用convertValue无法解决业务问题,需要在onFinish或onSubmit中进行数据转化。
EditorTable 可编辑表格
提交按钮一直Loading?
如果onSave时网络请求错误或者发生异常,返回Promise.reject,onSave就不会生效。
if (!activityIdField) {
const errorContent = '请先创建活动';
message.error(errorContent);
return Promise.reject(errorContent);
}
return handleSaveRow(record);
columns 自定义表单、自定义渲染
const columns: ProColumns<DataSourceType>[] = [
{
title: '模型文件',
dataIndex: '_File',
width: 150,
render: (_, entity) => {
return (
<Button
type={'link'}
onClick={() => {
downloadFile(entity._File!.originFileObj!);
}}
>
{entity._File?.name}
</Button>
);
},
formItemProps: {
valuePropName: 'file',
trigger: 'fileChange',
rules: [{ required: true, message: '此项是必填项.' }],
},
renderFormItem: () => <ModelUploadButton />,
}
]
formItemProps 它本质就是<Form.Item>,基本照着Form.Item那边去配置就行。
form / formRef 的 setFieldValue / getFieldsValue 无效?
原因一:
由于EditorTable的 Form实际上是新增的一行,是动态的,formRef 更新不及时可能导致formRef.current 为 undefined。
原因二:
普通的form组件内部的数据模型形如这样:
{
"homePath": "/",
"status": true,
"sort": 1
}
但是editorForm在编辑时内部的数据模型是这样的:
{
"229121": {
"ModelLoadName": "11",
"ModelShowName": "222",
"ModelNo": "333",
"MobileOS": "android",
"_Position": [
{
"position": [
123.42932734052755,
41.79745486673118
]
}
],
}
}
它在外面包了一层,因此设置列的时候需要这么写
renderFormItem: (schema, config, form, action) => {
const fieldsValue = form.getFieldsValue()
const key = Object.keys(fieldsValue)[0];
const fields = fieldsValue[key];
const fieldName = schema.dataIndex! as keyof typeof fields // you want setting field
fields[fieldName] = 'you want setting value';
formRef?.current?.setFieldValue(key, fields);
return <***ponent />
},
Upload / ProUploader 文件上传
ImgCrop 实现图片裁切
实现功能:
- 文件格式限制
- 文件上传尺寸限制
- 文件缩放大小限制
工具函数
function getImageFileAsync(file: File): Promise<{
width: number;
height: number;
aspectRatio: number;
image: HTMLImageElement;
}> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const img = new Image();
reader.onload = () => {
img.src = reader.result as string;
};
img.onload = () => {
const width = img.width;
const height = img.height;
const aspectRatio = width / height;
resolve({
width,
height,
aspectRatio,
image: img,
});
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
reader.onerror = () => {
reject(new Error('文件读取错误'));
};
// 读取文件内容
reader.readAsDataURL(file);
});
}
组件
import { FC, ReactNode, useRef, useState } from 'react';
import { message, Modal, Upload, UploadFile, UploadProps } from 'antd';
import ImgCrop, { ImgCropProps } from 'antd-img-crop';
import { RcFile } from 'antd/es/upload';
import { getBase64, getImageFileAsync } from '@/utils/***mon';
const fileTypes = ['image/jpg', 'image/jpeg', 'image/png'];
interface PictureUploadProps {
// 上传最大数量
maxCount?: number;
// 文件更新
filesChange?: (files: UploadFile[]) => void;
// 图片最小大小,宽,高
minImageSize?: number[];
// 图片裁切组件配置
imgCropProps?: Omit<ImgCropProps, 'children'>;
// 上传提示内容文本
children?: ReactNode | ReactNode[];
}
const PictureUpload: FC<PictureUploadProps> = ({
maxCount,
filesChange,
minImageSize,
imgCropProps,
children,
}) => {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [maxZoom, setMaxZoom] = useState(2);
const isCropRef = useRef<boolean>(false);
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
setFileList(newFileList);
if (filesChange) filesChange(fileList);
};
const handleCancel = () => setPreviewOpen(false);
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj as RcFile);
}
setPreviewImage(file.url || (file.preview as string));
setPreviewOpen(true);
setPreviewTitle(file.name || file.url!.substring(file.url!.lastIndexOf('/') + 1));
};
return (
<>
<ImgCrop
quality={1}
zoomSlider={true}
minZoom={1}
maxZoom={maxZoom}
aspect={minImageSize && minImageSize[0] / minImageSize[1]}
beforeCrop={async (file) => {
isCropRef.current = false;
// 判断文件类型
const typeMatch = fileTypes.some((type) => type === file.type);
if (!typeMatch) {
await message.error(
'图片格式仅支持' +
fileTypes.reduce(
(prev, cur, index, array) => prev + cur + (index === array.length - 1 ? '' : ','),
'',
),
);
return false;
}
// 判断图片大小限制
if (minImageSize) {
const { width: imageWidth, height: imageHeight } = await getImageFileAsync(file);
if (imageWidth < minImageSize[0]) {
await message.error(
`当前图片宽度为${imageWidth}像素,请上传不小于${minImageSize[0]}像素的图片.`,
);
return false;
}
if (imageHeight < minImageSize[1]) {
await message.error(
`当前图片高度为${imageHeight}像素,请上传不小于${minImageSize[1]}像素的图片.`,
);
return false;
}
// 计算最大缩放比例
const widthMaxZoom = Number((imageWidth / minImageSize[0]).toFixed(1));
const heightMaxZoom = Number((imageHeight / minImageSize[1]).toFixed(1));
setMaxZoom(Math.min(widthMaxZoom, heightMaxZoom));
}
isCropRef.current = true;
return true;
}}
{...imgCropProps}
>
<Upload
action="/"
listType="picture-card"
fileList={fileList}
onPreview={handlePreview}
onChange={(files) => {
handleChange(files);
console.log(files);
}}
maxCount={maxCount}
a***ept={'.jpg, .jpeg, .png'}
beforeUpload={async (file) => {
if (!isCropRef.current) return Upload.LIST_IGNORE;
return file;
}}
>
{maxCount ? fileList.length < maxCount && children : children}
</Upload>
</ImgCrop>
<Modal open={previewOpen} title={previewTitle} footer={null} onCancel={handleCancel}>
<img alt="example" style={{ width: '100%' }} src={previewImage} />
</Modal>
</>
);
};
export default PictureUpload;
ImgCrop 组件注意事项
-
拦截裁切事件
- ImgCrop 组件 的
beforeCrop
返回 false 后不再弹出模态框,但是文件会继续走 Upload 的beforeUpload
流程,如果想要拦截上传事件,需要在beforeUpload 中返回Upload.LIST_IGNORE
。 - 判断是否拦截的状态变量需要用 useRef ,useState测试无效。
- ImgCrop 组件 的
-
Upload组件 配合 ImgCrop组件时,一定要在 beforeUpload 中返回 事件回调中的 file,否则裁切无效。
-
如果不想做像素压缩,设置quality={1}
StepsForm 分布表单
如何在 StepsForm 中 更新子表单?
通过StepsForm的 formMapRef 属性,它可以拿到子StepForm的全部ref。
const stepFormMapRef = useRef<Array<MutableRefObject<ProFormInstance>>>([]);
return <StepsForm formMapRef={stepFormMapRef} />
打印 ref.current
[
{
"current": {
// getFieldError: f(name)
}
},
{
"current": {
// getFieldError: f(name)
}
},
{
"current": {
// getFieldError: f(name)
}
}
]
如何手动控制 步骤 前进、后退?
灵活使用 current、onCurrentChange、submitter属性
const [currentStep, setCurrentStep] = useState<number>(0);
return (
<StepsForm
current={currentStep}
onCurrentChange={setCurrentStep}
submitter={{
render: (props) => {
switch (props.step) {
case 0: {
return (
<Button type="primary" onClick={() => props.onSubmit?.()}>
下一步
</Button>
);
}
case 1: {
return (
<Button type="primary" onClick={() => props.onSubmit?.()}>
下一步
</Button>
);
}
case 2: {
return (
<Button
type="primary"
onClick={() => {
setCurrentStep(0);
onCancel();
}}
>
完成
</Button>
);
}
}
},
}}
stepsProps={{ direction: 'horizontal', style: { padding: '0 50px' } }}
>
{ // StepForm }
</StepsForm>
)
微前端 Qiankun
文档:https://umijs.org/docs/max/micro-frontend
子应用配置(@umi)
一、使用umi创建React App
二、配置umi
这里有一些WASM的配置,不想要可以去掉
import { defineConfig } from 'umi';
export default defineConfig({
title: 'xxxxxx',
routes: [
{
path: '/',
***ponent: 'index',
},
{ path: '/scene-obj', ***ponent: 'OBJScene' },
{ path: '/*', redirect: '/' },
],
npmClient: 'pnpm',
proxy: {
'/api': {
target: 'http://jsonplaceholder.typicode.***/',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
plugins: [
'@umijs/plugins/dist/model',
'@umijs/plugins/dist/qiankun',
'@umijs/plugins/dist/request',
],
model: {},
qiankun: {
slave: {},
},
request: {
dataField: 'data',
},
mfsu: {
mfName: 'umiR3f', // 默认的会冲突,所以需要随便取个名字避免冲突
},
chainWebpack(config) {
config.set('experiments', {
...config.get('experiments'),
asyncWebAssembly: true,
});
const REG = /\.wasm$/;
config.module.rule('asset').exclude.add(REG).end();
config.module
.rule('wasm')
.test(REG)
.exclude.add(/node_modules/)
.end()
.type('webassembly/async')
.end();
},
});
三、跨域配置
import type { IApi } from 'umi';
export default (api: IApi) => {
// 中间件支持 cors
api.addMiddlewares(() => {
return function cors(req, res, next) {
res.setHeader('A***ess-Control-Allow-Origin', '*');
res.setHeader('A***ess-Control-Allow-Headers', '*');
next();
};
});
api.onBeforeMiddleware(({ app }) => {
app.request.headers['a***ess-control-allow-origin'] = '*';
app.request.headers['a***ess-control-allow-headers'] = '*';
app.request.headers['a***ess-control-allow-credentials'] = '*';
app.request.originalUrl = '*';
});
};
四、修改app.ts,子应用配置生命周期钩子.
export const qiankun = {
// 应用加载之前
async bootstrap(props: any) {
console.log('app1 bootstrap', props);
},
// 应用 render 之前触发
async mount(props: any) {
console.log('app1 mount', props);
},
// 应用卸载之后触发
async unmount(props: any) {
console.log('app1 unmount', props);
},
};
父应用配置(@umi/max)
config.ts
export default defineConfig({
qiankun: {
master: {
apps: [
{
name: 'r3f-viewer', // 子应用的名称
entry: 'http://localhost:5174', // your microApp address
},
],
},
},
})
使用路由的方式引入子应用
export default [
{
name: 'slave',
path: '/slave/*',
microApp: 'slave',
microAppProps: {
autoSetLoading: true,
autoCaptureError: true,
className: 'MicroApp',
wrapperClassName: 'MicroAppWrapper'
},
},
]
使用组件的方式引入子应用
index.tsx
import { PageContainer } from '@ant-design/pro-***ponents';
import { memo } from 'react';
import { MicroAppWithMemoHistory } from '@umijs/max';
import './index.less';
const Role = () => {
return (
<PageContainer>
<MicroAppWithMemoHistory
name="r3f-viewer"
url="/umi-r3f-view"
autoSetLoading={true}
className={'microApp'}
/>
</PageContainer>
);
};
export default memo(Role);
index.less
.microApp,
#root {
min-height: 800px !important;
height: 800px !important;
max-height: 800px !important;
width: 100% !important;
}