refactor: strengthen client boundaries and quality gates

This commit is contained in:
hectorzhao
2026-08-28 14:26:58 +08:00
parent 3af145abe5
commit ad27acad7e
51 changed files with 7703 additions and 697 deletions
+143
View File
@@ -0,0 +1,143 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientUsersPage } from './ClientUsersPage';
const { clientApi } = vi.hoisted(() => ({
clientApi: {
listUsers: vi.fn(),
deleteUser: vi.fn(),
changeUserStatus: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
changeUserPassword: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
vi.mock('@/components/ui', () => ({
Button: ({
children,
icon: _icon,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => (
<button {...props}>{children}</button>
),
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
<label>
{label}
<input {...props} />
</label>
),
Select: ({
label,
options,
...props
}: React.SelectHTMLAttributes<HTMLSelectElement> & {
label: string;
options: Array<{ label: string; value: string }>;
}) => (
<label>
{label}
<select {...props}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
),
Tag: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Table: ({
columns,
data,
emptyText,
}: {
columns: Array<{ key: string; render?: (record: never) => React.ReactNode }>;
data: never[];
emptyText: string;
}) =>
data.length ? (
<div>
{data.map((row: never, index: number) => (
<div key={index}>
{columns.map((column) => (
<span key={column.key}>{column.render?.(row)}</span>
))}
</div>
))}
</div>
) : (
<p>{emptyText}</p>
),
Modal: ({
children,
footer,
open,
title,
}: {
children: React.ReactNode;
footer: React.ReactNode;
open: boolean;
title: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
{footer}
</div>
) : null,
}));
const user = {
id: 'user-1',
displayName: '截图用户',
username: 'screenshot',
email: 'user@example.com',
phone: '13800000000',
status: 'active',
roles: ['enterprise_admin'],
};
describe('ClientUsersPage states', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
});
it('renders the empty state from a successful real API-shaped response', async () => {
clientApi.listUsers.mockResolvedValue([]);
render(<ClientUsersPage />);
expect(await screen.findByText('暂无用户')).toBeVisible();
});
it('renders a backend loading error', async () => {
clientApi.listUsers.mockRejectedValue(new Error('用户服务暂不可用'));
render(<ClientUsersPage />);
expect(await screen.findByText('用户服务暂不可用')).toBeVisible();
});
it('shows query loading and requires confirmation before deletion', async () => {
let resolveQuery: (value: (typeof user)[]) => void = () => undefined;
clientApi.listUsers
.mockResolvedValueOnce([user])
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveQuery = resolve;
}),
)
.mockResolvedValueOnce([]);
clientApi.deleteUser.mockResolvedValue(user);
render(<ClientUsersPage />);
await screen.findByText('截图用户');
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(screen.getByRole('button', { name: '查询中...' })).toBeDisabled();
resolveQuery([user]);
await screen.findByRole('button', { name: '查询' });
fireEvent.click(screen.getByRole('button', { name: '删除' }));
expect(screen.getByRole('dialog', { name: '删除用户' })).toHaveTextContent('确认删除用户 截图用户');
expect(clientApi.deleteUser).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => expect(clientApi.deleteUser).toHaveBeenCalledWith('user-1'));
});
});
+256 -69
View File
@@ -1,8 +1,7 @@
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 { clientApi, type ManagedUser } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { readSession } from '@/api/session';
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import './ClientUsersPage.css';
@@ -42,19 +41,19 @@ const emptyFilters: UserFilters = {
};
function toForm(user?: ManagedUser): UserForm {
return user ? {
displayName: user.displayName,
username: user.username,
email: user.email ?? '',
phone: user.phone ?? '',
status: user.status,
password: '',
} : emptyForm;
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('client');
const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState<ManagedUser[]>([]);
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
@@ -72,16 +71,15 @@ export function ClientUsersPage() {
const [querying, setQuerying] = useState(false);
async function loadUsers(query: UserFilters = appliedFilters) {
if (!tenantId) return;
setUsers(await clientApi.listUsers(query, tenantId));
setUsers(await clientApi.listUsers(query));
}
useEffect(() => {
if (!tenantId) return;
void clientApi.listUsers({}, tenantId)
void clientApi
.listUsers({})
.then(setUsers)
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
}, [tenantId]);
}, []);
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
@@ -122,26 +120,28 @@ export function ClientUsersPage() {
}
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
if (
!form.displayName.trim() ||
(!form.email.trim() && !form.phone.trim()) ||
(creating && form.password.length < 6)
) {
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return;
}
setSaving(true);
setFormError('');
const body: UserPayload = {
const body = {
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,
};
try {
if (creating) {
await clientApi.createUser({ ...body, password: form.password }, tenantId);
await clientApi.createUser({ ...body, password: form.password });
} else if (editingUser) {
await clientApi.updateUser(editingUser.id, body, tenantId);
await clientApi.updateUser(editingUser.id, body);
}
setCreating(false);
setEditingUser(null);
@@ -159,9 +159,12 @@ export function ClientUsersPage() {
setConfirmError('');
try {
if (confirmAction.type === 'delete') {
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
await clientApi.deleteUser(confirmAction.user.id);
} else {
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
await clientApi.changeUserStatus(
confirmAction.user.id,
confirmAction.user.status === 'active' ? 'disabled' : 'active',
);
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败';
@@ -181,55 +184,136 @@ export function ClientUsersPage() {
async function savePassword() {
if (!passwordUser) return;
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
await clientApi.changeUserPassword(passwordUser.id, newPassword);
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">{formatDateTime(record.lastLoginAt)}</span> },
{
key: 'actions',
title: '操作',
width: '290px',
render: (record) => (
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button>
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div>
),
},
], []);
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">{formatDateTime(record.lastLoginAt)}</span>,
},
{
key: 'actions',
title: '操作',
width: '290px',
render: (record) => (
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">
</Button>
<Button
icon={<KeyRound size={15} />}
onClick={() => {
setPasswordUser(record);
setNewPassword('');
}}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => openConfirm({ type: 'status', user: record })}
size="sm"
variant={record.status === 'active' ? 'warning' : 'success'}
>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button
icon={<Trash2 size={15} />}
onClick={() => openConfirm({ type: 'delete', user: record })}
size="sm"
variant="danger"
>
</Button>
</div>
),
},
],
[],
);
return (
<section className="page-stack system-page">
<div className="system-page-toolbar">
<div className="sms-send-title">
<span className="sms-send-title__icon"><Users size={22} /></span>
<span className="sms-send-title__icon">
<Users size={22} />
</span>
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm"></Button>
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">
</Button>
</div>
<div className="surface system-filter-row client-user-filter">
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
<Input
label="用户姓名"
onChange={(event) => updateFilter('displayName', event.target.value)}
placeholder="请输入用户姓名"
value={filters.displayName}
/>
<Input
label="登录账号"
onChange={(event) => updateFilter('login', event.target.value)}
placeholder="用户名、邮箱或手机号"
value={filters.login}
/>
<Select
label="状态"
onChange={(event) => updateFilter('status', event.target.value)}
options={[{ label: '全部状态', value: '' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
options={[
{ label: '全部状态', value: '' },
{ label: '正常', value: 'active' },
{ label: '禁用', value: 'disabled' },
]}
value={filters.status}
/>
<div className="client-user-filter__actions">
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="secondary"></Button>
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>
{querying ? '查询中...' : '查询'}
</Button>
<Button
disabled={querying}
onClick={() => {
setFilters(emptyFilters);
void queryUsers(emptyFilters);
}}
variant="secondary"
>
</Button>
</div>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
@@ -237,38 +321,141 @@ export function ClientUsersPage() {
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
</div>
{(creating || editingUser) ? (
{creating || editingUser ? (
<Modal
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary"></Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
onClose={() => { setCreating(false); setEditingUser(null); }}
footer={
<>
<Button
disabled={saving}
onClick={() => {
setCreating(false);
setEditingUser(null);
}}
variant="secondary"
>
</Button>
<Button disabled={saving} onClick={() => void saveUser()}>
{saving ? '保存中...' : '保存'}
</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 hint="可用用户名、邮箱或手机号登录" 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} />
{formError ? <p className="form-error" role="alert">{formError}</p> : null}
<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
hint="可用用户名、邮箱或手机号登录"
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}
/>
{formError ? (
<p className="form-error" role="alert">
{formError}
</p>
) : null}
</div>
</Modal>
) : null}
{passwordUser ? (
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary"></Button><Button onClick={() => void savePassword()}></Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
<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} />
<Input
label="新密码"
onChange={(event) => setNewPassword(event.target.value)}
type="password"
value={newPassword}
/>
</div>
</Modal>
) : null}
{confirmAction ? (
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary"></Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p>
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
<Modal
footer={
<>
<Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary">
</Button>
<Button
disabled={confirming}
onClick={() => void runConfirm()}
variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}
>
{confirming ? '处理中...' : '确认'}
</Button>
</>
}
onClose={() => {
if (!confirming) setConfirmAction(null);
}}
open
title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}
>
<p>
{confirmAction.type === 'delete'
? `确认删除用户 ${confirmAction.user.displayName}`
: `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}
</p>
{confirmError ? (
<p className="form-error" role="alert">
{confirmError}
</p>
) : null}
</Modal>
) : null}
</section>