feat: complete login and user management flow
This commit is contained in:
+146
-113
@@ -1,95 +1,138 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||
import { readSession } from '@/api/session';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type UserRole = 'enterprise_admin' | 'user';
|
||||
type UserStatus = 'active' | 'disabled';
|
||||
|
||||
type ClientUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
type UserForm = {
|
||||
displayName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
lastLoginAt: string;
|
||||
status: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const roleLabelMap: Record<UserRole, string> = {
|
||||
enterprise_admin: '企业管理员',
|
||||
user: '普通用户',
|
||||
type ConfirmAction = {
|
||||
type: 'status' | 'delete';
|
||||
user: ManagedUser;
|
||||
};
|
||||
|
||||
const usersSeed: ClientUser[] = [
|
||||
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'enterprise_admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' },
|
||||
{ id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' },
|
||||
{ id: 'USER003', name: '王五', email: 'wangwu@example.com', phone: '13800138002', role: 'user', status: 'active', lastLoginAt: '2026-03-15 11:30:00' },
|
||||
{ id: 'USER004', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138003', role: 'user', status: 'disabled', lastLoginAt: '2026-02-20 14:00:00' },
|
||||
{ id: 'USER005', name: '孙七', email: 'sunqi@example.com', phone: '13800138004', role: 'user', status: 'active', lastLoginAt: '2026-03-17 08:00:00' },
|
||||
];
|
||||
|
||||
const emptyUser: ClientUser = {
|
||||
id: 'NEW',
|
||||
name: '',
|
||||
const emptyForm: UserForm = {
|
||||
displayName: '',
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
lastLoginAt: '-',
|
||||
password: '',
|
||||
};
|
||||
|
||||
function toForm(user?: ManagedUser): UserForm {
|
||||
return user ? {
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
status: user.status,
|
||||
password: '',
|
||||
} : emptyForm;
|
||||
}
|
||||
|
||||
export function ClientUsersPage() {
|
||||
const session = readSession();
|
||||
const tenantId = session?.user.tenantId ?? undefined;
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<ClientUser | null>(null);
|
||||
const [draft, setDraft] = useState<ClientUser>(emptyUser);
|
||||
const enterpriseAdmin = usersSeed.find((item) => item.role === 'enterprise_admin');
|
||||
const canSelectEnterpriseAdmin = !enterpriseAdmin || editingUser?.id === enterpriseAdmin.id;
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const filteredUsers = usersSeed.filter((item) => {
|
||||
const target = `${item.name} ${item.email} ${item.phone}`;
|
||||
return !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
||||
});
|
||||
|
||||
function openEditor(user?: ClientUser) {
|
||||
const nextUser = user ?? emptyUser;
|
||||
setEditingUser(nextUser);
|
||||
setDraft(nextUser);
|
||||
async function load() {
|
||||
if (!tenantId) return;
|
||||
setUsers(await clientApi.listUsers(tenantId));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ClientUser>>>(() => [
|
||||
{ key: 'name', title: '用户名', width: '120px', render: (record) => <strong className="text-strong">{record.name}</strong> },
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email}</span> },
|
||||
{ key: 'phone', title: '手机号', width: '180px', render: (record) => <span className="muted">{record.phone}</span> },
|
||||
{
|
||||
key: 'role',
|
||||
title: '角色',
|
||||
width: '150px',
|
||||
render: (record) => <Tag tone={record.role === 'enterprise_admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '130px',
|
||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag>,
|
||||
},
|
||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '210px', render: (record) => <span className="muted">{record.lastLoginAt}</span> },
|
||||
useEffect(() => {
|
||||
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
}, [tenantId]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const value = keyword.trim().toLowerCase();
|
||||
return users.filter((item) => {
|
||||
const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase();
|
||||
return !value || target.includes(value);
|
||||
});
|
||||
}, [keyword, users]);
|
||||
|
||||
function openEditor(user?: ManagedUser) {
|
||||
setForm(toForm(user));
|
||||
setEditingUser(user ?? null);
|
||||
setCreating(!user);
|
||||
}
|
||||
|
||||
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const body: UserPayload = {
|
||||
displayName: form.displayName,
|
||||
username: form.username || form.email || form.phone,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
status: form.status,
|
||||
roleCode: 'enterprise_admin',
|
||||
operatorId: session?.user.id,
|
||||
};
|
||||
if (creating) {
|
||||
await clientApi.createUser({ ...body, password: form.password }, tenantId);
|
||||
} else if (editingUser) {
|
||||
await clientApi.updateUser(editingUser.id, body, tenantId);
|
||||
}
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.type === 'delete') {
|
||||
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
|
||||
} else {
|
||||
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
|
||||
}
|
||||
setConfirmAction(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
if (!passwordUser) return;
|
||||
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
|
||||
setPasswordUser(null);
|
||||
setNewPassword('');
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
|
||||
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> },
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
|
||||
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
|
||||
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
|
||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '170px',
|
||||
width: '290px',
|
||||
render: (record) => (
|
||||
<div className="inline-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} size="sm" variant="danger">删除</Button>
|
||||
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -106,56 +149,46 @@ export function ClientUsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="system-filter-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索用户名、邮箱或手机号"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||
</div>
|
||||
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
<Pagination total={filteredUsers.length} page={1} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setEditingUser(null)} variant="secondary">取消</Button>
|
||||
<Button onClick={() => setEditingUser(null)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setEditingUser(null)}
|
||||
open={Boolean(editingUser)}
|
||||
size="xl"
|
||||
title={editingUser?.id === 'NEW' ? '添加用户' : '编辑用户'}
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="用户名 *" onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="请输入用户名" value={draft.name} />
|
||||
<Input label="邮箱 *" onChange={(event) => setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} />
|
||||
<Input label="手机号 *" onChange={(event) => setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} />
|
||||
<Select
|
||||
hint={canSelectEnterpriseAdmin ? '企业管理员拥有企业空间最高权限。' : `当前企业管理员为 ${enterpriseAdmin?.name},每个企业仅允许 1 位企业管理员。`}
|
||||
label="角色 *"
|
||||
onChange={(event) => setDraft({ ...draft, role: event.target.value as UserRole })}
|
||||
options={[
|
||||
...(canSelectEnterpriseAdmin ? [{ label: '企业管理员', value: 'enterprise_admin' }] : []),
|
||||
{ label: '普通用户', value: 'user' },
|
||||
]}
|
||||
value={draft.role}
|
||||
/>
|
||||
<Select
|
||||
label="状态 *"
|
||||
onChange={(event) => setDraft({ ...draft, status: event.target.value as UserStatus })}
|
||||
options={[
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
value={draft.status}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
{(creating || editingUser) ? (
|
||||
<Modal
|
||||
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
||||
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||
open
|
||||
size="xl"
|
||||
title={creating ? '添加用户' : '编辑用户'}
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
|
||||
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
|
||||
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{passwordUser ? (
|
||||
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary">取消</Button><Button onClick={() => void savePassword()}>保存</Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
|
||||
<div className="system-user-form">
|
||||
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="secondary">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user