feat(admin): 用户菜单新增修改密码弹窗

This commit is contained in:
2026-08-12 09:33:57 +08:00
parent a4210a5373
commit d9cbaf0eda
4 changed files with 109 additions and 15 deletions
@@ -0,0 +1,83 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, Modal } from 'antd';
import api from '../api';
import { useApiMutation } from '../hooks/useApiMutation';
import { message } from '../ui/app-message';
import { PASSWORD_RULES } from '../utils/password-rules';
interface ChangePasswordModalProps {
open: boolean;
onClose: () => void;
}
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ open, onClose }) => {
const [form] = Form.useForm();
const mutation = useApiMutation((values: { newPassword: string }) =>
api.post('/auth/change-password', values),
);
// 关闭时重置表单,避免下次打开残留旧值
useEffect(() => {
if (!open) {
form.resetFields();
}
}, [open, form]);
// 同步守卫防止快速双击:confirmLoading 要等 validateFields 返回后才生效
const submittingRef = useRef(false);
const handleOk = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const values = await form.validateFields();
await mutation.mutateAsync({
newPassword: values.newPassword,
});
message.success('密码已修改');
form.resetFields();
onClose();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
submittingRef.current = false;
}
};
return (
<Modal
title="修改密码"
open={open}
onOk={handleOk}
onCancel={onClose}
confirmLoading={mutation.isPending}
okText="确认修改"
cancelText="取消"
forceRender
>
<Form form={form} layout="vertical">
<Form.Item name="newPassword" label="新密码" rules={PASSWORD_RULES}>
<Input.Password autoComplete="new-password" placeholder="至少8位,不含空格" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator: (_: unknown, value: string) =>
!value || getFieldValue('newPassword') === value
? Promise.resolve()
: Promise.reject(new Error('两次输入的密码不一致')),
}),
]}
>
<Input.Password autoComplete="new-password" placeholder="请再次输入新密码" />
</Form.Item>
</Form>
</Modal>
);
};
export default ChangePasswordModal;
+10 -1
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
import { BrandLogo } from '../components/BrandLogo';
@@ -41,6 +41,7 @@ import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock';
import RouteKeeper from '../components/RouteKeeper';
import BackTop from '../components/BackTop';
import ChangePasswordModal from '../components/ChangePasswordModal';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
@@ -93,6 +94,7 @@ const MainLayout: React.FC = () => {
const setDrawerOpen = useAppStore((state) => state.setMobileDrawerOpen);
const setAiChatOpen = useAppStore((state) => state.setAiChatOpen);
const setAiWorking = useAppStore((state) => state.setAiWorking);
const [pwdOpen, setPwdOpen] = useState(false);
const setOpenKeys = useAppStore((state) => state.setMenuOpenKeys);
const { permissions, hasPermission } = usePermission();
@@ -385,6 +387,12 @@ const MainLayout: React.FC = () => {
<Dropdown
menu={{
items: [
{
key: 'change-password',
icon: <KeyOutlined />,
label: '修改密码',
onClick: () => setPwdOpen(true),
},
{
key: 'logout',
icon: <LogoutOutlined />,
@@ -433,6 +441,7 @@ const MainLayout: React.FC = () => {
/>
</React.Suspense>
)}
<ChangePasswordModal open={pwdOpen} onClose={() => setPwdOpen(false)} />
<BackTop />
</Layout>
);
+1 -14
View File
@@ -20,7 +20,7 @@ import { useDirtyGuard } from '../../hooks/useDirtyGuard';
import { validateResponse } from '../../utils/validate';
import { rolesSchema, usersSchema } from '../../api/schemas';
import { getErrorMessage } from '../../utils/error';
import type { Rule } from 'antd/es/form';
import { PASSWORD_RULES } from '../../utils/password-rules';
const USER_FIELDS = {
username: 'username',
@@ -30,19 +30,6 @@ const USER_FIELDS = {
status: 'status',
} as const;
// 与后端 CreateUserDto/ResetPasswordDto 保持一致:bcrypt 截断于 72 字节(UTF-8),
// 多字节字符(如中文 3 字节)下按字符数上限会漏检,这里按字节数校验。
const PASSWORD_RULES: Rule[] = [
{ required: true, min: 8, message: '密码至少8位' },
{
validator: (_: unknown, value: string) =>
!value || new TextEncoder().encode(value).length <= 72
? Promise.resolve()
: Promise.reject(new Error('密码不能超过72字节(bcrypt 上限)')),
},
{ pattern: /^\S+$/, message: '密码不能包含空格' },
];
const UsersPage: React.FC = () => {
const { modal } = App.useApp();
const { hasPermission } = usePermission();
+15
View File
@@ -0,0 +1,15 @@
import type { Rule } from 'antd/es/form';
// 与后端 CreateUserDto/ResetPasswordDto/ChangePasswordDto 保持一致:
// bcrypt 截断于 72 字节(UTF-8),多字节字符(如中文 3 字节)下按字符数上限会漏检,
// 这里按字节数校验。
export const PASSWORD_RULES: Rule[] = [
{ required: true, min: 8, message: '密码至少8位' },
{
validator: (_: unknown, value: string) =>
!value || new TextEncoder().encode(value).length <= 72
? Promise.resolve()
: Promise.reject(new Error('密码不能超过72字节(bcrypt 上限)')),
},
{ pattern: /^\S+$/, message: '密码不能包含空格' },
];