feat: strengthen risk controls and review workflows
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user