多用户与角色:新增系统管理员 + 用户管理

- 新增 users 表(scrypt 口令哈希)与持久化层,启动兜底种子账号
- 登录改为后端用户表校验账号密码;JWT 带角色;保留无DB演示回退
- 新增系统管理员角色 + 用户管理页(增删改/改角色/启停/重置密码)
- 用户管理端点按 系统管理员 角色强制校验(RBAC)
- 各角色可建任意多个账号(多销售/多风控/多管理)
- 更新登录页快速登录与首屏快照
This commit is contained in:
freedakgmail
2026-06-13 17:35:52 +08:00
parent 2537e5beef
commit 6562208b13
12 changed files with 563 additions and 42 deletions
+3 -3
View File
@@ -20,10 +20,10 @@ export function Login(): JSX.Element {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent): void => {
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
clearError();
const ok = login(username, password);
const ok = await login(username, password);
if (ok) {
navigate('/');
}
@@ -109,7 +109,7 @@ export function Login(): JSX.Element {
</div>
<div style={cardStyle}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px` }}>
<form onSubmit={(e) => { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px` }}>
<div>
<label style={{ display: 'block', marginBottom: `${space(1)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
+195
View File
@@ -0,0 +1,195 @@
/**
* 用户管理 — 系统管理员维护多用户与角色(多销售/多风控/多管理)。
* 支持:新增、改显示名/角色、启用/停用、重置密码、删除。
*/
import { useCallback, useEffect, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import {
listUsers, createUserApi, updateUserApi, resetUserPasswordApi, deleteUserApi,
type UserItem, type UserRole,
} from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
const ROLES: readonly UserRole[] = ['商务/销售', '风控', '管理层', '系统管理员'];
const ROLE_STYLE: Record<UserRole, { bg: string; fg: string }> = {
'商务/销售': { bg: 'rgba(16,128,61,0.12)', fg: '#15803D' },
'风控': { bg: 'rgba(180,83,9,0.12)', fg: '#B45309' },
'管理层': { bg: 'rgba(79,70,229,0.12)', fg: '#4F46E5' },
'系统管理员': { bg: 'rgba(8,145,178,0.12)', fg: '#0891B2' },
};
const EMPTY = { username: '', displayName: '', password: '', role: '商务/销售' as UserRole };
export function UserManagement(): JSX.Element {
const { user: current } = useAuthStore();
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState(EMPTY);
const [filterRole, setFilterRole] = useState<string>('');
const load = useCallback(() => {
setLoading(true);
listUsers().then(setUsers).catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')).finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
async function handleCreate(): Promise<void> {
setError(null);
if (form.username.trim() === '' || form.password.length < 6) { setError('用户名必填,密码至少 6 位'); return; }
try {
await createUserApi({ username: form.username.trim(), ...(form.displayName.trim() !== '' ? { displayName: form.displayName.trim() } : {}), password: form.password, role: form.role });
setForm(EMPTY); setShowForm(false); setNotice('已新增用户'); load();
} catch (e) { setError(e instanceof Error ? e.message : '新增失败'); }
}
async function changeRole(u: UserItem, role: UserRole): Promise<void> {
try { await updateUserApi(u.id, { role }); setNotice(`已将 ${u.username} 调整为 ${role}`); load(); }
catch (e) { setError(e instanceof Error ? e.message : '更新失败'); }
}
async function toggleActive(u: UserItem): Promise<void> {
try { await updateUserApi(u.id, { active: !u.active }); load(); }
catch (e) { setError(e instanceof Error ? e.message : '更新失败'); }
}
async function resetPwd(u: UserItem): Promise<void> {
const pw = window.prompt(`为「${u.username}」设置新密码(至少 6 位)`, '');
if (pw === null) return;
if (pw.length < 6) { setError('密码至少 6 位'); return; }
try { await resetUserPasswordApi(u.id, pw); setNotice(`已重置 ${u.username} 的密码`); }
catch (e) { setError(e instanceof Error ? e.message : '重置失败'); }
}
async function remove(u: UserItem): Promise<void> {
if (!window.confirm(`确认删除用户「${u.username}」?此操作不可恢复。`)) return;
try { await deleteUserApi(u.id); setNotice(`已删除 ${u.username}`); load(); }
catch (e) { setError(e instanceof Error ? e.message : '删除失败'); }
}
const filtered = filterRole ? users.filter((u) => u.role === filterRole) : users;
const byRole = (r: UserRole): number => users.filter((u) => u.role === r).length;
const inputStyle: React.CSSProperties = {
padding: `${space(2)}px ${space(3)}px`, border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'), width: '100%',
};
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1100, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
<div>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}></h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0` }}>
//
</p>
</div>
<button onClick={() => { setShowForm((v) => !v); setForm(EMPTY); setError(null); }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>
<Icon name={showForm ? 'close' : 'plus'} size={16} color="#fff" /> {showForm ? '收起' : '新增用户'}
</button>
</div>
{/* 角色统计 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
{ROLES.map((r) => (
<div key={r} style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: ROLE_STYLE[r].bg, color: ROLE_STYLE[r].fg }}>
<Icon name="user" size={16} />
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{r}</span>
<span style={{ ...typographyStyle('title'), fontWeight: 800, color: ROLE_STYLE[r].fg }}>{byRole(r)}</span>
</div>
</div>
))}
</div>
{error !== null && <div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.08)', color: colorVar('color.risk.critical'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>{error}</div>}
{notice !== null && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(3)}px`, backgroundColor: 'rgba(16,128,61,0.08)', color: '#15803D', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, fontWeight: 600 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={16} /> {notice}</span>
<button type="button" onClick={() => setNotice(null)} style={{ display: 'inline-flex', border: 'none', background: 'transparent', color: '#15803D', cursor: 'pointer' }} aria-label="关闭"><Icon name="close" size={16} /></button>
</div>
)}
{showForm && (
<div style={{ marginBottom: `${space(4)}px`, padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: SHADOW.sm }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: `${space(3)}px` }}>
<Field label="用户名(登录名,唯一)"><input style={inputStyle} value={form.username} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} placeholder="如 zhangsan" /></Field>
<Field label="显示名"><input style={inputStyle} value={form.displayName} onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))} placeholder="如 张三" /></Field>
<Field label="初始密码(≥6 位)"><input style={inputStyle} type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="至少 6 位" /></Field>
<Field label="角色"><select style={inputStyle} value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as UserRole }))}>{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}</select></Field>
</div>
<div style={{ display: 'flex', gap: `${space(2)}px`, marginTop: `${space(3)}px`, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowForm(false); setForm(EMPTY); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
<button onClick={() => { void handleCreate(); }} style={{ padding: `${space(2)}px ${space(5)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}></button>
</div>
</div>
)}
<Card title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{filtered.length}</span>
<select value={filterRole} onChange={(e) => setFilterRole(e.target.value)} style={{ ...inputStyle, width: 'auto', ...typographyStyle('caption'), fontWeight: 400 }}>
<option value=""></option>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
}>
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['用户名', '显示名', '角色', '状态', '创建时间', '操作'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(2)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{filtered.map((u) => {
const isSelf = current?.username === u.username;
return (
<tr key={u.id}>
<td style={{ ...tds, fontWeight: 600 }}>{u.username}{isSelf && <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></span>}</td>
<td style={tds}>{u.displayName ?? '—'}</td>
<td style={tds}>
<select value={u.role} disabled={isSelf} onChange={(e) => { void changeRole(u, e.target.value as UserRole); }}
style={{ padding: '2px 8px', borderRadius: '999px', border: `1px solid ${ROLE_STYLE[u.role].fg}33`, backgroundColor: ROLE_STYLE[u.role].bg, color: ROLE_STYLE[u.role].fg, fontWeight: 700, ...typographyStyle('caption'), cursor: isSelf ? 'not-allowed' : 'pointer' }}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td style={tds}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: u.active ? '#15803D' : colorVar('color.text.secondary') }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: u.active ? '#15803D' : colorVar('color.text.secondary'), display: 'inline-block' }} />
{u.active ? '启用' : '停用'}
</span>
</td>
<td style={{ ...tds, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{u.createdAt.slice(0, 10)}</td>
<td style={tds}>
<div style={{ display: 'flex', gap: `${space(2)}px`, whiteSpace: 'nowrap' }}>
<button onClick={() => { void resetPwd(u); }} style={linkBtn(colorVar('color.brand.primary'))}></button>
{!isSelf && <button onClick={() => { void toggleActive(u); }} style={linkBtn('#B45309')}>{u.active ? '停用' : '启用'}</button>}
{!isSelf && <button onClick={() => { void remove(u); }} style={linkBtn('#BE123C')}></button>}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
const tds: React.CSSProperties = { padding: `${space(2)}px ${space(3)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' };
function linkBtn(color: string): React.CSSProperties {
return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' };
}
function Field({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
return <div><label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'block', marginBottom: 4 }}>{label}</label>{children}</div>;
}