feat: strengthen risk controls and review workflows

This commit is contained in:
hectorzhao
2026-07-26 13:08:12 +08:00
parent b461532075
commit 2ce682c3fc
34 changed files with 2167 additions and 390 deletions
+27
View File
@@ -1,4 +1,31 @@
.client-user-filter {
align-items: end;
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(180px, 1fr)) auto;
max-width: none;
}
.client-user-filter__actions {
display: flex;
gap: var(--space-3);
}
@media (max-width: 780px) {
.client-user-filter {
grid-template-columns: 1fr;
}
.client-user-filter__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.client-user-filter__actions .ui-button {
justify-content: center;
width: 100%;
}
.client-users-table-card .client-user-actions {
display: grid;
gap: var(--space-2);
+94 -25
View File
@@ -20,6 +20,12 @@ type ConfirmAction = {
user: ManagedUser;
};
type UserFilters = {
displayName: string;
login: string;
status: string;
};
const emptyForm: UserForm = {
displayName: '',
username: '',
@@ -29,6 +35,12 @@ const emptyForm: UserForm = {
password: '',
};
const emptyFilters: UserFilters = {
displayName: '',
login: '',
status: '',
};
function toForm(user?: ManagedUser): UserForm {
return user ? {
displayName: user.displayName,
@@ -44,35 +56,63 @@ export function ClientUsersPage() {
const session = readSession('client');
const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState<ManagedUser[]>([]);
const [keyword, setKeyword] = useState('');
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
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 [confirmError, setConfirmError] = useState('');
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState('');
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
const [querying, setQuerying] = useState(false);
async function load() {
async function loadUsers(query: UserFilters = appliedFilters) {
if (!tenantId) return;
setUsers(await clientApi.listUsers(tenantId));
setUsers(await clientApi.listUsers(query, tenantId));
}
useEffect(() => {
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
if (!tenantId) return;
void clientApi.listUsers({}, tenantId)
.then(setUsers)
.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 updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
}
async function queryUsers(nextFilters = filters) {
const next = {
...nextFilters,
displayName: nextFilters.displayName.trim(),
login: nextFilters.login.trim(),
};
setQuerying(true);
setError('');
try {
await loadUsers(next);
setAppliedFilters(next);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '查询用户失败');
} finally {
setQuerying(false);
}
}
function openConfirm(action: ConfirmAction) {
setConfirmError('');
setConfirmAction(action);
}
function openEditor(user?: ManagedUser) {
setForm(toForm(user));
setFormError('');
setEditingUser(user ?? null);
setCreating(!user);
}
@@ -83,10 +123,11 @@ export function ClientUsersPage() {
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return;
}
setSaving(true);
setFormError('');
const body: UserPayload = {
displayName: form.displayName,
username: form.username || form.email || form.phone,
@@ -104,9 +145,9 @@ export function ClientUsersPage() {
}
setCreating(false);
setEditingUser(null);
await load();
await loadUsers();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户保存失败');
setFormError(failure instanceof Error ? failure.message : '用户保存失败');
} finally {
setSaving(false);
}
@@ -114,13 +155,28 @@ export function ClientUsersPage() {
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);
setConfirming(true);
setConfirmError('');
try {
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);
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败';
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
setConfirming(false);
return;
}
setConfirmAction(null);
await load();
try {
await loadUsers();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户列表刷新失败');
} finally {
setConfirming(false);
}
}
async function savePassword() {
@@ -145,8 +201,8 @@ export function ClientUsersPage() {
<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={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></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>
),
},
@@ -162,12 +218,23 @@ export function ClientUsersPage() {
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm"></Button>
</div>
<div className="system-filter-row">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
<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} />
<Select
label="状态"
onChange={(event) => updateFilter('status', event.target.value)}
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>
</div>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface system-table-card client-users-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
</div>
{(creating || editingUser) ? (
@@ -185,6 +252,7 @@ export function ClientUsersPage() {
<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}
@@ -198,8 +266,9 @@ export function ClientUsersPage() {
) : 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' ? '删除用户' : '变更用户状态'}>
<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>