Files
erp/apps/web/src/pages/Users.tsx
iven 89fc482d99
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat(web): 采用 UI UX Pro Max Soft UI Evolution 设计系统
从 Pinterest 风格切换到 Soft UI Evolution 设计系统,使用 UI UX Pro Max
推理引擎生成适合跨行业 ERP 业务用户的专业设计方案。

设计变更:
- 主色从 Pinterest Red (#e60023) 切换到 Trust Blue (#2563EB)
- 字体从系统默认切换到 Noto Sans SC(中文优先)
- 圆角从 16-20px 调整到 10-12px(专业但不夸张)
- 中性色从暖橄榄调切换到 Slate 石板蓝调
- 成功色 #103c25 → #059669,警告色 #b56e1a → #d97706
- 暗色模式从暖黑 (#1a1a18) 切换到深海军蓝 (#0f172a)

涉及文件:DESIGN.md + index.css + App.tsx + 24 个组件文件
2026-04-20 23:27:24 +08:00

486 lines
13 KiB
TypeScript

import { useEffect, useState, useCallback, useRef } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Tag,
Popconfirm,
Checkbox,
message,
theme,
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
EditOutlined,
DeleteOutlined,
UserOutlined,
SafetyCertificateOutlined,
StopOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import {
listUsers,
createUser,
updateUser,
deleteUser,
assignRoles,
type CreateUserRequest,
type UpdateUserRequest,
} from '../api/users';
import { listRoles, type RoleInfo } from '../api/roles';
import type { UserInfo } from '../api/auth';
const STATUS_COLOR_MAP: Record<string, string> = {
active: '#059669',
disabled: '#dc2626',
locked: '#d97706',
};
const STATUS_BG_MAP: Record<string, string> = {
active: '#ECFDF5',
disabled: '#FEF2F2',
locked: '#FFFBEB',
};
const STATUS_LABEL_MAP: Record<string, string> = {
active: '正常',
disabled: '禁用',
locked: '锁定',
};
export default function Users() {
const [users, setUsers] = useState<UserInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editUser, setEditUser] = useState<UserInfo | null>(null);
const [roleModalOpen, setRoleModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
const [allRoles, setAllRoles] = useState<RoleInfo[]>([]);
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
const [form] = Form.useForm();
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchUsers = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listUsers(p, 20, searchText);
setUsers(result.data);
setTotal(result.total);
} catch {
message.error('加载用户列表失败');
}
setLoading(false);
}, [page, searchText]);
// 搜索防抖:输入后 300ms 才触发查询
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearch = useCallback((_text: string) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
setPage(1);
}, 300);
}, []);
const fetchRoles = useCallback(async () => {
try {
const result = await listRoles();
setAllRoles(result.data);
} catch {
// 静默处理
}
}, []);
useEffect(() => {
fetchUsers();
fetchRoles();
}, [fetchUsers, fetchRoles]);
const handleCreateOrEdit = async (values: {
username: string;
password?: string;
display_name?: string;
email?: string;
phone?: string;
}) => {
try {
if (editUser) {
const req: UpdateUserRequest = {
display_name: values.display_name,
email: values.email,
phone: values.phone,
version: editUser.version,
};
await updateUser(editUser.id, req);
message.success('用户更新成功');
} else {
const req: CreateUserRequest = {
username: values.username,
password: values.password ?? '',
display_name: values.display_name,
email: values.email,
phone: values.phone,
};
await createUser(req);
message.success('用户创建成功');
}
setCreateModalOpen(false);
setEditUser(null);
form.resetFields();
fetchUsers();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
try {
await deleteUser(id);
message.success('用户已删除');
fetchUsers();
} catch {
message.error('删除失败');
}
};
const handleToggleStatus = async (id: string, status: string) => {
try {
const user = users.find(u => u.id === id);
if (!user) return;
await updateUser(id, { status, version: user.version });
message.success(status === 'disabled' ? '用户已禁用' : '用户已启用');
fetchUsers();
} catch {
message.error('状态更新失败');
}
};
const handleAssignRoles = async () => {
if (!selectedUser) return;
try {
await assignRoles(selectedUser.id, selectedRoleIds);
message.success('角色分配成功');
setRoleModalOpen(false);
fetchUsers();
} catch {
message.error('角色分配失败');
}
};
const openCreateModal = () => {
setEditUser(null);
form.resetFields();
setCreateModalOpen(true);
};
const openEditModal = (user: UserInfo) => {
setEditUser(user);
form.setFieldsValue({
username: user.username,
display_name: user.display_name,
email: user.email,
phone: user.phone,
});
setCreateModalOpen(true);
};
const closeCreateModal = () => {
setCreateModalOpen(false);
setEditUser(null);
form.resetFields();
};
const openRoleModal = (user: UserInfo) => {
setSelectedUser(user);
setSelectedRoleIds(user.roles.map((r) => r.id));
setRoleModalOpen(true);
};
const filteredUsers = users;
const columns = [
{
title: '用户',
dataIndex: 'username',
key: 'username',
render: (v: string, record: UserInfo) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #2563eb, #60a5fa)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 13,
fontWeight: 600,
}}
>
{(record.display_name?.[0] || v?.[0] || 'U').toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
{record.display_name && (
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
{record.display_name}
</div>
)}
</div>
</div>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
render: (v: string | undefined) => v || '-',
},
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
render: (v: string | undefined) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => (
<Tag
style={{
color: STATUS_COLOR_MAP[status] || '#62625b',
background: STATUS_BG_MAP[status] || '#f8fafc',
border: 'none',
fontWeight: 500,
}}
>
{STATUS_LABEL_MAP[status] || status}
</Tag>
),
},
{
title: '角色',
dataIndex: 'roles',
key: 'roles',
render: (roles: RoleInfo[]) =>
roles.length > 0
? roles.map((r) => (
<Tag key={r.id} style={{
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
{r.name}
</Tag>
))
: <span style={{ color: isDark ? '#475569' : '#CBD5E1' }}>-</span>,
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: UserInfo) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Button
size="small"
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openRoleModal(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
{record.status === 'active' ? (
<Popconfirm
title="确定禁用此用户?"
onConfirm={() => handleToggleStatus(record.id, 'disabled')}
>
<Button
size="small"
type="text"
icon={<StopOutlined />}
danger
/>
</Popconfirm>
) : (
<Button
size="small"
type="text"
icon={<CheckCircleOutlined />}
onClick={() => handleToggleStatus(record.id, 'active')}
style={{ color: '#059669' }}
/>
)}
<Popconfirm
title="确定删除此用户?"
onConfirm={() => handleDelete(record.id)}
>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
danger
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{/* 页面标题和工具栏 */}
<div className="erp-page-header">
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
<Space size={8}>
<Input
placeholder="搜索用户名..."
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
debouncedSearch(e.target.value);
}}
allowClear
style={{ width: 220, borderRadius: 8 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
</Button>
</Space>
</div>
{/* 表格容器 */}
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table
columns={columns}
dataSource={filteredUsers}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchUsers(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
{/* 新建/编辑用户弹窗 */}
<Modal
title={editUser ? '编辑用户' : '新建用户'}
open={createModalOpen}
onCancel={closeCreateModal}
onOk={() => form.submit()}
width={480}
>
<Form form={form} onFinish={handleCreateOrEdit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!editUser} />
</Form.Item>
{!editUser && (
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password />
</Form.Item>
)}
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
</Form>
</Modal>
{/* 角色分配弹窗 */}
<Modal
title={`分配角色 - ${selectedUser?.username || ''}`}
open={roleModalOpen}
onCancel={() => setRoleModalOpen(false)}
onOk={handleAssignRoles}
width={480}
>
<div style={{ marginTop: 8 }}>
<Checkbox.Group
value={selectedRoleIds}
onChange={(values) => setSelectedRoleIds(values as string[])}
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
>
{allRoles.map((r) => (
<div
key={r.id}
style={{
padding: '10px 14px',
borderRadius: 8,
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#f1f5f9',
}}
>
<Checkbox value={r.id}>
<span style={{ fontWeight: 500 }}>{r.name}</span>
<span style={{ color: isDark ? '#475569' : '#94a3b8', marginLeft: 8, fontSize: 12 }}>
{r.code}
</span>
</Checkbox>
</div>
))}
</Checkbox.Group>
</div>
</Modal>
</div>
);
}