From 555d3b164e8abf64e00b1676d95a19cc21430639 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Tue, 18 Nov 2025 20:47:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8A=9F=E8=83=BD=E9=A1=B5=E9=9D=A2):=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=99=BB=E5=BD=95=E3=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=92=8C=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录模块: - 集成 Zustand 状态管理 - 使用宽松相等判断响应码 用户信息页面: - 个人信息展示和编辑 - 头像上传功能 - 密码修改功能 用户管理模块: - 用户列表展示(支持头像显示) - 新增/编辑/删除用户功能 - 密码重置功能 - 隐藏超级管理员操作按钮 - 支持批量操作 - 列设置组件 --- .../ColumnSetting/index.module.less | 28 + src/components/ColumnSetting/index.tsx | 98 +++ src/pages/login/form.tsx | 116 ++- src/pages/user-info/index.tsx | 765 +++++++++--------- src/pages/user-info/style/index.module.less | 212 +++-- .../user-management/ResetPasswordModal.tsx | 120 +++ src/pages/user-management/UserFormModal.tsx | 269 ++++++ src/pages/user-management/constants.tsx | 190 +++-- src/pages/user-management/form.tsx | 173 ++-- src/pages/user-management/index.tsx | 466 +++++++++-- src/pages/user-management/locale/index.ts | 27 + .../user-management/style/index.module.less | 247 +++++- .../user-management/style/modal.module.less | 13 + 13 files changed, 1971 insertions(+), 753 deletions(-) create mode 100644 src/components/ColumnSetting/index.module.less create mode 100644 src/components/ColumnSetting/index.tsx create mode 100644 src/pages/user-management/ResetPasswordModal.tsx create mode 100644 src/pages/user-management/UserFormModal.tsx create mode 100644 src/pages/user-management/style/modal.module.less diff --git a/src/components/ColumnSetting/index.module.less b/src/components/ColumnSetting/index.module.less new file mode 100644 index 0000000..3f7676a --- /dev/null +++ b/src/components/ColumnSetting/index.module.less @@ -0,0 +1,28 @@ +.setting-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.setting-list { + max-height: 320px; + overflow-y: auto; +} + +.setting-item { + display: flex; + align-items: center; + padding: 6px 0; + gap: 6px; +} + +.setting-drag { + color: #c9cdd4; + font-size: 16px; +} + +.setting-footer { + display: flex; + justify-content: flex-end; + gap: 12px; +} diff --git a/src/components/ColumnSetting/index.tsx b/src/components/ColumnSetting/index.tsx new file mode 100644 index 0000000..bc98c0c --- /dev/null +++ b/src/components/ColumnSetting/index.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { Drawer, Checkbox, Button, Divider } from '@arco-design/web-react'; +import { IconDragDotVertical } from '@arco-design/web-react/icon'; +import styles from './index.module.less'; + +export interface ColumnItemConfig { + key: string; + title: string; + fixed?: 'left' | 'right'; + disabled?: boolean; +} + +export interface ColumnSettingProps { + visible: boolean; + columns: ColumnItemConfig[]; + value: string[]; + onChange: (keys: string[]) => void; + onClose: () => void; +} + +const ColumnSetting: React.FC = ({ + visible, + columns, + value, + onChange, + onClose, +}) => { + const groups = useMemo(() => { + const selectMap = new Map(value.map((key) => [key, true])); + return columns.map((item) => ({ + ...item, + checked: !!selectMap.get(item.key), + })); + }, [columns, value]); + + const handleToggle = (key: string, checked: boolean) => { + if (checked) { + onChange([...value, key]); + } else { + onChange(value.filter((item) => item !== key)); + } + }; + + const isAllSelected = value.length === columns.length; + + const handleSelectAll = () => { + if (isAllSelected) { + onChange([]); + } else { + onChange(columns.map((item) => item.key)); + } + }; + + return ( + + + + + } + onCancel={onClose} + maskClosable + unmountOnExit + > +
+ + 全选 + + +
+ +
+ {groups.map((item) => ( +
+ + handleToggle(item.key, checked)} + > + {item.title} + +
+ ))} +
+
+ ); +}; + +export default ColumnSetting; diff --git a/src/pages/login/form.tsx b/src/pages/login/form.tsx index 27319c4..15b279b 100644 --- a/src/pages/login/form.tsx +++ b/src/pages/login/form.tsx @@ -1,61 +1,72 @@ -import { - Form, - Input, - Checkbox, - Link, - Button, - Space, - Message, -} from '@arco-design/web-react'; +import { Form, Input, Button, Message } from '@arco-design/web-react'; import { FormInstance } from '@arco-design/web-react/es/Form'; -import { IconLock, IconUser } from '@arco-design/web-react/icon'; -import React, { useEffect, useRef, useState } from 'react'; -import axios from 'axios'; -import useStorage from '@/utils/useStorage'; +import React, { useRef, useState } from 'react'; +import { login as loginAPI } from '@/api/auth'; +import { setToken } from '@/utils/storage'; +import { useUserStore } from '@/store/userStore'; +import { SUCCESS_CODE } from '@/constants'; import useLocale from '@/utils/useLocale'; import locale from './locale'; import styles from './style/index.module.less'; export default function LoginForm() { const formRef = useRef(); - const [errorMessage, setErrorMessage] = useState(''); const [loading, setLoading] = useState(false); - const [loginParams, setLoginParams, removeLoginParams] = - useStorage('loginParams'); - const t = useLocale(locale); - const [rememberPassword, setRememberPassword] = useState(!!loginParams); + // 使用 Zustand + const setUserInfo = useUserStore((state) => state.setUserInfo); - function afterLoginSuccess(params) { - // 记住密码 - if (rememberPassword) { - setLoginParams(JSON.stringify(params)); - } else { - removeLoginParams(); - } - // 记录登录状态 + function afterLoginSuccess(loginData) { + const { token, userId, username, nickname, role, avatar } = loginData; + + // 存储 Token + setToken(token); + + // 使用 Zustand 存储用户信息(自动持久化到 localStorage) + setUserInfo({ + userId, + username, + nickname, + role, + avatar, + name: nickname, + }); + + // 记录登录状态(兼容原有逻辑) localStorage.setItem('userStatus', 'login'); - // 跳转首页 - window.location.href = '/'; + + // 显示成功提示 + Message.success('登录成功'); + + // 延迟跳转,让用户看到提示 + setTimeout(() => { + window.location.href = '/'; + }, 500); } - function login(params) { - setErrorMessage(''); + async function login(params) { setLoading(true); - axios - .post('/api/user/login', params) - .then((res) => { - const { status, msg } = res.data; - if (status === 'ok') { - afterLoginSuccess(params); - } else { - setErrorMessage(msg || t['login.form.login.errMsg']); - } - }) - .finally(() => { - setLoading(false); + + try { + // 调用后端登录 API + const result = await loginAPI({ + account: params.userName, + password: params.password, }); + + // 判断业务响应码(使用宽松相等,兼容字符串和数字) + if (result.code == SUCCESS_CODE) { + // 登录成功 + afterLoginSuccess(result.data); + } + // 失败的情况由 Axios 拦截器自动显示 Message 提示 + } catch (error: any) { + // 网络错误或其他异常(Axios 拦截器已经显示了 Message 提示) + console.error('登录失败:', error); + } finally { + setLoading(false); + } } function onSubmitClick() { @@ -64,24 +75,10 @@ export default function LoginForm() { }); } - // 读取 localStorage,设置初始值 - useEffect(() => { - const rememberPassword = !!loginParams; - setRememberPassword(rememberPassword); - if (formRef.current && rememberPassword) { - const parseParams = JSON.parse(loginParams); - formRef.current.setFieldsValue(parseParams); - } - }, [loginParams]); - return (
账号登录
- {errorMessage && ( -
{errorMessage}
- )} -
-
- - {t['login.form.rememberPassword']} - - {t['login.form.forgetPassword']} -
- + + + +
+ + +
+ -
- -
更换头像
-
- - )} -
- - {userInfo.name} - - - {userInfo.position} - -
- {userInfo.department} -
- - - - {/* 右侧统计信息 */} - -
- - - - - - - - - - - - - - - - - - -
- - - - - {/* 详细信息卡片 */} - } - onClick={() => setIsEditing(true)} - > - 编辑资料 - - ) : ( - - - - - ) - } - > - - - - - } - disabled={!isEditing} - /> - - - - - } - disabled={!isEditing} - /> - - - - - } - disabled={!isEditing} - /> - - - - - } - disabled={!isEditing} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - } - disabled={!isEditing} - /> - - - - - - - - - - - - {/* 账号安全卡片 */} - - - 当前密码强度:强 - - - ), - }, - { - label: '密保手机', - value: ( - - {userInfo.phone} - - - ), - }, - { - label: '密保邮箱', - value: ( - - {userInfo.email} - - - ), - }, - ]} - /> - + } + /> + + + } + /> + + + } + /> + + + + + + + + + + + + + ); } diff --git a/src/pages/user-info/style/index.module.less b/src/pages/user-info/style/index.module.less index e8b8e59..5040172 100644 --- a/src/pages/user-info/style/index.module.less +++ b/src/pages/user-info/style/index.module.less @@ -1,81 +1,189 @@ .container { - padding: 20px; - background: var(--color-bg-2); + padding: 24px 0 32px; + background: transparent; min-height: calc(100vh - 60px); + box-sizing: border-box; } .profile-card { + background: #fff; + border-radius: 12px; + border: 1px solid var(--color-border-2); + box-shadow: none; + :global(.arco-card-body) { - padding: 32px; + padding: 24px; } } -.avatar-section { +.profile-header { display: flex; flex-direction: column; align-items: center; + text-align: center; + margin-bottom: 20px; } -.avatar-wrapper { +.avatar-shell { position: relative; - display: inline-block; + border-radius: 50%; + padding: 4px; + background: linear-gradient(135deg, #f0f2f5, #fff); + cursor: pointer; + transition: box-shadow 0.2s ease; - .avatar { - border: 4px solid var(--color-border-2); - box-shadow: 0 2px 12px rgba(0, 0, 0, 10%); - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - .avatar-upload { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 50%); - border-radius: 50%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #fff; - cursor: pointer; - opacity: 0; - transition: opacity 0.3s; - font-size: 12px; - - &:hover { - opacity: 1; - } + &:hover { + box-shadow: 0 6px 18px rgba(15, 18, 36, 14%); } } -.stats-section { - :global(.arco-statistic-title) { - color: var(--color-text-3); - font-size: 14px; - } +.avatar { + background: #f7f8fa; - :global(.arco-statistic-value) { - color: var(--color-text-1); - font-weight: 600; + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.avatar-tip { + position: absolute; + right: 10px; + bottom: 10px; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(0, 0, 0, 60%); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.avatar-shell:hover .avatar-tip { + opacity: 1; +} + +.profile-meta { + margin-top: 16px; + + :global(.arco-typography-title) { + margin-bottom: 4px; + } + + :global(.arco-typography) { + margin: 0; + } +} + +.status-pill { + margin-top: 8px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(0, 194, 111, 12%); + color: #00a36a; + font-size: 13px; + font-weight: 600; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #00c26f; +} + +.info-list { + border-top: 1px solid var(--color-border-2); + margin-top: 12px; + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.info-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 8px 0; + border-bottom: 1px solid var(--color-border-1); + + &:last-child { + border-bottom: none; + } +} + +.info-label { + display: flex; + align-items: center; + gap: 6px; + color: var(--color-text-3); + font-size: 13px; +} + +.info-icon { + color: rgb(var(--primary-6)); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.info-value { + font-weight: 600; + color: var(--color-text-1); + max-width: 60%; + text-align: right; + word-break: break-all; +} + +.form-panel { + background: #fff; + border-radius: 12px; + border: 1px solid var(--color-border-2); + box-shadow: none; + + :global(.arco-card-body) { + padding: 28px; + } + + :global(.arco-tabs-nav) { + margin-bottom: 16px; + } +} + +.panel-header { + margin-bottom: 12px; + + :global(.arco-typography) { + margin: 0; + } + + :global(.arco-typography + .arco-typography) { + margin-top: 4px; + } +} + +@media (max-width: 1024px) { + .container { + padding: 24px; } } -// 响应式布局 @media (max-width: 768px) { .container { - padding: 12px; + padding: 16px; } - .profile-card { - :global(.arco-card-body) { - padding: 16px; - } + .info-value { + max-width: 100%; + text-align: left; } } diff --git a/src/pages/user-management/ResetPasswordModal.tsx b/src/pages/user-management/ResetPasswordModal.tsx new file mode 100644 index 0000000..fce21b4 --- /dev/null +++ b/src/pages/user-management/ResetPasswordModal.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Modal, Form, Input, Message } from '@arco-design/web-react'; +import type { UserResp } from '@/types/user'; +import { resetUserPassword } from '@/api/user'; +import useLocale from '@/utils/useLocale'; +import locale from './locale'; + +interface ResetPasswordModalProps { + visible: boolean; + user?: (UserResp & { name?: string }) | null; + onCancel: () => void; + onSuccess?: () => void; +} + +const PASSWORD_PATTERN = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,20}$/; + +const ResetPasswordModal: React.FC = ({ + visible, + user, + onCancel, + onSuccess, +}) => { + const t = useLocale(locale); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!visible) { + form.resetFields(); + setSubmitting(false); + } + }, [visible, form]); + + const title = useMemo(() => { + const displayName = user?.name || user?.nickname || user?.username || ''; + if (displayName) { + return `${t['userManagement.resetPassword.title']} - ${displayName}`; + } + return t['userManagement.resetPassword.title']; + }, [t, user]); + + const handleOk = async () => { + if (!user?.id) { + return; + } + try { + const values = await form.validate(); + setSubmitting(true); + await resetUserPassword({ + userId: user.id, + newPassword: values.newPassword, + }); + Message.success(t['userManagement.resetPassword.success']); + if (onSuccess) { + onSuccess(); + } + onCancel(); + } catch (error: any) { + if (!error?.errorFields) { + console.error('重置密码失败:', error); + Message.error('重置密码失败,请稍后再试'); + } + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ + + + { + if (!value) { + callback('请再次输入新密码'); + return; + } + if (value !== form.getFieldValue('newPassword')) { + callback('两次输入的密码不一致'); + return; + } + callback(); + }, + }, + ]} + > + + +
+
+ ); +}; + +export default ResetPasswordModal; diff --git a/src/pages/user-management/UserFormModal.tsx b/src/pages/user-management/UserFormModal.tsx new file mode 100644 index 0000000..40e5d48 --- /dev/null +++ b/src/pages/user-management/UserFormModal.tsx @@ -0,0 +1,269 @@ +/** + * 用户表单弹窗组件 + * 支持新建和编辑用户 + */ +import React, { useEffect } from 'react'; +import { Modal, Form, Input, Select, Message } from '@arco-design/web-react'; +import { createUser, updateUser } from '@/api/user'; +import { SUCCESS_CODE } from '@/constants'; +import type { UserResp } from '@/types/user'; +import styles from './style/modal.module.less'; + +const { useForm } = Form; + +interface UserFormModalProps { + /** 是否显示弹窗 */ + visible: boolean; + /** 关闭弹窗回调 */ + onCancel: () => void; + /** 提交成功回调 */ + onSuccess: () => void; + /** 编辑模式:传入用户数据,新建模式:undefined */ + editData?: UserResp; +} + +export default function UserFormModal({ + visible, + onCancel, + onSuccess, + editData, +}: UserFormModalProps) { + const [form] = useForm(); + const isEdit = !!editData; // 是否为编辑模式 + + // 角色选项 + const roleOptions = [ + { label: '管理员', value: 'ADMIN' }, + { label: '普通用户', value: 'USER' }, + ]; + + // 状态选项 + const statusOptions = [ + { label: '禁用', value: 0 }, + { label: '启用', value: 1 }, + ]; + + // 编辑模式:回填表单数据 + useEffect(() => { + if (visible && editData) { + form.setFieldsValue({ + username: editData.username, + role: editData.role, + email: editData.email, + phone: editData.phone, + nickname: editData.nickname, + status: editData.status, + }); + } + }, [visible, editData, form]); + + useEffect(() => { + if (!visible) { + return; + } + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleCancel(); + } + }; + window.addEventListener('keydown', handleEsc); + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, [visible]); + + // 提交表单 + const handleSubmit = async () => { + try { + // 表单验证 + const values = await form.validate(); + + // 如果是编辑模式且没有填写密码,则删除密码字段 + if (isEdit && !values.password) { + delete values.password; + delete values.confirmPassword; + } + + if (isEdit) { + // 编辑用户 + const result = await updateUser(editData.id, { + username: values.username, + role: values.role, + password: values.password, + email: values.email, + phone: values.phone, + nickname: values.nickname, + status: values.status, + }); + + if (result.code == SUCCESS_CODE) { + Message.success('用户更新成功'); + onSuccess(); + handleCancel(); + } + } else { + // 新建用户 + const result = await createUser({ + username: values.username, + role: values.role, + password: values.password, + email: values.email, + phone: values.phone, + nickname: values.nickname, + status: values.status ?? 1, // 默认启用 + }); + + if (result.code == SUCCESS_CODE) { + Message.success('用户创建成功'); + onSuccess(); + handleCancel(); + } + } + } catch (error: any) { + console.error('提交失败:', error); + // 表单验证失败或 API 调用失败(API 错误已经在拦截器中提示) + } + }; + + // 取消/关闭 + const handleCancel = () => { + form.resetFields(); + onCancel(); + }; + + return ( + +
+ + + + + + + + + + + + + + + + + + - - - - - - - - + + + - + + + + + + + + - + - - - - - - - -