feat(功能页面): 完成登录、用户信息和用户管理模块
登录模块: - 集成 Zustand 状态管理 - 使用宽松相等判断响应码 用户信息页面: - 个人信息展示和编辑 - 头像上传功能 - 密码修改功能 用户管理模块: - 用户列表展示(支持头像显示) - 新增/编辑/删除用户功能 - 密码重置功能 - 隐藏超级管理员操作按钮 - 支持批量操作 - 列设置组件
This commit is contained in:
parent
f20951fb92
commit
555d3b164e
28
src/components/ColumnSetting/index.module.less
Normal file
28
src/components/ColumnSetting/index.module.less
Normal file
@ -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;
|
||||
}
|
||||
98
src/components/ColumnSetting/index.tsx
Normal file
98
src/components/ColumnSetting/index.tsx
Normal file
@ -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<ColumnSettingProps> = ({
|
||||
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 (
|
||||
<Drawer
|
||||
title="列显示设置"
|
||||
visible={visible}
|
||||
width={280}
|
||||
footer={
|
||||
<div className={styles['setting-footer']}>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onCancel={onClose}
|
||||
maskClosable
|
||||
unmountOnExit
|
||||
>
|
||||
<div className={styles['setting-header']}>
|
||||
<Checkbox checked={isAllSelected} onChange={handleSelectAll}>
|
||||
全选
|
||||
</Checkbox>
|
||||
<Button size="mini" type="text" onClick={() => onChange([])}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<div className={styles['setting-list']}>
|
||||
{groups.map((item) => (
|
||||
<div key={item.key} className={styles['setting-item']}>
|
||||
<IconDragDotVertical className={styles['setting-drag']} />
|
||||
<Checkbox
|
||||
disabled={item.disabled}
|
||||
checked={item.checked}
|
||||
onChange={(checked) => handleToggle(item.key, checked)}
|
||||
>
|
||||
{item.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSetting;
|
||||
@ -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<FormInstance>();
|
||||
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 (
|
||||
<div className={styles['login-form-wrapper']}>
|
||||
<div className={styles['login-form-title']}>账号登录</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className={styles['login-form-error-msg']}>{errorMessage}</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
className={styles['login-form']}
|
||||
layout="vertical"
|
||||
@ -110,13 +107,6 @@ export default function LoginForm() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles['login-form-actions']}>
|
||||
<Checkbox checked={rememberPassword} onChange={setRememberPassword}>
|
||||
{t['login.form.rememberPassword']}
|
||||
</Checkbox>
|
||||
<Link>{t['login.form.forgetPassword']}</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
long
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
@ -8,406 +8,423 @@ import {
|
||||
Upload,
|
||||
Space,
|
||||
Typography,
|
||||
Divider,
|
||||
Grid,
|
||||
Message,
|
||||
Select,
|
||||
DatePicker,
|
||||
Radio,
|
||||
Descriptions,
|
||||
Tag,
|
||||
Statistic,
|
||||
Tabs,
|
||||
} from '@arco-design/web-react';
|
||||
import {
|
||||
IconUser,
|
||||
IconEmail,
|
||||
IconPhone,
|
||||
IconLocation,
|
||||
IconEdit,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconClose,
|
||||
IconLock,
|
||||
IconIdcard,
|
||||
IconUserGroup,
|
||||
} from '@arco-design/web-react/icon';
|
||||
import { getUserInfo } from '@/api/auth';
|
||||
import { updateProfile, uploadAvatar } from '@/api/user';
|
||||
import { useUserStore } from '@/store/userStore';
|
||||
import styles from './style/index.module.less';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Row, Col } = Grid;
|
||||
const FormItem = Form.Item;
|
||||
const Option = Select.Option;
|
||||
const RadioGroup = Radio.Group;
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
function pad(num: number) {
|
||||
return `${num}`.padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | number | Date) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||
date.getDate()
|
||||
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
|
||||
date.getSeconds()
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人信息页面
|
||||
*/
|
||||
function UserInfo() {
|
||||
const [form] = Form.useForm();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [profileForm] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
|
||||
// 模拟用户数据
|
||||
const [userInfo, setUserInfo] = useState({
|
||||
avatar:
|
||||
'https://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
|
||||
name: '张三',
|
||||
username: 'zhangsan',
|
||||
email: 'zhangsan@example.com',
|
||||
phone: '138****8888',
|
||||
gender: 'male',
|
||||
birthday: '1990-01-01',
|
||||
department: '技术部',
|
||||
position: '高级工程师',
|
||||
location: '北京市朝阳区',
|
||||
introduction:
|
||||
'热爱技术,专注于前端开发领域,有丰富的 React 和 TypeScript 开发经验。',
|
||||
joinDate: '2020-01-15',
|
||||
employeeId: 'EMP001',
|
||||
});
|
||||
// 使用 Zustand
|
||||
const { userInfo, updateUserInfo } = useUserStore();
|
||||
const [localUserInfo, setLocalUserInfo] = useState<any>(userInfo);
|
||||
const [pageLoading, setPageLoading] = useState(!userInfo?.userId);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
|
||||
// 统计数据
|
||||
const statistics = {
|
||||
projectCount: 28,
|
||||
taskCompleted: 156,
|
||||
contribution: 892,
|
||||
teamSize: 12,
|
||||
};
|
||||
useEffect(() => {
|
||||
if (userInfo) {
|
||||
profileForm.setFieldsValue({
|
||||
nickname: userInfo.nickname,
|
||||
email: userInfo.email,
|
||||
phone: userInfo.phone,
|
||||
});
|
||||
}
|
||||
}, [profileForm, userInfo]);
|
||||
|
||||
// 初始化表单值
|
||||
React.useEffect(() => {
|
||||
form.setFieldsValue(userInfo);
|
||||
}, [userInfo, form]);
|
||||
|
||||
// 保存用户信息
|
||||
const handleSave = async () => {
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const values = await form.validate();
|
||||
setLoading(true);
|
||||
|
||||
// 模拟保存
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
setUserInfo({ ...userInfo, ...values });
|
||||
setIsEditing(false);
|
||||
Message.success('保存成功');
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancel = () => {
|
||||
form.setFieldsValue(userInfo);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 上传头像
|
||||
const handleAvatarChange = (fileList) => {
|
||||
if (fileList && fileList.length > 0) {
|
||||
const file = fileList[0];
|
||||
if (file.originFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setUserInfo({ ...userInfo, avatar: e.target?.result as string });
|
||||
Message.success('头像上传成功');
|
||||
};
|
||||
reader.readAsDataURL(file.originFile);
|
||||
setPageLoading(true);
|
||||
const result = await getUserInfo();
|
||||
if (result.code == 0 && result.data) {
|
||||
const data = result.data;
|
||||
// 使用 updateUserInfo 部分更新
|
||||
updateUserInfo({
|
||||
userId: data.id || data.userId,
|
||||
username: data.username,
|
||||
nickname: data.nickname || data.name,
|
||||
name: data.nickname || data.name,
|
||||
role: data.role,
|
||||
avatar: data.avatar || userInfo?.avatar, // 保留现有 avatar
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
});
|
||||
setLocalUserInfo(data);
|
||||
profileForm.setFieldsValue({
|
||||
nickname: data.nickname || data.name,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
Message.error('获取用户信息失败');
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
}, []);
|
||||
|
||||
const handleProfileSubmit = async () => {
|
||||
try {
|
||||
const values = await profileForm.validate();
|
||||
setProfileLoading(true);
|
||||
const result = await updateProfile({
|
||||
nickname: values.nickname,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
});
|
||||
if (result.code == 0) {
|
||||
Message.success('个人信息修改成功');
|
||||
fetchUserInfo();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileReset = () => {
|
||||
profileForm.setFieldsValue({
|
||||
nickname: userInfo?.nickname,
|
||||
email: userInfo?.email,
|
||||
phone: userInfo?.phone,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
try {
|
||||
const values = await passwordForm.validate();
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
Message.error('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setPasswordLoading(true);
|
||||
Message.success('已提交密码修改请求(示例)');
|
||||
passwordForm.resetFields();
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = async (fileList) => {
|
||||
if (!fileList?.length) {
|
||||
return;
|
||||
}
|
||||
const file = fileList[0];
|
||||
if (!file.originFile) {
|
||||
return;
|
||||
}
|
||||
if (avatarUploading) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file.originFile);
|
||||
const result = await uploadAvatar(formData);
|
||||
if (result.code == 0) {
|
||||
Message.success('头像上传成功');
|
||||
// 更新 Zustand 中的 avatar
|
||||
updateUserInfo({ avatar: result.data.avatar });
|
||||
fetchUserInfo();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error);
|
||||
Message.error('头像上传失败');
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayAvatar = userInfo?.avatar || '';
|
||||
const roleLabel =
|
||||
userInfo?.role === 'ADMIN' || userInfo?.role === 0
|
||||
? '超级管理员'
|
||||
: '普通用户';
|
||||
const registerTime = formatDateTime(localUserInfo?.createTime);
|
||||
const infoItems = [
|
||||
{
|
||||
key: 'username',
|
||||
label: '用户名',
|
||||
icon: <IconUser />,
|
||||
value: userInfo?.username || '-',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: '手机号',
|
||||
icon: <IconPhone />,
|
||||
value: userInfo?.phone || '-',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: '邮箱',
|
||||
icon: <IconEmail />,
|
||||
value: userInfo?.email || '-',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
icon: <IconUserGroup />,
|
||||
value: roleLabel,
|
||||
},
|
||||
{
|
||||
key: 'registerTime',
|
||||
label: '注册时间',
|
||||
icon: <IconIdcard />,
|
||||
value: registerTime,
|
||||
},
|
||||
];
|
||||
|
||||
if (pageLoading || !userInfo?.userId) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 顶部信息卡片 */}
|
||||
<Card className={styles['profile-card']}>
|
||||
<Row gutter={24}>
|
||||
{/* 左侧头像区域 */}
|
||||
<Col span={6}>
|
||||
<div className={styles['avatar-section']}>
|
||||
<div className={styles['avatar-wrapper']}>
|
||||
<Avatar size={120} className={styles.avatar}>
|
||||
<img src={userInfo.avatar} alt="avatar" />
|
||||
</Avatar>
|
||||
{isEditing && (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
onChange={handleAvatarChange}
|
||||
action="/"
|
||||
<Row gutter={24} wrap>
|
||||
<Col xs={24} xl={10}>
|
||||
<Card className={styles['profile-card']} bordered={false}>
|
||||
<div className={styles['profile-header']}>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
onChange={handleAvatarChange}
|
||||
action="/"
|
||||
>
|
||||
<div className={styles['avatar-shell']}>
|
||||
<Avatar size={112} className={styles.avatar}>
|
||||
{displayAvatar ? (
|
||||
<img src={displayAvatar} alt="avatar" />
|
||||
) : (
|
||||
<IconUser style={{ fontSize: 44 }} />
|
||||
)}
|
||||
</Avatar>
|
||||
<div className={styles['avatar-tip']}>
|
||||
<IconCamera />
|
||||
</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<div className={styles['profile-meta']}>
|
||||
<Title heading={6}>
|
||||
{userInfo?.nickname || userInfo?.username}
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
{userInfo?.username || roleLabel}
|
||||
</Paragraph>
|
||||
<div className={styles['status-pill']}>
|
||||
<span className={styles['status-dot']} />
|
||||
{localUserInfo?.status === 1 ? '状态 · 正常' : '状态 · 禁用'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles['info-list']}>
|
||||
{infoItems.map((item) => (
|
||||
<div className={styles['info-row']} key={item.key}>
|
||||
<div className={styles['info-label']}>
|
||||
<span className={styles['info-icon']}>{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className={styles['info-value']}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={14}>
|
||||
<Card className={styles['form-panel']} bordered={false}>
|
||||
<div className={styles['panel-header']}>
|
||||
<div>
|
||||
<Title heading={6}>账户资料</Title>
|
||||
<Paragraph type="secondary">
|
||||
管理基础资料与密码信息,保存后即时生效。
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultActiveTab="profile" type="line">
|
||||
<TabPane key="profile" title="基本资料">
|
||||
<Form
|
||||
form={profileForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
nickname: userInfo?.nickname,
|
||||
email: userInfo?.email,
|
||||
phone: userInfo?.phone,
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<FormItem label="用户名">
|
||||
<Input value={userInfo?.username} disabled />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem label="角色">
|
||||
<Input value={roleLabel} disabled />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="昵称"
|
||||
field="nickname"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input placeholder="请输入昵称" prefix={<IconUser />} />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="邮箱"
|
||||
field="email"
|
||||
rules={[
|
||||
{ type: 'email', message: '请输入正确的邮箱格式' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入邮箱"
|
||||
prefix={<IconEmail />}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="手机号"
|
||||
field="phone"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的手机号',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入手机号"
|
||||
prefix={<IconPhone />}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<FormItem>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={profileLoading}
|
||||
onClick={handleProfileSubmit}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button onClick={handleProfileReset}>重置</Button>
|
||||
</Space>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</TabPane>
|
||||
<TabPane key="password" title="修改密码">
|
||||
<Form form={passwordForm} layout="vertical">
|
||||
<FormItem
|
||||
label="当前密码"
|
||||
field="currentPassword"
|
||||
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||
>
|
||||
<div className={styles['avatar-upload']}>
|
||||
<IconCamera style={{ fontSize: 20 }} />
|
||||
<div>更换头像</div>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
<Title heading={5} style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
{userInfo.name}
|
||||
</Title>
|
||||
<Paragraph
|
||||
style={{ textAlign: 'center', color: 'var(--color-text-3)' }}
|
||||
>
|
||||
{userInfo.position}
|
||||
</Paragraph>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Tag color="blue">{userInfo.department}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* 右侧统计信息 */}
|
||||
<Col span={18}>
|
||||
<div className={styles['stats-section']}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="参与项目"
|
||||
value={statistics.projectCount}
|
||||
suffix="个"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="完成任务"
|
||||
value={statistics.taskCompleted}
|
||||
suffix="个"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="代码贡献"
|
||||
value={statistics.contribution}
|
||||
suffix="次"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="团队人数"
|
||||
value={statistics.teamSize}
|
||||
suffix="人"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Descriptions
|
||||
column={2}
|
||||
data={[
|
||||
{
|
||||
label: '工号',
|
||||
value: userInfo.employeeId,
|
||||
},
|
||||
{
|
||||
label: '入职时间',
|
||||
value: userInfo.joinDate,
|
||||
},
|
||||
{
|
||||
label: '邮箱',
|
||||
value: userInfo.email,
|
||||
},
|
||||
{
|
||||
label: '联系电话',
|
||||
value: userInfo.phone,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 详细信息卡片 */}
|
||||
<Card
|
||||
style={{ marginTop: 20 }}
|
||||
title="个人信息"
|
||||
extra={
|
||||
!isEditing ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
编辑资料
|
||||
</Button>
|
||||
) : (
|
||||
<Space>
|
||||
<Button icon={<IconClose />} onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconCheck />}
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={!isEditing}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="姓名"
|
||||
field="name"
|
||||
rules={[{ required: true, message: '请输入姓名' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入姓名"
|
||||
prefix={<IconUser />}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="用户名"
|
||||
field="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
prefix={<IconUser />}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="邮箱"
|
||||
field="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入邮箱"
|
||||
prefix={<IconEmail />}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
label="手机号"
|
||||
field="phone"
|
||||
rules={[{ required: true, message: '请输入手机号' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入手机号"
|
||||
prefix={<IconPhone />}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem label="性别" field="gender">
|
||||
<RadioGroup disabled={!isEditing}>
|
||||
<Radio value="male">男</Radio>
|
||||
<Radio value="female">女</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem label="生日" field="birthday">
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择生日"
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem label="部门" field="department">
|
||||
<Select placeholder="请选择部门" disabled={!isEditing}>
|
||||
<Option value="技术部">技术部</Option>
|
||||
<Option value="产品部">产品部</Option>
|
||||
<Option value="设计部">设计部</Option>
|
||||
<Option value="运营部">运营部</Option>
|
||||
<Option value="市场部">市场部</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem label="职位" field="position">
|
||||
<Input placeholder="请输入职位" disabled={!isEditing} />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<FormItem label="所在地" field="location">
|
||||
<Input
|
||||
placeholder="请输入所在地"
|
||||
prefix={<IconLocation />}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<FormItem label="个人简介" field="introduction">
|
||||
<Input.TextArea
|
||||
placeholder="请输入个人简介"
|
||||
rows={4}
|
||||
disabled={!isEditing}
|
||||
maxLength={200}
|
||||
showWordLimit
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 账号安全卡片 */}
|
||||
<Card style={{ marginTop: 20 }} title="账号安全">
|
||||
<Descriptions
|
||||
column={1}
|
||||
data={[
|
||||
{
|
||||
label: '登录密码',
|
||||
value: (
|
||||
<Space>
|
||||
<Text>当前密码强度:强</Text>
|
||||
<Button type="text" size="small">
|
||||
修改密码
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '密保手机',
|
||||
value: (
|
||||
<Space>
|
||||
<Text>{userInfo.phone}</Text>
|
||||
<Button type="text" size="small">
|
||||
修改手机
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '密保邮箱',
|
||||
value: (
|
||||
<Space>
|
||||
<Text>{userInfo.email}</Text>
|
||||
<Button type="text" size="small">
|
||||
修改邮箱
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Input.Password
|
||||
placeholder="请输入当前密码"
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label="新密码"
|
||||
field="newPassword"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入新密码"
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label="确认新密码"
|
||||
field="confirmPassword"
|
||||
rules={[{ required: true, message: '请再次输入新密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请再次输入新密码"
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={passwordLoading}
|
||||
onClick={handlePasswordSubmit}
|
||||
>
|
||||
更新密码
|
||||
</Button>
|
||||
<Button onClick={() => passwordForm.resetFields()}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
120
src/pages/user-management/ResetPasswordModal.tsx
Normal file
120
src/pages/user-management/ResetPasswordModal.tsx
Normal file
@ -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<ResetPasswordModalProps> = ({
|
||||
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 (
|
||||
<Modal
|
||||
title={title}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
confirmLoading={submitting}
|
||||
onCancel={onCancel}
|
||||
unmountOnExit
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
field="newPassword"
|
||||
label={t['userManagement.resetPassword.newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{
|
||||
match: PASSWORD_PATTERN,
|
||||
message: '密码需8-20位且包含大小写字母与数字',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
field="confirmPassword"
|
||||
label={t['userManagement.resetPassword.confirmPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
{
|
||||
validator: (value, callback) => {
|
||||
if (!value) {
|
||||
callback('请再次输入新密码');
|
||||
return;
|
||||
}
|
||||
if (value !== form.getFieldValue('newPassword')) {
|
||||
callback('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请再次输入新密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordModal;
|
||||
269
src/pages/user-management/UserFormModal.tsx
Normal file
269
src/pages/user-management/UserFormModal.tsx
Normal file
@ -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 (
|
||||
<Modal
|
||||
title={isEdit ? '编辑用户' : '新建用户'}
|
||||
visible={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
autoFocus={false}
|
||||
focusLock={true}
|
||||
maskClosable={false}
|
||||
style={{ width: 720 }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
className={styles['form-grid']}
|
||||
>
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
field="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_]{4,20}$/,
|
||||
message: '用户名必须是4-20位字母、数字或下划线',
|
||||
},
|
||||
]}
|
||||
className={styles['full-line']}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名(4-20位字母、数字或下划线)"
|
||||
disabled={isEdit} // 编辑模式不允许修改用户名
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
field="nickname"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="角色"
|
||||
field="role"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
initialValue="USER"
|
||||
>
|
||||
<Select placeholder="请选择角色" options={roleOptions} />
|
||||
</Form.Item>
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="密码"
|
||||
field="password"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,20}$/,
|
||||
message: '密码必须是8-20位,且包含大小写字母和数字',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码(8-20位,包含大小写字母和数字)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
field="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请确认密码',
|
||||
},
|
||||
{
|
||||
validator: (value, callback) => {
|
||||
const password = form.getFieldValue('password');
|
||||
if (password && value !== password) {
|
||||
callback('两次密码输入不一致');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请再次输入密码" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
field="email"
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: '请输入正确的邮箱格式',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
field="phone"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的手机号格式',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="状态"
|
||||
field="status"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
initialValue={1}
|
||||
>
|
||||
<Select placeholder="请选择状态" options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,43 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Button, Typography, Badge, Space } from '@arco-design/web-react';
|
||||
import { IconEye, IconEdit, IconDelete } from '@arco-design/web-react/icon';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Avatar,
|
||||
Tag,
|
||||
Dropdown,
|
||||
Menu,
|
||||
} from '@arco-design/web-react';
|
||||
import {
|
||||
IconEye,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconMoreVertical,
|
||||
IconLock,
|
||||
} from '@arco-design/web-react/icon';
|
||||
import styles from './style/index.module.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export const RoleType = ['管理员', '普通用户', '访客'];
|
||||
export const Status = ['禁用', '启用'];
|
||||
|
||||
const getInitial = (name?: string, username?: string) => {
|
||||
const base = name || username || '-';
|
||||
return base.substring(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
export function getColumns(
|
||||
t: any,
|
||||
callback: (record: Record<string, any>, type: string) => Promise<void>
|
||||
callback: (record: Record<string, any>, type: string) => Promise<void>,
|
||||
pagination?: { current?: number; pageSize?: number }
|
||||
) {
|
||||
const current = pagination?.current || 1;
|
||||
const pageSize = pagination?.pageSize || 10;
|
||||
return [
|
||||
{
|
||||
title: t['userManagement.columns.id'],
|
||||
dataIndex: 'id',
|
||||
width: 100,
|
||||
render: (value) => <Text copyable>{value}</Text>,
|
||||
title: '#',
|
||||
dataIndex: 'index',
|
||||
width: 70,
|
||||
render: (_, __, index) => (current - 1) * pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.name'],
|
||||
dataIndex: 'name',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.username'],
|
||||
dataIndex: 'username',
|
||||
width: 120,
|
||||
width: 180,
|
||||
render: (value, record) => (
|
||||
<div className={styles['user-cell']}>
|
||||
<Avatar
|
||||
size={32}
|
||||
style={{ backgroundColor: '#165DFF1A', color: '#165DFF' }}
|
||||
>
|
||||
{record.avatar ? (
|
||||
<img src={record.avatar} alt="avatar" />
|
||||
) : (
|
||||
getInitial(value, record.username)
|
||||
)}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className={styles['user-name']}>{value || '-'}</div>
|
||||
<div className={styles['user-sub']}>{record.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.email'],
|
||||
dataIndex: 'email',
|
||||
width: 200,
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.phone'],
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.role'],
|
||||
@ -50,20 +86,24 @@ export function getColumns(
|
||||
2: { text: t['userManagement.role.guest'], color: 'gray' },
|
||||
};
|
||||
const role = roleMap[value] || roleMap[1];
|
||||
return <Badge color={role.color} text={role.text} />;
|
||||
return (
|
||||
<Tag size="small" className={styles['role-tag']} color={role.color}>
|
||||
{role.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.department'],
|
||||
dataIndex: 'department',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.createdTime'],
|
||||
dataIndex: 'createdTime',
|
||||
width: 180,
|
||||
render: (x) => dayjs().subtract(x, 'days').format('YYYY-MM-DD HH:mm:ss'),
|
||||
sorter: (a, b) => b.createdTime - a.createdTime,
|
||||
render: (x) => x || '-',
|
||||
sorter: (a, b) => {
|
||||
// 按时间字符串排序
|
||||
const timeA = new Date(a.createdTime || 0).getTime();
|
||||
const timeB = new Date(b.createdTime || 0).getTime();
|
||||
return timeB - timeA;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.status'],
|
||||
@ -71,45 +111,83 @@ export function getColumns(
|
||||
width: 100,
|
||||
render: (x) => {
|
||||
if (x === 0) {
|
||||
return <Badge status="error" text={Status[x]}></Badge>;
|
||||
return (
|
||||
<div className={styles['status-cell']}>
|
||||
<span
|
||||
className={`${styles['status-dot']} ${styles['is-offline']}`}
|
||||
/>
|
||||
{Status[x]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Badge status="success" text={Status[x]}></Badge>;
|
||||
return (
|
||||
<div className={styles['status-cell']}>
|
||||
<span
|
||||
className={`${styles['status-dot']} ${styles['is-online']}`}
|
||||
/>
|
||||
{Status[x]}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t['userManagement.columns.operations'],
|
||||
dataIndex: 'operations',
|
||||
width: 240,
|
||||
width: 200,
|
||||
fixed: 'right' as const,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<IconEye />}
|
||||
onClick={() => callback(record, 'view')}
|
||||
>
|
||||
{t['userManagement.columns.operations.view']}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => callback(record, 'edit')}
|
||||
>
|
||||
{t['userManagement.columns.operations.edit']}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => callback(record, 'delete')}
|
||||
>
|
||||
{t['userManagement.columns.operations.delete']}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
render: (_, record) => {
|
||||
// 超级管理员不显示操作按钮
|
||||
if (record.role === 0) {
|
||||
return <span style={{ color: '#86909c' }}>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={8} align="center">
|
||||
<Button
|
||||
type="text"
|
||||
size="mini"
|
||||
onClick={() => callback(record, 'view')}
|
||||
>
|
||||
<IconEye /> {t['userManagement.columns.operations.view']}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="mini"
|
||||
onClick={() => callback(record, 'edit')}
|
||||
>
|
||||
<IconEdit /> {t['userManagement.columns.operations.edit']}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
droplist={
|
||||
<Menu>
|
||||
<Menu.Item onClick={() => callback(record, 'resetPassword')}>
|
||||
<Space size={6} align="center">
|
||||
<IconLock />
|
||||
<span>
|
||||
{t['userManagement.columns.operations.resetPassword']}
|
||||
</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => callback(record, 'delete')}
|
||||
style={{ color: '#f53f3f' }}
|
||||
>
|
||||
<Space size={6} align="center">
|
||||
<IconDelete />
|
||||
<span>
|
||||
{t['userManagement.columns.operations.delete']}
|
||||
</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button type="text" size="mini" icon={<IconMoreVertical />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -1,28 +1,33 @@
|
||||
import React, { useContext } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
Button,
|
||||
Grid,
|
||||
Space,
|
||||
} from '@arco-design/web-react';
|
||||
import { GlobalContext } from '@/context';
|
||||
import {
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconDown,
|
||||
IconUp,
|
||||
} from '@arco-design/web-react/icon';
|
||||
import locale from './locale';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import { IconRefresh, IconSearch } from '@arco-design/web-react/icon';
|
||||
import { RoleType, Status } from './constants';
|
||||
import { Status } from './constants';
|
||||
import styles from './style/index.module.less';
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
const { useForm } = Form;
|
||||
|
||||
function SearchForm(props: {
|
||||
interface SearchFormProps {
|
||||
onSearch: (values: Record<string, any>) => void;
|
||||
}) {
|
||||
const { lang } = useContext(GlobalContext);
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function SearchForm(props: SearchFormProps) {
|
||||
const t = useLocale(locale);
|
||||
const [form] = useForm();
|
||||
|
||||
@ -36,35 +41,11 @@ function SearchForm(props: {
|
||||
props.onSearch({});
|
||||
};
|
||||
|
||||
const colSpan = lang === 'zh-CN' ? 8 : 12;
|
||||
|
||||
return (
|
||||
<div className={styles['search-form-wrapper']}>
|
||||
<Form
|
||||
form={form}
|
||||
className={styles['search-form']}
|
||||
labelAlign="left"
|
||||
labelCol={{ span: 5 }}
|
||||
wrapperCol={{ span: 19 }}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t['userManagement.columns.id']} field="id">
|
||||
<Input
|
||||
placeholder={t['userManagement.form.id.placeholder']}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t['userManagement.columns.name']} field="name">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={t['userManagement.form.name.placeholder']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Form form={form} className={styles['search-form']} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={12} sm={8} md={6} lg={6} xl={6}>
|
||||
<Form.Item
|
||||
label={t['userManagement.columns.username']}
|
||||
field="username"
|
||||
@ -75,7 +56,37 @@ function SearchForm(props: {
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Col xs={12} sm={8} md={6} lg={6} xl={6}>
|
||||
<Form.Item label={t['userManagement.columns.name']} field="name">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={t['userManagement.form.name.placeholder']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={6} xl={6}>
|
||||
<Form.Item
|
||||
label={t['userManagement.columns.status']}
|
||||
field="status"
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t['userManagement.form.all.placeholder']}
|
||||
options={Status.map((item, index) => ({
|
||||
label: item,
|
||||
value: index,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col
|
||||
xs={12}
|
||||
sm={8}
|
||||
md={6}
|
||||
lg={6}
|
||||
xl={6}
|
||||
className={props.collapsed ? styles['field-hidden'] : ''}
|
||||
>
|
||||
<Form.Item label={t['userManagement.columns.email']} field="email">
|
||||
<Input
|
||||
allowClear
|
||||
@ -83,7 +94,14 @@ function SearchForm(props: {
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Col
|
||||
xs={12}
|
||||
sm={8}
|
||||
md={6}
|
||||
lg={6}
|
||||
xl={6}
|
||||
className={props.collapsed ? styles['field-hidden'] : ''}
|
||||
>
|
||||
<Form.Item label={t['userManagement.columns.phone']} field="phone">
|
||||
<Input
|
||||
allowClear
|
||||
@ -91,68 +109,27 @@ function SearchForm(props: {
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t['userManagement.columns.role']} field="role">
|
||||
<Select
|
||||
placeholder={t['userManagement.form.all.placeholder']}
|
||||
options={RoleType.map((item, index) => ({
|
||||
label: item,
|
||||
value: index,
|
||||
}))}
|
||||
mode="multiple"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Form.Item
|
||||
label={t['userManagement.columns.department']}
|
||||
field="department"
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={t['userManagement.form.department.placeholder']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Form.Item
|
||||
label={t['userManagement.columns.status']}
|
||||
field="status"
|
||||
>
|
||||
<Select
|
||||
placeholder={t['userManagement.form.all.placeholder']}
|
||||
options={Status.map((item, index) => ({
|
||||
label: item,
|
||||
value: index,
|
||||
}))}
|
||||
mode="multiple"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={colSpan}>
|
||||
<Form.Item
|
||||
label={t['userManagement.columns.createdTime']}
|
||||
field="createdTime"
|
||||
>
|
||||
<DatePicker.RangePicker
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
disabledDate={(date) => dayjs(date).isAfter(dayjs())}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className={styles['search-actions']}>
|
||||
<Space size={12}>
|
||||
<Button type="primary" icon={<IconSearch />} onClick={handleSubmit}>
|
||||
{t['userManagement.form.search']}
|
||||
</Button>
|
||||
<Button icon={<IconRefresh />} onClick={handleReset}>
|
||||
{t['userManagement.form.reset']}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={props.collapsed ? <IconDown /> : <IconUp />}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
{props.collapsed
|
||||
? t['userManagement.form.expand']
|
||||
: t['userManagement.form.collapse']}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
<div className={styles['right-button']}>
|
||||
<Button type="primary" icon={<IconSearch />} onClick={handleSubmit}>
|
||||
{t['userManagement.form.search']}
|
||||
</Button>
|
||||
<Button icon={<IconRefresh />} onClick={handleReset}>
|
||||
{t['userManagement.form.reset']}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,48 +1,162 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
PaginationProps,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Message,
|
||||
Modal,
|
||||
} from '@arco-design/web-react';
|
||||
import PermissionWrapper from '@/components/PermissionWrapper';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import ColumnSetting from '@/components/ColumnSetting';
|
||||
import { IconPlus, IconDelete, IconRefresh } from '@arco-design/web-react/icon';
|
||||
import {
|
||||
IconDownload,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
} from '@arco-design/web-react/icon';
|
||||
import axios from 'axios';
|
||||
getUserPage,
|
||||
deleteUser,
|
||||
batchDeleteUsers,
|
||||
exportUsers,
|
||||
getUserDetail,
|
||||
} from '@/api/user';
|
||||
import { SUCCESS_CODE } from '@/constants';
|
||||
import type { UserResp } from '@/types/user';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import SearchForm from './form';
|
||||
import UserFormModal from './UserFormModal';
|
||||
import ResetPasswordModal from './ResetPasswordModal';
|
||||
import locale from './locale';
|
||||
import styles from './style/index.module.less';
|
||||
import './mock';
|
||||
import { getColumns } from './constants';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const pad = (num: number) => `${num}`.padStart(2, '0');
|
||||
const formatDateTime = (value?: string | number | Date | null) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||
date.getDate()
|
||||
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
|
||||
date.getSeconds()
|
||||
)}`;
|
||||
};
|
||||
|
||||
function UserManagement() {
|
||||
const t = useLocale(locale);
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(true);
|
||||
const COLUMN_STORAGE_KEY = 'user-management-visible-columns';
|
||||
|
||||
const [resetModalVisible, setResetModalVisible] = useState(false);
|
||||
const [resetUserData, setResetUserData] = useState<
|
||||
(UserResp & { name?: string }) | undefined
|
||||
>(undefined);
|
||||
|
||||
const tableCallback = async (record, type) => {
|
||||
console.log(record, type);
|
||||
|
||||
// 简单的操作提示
|
||||
if (type === 'view') {
|
||||
Message.info(`查看用户: ${record.name}`);
|
||||
// 查看用户详情
|
||||
try {
|
||||
const result = await getUserDetail(record.id);
|
||||
if (result.code == SUCCESS_CODE) {
|
||||
const detail = result.data;
|
||||
const infoList = [
|
||||
{ label: '用户ID', value: detail.id || '-' },
|
||||
{ label: '角色', value: detail.role || '-' },
|
||||
{ label: '邮箱', value: detail.email || '-' },
|
||||
{ label: '手机号', value: detail.phone || '-' },
|
||||
{
|
||||
label: '状态',
|
||||
value: detail.status === 1 ? '正常' : '禁用',
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
value: formatDateTime(detail.createTime),
|
||||
},
|
||||
];
|
||||
Modal.info({
|
||||
title: null,
|
||||
icon: null,
|
||||
okText: '确定',
|
||||
style: { width: 720 },
|
||||
content: (
|
||||
<div className={styles['detail-modal']}>
|
||||
<div className={styles['detail-header']}>
|
||||
<div className={styles['detail-avatar']}>
|
||||
{(detail.nickname || detail.username || 'U')
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className={styles['detail-names']}>
|
||||
<div className={styles['detail-name']}>
|
||||
{detail.nickname || detail.username || '-'}
|
||||
</div>
|
||||
<div className={styles['detail-sub']}>
|
||||
{detail.username || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles['detail-status']} ${
|
||||
detail.status === 1
|
||||
? styles['status-active']
|
||||
: styles['status-disabled']
|
||||
}`}
|
||||
>
|
||||
{detail.status === 1 ? '状态 · 正常' : '状态 · 禁用'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['detail-grid']}>
|
||||
{infoList.map((item) => (
|
||||
<div className={styles['detail-item']} key={item.label}>
|
||||
<div className={styles['item-label']}>{item.label}</div>
|
||||
<div className={styles['item-value']}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户详情失败:', error);
|
||||
}
|
||||
} else if (type === 'edit') {
|
||||
Message.info(`编辑用户: ${record.name}`);
|
||||
// 编辑用户
|
||||
try {
|
||||
const result = await getUserDetail(record.id);
|
||||
if (result.code == SUCCESS_CODE) {
|
||||
setEditUserData(result.data);
|
||||
setModalVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户详情失败:', error);
|
||||
}
|
||||
} else if (type === 'resetPassword') {
|
||||
setResetUserData(record);
|
||||
setResetModalVisible(true);
|
||||
} else if (type === 'delete') {
|
||||
Message.warning(`删除用户: ${record.name}`);
|
||||
// 删除用户
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除用户 "${record.name}" 吗?此操作不可恢复。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await deleteUser(record.id);
|
||||
if (result.code == SUCCESS_CODE) {
|
||||
Message.success('删除成功');
|
||||
fetchData(); // 刷新列表
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (type === 'enable' || type === 'disable') {
|
||||
Message.info('状态切换功能将与后端联调后生效');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => getColumns(t, tableCallback), [t]);
|
||||
|
||||
const [data, setData] = useState([]);
|
||||
const [pagination, setPagination] = useState<PaginationProps>({
|
||||
sizeCanChange: true,
|
||||
@ -54,35 +168,103 @@ function UserManagement() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formParams, setFormParams] = useState({});
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [visibleColumns, setVisibleColumns] = useState<string[]>([]);
|
||||
const [columnSettingVisible, setColumnSettingVisible] = useState(false);
|
||||
|
||||
// 弹窗状态
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editUserData, setEditUserData] = useState<UserResp | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const baseColumns = useMemo(
|
||||
() => getColumns(t, tableCallback, pagination),
|
||||
[t, pagination]
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!visibleColumns.length) {
|
||||
return baseColumns;
|
||||
}
|
||||
const visibleSet = new Set(visibleColumns);
|
||||
return baseColumns.filter((col) => visibleSet.has(col.dataIndex));
|
||||
}, [baseColumns, visibleColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [pagination.current, pagination.pageSize, JSON.stringify(formParams)]);
|
||||
|
||||
function fetchData() {
|
||||
useEffect(() => {
|
||||
if (!visibleColumns.length && baseColumns.length) {
|
||||
const cached = localStorage.getItem(COLUMN_STORAGE_KEY);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (Array.isArray(parsed) && parsed.length) {
|
||||
setVisibleColumns(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setVisibleColumns(baseColumns.map((col) => col.dataIndex));
|
||||
}
|
||||
}, [baseColumns, visibleColumns.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleColumns.length) {
|
||||
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
async function fetchData() {
|
||||
const { current, pageSize } = pagination;
|
||||
setLoading(true);
|
||||
axios
|
||||
.get('/api/user-management', {
|
||||
params: {
|
||||
page: current,
|
||||
pageSize,
|
||||
...formParams,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
setData(res.data.list);
|
||||
|
||||
try {
|
||||
// 调用后端 API
|
||||
const result = await getUserPage({
|
||||
current: current,
|
||||
size: pageSize,
|
||||
...formParams,
|
||||
});
|
||||
|
||||
if (result.code == SUCCESS_CODE) {
|
||||
// 后端返回的字段是 list,不是 records
|
||||
const { list, total } = result.data;
|
||||
|
||||
// 字段映射:后端字段 -> 前端字段
|
||||
const mappedData = (list || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.nickname,
|
||||
username: item.username,
|
||||
email: item.email,
|
||||
phone: item.phone,
|
||||
role: mapRoleToNumber(item.role),
|
||||
createdTime: item.createTime,
|
||||
status: item.status,
|
||||
avatar: item.avatar,
|
||||
}));
|
||||
|
||||
setData(mappedData);
|
||||
setPagination({
|
||||
...pagination,
|
||||
current,
|
||||
pageSize,
|
||||
total: res.data.total,
|
||||
total,
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询用户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 角色映射:字符串 -> 数字
|
||||
function mapRoleToNumber(role: string): number {
|
||||
const roleMap = {
|
||||
ADMIN: 0,
|
||||
USER: 1,
|
||||
};
|
||||
return roleMap[role] ?? 1; // 默认为普通用户
|
||||
}
|
||||
|
||||
function onChangeTable(current: number, pageSize: number) {
|
||||
@ -94,77 +276,199 @@ function UserManagement() {
|
||||
}
|
||||
|
||||
function handleSearch(params) {
|
||||
// 处理搜索参数:类型转换、字段映射、移除空值
|
||||
const processedParams: any = {};
|
||||
|
||||
// 用户名:直接传递
|
||||
if (params.username) {
|
||||
processedParams.username = params.username;
|
||||
}
|
||||
|
||||
// 昵称:前端 name -> 后端 nickname
|
||||
if (params.name) {
|
||||
processedParams.nickname = params.name;
|
||||
}
|
||||
|
||||
// 邮箱:直接传递
|
||||
if (params.email) {
|
||||
processedParams.email = params.email;
|
||||
}
|
||||
|
||||
// 手机号:直接传递
|
||||
if (params.phone) {
|
||||
processedParams.phone = params.phone;
|
||||
}
|
||||
|
||||
// 状态:直接传递(现在是单选,不是多选)
|
||||
if (params.status !== undefined && params.status !== null) {
|
||||
processedParams.status = params.status;
|
||||
}
|
||||
|
||||
setPagination({ ...pagination, current: 1 });
|
||||
setFormParams(params);
|
||||
setFormParams(processedParams);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
Message.info('导入功能待接入后端接口');
|
||||
}
|
||||
|
||||
function handleToolbarRefresh() {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
// 新建用户
|
||||
function handleAdd() {
|
||||
Message.info(t['userManagement.operations.add']);
|
||||
setEditUserData(undefined); // 清空编辑数据
|
||||
setModalVisible(true);
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
function handleBatchDelete() {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
Message.warning('请选择要删除的用户');
|
||||
return;
|
||||
}
|
||||
Message.warning(`批量删除 ${selectedRowKeys.length} 个用户`);
|
||||
|
||||
Modal.confirm({
|
||||
title: '批量删除确认',
|
||||
content: `确定要删除选中的 ${selectedRowKeys.length} 个用户吗?此操作不可恢复。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await batchDeleteUsers(selectedRowKeys as number[]);
|
||||
if (result.code == SUCCESS_CODE) {
|
||||
Message.success('批量删除成功');
|
||||
setSelectedRowKeys([]); // 清空选择
|
||||
fetchData(); // 刷新列表
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
Message.info(t['userManagement.operations.export']);
|
||||
// 导出用户
|
||||
async function handleExport() {
|
||||
try {
|
||||
const response: any = await exportUsers(formParams);
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `用户列表_${new Date().getTime()}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
Message.success('导出成功');
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
Message.error('导出失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗关闭
|
||||
function handleModalCancel() {
|
||||
setModalVisible(false);
|
||||
setEditUserData(undefined);
|
||||
}
|
||||
|
||||
// 弹窗提交成功
|
||||
function handleModalSuccess() {
|
||||
fetchData(); // 刷新列表
|
||||
}
|
||||
|
||||
function handleResetModalClose() {
|
||||
setResetModalVisible(false);
|
||||
setResetUserData(undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title heading={6}>{t['menu.list.userManagement']}</Title>
|
||||
<SearchForm onSearch={handleSearch} />
|
||||
<PermissionWrapper
|
||||
requiredPermissions={[
|
||||
{ resource: 'menu.list.userManagement', actions: ['write'] },
|
||||
]}
|
||||
>
|
||||
<div className={styles['button-group']}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={handleAdd}>
|
||||
{t['userManagement.operations.add']}
|
||||
</Button>
|
||||
<div className={styles['page']}>
|
||||
<div className={styles['filters-card']}>
|
||||
<div className={styles['filters-header']}>
|
||||
<div>
|
||||
<Title heading={5}>{t['menu.list.userManagement']}</Title>
|
||||
<Paragraph type="secondary" className={styles['page-desc']}>
|
||||
{t['userManagement.page.desc']}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
collapsed={filterCollapsed}
|
||||
onToggle={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles['table-card']}>
|
||||
<div className={styles['table-head']}>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={handleAdd}>
|
||||
{t['userManagement.operations.add']}
|
||||
</Button>
|
||||
<Space size={10}>
|
||||
<Button icon={<IconDelete />} onClick={handleBatchDelete}>
|
||||
{t['userManagement.operations.batchDelete']}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<IconDownload />} onClick={handleExport}>
|
||||
{t['userManagement.operations.export']}
|
||||
<Button onClick={() => setColumnSettingVisible(true)}>
|
||||
列设置
|
||||
</Button>
|
||||
<Button icon={<IconRefresh />} onClick={handleToolbarRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</PermissionWrapper>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1640 }}
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
setSelectedRowKeys(keys);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1640 }}
|
||||
/>
|
||||
<Pagination
|
||||
total={pagination.total}
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
sizeCanChange={pagination.sizeCanChange}
|
||||
onChange={onChangeTable}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setPagination({ ...pagination, current: 1, pageSize });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserFormModal
|
||||
visible={modalVisible}
|
||||
onCancel={handleModalCancel}
|
||||
onSuccess={handleModalSuccess}
|
||||
editData={editUserData}
|
||||
/>
|
||||
<Pagination
|
||||
total={pagination.total}
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
sizeCanChange={pagination.sizeCanChange}
|
||||
onChange={onChangeTable}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setPagination({ ...pagination, current: 1, pageSize });
|
||||
}}
|
||||
<ColumnSetting
|
||||
visible={columnSettingVisible}
|
||||
columns={baseColumns.map((col) => ({
|
||||
key: col.dataIndex,
|
||||
title: col.title,
|
||||
}))}
|
||||
value={visibleColumns}
|
||||
onChange={setVisibleColumns}
|
||||
onClose={() => setColumnSettingVisible(false)}
|
||||
/>
|
||||
</Card>
|
||||
<ResetPasswordModal
|
||||
visible={resetModalVisible}
|
||||
user={resetUserData}
|
||||
onCancel={handleResetModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ const i18n = {
|
||||
'menu.list.userManagement': 'User Management',
|
||||
'userManagement.form.search': 'Search',
|
||||
'userManagement.form.reset': 'Reset',
|
||||
'userManagement.form.collapse': 'Collapse filters',
|
||||
'userManagement.form.expand': 'Expand filters',
|
||||
'userManagement.columns.id': 'User ID',
|
||||
'userManagement.columns.name': 'Name',
|
||||
'userManagement.columns.username': 'Username',
|
||||
@ -15,12 +17,15 @@ const i18n = {
|
||||
'userManagement.columns.operations': 'Operations',
|
||||
'userManagement.columns.operations.view': 'View',
|
||||
'userManagement.columns.operations.edit': 'Edit',
|
||||
'userManagement.columns.operations.resetPassword': 'Reset Password',
|
||||
'userManagement.columns.operations.delete': 'Delete',
|
||||
'userManagement.columns.operations.enable': 'Enable',
|
||||
'userManagement.columns.operations.disable': 'Disable',
|
||||
'userManagement.operations.add': 'Add User',
|
||||
'userManagement.operations.batchDelete': 'Batch Delete',
|
||||
'userManagement.operations.export': 'Export',
|
||||
'userManagement.operations.import': 'Import',
|
||||
'userManagement.operations.batch': 'Batch Actions',
|
||||
'userManagement.role.admin': 'Administrator',
|
||||
'userManagement.role.user': 'User',
|
||||
'userManagement.role.guest': 'Guest',
|
||||
@ -33,11 +38,22 @@ const i18n = {
|
||||
'userManagement.form.phone.placeholder': 'Please enter phone',
|
||||
'userManagement.form.department.placeholder': 'Please enter department',
|
||||
'userManagement.form.all.placeholder': 'All',
|
||||
'userManagement.page.desc':
|
||||
'Manage platform users, permissions and lifecycle.',
|
||||
'userManagement.stat.total': 'Total Users',
|
||||
'userManagement.stat.enabled': 'Enabled',
|
||||
'userManagement.stat.disabled': 'Disabled',
|
||||
'userManagement.resetPassword.title': 'Reset Password',
|
||||
'userManagement.resetPassword.newPassword': 'New Password',
|
||||
'userManagement.resetPassword.confirmPassword': 'Confirm Password',
|
||||
'userManagement.resetPassword.success': 'Password reset successfully',
|
||||
},
|
||||
'zh-CN': {
|
||||
'menu.list.userManagement': '用户管理',
|
||||
'userManagement.form.search': '查询',
|
||||
'userManagement.form.reset': '重置',
|
||||
'userManagement.form.collapse': '收起筛选',
|
||||
'userManagement.form.expand': '展开筛选',
|
||||
'userManagement.columns.id': '用户ID',
|
||||
'userManagement.columns.name': '姓名',
|
||||
'userManagement.columns.username': '用户名',
|
||||
@ -50,12 +66,15 @@ const i18n = {
|
||||
'userManagement.columns.operations': '操作',
|
||||
'userManagement.columns.operations.view': '查看',
|
||||
'userManagement.columns.operations.edit': '编辑',
|
||||
'userManagement.columns.operations.resetPassword': '重置密码',
|
||||
'userManagement.columns.operations.delete': '删除',
|
||||
'userManagement.columns.operations.enable': '启用',
|
||||
'userManagement.columns.operations.disable': '禁用',
|
||||
'userManagement.operations.add': '新建用户',
|
||||
'userManagement.operations.batchDelete': '批量删除',
|
||||
'userManagement.operations.export': '导出',
|
||||
'userManagement.operations.import': '导入',
|
||||
'userManagement.operations.batch': '批量操作',
|
||||
'userManagement.role.admin': '管理员',
|
||||
'userManagement.role.user': '普通用户',
|
||||
'userManagement.role.guest': '访客',
|
||||
@ -68,6 +87,14 @@ const i18n = {
|
||||
'userManagement.form.phone.placeholder': '请输入手机号',
|
||||
'userManagement.form.department.placeholder': '请输入部门',
|
||||
'userManagement.form.all.placeholder': '全部',
|
||||
'userManagement.page.desc': '统一管理系统用户、权限及生命周期。',
|
||||
'userManagement.stat.total': '用户总数',
|
||||
'userManagement.stat.enabled': '启用',
|
||||
'userManagement.stat.disabled': '禁用',
|
||||
'userManagement.resetPassword.title': '重置用户密码',
|
||||
'userManagement.resetPassword.newPassword': '新密码',
|
||||
'userManagement.resetPassword.confirmPassword': '确认新密码',
|
||||
'userManagement.resetPassword.success': '密码重置成功',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,41 +1,230 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
.page {
|
||||
background: #f5f6f8;
|
||||
padding: 0 4px 16px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.operations {
|
||||
.filters-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
padding: 18px 20px;
|
||||
box-shadow: none;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filters-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-form-wrapper {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border-1);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.right-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding-left: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 1px solid var(--color-border-2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
padding-right: 20px;
|
||||
:global(.arco-form-item-label) {
|
||||
color: #4e5969;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.arco-form-label-item-left) {
|
||||
> label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(.arco-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px dashed #e5e6eb;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
padding: 20px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:global(.arco-btn-text) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.user-sub {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid rgba(22, 93, 255, 20%);
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.is-online {
|
||||
background: #1dc779;
|
||||
}
|
||||
|
||||
.is-offline {
|
||||
background: #f77234;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
min-width: 620px;
|
||||
max-width: 680px;
|
||||
padding: 8px 4px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 8px 20px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.detail-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #165dff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 16px rgba(22, 93, 255, 30%);
|
||||
}
|
||||
|
||||
.detail-names {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(29, 199, 121, 16%);
|
||||
color: #00a36a;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background: rgba(255, 125, 76, 16%);
|
||||
color: #f86d39;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
padding: 20px 8px 4px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e5e6eb;
|
||||
}
|
||||
|
||||
.detail-item:nth-last-child(-n + 2) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filters-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
13
src/pages/user-management/style/modal.module.less
Normal file
13
src/pages/user-management/style/modal.module.less
Normal file
@ -0,0 +1,13 @@
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
|
||||
:global(.arco-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.full-line {
|
||||
grid-column: span 2;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user