update: 升级前端主要依赖并替换头像裁剪组件

- 升级主要前端依赖
    - React / React DOM
    - Umi Max / Umi lint
    - Ant Design Icons / ProComponents
    - WangEditor
    - Axios
    - ECharts
    - TanStack Query
    - oxlint / oxfmt
    - 相关 TypeScript 类型包
- 替换头像裁剪实现 react-easy-crop
- 适配 Ant Design 6 废弃 API
This commit is contained in:
疯狂的狮子Li 2026-06-08 18:43:08 +08:00
parent d5e1b42ad1
commit eafce61dac
8 changed files with 1529 additions and 1452 deletions

View File

@ -17,30 +17,29 @@
"start": "cross-env UMI_ENV=development MOCK=none BABEL_POLYFILL=none max dev" "start": "cross-env UMI_ENV=development MOCK=none BABEL_POLYFILL=none max dev"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.3", "@ant-design/icons": "^6.2.5",
"@ant-design/pro-components": "^3.1.12-0", "@ant-design/pro-components": "3.1.12-0",
"@ant-design/x": "^2.7.0", "@ant-design/x": "^2.7.0",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.100.6", "@tanstack/react-query": "^5.101.0",
"@umijs/max": "^4.6.51", "@umijs/max": "^4.6.61",
"@wangeditor-next/editor": "^5.6.4", "@wangeditor-next/editor": "^5.7.10",
"@wangeditor-next/editor-for-react": "^1.0.10", "@wangeditor-next/editor-for-react": "^2.0.2",
"ahooks": "^3.9.7", "ahooks": "^3.9.7",
"antd": "^6.4.3", "antd": "^6.4.3",
"antd-style": "^4.1.0", "antd-style": "^4.1.0",
"axios": "1.16.1", "axios": "1.17.0",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cropperjs": "^1.6.2",
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.21",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.6",
"jsencrypt": "3.5.4", "jsencrypt": "3.5.4",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"react": "^19.2.5", "react": "^19.2.7",
"react-cropper": "^2.3.3", "react-dom": "^19.2.7",
"react-dom": "^19.2.5", "react-easy-crop": "^5.5.7",
"react-json-view-lite": "^2.5.0", "react-json-view-lite": "^2.5.0",
"react-syntax-highlighter": "^16.1.1", "react-syntax-highlighter": "^16.1.1",
"screenfull": "^6.0.2", "screenfull": "^6.0.2",
@ -48,16 +47,16 @@
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "4.2.2", "@types/crypto-js": "4.2.2",
"@types/node": "^25.9.1", "@types/node": "^25.9.2",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@umijs/lint": "^4.6.51", "@umijs/lint": "^4.6.61",
"@umijs/max-plugin-openapi": "^2.0.3", "@umijs/max-plugin-openapi": "^2.0.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"oxfmt": "^0.51.0", "oxfmt": "^0.53.0",
"oxlint": "^1.66.0", "oxlint": "^1.68.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"engines": { "engines": {

2804
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import 'antd/dist/reset.css'; import 'antd/dist/reset.css';
import '@wangeditor-next/editor/dist/css/style.css'; import '@wangeditor-next/editor/dist/css/style.css';
import 'react-json-view-lite/dist/index.css'; import 'react-json-view-lite/dist/index.css';
import 'cropperjs/dist/cropper.css'; import 'react-easy-crop/react-easy-crop.css';
import './assets/styles/index.less'; import './assets/styles/index.less';

View File

@ -109,7 +109,7 @@ export default function MonitorCachePage() {
return ( return (
<PageContainer title="缓存监控"> <PageContainer title="缓存监控">
<Spin spinning={loading}> <Spin spinning={loading}>
<Space direction="vertical" size={12} style={{ width: '100%' }}> <Space orientation="vertical" size={12} style={{ width: '100%' }}>
<ProCard <ProCard
title="缓存概览" title="缓存概览"
extra={ extra={

View File

@ -88,7 +88,7 @@ export default function UserImportModal({ open, onClose, onSuccess }: UserImport
<p className="ant-upload-text"></p> <p className="ant-upload-text"></p>
<p className="ant-upload-hint"> xlsxlsx </p> <p className="ant-upload-hint"> xlsxlsx </p>
</Upload.Dragger> </Upload.Dragger>
<Space direction="vertical" style={{ marginTop: 12 }}> <Space orientation="vertical" style={{ marginTop: 12 }}>
<Checkbox checked={updateSupport} onChange={event => setUpdateSupport(event.target.checked)}> <Checkbox checked={updateSupport} onChange={event => setUpdateSupport(event.target.checked)}>
</Checkbox> </Checkbox>

View File

@ -13,13 +13,14 @@ import {
Popconfirm, Popconfirm,
Radio, Radio,
Row, Row,
Slider,
Space, Space,
Tabs, Tabs,
Upload, Upload,
type UploadProps type UploadProps
} from 'antd'; } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import Cropper, { type ReactCropperElement } from 'react-cropper'; import Cropper, { type Area, type Point } from 'react-easy-crop';
import type { OnlineVO } from '@/api/monitor/online/types'; import type { OnlineVO } from '@/api/monitor/online/types';
import type { SocialAuthVO } from '@/api/system/social/types'; import type { SocialAuthVO } from '@/api/system/social/types';
import type { UserInfoVO, UserProfileForm } from '@/api/system/user/types'; import type { UserInfoVO, UserProfileForm } from '@/api/system/user/types';
@ -48,6 +49,8 @@ const socialProviders = [
{ source: 'github', label: 'GitHub', icon: githubIcon } { source: 'github', label: 'GitHub', icon: githubIcon }
]; ];
const avatarFileTypes = ['png', 'jpg', 'jpeg']; const avatarFileTypes = ['png', 'jpg', 'jpeg'];
const avatarOutputSize = 200;
const initialAvatarCrop: Point = { x: 0, y: 0 };
function socialProvider(source?: string) { function socialProvider(source?: string) {
return socialProviders.find(item => item.source === source); return socialProviders.find(item => item.source === source);
@ -72,6 +75,57 @@ function socialProviderNode(source?: string) {
); );
} }
function createImage(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new window.Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', error => reject(error));
image.src = url;
});
}
function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180;
}
function rotateSize(width: number, height: number, rotation: number) {
const rotRad = getRadianAngle(rotation);
return {
width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height)
};
}
async function getCroppedAvatarBlob(imageSrc: string, crop: Area, rotation: number) {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rotRad = getRadianAngle(rotation);
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(image.naturalWidth, image.naturalHeight, rotation);
canvas.width = bBoxWidth;
canvas.height = bBoxHeight;
ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
ctx.rotate(rotRad);
ctx.translate(-image.naturalWidth / 2, -image.naturalHeight / 2);
ctx.drawImage(image, 0, 0);
const croppedCanvas = document.createElement('canvas');
const croppedCtx = croppedCanvas.getContext('2d');
if (!croppedCtx) return null;
croppedCanvas.width = avatarOutputSize;
croppedCanvas.height = avatarOutputSize;
croppedCtx.drawImage(canvas, crop.x, crop.y, crop.width, crop.height, 0, 0, avatarOutputSize, avatarOutputSize);
return new Promise<Blob | null>(resolve => croppedCanvas.toBlob(resolve, 'image/png'));
}
function avatarUploadFileName(fileName: string) {
return fileName.replace(/\.[^.]+$/, '') || 'avatar';
}
function normalizeOnlineDevices(payload: OnlineVO[] | { rows?: OnlineVO[] } | undefined) { function normalizeOnlineDevices(payload: OnlineVO[] | { rows?: OnlineVO[] } | undefined) {
if (Array.isArray(payload)) return payload; if (Array.isArray(payload)) return payload;
@ -91,10 +145,17 @@ export default function Profile() {
const [avatarCropOpen, setAvatarCropOpen] = useState(false); const [avatarCropOpen, setAvatarCropOpen] = useState(false);
const [avatarCropUrl, setAvatarCropUrl] = useState(''); const [avatarCropUrl, setAvatarCropUrl] = useState('');
const [avatarFileName, setAvatarFileName] = useState('avatar.png'); const [avatarFileName, setAvatarFileName] = useState('avatar.png');
const cropperRef = useRef<ReactCropperElement>(null); const [avatarCrop, setAvatarCrop] = useState<Point>(initialAvatarCrop);
const [avatarZoom, setAvatarZoom] = useState(1);
const [avatarRotation, setAvatarRotation] = useState(0);
const [avatarCroppedAreaPixels, setAvatarCroppedAreaPixels] = useState<Area>();
const user = profile?.user || {}; const user = profile?.user || {};
const genderOptions = useMemo(() => dictOptions(dicts.sys_user_gender), [dicts.sys_user_gender]); const genderOptions = useMemo(() => dictOptions(dicts.sys_user_gender), [dicts.sys_user_gender]);
const onAvatarCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setAvatarCroppedAreaPixels(croppedAreaPixels);
}, []);
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
await withLoading(async () => { await withLoading(async () => {
const [profileRes, authRes, onlineRes] = await Promise.all([getUserProfile(), getAuthList(), getOnline()]); const [profileRes, authRes, onlineRes] = await Promise.all([getUserProfile(), getAuthList(), getOnline()]);
@ -143,6 +204,10 @@ export default function Profile() {
window.URL.revokeObjectURL(avatarCropUrl); window.URL.revokeObjectURL(avatarCropUrl);
} }
setAvatarFileName(file.name || 'avatar.png'); setAvatarFileName(file.name || 'avatar.png');
setAvatarCrop(initialAvatarCrop);
setAvatarZoom(1);
setAvatarRotation(0);
setAvatarCroppedAreaPixels(undefined);
setAvatarCropUrl(window.URL.createObjectURL(file)); setAvatarCropUrl(window.URL.createObjectURL(file));
setAvatarCropOpen(true); setAvatarCropOpen(true);
return Upload.LIST_IGNORE; return Upload.LIST_IGNORE;
@ -155,22 +220,24 @@ export default function Profile() {
} }
setAvatarCropOpen(false); setAvatarCropOpen(false);
setAvatarCropUrl(''); setAvatarCropUrl('');
setAvatarCrop(initialAvatarCrop);
setAvatarZoom(1);
setAvatarRotation(0);
setAvatarCroppedAreaPixels(undefined);
}; };
const submitAvatarCrop = async () => { const submitAvatarCrop = async () => {
const cropper = cropperRef.current?.cropper; if (!avatarCropUrl || !avatarCroppedAreaPixels) {
const canvas = cropper?.getCroppedCanvas({ width: 200, height: 200, imageSmoothingQuality: 'high' });
if (!canvas) {
message.warning('请先选择头像图片'); message.warning('请先选择头像图片');
return; return;
} }
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png')); const blob = await getCroppedAvatarBlob(avatarCropUrl, avatarCroppedAreaPixels, avatarRotation);
if (!blob) { if (!blob) {
message.error('头像裁剪失败'); message.error('头像裁剪失败');
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', blob, avatarFileName || 'avatar.png'); formData.append('file', blob, `${avatarUploadFileName(avatarFileName)}.png`);
await withAvatarUploading(async () => { await withAvatarUploading(async () => {
try { try {
const res = await uploadOss(formData); const res = await uploadOss(formData);
@ -260,7 +327,7 @@ export default function Profile() {
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} lg={7}> <Col xs={24} lg={7}>
<Card title="个人信息"> <Card title="个人信息">
<Space direction="vertical" size={18} style={{ width: '100%' }}> <Space orientation="vertical" size={18} style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Avatar size={112} src={user.avatar || defaultAvatar} /> <Avatar size={112} src={user.avatar || defaultAvatar} />
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
@ -385,7 +452,7 @@ export default function Profile() {
key: 'thirdParty', key: 'thirdParty',
label: '第三方应用', label: '第三方应用',
children: ( children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space orientation="vertical" size={16} style={{ width: '100%' }}>
<ProTable<SocialAuthVO> <ProTable<SocialAuthVO>
rowKey="id" rowKey="id"
columns={authColumns} columns={authColumns}
@ -440,24 +507,31 @@ export default function Profile() {
> >
{avatarCropUrl && ( {avatarCropUrl && (
<div className="avatar-cropper-react"> <div className="avatar-cropper-react">
<Cropper <div style={{ position: 'relative', height: 360, width: '100%', background: '#111', borderRadius: 6 }}>
ref={cropperRef} <Cropper
src={avatarCropUrl} image={avatarCropUrl}
viewMode={1} crop={avatarCrop}
aspectRatio={1} zoom={avatarZoom}
guides rotation={avatarRotation}
background={false} aspect={1}
autoCropArea={0.8} cropShape="round"
dragMode="move" showGrid={false}
style={{ height: 360, width: '100%' }} onCropChange={setAvatarCrop}
/> onZoomChange={setAvatarZoom}
<Space style={{ marginTop: 12 }}> onRotationChange={setAvatarRotation}
<Button icon={<RotateLeftOutlined />} onClick={() => cropperRef.current?.cropper.rotate(-90)}> onCropComplete={onAvatarCropComplete}
/>
</Button> </div>
<Button icon={<RotateRightOutlined />} onClick={() => cropperRef.current?.cropper.rotate(90)}> <Space orientation="vertical" size={12} style={{ marginTop: 12, width: '100%' }}>
<Space>
</Button> <Button icon={<RotateLeftOutlined />} onClick={() => setAvatarRotation(value => value - 90)}>
</Button>
<Button icon={<RotateRightOutlined />} onClick={() => setAvatarRotation(value => value + 90)}>
</Button>
</Space>
<Slider min={1} max={3} step={0.1} value={avatarZoom} onChange={setAvatarZoom} />
</Space> </Space>
</div> </div>
)} )}

View File

@ -68,7 +68,7 @@ export default function ImportTableModal({
return ( return (
<Modal title="导入表" open={open} width={900} onOk={submit} onCancel={closeModal} destroyOnHidden> <Modal title="导入表" open={open} width={900} onOk={submit} onCancel={closeModal} destroyOnHidden>
<Space direction="vertical" size={12} style={{ width: '100%' }}> <Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Select <Select
showSearch showSearch
value={currentDataName} value={currentDataName}

View File

@ -58,7 +58,7 @@ export default function DefinitionImportModal({
return ( return (
<Modal title="部署流程文件" open={open} footer={null} onCancel={onCancel} destroyOnHidden> <Modal title="部署流程文件" open={open} footer={null} onCancel={onCancel} destroyOnHidden>
<Space direction="vertical" size={12} style={{ width: '100%' }}> <Space orientation="vertical" size={12} style={{ width: '100%' }}>
<TreeSelect <TreeSelect
value={uploadCategory} value={uploadCategory}
onChange={setUploadCategory} onChange={setUploadCategory}