update 升级全部依赖至最新版本并适配

- react/react-dom 升级 19.3.0 应用了 useEffectEvent 新特性(TypeScript 保持 6.x)
  - 使用 @noble/ciphers 替换已废弃的 crypto-js 保持与后端 AES 加解密兼容
  - @wangeditor-next 5->6 升级(editor/editor-for-react 6.4.1)
  - antd/@ant-design/icons/@ant-design/x/ahooks/zustand/axios 等升级至最新
  - 适配 oxlint 1.82 新规则(refs/set-state-in-effect/immutability/exhaustive-effect-dependencies)
  - 适配 axios 1.20 类型变化 使用 useEffectEvent 重构弹窗初始化逻辑
This commit is contained in:
疯狂的狮子Li 2026-09-10 12:05:28 +08:00
parent 29fc02f0a6
commit b56ebf5ae6
22 changed files with 2126 additions and 1685 deletions

View File

@ -19,46 +19,45 @@
"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.3.2", "@ant-design/icons": "^6.3.4",
"@ant-design/pro-components": "3.1.12-0", "@ant-design/pro-components": "3.1.12-0",
"@ant-design/x": "^2.8.0", "@ant-design/x": "^2.9.0",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.101.4", "@noble/ciphers": "^2.4.0",
"@umijs/max": "^4.6.82", "@tanstack/react-query": "^5.102.8",
"@wangeditor-next/editor": "^5.7.16", "@umijs/max": "^4.7.16",
"@wangeditor-next/editor-for-react": "^2.0.3", "@wangeditor-next/editor": "^6.4.1",
"ahooks": "^3.9.7", "@wangeditor-next/editor-for-react": "^6.4.1",
"antd": "^6.5.2", "ahooks": "^3.10.0",
"antd": "^6.6.3",
"antd-style": "^4.1.0", "antd-style": "^4.1.0",
"axios": "1.18.1", "axios": "1.20.0",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "4.2.0", "dayjs": "^1.11.23",
"dayjs": "^1.11.21",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"echarts-for-react": "^3.0.6", "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.8", "react": "^19.3.0",
"react-dom": "^19.2.8", "react-dom": "^19.3.0",
"react-easy-crop": "^6.2.3", "react-easy-crop": "^6.2.3",
"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",
"zustand": "^5.0.14" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "4.2.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/react": "^19.2.17", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.3.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@umijs/lint": "^4.6.82", "@umijs/lint": "^4.7.16",
"@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.60.0", "oxfmt": "^0.67.0",
"oxlint": "^1.75.0", "oxlint": "^1.82.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"engines": { "engines": {

3426
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -240,5 +240,6 @@ service.interceptors.response.use(
); );
export default function request<T = unknown, D = unknown>(config: RequestConfig<D>): Promise<T> { export default function request<T = unknown, D = unknown>(config: RequestConfig<D>): Promise<T> {
return service.request<T, T, D>(config); // axios 1.20 泛型返回为条件类型 AxiosResponseResult 需要断言 响应拦截器已返回 response.data
return service.request<T, T, D>(config) as Promise<T>;
} }

View File

@ -1,7 +1,7 @@
import type { DataNode, EventDataNode } from 'antd/es/tree'; import type { DataNode, EventDataNode } from 'antd/es/tree';
import { LeftOutlined, SearchOutlined } from '@ant-design/icons'; import { LeftOutlined, SearchOutlined } from '@ant-design/icons';
import { Button, Card, Input, Tree } from 'antd'; import { Button, Card, Input, Tree } from 'antd';
import { useEffect, useMemo, useState, type Key } from 'react'; import { useMemo, useState, type Key } from 'react';
import { collectTreeKeys } from '@/utils/ruoyi'; import { collectTreeKeys } from '@/utils/ruoyi';
export interface TreePanelProps<T extends object = Record<string, unknown>> { export interface TreePanelProps<T extends object = Record<string, unknown>> {
@ -94,16 +94,12 @@ export default function TreePanel<T extends object = Record<string, unknown>>({
); );
const allTreeKeys = useMemo(() => collectTreeKeys(treeData, node => node.key), [treeData]); const allTreeKeys = useMemo(() => collectTreeKeys(treeData, node => node.key), [treeData]);
const mergedCollapsed = collapsed ?? innerCollapsed; const mergedCollapsed = collapsed ?? innerCollapsed;
// 数据变化时默认展开全部 通过渲染期对比 prev 派生 而非 effect 中 setState
useEffect(() => { const [prevAllTreeKeys, setPrevAllTreeKeys] = useState(allTreeKeys);
if (collapsed !== undefined) { if (allTreeKeys !== prevAllTreeKeys) {
setInnerCollapsed(collapsed); setPrevAllTreeKeys(allTreeKeys);
}
}, [collapsed]);
useEffect(() => {
setExpandedKeys(allTreeKeys); setExpandedKeys(allTreeKeys);
}, [allTreeKeys]); }
const toggleCollapsed = () => { const toggleCollapsed = () => {
const nextCollapsed = !mergedCollapsed; const nextCollapsed = !mergedCollapsed;

View File

@ -3,7 +3,7 @@ import type { DataNode } from 'antd/es/tree';
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from '@ant-design/icons';
import { useBoolean, useRequest } from 'ahooks'; import { useBoolean, useRequest } from 'ahooks';
import { Button, Form, Input, Modal, Space, Table, Tag, Tree } from 'antd'; import { Button, Form, Input, Modal, Space, Table, Tag, Tree } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useEffectEvent, useMemo, useState } from 'react';
import type { DeptTreeVO } from '@/api/system/dept/types'; import type { DeptTreeVO } from '@/api/system/dept/types';
import type { UserQuery, UserVO } from '@/api/system/user/types'; import type { UserQuery, UserVO } from '@/api/system/user/types';
import { deptTreeSelect, listUser, optionSelect } from '@/api/system/user'; import { deptTreeSelect, listUser, optionSelect } from '@/api/system/user';
@ -98,23 +98,38 @@ export default function UserSelect({
setSelectedUsers(res.data || []); setSelectedUsers(res.data || []);
}; };
// biome-ignore lint/correctness/useExhaustiveDependencies: modal initialization is intentionally tied to open state. // useEffectEvent 读取最新的 value/data/query 等非响应式逻辑 只在 open 变化时执行
useEffect(() => { const initModal = useEffectEvent(() => {
if (!open) {
form.resetFields();
setQuery({ pageNum: 1, pageSize: 10 });
setUsers([]);
setTotal(0);
setSelectedUsers([]);
return;
}
Promise.all([ Promise.all([
deptTreeSelect().then(res => setDeptOptions(res.data || [])), deptTreeSelect().then(res => setDeptOptions(res.data || [])),
loadUsers({ pageNum: 1, pageSize: 10 }), loadUsers({ pageNum: 1, pageSize: 10 }),
initSelectedUsers() initSelectedUsers()
]); ]);
}, [open]); });
// 关闭时重置弹窗状态
const resetModal = useEffectEvent(() => {
form.resetFields();
setQuery({ pageNum: 1, pageSize: 10 });
setUsers([]);
setTotal(0);
setSelectedUsers([]);
});
useEffect(() => {
if (!open) {
// useEffectEvent 在 effect 中调用是 React 官方推荐用法
// oxlint-disable-next-line react/set-state-in-effect
resetModal();
return;
}
// useEffectEvent 在 effect 中调用是 React 官方推荐用法
// oxlint-disable-next-line react/set-state-in-effect
initModal();
// initModal/resetModal 为 useEffectEvent 引用稳定 无需作为依赖
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [initModal, open, resetModal]);
const handleQuery = async () => { const handleQuery = async () => {
const values = form.getFieldsValue(); const values = form.getFieldsValue();

View File

@ -1,6 +1,6 @@
import { CheckOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'; import { CheckOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, Divider, Drawer, Input, message, Segmented, Slider, Space, Switch } from 'antd'; import { Button, Divider, Drawer, Input, message, Segmented, Slider, Space, Switch } from 'antd';
import { useEffect, useRef } from 'react'; import { useState } from 'react';
import { defaultLayoutSettings, useAppStore, type LayoutSettingsValue, type NavLayout } from '@/stores/appStore'; import { defaultLayoutSettings, useAppStore, type LayoutSettingsValue, type NavLayout } from '@/stores/appStore';
interface LayoutSettingsProps { interface LayoutSettingsProps {
@ -21,22 +21,17 @@ function SettingRow({ label, children }: { label: string; children: React.ReactN
export default function LayoutSettings({ open, value, onChange, onOpenChange }: LayoutSettingsProps) { export default function LayoutSettings({ open, value, onChange, onOpenChange }: LayoutSettingsProps) {
const resetLayoutSettings = useAppStore(state => state.resetLayoutSettings); const resetLayoutSettings = useAppStore(state => state.resetLayoutSettings);
const preferredSideThemeRef = useRef(value.sideTheme); // 记住非暗色模式下选中的主题 仅在事件回调中更新 避免渲染期读取 ref
const [preferredSideTheme, setPreferredSideTheme] = useState(value.sideTheme);
useEffect(() => {
if (!value.darkMode) {
preferredSideThemeRef.current = value.sideTheme;
}
}, [value.darkMode, value.sideTheme]);
const patchValue = (patch: Partial<LayoutSettingsValue>) => { const patchValue = (patch: Partial<LayoutSettingsValue>) => {
if (patch.sideTheme) { if (patch.sideTheme) {
preferredSideThemeRef.current = patch.sideTheme; setPreferredSideTheme(patch.sideTheme);
} }
const next = { ...value, ...patch }; const next = { ...value, ...patch };
if (patch.darkMode === false) { if (patch.darkMode === false) {
next.sideTheme = preferredSideThemeRef.current; next.sideTheme = preferredSideTheme;
} }
if (patch.tagsView === false) { if (patch.tagsView === false) {
next.tagsViewPersist = false; next.tagsViewPersist = false;
@ -46,10 +41,11 @@ export default function LayoutSettings({ open, value, onChange, onOpenChange }:
const resetSettings = () => { const resetSettings = () => {
resetLayoutSettings(); resetLayoutSettings();
setPreferredSideTheme(defaultLayoutSettings.sideTheme);
onChange(defaultLayoutSettings); onChange(defaultLayoutSettings);
message.success('配置已重置'); message.success('配置已重置');
}; };
const selectedSideTheme = value.darkMode ? preferredSideThemeRef.current : value.sideTheme; const selectedSideTheme = value.darkMode ? preferredSideTheme : value.sideTheme;
return ( return (
<Drawer <Drawer

View File

@ -2,7 +2,7 @@ import { DownloadOutlined } from '@ant-design/icons';
import { ProTable, type ProColumns } from '@ant-design/pro-components'; import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { Button, Modal, Popover, Spin, Table, Tabs } from 'antd'; import { Button, Modal, Popover, Spin, Table, Tabs } from 'antd';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { OssVO } from '@/api/system/oss/types'; import type { OssVO } from '@/api/system/oss/types';
import type { FlowHistoryVO } from '@/api/workflow/task/types'; import type { FlowHistoryVO } from '@/api/workflow/task/types';
import { downloadOss, listByIds } from '@/api/system/oss'; import { downloadOss, listByIds } from '@/api/system/oss';
@ -52,19 +52,21 @@ export default function ApprovalRecord({ businessId, open, onOpenChange, width =
} }
); );
useEffect(() => { // 打开时清空旧数据并加载 打开弹窗同步外部状态属于合理场景
if (!open || !businessId) { const resetAndLoad = useCallback(async (nextBusinessId?: string | number) => {
if (!open) {
setRecordList([]);
setInstanceId(undefined);
}
return;
}
setRecordList([]); setRecordList([]);
setInstanceId(undefined); setInstanceId(undefined);
loadRecords(businessId); if (nextBusinessId) {
}, [businessId, loadRecords, open]); await loadRecords(nextBusinessId);
}
}, [loadRecords]);
useEffect(() => {
if (!open) return;
// 打开弹窗时同步清空旧数据属于与外部状态对齐的合理场景
// oxlint-disable-next-line react/set-state-in-effect
void resetAndLoad(businessId);
}, [businessId, open, resetAndLoad]);
const downloadAttachment = async (row: OssVO) => { const downloadAttachment = async (row: OssVO) => {
const blob = await downloadOss(row.ossId); const blob = await downloadOss(row.ossId);

View File

@ -10,19 +10,21 @@ const maxScale = 3;
export default function FlowChartImage({ imgUrl }: FlowChartImageProps) { export default function FlowChartImage({ imgUrl }: FlowChartImageProps) {
const wrapperRef = useRef<HTMLButtonElement>(null); const wrapperRef = useRef<HTMLButtonElement>(null);
const dragRef = useRef({ dragging: false, x: 0, y: 0 }); const dragRef = useRef({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [translate, setTranslate] = useState({ x: 0, y: 0 }); const [translate, setTranslate] = useState({ x: 0, y: 0 });
const transformStyle = useMemo( const transformStyle = useMemo(
() => ({ () => ({
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`, transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
transition: dragRef.current.dragging ? 'none' : 'transform 0.2s ease' transition: dragging ? 'none' : 'transform 0.2s ease'
}), }),
[scale, translate] [dragging, scale, translate]
); );
const resetTransform = () => { const resetTransform = () => {
setDragging(false);
setScale(1); setScale(1);
setTranslate({ x: 0, y: 0 }); setTranslate({ x: 0, y: 0 });
}; };
@ -37,19 +39,20 @@ export default function FlowChartImage({ imgUrl }: FlowChartImageProps) {
const handleMouseDown = (event: MouseEvent<HTMLButtonElement>) => { const handleMouseDown = (event: MouseEvent<HTMLButtonElement>) => {
if (scale <= 1) return; if (scale <= 1) return;
event.preventDefault(); event.preventDefault();
dragRef.current = { dragging: true, x: event.clientX, y: event.clientY }; dragRef.current = { x: event.clientX, y: event.clientY };
setDragging(true);
}; };
const handleMouseMove = (event: MouseEvent<HTMLButtonElement>) => { const handleMouseMove = (event: MouseEvent<HTMLButtonElement>) => {
if (!dragRef.current.dragging) return; if (!dragging) return;
const deltaX = event.clientX - dragRef.current.x; const deltaX = event.clientX - dragRef.current.x;
const deltaY = event.clientY - dragRef.current.y; const deltaY = event.clientY - dragRef.current.y;
dragRef.current = { dragging: true, x: event.clientX, y: event.clientY }; dragRef.current = { x: event.clientX, y: event.clientY };
setTranslate(prev => ({ x: prev.x + deltaX, y: prev.y + deltaY })); setTranslate(prev => ({ x: prev.x + deltaX, y: prev.y + deltaY }));
}; };
const stopDrag = () => { const stopDrag = () => {
dragRef.current.dragging = false; setDragging(false);
}; };
if (!imgUrl) { if (!imgUrl) {

View File

@ -9,7 +9,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useBoolean } from 'ahooks'; import { useBoolean } from 'ahooks';
import { Button, Form, Input, message, Modal, Select, Space, Table, Tag } from 'antd'; import { Button, Form, Input, message, Modal, Select, Space, Table, Tag } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useEffectEvent, useState } from 'react';
import type { UserVO } from '@/api/system/user/types'; import type { UserVO } from '@/api/system/user/types';
import type { FlowNextNodeVO, FlowTaskVO } from '@/api/workflow/task/types'; import type { FlowNextNodeVO, FlowTaskVO } from '@/api/workflow/task/types';
import { import {
@ -72,29 +72,31 @@ export default function SubmitVerify({ open, taskId, variables = emptyVariables,
const [signatureOpen, { setTrue: openSignatureModal, setFalse: closeSignatureModal }] = useBoolean(false); const [signatureOpen, { setTrue: openSignatureModal, setFalse: closeSignatureModal }] = useBoolean(false);
const [signatureUsers, setSignatureUsers] = useState<SignatureUser[]>([]); const [signatureUsers, setSignatureUsers] = useState<SignatureUser[]>([]);
// useEffectEvent 让 variables 始终读到最新值 避免其引用变化触发重复加载
const loadTask = useEffectEvent(async (taskId: string | number) => {
setApprovalLoading(true);
approveForm.setFieldsValue({ message: '', messageType: ['1'], assigneeMap: {}, fileId: undefined });
setCopyUsers([]);
setAssigneeNames({});
setCurrentTask(undefined);
setNextNodes([]);
try {
const taskRes = await getTask(taskId);
setCurrentTask(taskRes.data);
setCopyUsers((taskRes.data.copyList || []).map(user => ({ userId: user.userId, nickName: user.nickName })));
const nextRes = await getNextNodeList({ taskId, variables });
setNextNodes(nextRes.data || []);
} finally {
setApprovalLoading(false);
}
});
useEffect(() => { useEffect(() => {
if (!open || !taskId) return; if (!open || !taskId) return;
// useEffectEvent 在 effect 中调用是 React 官方推荐用法
const loadTask = async () => { // oxlint-disable-next-line react/set-state-in-effect
setApprovalLoading(true); loadTask(taskId);
approveForm.setFieldsValue({ message: '', messageType: ['1'], assigneeMap: {}, fileId: undefined }); }, [open, taskId]);
setCopyUsers([]);
setAssigneeNames({});
setCurrentTask(undefined);
setNextNodes([]);
try {
const taskRes = await getTask(taskId);
setCurrentTask(taskRes.data);
setCopyUsers((taskRes.data.copyList || []).map(user => ({ userId: user.userId, nickName: user.nickName })));
const nextRes = await getNextNodeList({ taskId, variables });
setNextNodes(nextRes.data || []);
} finally {
setApprovalLoading(false);
}
};
loadTask();
}, [approveForm, open, taskId, variables]);
const closeApprovalModal = () => { const closeApprovalModal = () => {
onOpenChange(false); onOpenChange(false);

View File

@ -34,8 +34,8 @@ export function useTableScroll(options: TableScrollX | TableScrollOptions) {
const refreshTableScroll = useCallback(() => { const refreshTableScroll = useCallback(() => {
if (!fullHeightTable || disabled || typeof window === 'undefined') { if (!fullHeightTable || disabled || typeof window === 'undefined') {
// tableY 仅在启用时参与 memo 计算 这里无需同步重置
clearTableBodyHeight(); clearTableBodyHeight();
setTableY(undefined);
return; return;
} }

View File

@ -14,11 +14,10 @@ function buildChatUrl(openId: string, trustedCredential: string) {
export default function AiChatPage() { export default function AiChatPage() {
const [chatUrl, setChatUrl] = useState(''); const [chatUrl, setChatUrl] = useState('');
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [loading, setLoading] = useState(false); // 初始即为加载中 effect 首次触发时无需同步 setState
const [loading, setLoading] = useState(true);
const loadChat = useCallback(async () => { const loadChat = useCallback(async () => {
setLoading(true);
setLoadError('');
try { try {
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
@ -44,6 +43,8 @@ export default function AiChatPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// 挂载时加载 AI 聊天地址属于与外部系统同步的合理场景
// oxlint-disable-next-line react/set-state-in-effect
loadChat(); loadChat();
}, [loadChat]); }, [loadChat]);
@ -60,7 +61,15 @@ export default function AiChatPage() {
) : ( ) : (
<Empty className="ai-chat-empty" description={loadError || '正在加载 Snail AI'}> <Empty className="ai-chat-empty" description={loadError || '正在加载 Snail AI'}>
{loadError ? ( {loadError ? (
<Button type="primary" icon={<ReloadOutlined />} onClick={loadChat}> <Button
type="primary"
icon={<ReloadOutlined />}
onClick={() => {
setLoading(true);
setLoadError('');
loadChat();
}}
>
</Button> </Button>
) : null} ) : null}

View File

@ -124,6 +124,8 @@ export default function Login() {
useEffect(() => { useEffect(() => {
if (getToken()) return; if (getToken()) return;
// 挂载时加载验证码属于与外部系统同步的合理场景
// oxlint-disable-next-line react/set-state-in-effect
loadCaptcha().catch(() => { loadCaptcha().catch(() => {
setCaptcha({ captchaEnabled: false }); setCaptcha({ captchaEnabled: false });
}); });
@ -135,7 +137,7 @@ export default function Login() {
const doSocialLogin = async (source: string) => { const doSocialLogin = async (source: string) => {
const res = await authRouterUrl(source); const res = await authRouterUrl(source);
window.location.href = res.data; window.location.assign(res.data);
}; };
return ( return (

View File

@ -83,6 +83,8 @@ export default function Register() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// 挂载时加载验证码属于与外部系统同步的合理场景
// oxlint-disable-next-line react/set-state-in-effect
loadCaptcha().catch(() => setCaptcha({ captchaEnabled: false })); loadCaptcha().catch(() => setCaptcha({ captchaEnabled: false }));
}, [loadCaptcha]); }, [loadCaptcha]);

View File

@ -123,6 +123,8 @@ export default function SystemNoticePage() {
useEffect(() => { useEffect(() => {
const noticeId = new URLSearchParams(location.search).get('noticeId'); const noticeId = new URLSearchParams(location.search).get('noticeId');
if (noticeId) { if (noticeId) {
// URL 深链同步打开公告详情属于与外部状态对齐的合理场景
// oxlint-disable-next-line react/set-state-in-effect
openDetail(noticeId); openDetail(noticeId);
} }
}, [location.search, openDetail]); }, [location.search, openDetail]);

View File

@ -1,7 +1,7 @@
import type { DataNode } from 'antd/es/tree'; import type { DataNode } from 'antd/es/tree';
import { useBoolean } from 'ahooks'; import { useBoolean } from 'ahooks';
import { Checkbox, message, type FormInstance } from 'antd'; import { Checkbox, message, type FormInstance } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useEffectEvent, useMemo, useState } from 'react';
import type { DeptTreeVO } from '@/api/system/dept/types'; import type { DeptTreeVO } from '@/api/system/dept/types';
import type { MenuTreeOption } from '@/api/system/menu/types'; import type { MenuTreeOption } from '@/api/system/menu/types';
import type { RoleForm } from '@/api/system/role/types'; import type { RoleForm } from '@/api/system/role/types';
@ -104,39 +104,37 @@ export function useRolePermission({ open, roleId, form, onSuccess }: UseRolePerm
buttonAllKeys.every(key => hasId(buttonCheckedKeys, key)); buttonAllKeys.every(key => hasId(buttonCheckedKeys, key));
const deptAllChecked = deptAllKeys.length > 0 && deptAllKeys.every(key => deptCheckedKeys.includes(key)); const deptAllChecked = deptAllKeys.length > 0 && deptAllKeys.every(key => deptCheckedKeys.includes(key));
// useEffectEvent 隔离 form 实例等非响应式逻辑 依赖项只保留 open/roleId
const loadPermission = useEffectEvent(async (roleId: string | number) => {
setLoading(true);
setActiveTab('menu');
try {
const [roleRes, menuRes, deptRes] = await Promise.all([getRole(roleId), roleMenuTreeselect(roleId), deptTreeSelect(roleId)]);
form.setFieldsValue({
...defaultRoleForm,
...roleRes.data
});
setMenuConnect(roleRes.data.menuCheckStrictly ?? true);
setDeptConnect(roleRes.data.deptCheckStrictly ?? true);
const nextMenuTree = menuRes.data.menus || [];
const nextMenuMeta = buildMenuPermissionMeta(nextMenuTree);
const checkedKeys = menuRes.data.checkedKeys || [];
setMenuTree(nextMenuTree);
setDeptTree(deptRes.data.depts || []);
setMenuCheckedKeys(checkedKeys.filter(key => !hasId(nextMenuMeta.buttonIds, key)));
setButtonCheckedKeys(checkedKeys.filter(key => hasId(nextMenuMeta.buttonIds, key)));
setDeptCheckedKeys(deptRes.data.checkedKeys || []);
} finally {
setLoading(false);
}
});
useEffect(() => { useEffect(() => {
if (!open || !roleId) return; if (!open || !roleId) return;
// useEffectEvent 在 effect 中调用是 React 官方推荐用法
const loadPermission = async () => { // oxlint-disable-next-line react/set-state-in-effect
setLoading(true); loadPermission(roleId);
setActiveTab('menu'); }, [open, roleId]);
try {
const [roleRes, menuRes, deptRes] = await Promise.all([
getRole(roleId),
roleMenuTreeselect(roleId),
deptTreeSelect(roleId)
]);
form.setFieldsValue({
...defaultRoleForm,
...roleRes.data
});
setMenuConnect(roleRes.data.menuCheckStrictly ?? true);
setDeptConnect(roleRes.data.deptCheckStrictly ?? true);
const nextMenuTree = menuRes.data.menus || [];
const nextMenuMeta = buildMenuPermissionMeta(nextMenuTree);
const checkedKeys = menuRes.data.checkedKeys || [];
setMenuTree(nextMenuTree);
setDeptTree(deptRes.data.depts || []);
setMenuCheckedKeys(checkedKeys.filter(key => !hasId(nextMenuMeta.buttonIds, key)));
setButtonCheckedKeys(checkedKeys.filter(key => hasId(nextMenuMeta.buttonIds, key)));
setDeptCheckedKeys(deptRes.data.checkedKeys || []);
} finally {
setLoading(false);
}
};
loadPermission();
}, [form, open, roleId]);
const resetPermission = () => { const resetPermission = () => {
form.resetFields(); form.resetFields();

View File

@ -27,14 +27,20 @@ export default function UserAuthRolePage() {
const [roleIds, setRoleIds] = useState<Array<string | number>>([]); const [roleIds, setRoleIds] = useState<Array<string | number>>([]);
useEffect(() => { useEffect(() => {
setLoading(true); const loadAuthRole = async () => {
getAuthRole(userId) setLoading(true);
.then(res => { try {
const res = await getAuthRole(userId);
setUser(res.data.user); setUser(res.data.user);
setRoles(res.data.roles || []); setRoles(res.data.roles || []);
setRoleIds((res.data.roles || []).filter(role => role.flag).map(role => role.roleId)); setRoleIds((res.data.roles || []).filter(role => role.flag).map(role => role.roleId));
}) } finally {
.finally(() => setLoading(false)); setLoading(false);
}
};
loadAuthRole();
// userId 来自路由参数 变化时需重新加载
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [userId]); }, [userId]);
const columns: ProColumns<RoleVO>[] = useMemo( const columns: ProColumns<RoleVO>[] = useMemo(

View File

@ -275,7 +275,7 @@ export default function Profile() {
const bindAuth = async (source: string) => { const bindAuth = async (source: string) => {
const res = await authRouterUrl(source); const res = await authRouterUrl(source);
window.location.href = res.data; window.location.assign(res.data);
}; };
const deviceColumns: ProColumns<OnlineVO>[] = [ const deviceColumns: ProColumns<OnlineVO>[] = [

View File

@ -90,6 +90,8 @@ export default function ToolGenPage() {
}; };
useEffect(() => { useEffect(() => {
// 挂载时加载数据表名属于与外部系统同步的合理场景
// oxlint-disable-next-line react/set-state-in-effect
loadDataNames(); loadDataNames();
}, [loadDataNames]); }, [loadDataNames]);

View File

@ -1,6 +1,6 @@
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import { message, Modal, Space, TreeSelect, Upload, type UploadProps } from 'antd'; import { message, Modal, Space, TreeSelect, Upload, type UploadProps } from 'antd';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import type { CategoryTreeVO } from '@/api/workflow/category/types'; import type { CategoryTreeVO } from '@/api/workflow/category/types';
import { importDefinition } from '@/api/workflow/definition'; import { importDefinition } from '@/api/workflow/definition';
import { buildAccept, getUploadErrorMessage, validateUploadFile } from '@/utils/upload'; import { buildAccept, getUploadErrorMessage, validateUploadFile } from '@/utils/upload';
@ -22,13 +22,8 @@ export default function DefinitionImportModal({
onCancel, onCancel,
onSuccess onSuccess
}: DefinitionImportModalProps) { }: DefinitionImportModalProps) {
const [uploadCategory, setUploadCategory] = useState<string | number>(); // 父组件通过 key 重挂载打开弹窗 这里的初值即为每次打开时的 initialCategory 无需 effect 同步
const [uploadCategory, setUploadCategory] = useState<string | number | undefined>(initialCategory);
useEffect(() => {
if (open) {
setUploadCategory(initialCategory);
}
}, [initialCategory, open]);
const handleImportDefinition: UploadProps['customRequest'] = async options => { const handleImportDefinition: UploadProps['customRequest'] = async options => {
const file = options.file as File; const file = options.file as File;

View File

@ -120,6 +120,8 @@ export default function WorkflowProcessDefinitionPage() {
useEffect(() => { useEffect(() => {
const nextTab = getExplicitActiveTabFromSearch(location.search); const nextTab = getExplicitActiveTabFromSearch(location.search);
if (!nextTab) return; if (!nextTab) return;
// URL 查询参数同步页签属于与外部状态对齐的合理场景
// oxlint-disable-next-line react/set-state-in-effect
switchTab(nextTab); switchTab(nextTab);
}, [location.search, switchTab]); }, [location.search, switchTab]);
@ -414,6 +416,7 @@ export default function WorkflowProcessDefinitionPage() {
/> />
<DefinitionImportModal <DefinitionImportModal
key={uploadOpen ? 'open' : 'closed'}
open={uploadOpen} open={uploadOpen}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
initialCategory={category} initialCategory={category}

View File

@ -61,7 +61,6 @@ export default function WorkflowSpelPage() {
const { ids, selectedOne, handleSelectionChange, clearSelection } = useTableSelection<SpelVO>(row => row.id); const { ids, selectedOne, handleSelectionChange, clearSelection } = useTableSelection<SpelVO>(row => row.id);
const [modalOpen, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false); const [modalOpen, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false);
const [modalTitle, setModalTitle] = useState(''); const [modalTitle, setModalTitle] = useState('');
const [preview, setPreview] = useState('');
const canAdd = hasPermi(userInfo, ['workflow:spel:add']); const canAdd = hasPermi(userInfo, ['workflow:spel:add']);
const canEdit = hasPermi(userInfo, ['workflow:spel:edit']); const canEdit = hasPermi(userInfo, ['workflow:spel:edit']);
@ -69,17 +68,17 @@ export default function WorkflowSpelPage() {
const statusOptions = useMemo(() => dictOptions(dicts.sys_normal_disable), [dicts.sys_normal_disable]); const statusOptions = useMemo(() => dictOptions(dicts.sys_normal_disable), [dicts.sys_normal_disable]);
const watchedValues = Form.useWatch([], form); const watchedValues = Form.useWatch([], form);
// preview 由表单值派生 无需独立 state
const preview = useMemo(() => buildViewSpel(watchedValues || {}), [watchedValues]);
useEffect(() => { useEffect(() => {
const nextPreview = buildViewSpel(watchedValues || {}); form.setFieldValue('viewSpel', preview);
setPreview(nextPreview); }, [form, preview]);
form.setFieldValue('viewSpel', nextPreview);
}, [form, watchedValues]);
const openAdd = () => { const openAdd = () => {
form.resetFields(); form.resetFields();
form.setFieldsValue(defaultSpelForm); form.setFieldsValue(defaultSpelForm);
setModalTitle('添加流程spel表达式定义'); setModalTitle('添加流程spel表达式定义');
setPreview('');
openModal(); openModal();
}; };
@ -90,7 +89,6 @@ export default function WorkflowSpelPage() {
form.resetFields(); form.resetFields();
form.setFieldsValue(res.data); form.setFieldsValue(res.data);
setModalTitle('修改流程spel表达式定义'); setModalTitle('修改流程spel表达式定义');
setPreview(res.data.viewSpel || '');
openModal(); openModal();
}; };

View File

@ -1,6 +1,5 @@
import * as CryptoJSModule from 'crypto-js'; import { utf8ToBytes } from '@noble/ciphers/utils.js';
import { ecb } from '@noble/ciphers/aes.js';
const CryptoJS = ('default' in CryptoJSModule ? CryptoJSModule.default : CryptoJSModule) as typeof CryptoJSModule;
function generateRandomString() { function generateRandomString() {
const array = new Uint8Array(32); const array = new Uint8Array(32);
@ -10,29 +9,36 @@ function generateRandomString() {
.slice(0, 32); .slice(0, 32);
} }
export function generateAesKey(): CryptoJSModule.lib.WordArray { function bytesToBase64(bytes: Uint8Array) {
return CryptoJS.enc.Utf8.parse(generateRandomString()); return btoa(String.fromCharCode(...bytes));
} }
export function encryptBase64(str: CryptoJSModule.lib.WordArray) { function base64ToBytes(base64: string) {
return CryptoJS.enc.Base64.stringify(str); return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
}
/**
* AES-256 (32 ) SecureUtil.aes(key)
*/
export function generateAesKey(): Uint8Array {
return utf8ToBytes(generateRandomString());
}
export function encryptBase64(bytes: Uint8Array) {
return bytesToBase64(bytes);
} }
export function decryptBase64(str: string) { export function decryptBase64(str: string) {
return CryptoJS.enc.Base64.parse(str); return base64ToBytes(str);
} }
export function encryptWithAes(message: string, aesKey: CryptoJSModule.lib.WordArray) { /**
return CryptoJS.AES.encrypt(message, aesKey, { * AES-256-ECB + PKCS7 Base64 AES/ECB/PKCS5Padding
mode: CryptoJS.mode.ECB, */
padding: CryptoJS.pad.Pkcs7 export function encryptWithAes(message: string, aesKey: Uint8Array) {
}).toString(); return bytesToBase64(ecb(aesKey).encrypt(utf8ToBytes(message)));
} }
export function decryptWithAes(message: string, aesKey: CryptoJSModule.lib.WordArray) { export function decryptWithAes(message: string, aesKey: Uint8Array) {
const decrypted = CryptoJS.AES.decrypt(message, aesKey, { return new TextDecoder().decode(ecb(aesKey).decrypt(base64ToBytes(message)));
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return decrypted.toString(CryptoJS.enc.Utf8);
} }