feat(功能页面): 完成登录、用户信息和用户管理模块

登录模块:
- 集成 Zustand 状态管理
- 使用宽松相等判断响应码

用户信息页面:
- 个人信息展示和编辑
- 头像上传功能
- 密码修改功能

用户管理模块:
- 用户列表展示(支持头像显示)
- 新增/编辑/删除用户功能
- 密码重置功能
- 隐藏超级管理员操作按钮
- 支持批量操作
- 列设置组件
This commit is contained in:
gaoziman 2025-11-18 20:47:15 +08:00
parent f20951fb92
commit 555d3b164e
13 changed files with 1971 additions and 753 deletions

View 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;
}

View 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;

View File

@ -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

View File

@ -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>
);
}

View File

@ -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;
}
}

View 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;

View 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>
);
}

View File

@ -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>
);
},
},
];
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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': '密码重置成功',
},
};

View File

@ -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;
}
}

View 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;
}