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

- 新增 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
+5
View File
@@ -11,6 +11,7 @@ import { Login } from './pages/Login.js';
import { RateManagement } from './pages/RateManagement.js';
import { RedlineManagement } from './pages/RedlineManagement.js';
import { CustomerManagement } from './pages/CustomerManagement.js';
import { UserManagement } from './pages/UserManagement.js';
/** 路由守卫:未登录重定向到登录页。 */
function ProtectedRoute(): JSX.Element {
@@ -49,6 +50,10 @@ export function App(): JSX.Element {
<Route element={<RoleRoute allow={['商务/销售', '管理层']} />}>
<Route path="/customers" element={<CustomerManagement />} />
</Route>
{/* 用户管理:系统管理员 */}
<Route element={<RoleRoute allow={['系统管理员']} />}>
<Route path="/users" element={<UserManagement />} />
</Route>
</Route>
</Route>
</Routes>
File diff suppressed because one or more lines are too long
+45
View File
@@ -799,6 +799,51 @@ export async function deleteDraftApi(id: string): Promise<void> {
await fetch(`${API_BASE}/api/drafts/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() });
}
/** 用户角色。 */
export type UserRole = '商务/销售' | '风控' | '管理层' | '系统管理员';
/** 用户账号(不含密码)。 */
export interface UserItem {
id: string;
username: string;
displayName: string | null;
role: UserRole;
active: boolean;
createdAt: string;
}
/** 列出全部用户(系统管理员)。 */
export async function listUsers(): Promise<UserItem[]> {
return request<UserItem[]>('GET', '/api/users');
}
/** 新增用户。 */
export async function createUserApi(input: { username: string; displayName?: string; password: string; role: UserRole }): Promise<UserItem> {
return request<UserItem>('POST', '/api/users', input);
}
/** 更新用户显示名/角色/启用状态。 */
export async function updateUserApi(id: string, patch: { displayName?: string | null; role?: UserRole; active?: boolean }): Promise<UserItem> {
const res = await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify(patch),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `HTTP ${res.status}`);
return data as UserItem;
}
/** 重置用户密码。 */
export async function resetUserPasswordApi(id: string, password: string): Promise<void> {
await request('POST', `/api/users/${encodeURIComponent(id)}/password`, { password });
}
/** 删除用户。 */
export async function deleteUserApi(id: string): Promise<void> {
await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() });
}
/** 方案对比。 */
export interface ScenarioItem {
id: string;
+7
View File
@@ -44,6 +44,7 @@ const ROLE_STYLE: Record<AuthRole, { 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' },
};
export function AppShell(): JSX.Element {
@@ -179,6 +180,12 @@ export function AppShell(): JSX.Element {
</span>
)}
{role === '系统管理员' && (
<span data-nav-link style={navLinkStyle('/users')} onClick={() => navigate('/users')}>
</span>
)}
<GlossaryButton />
<div
+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>;
}
+31 -32
View File
@@ -9,34 +9,34 @@ import { create } from 'zustand';
import { API_BASE } from '../api/client.js';
/** 登录用户角色。 */
export type AuthRole = '商务/销售' | '风控' | '管理层';
export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理员';
/** 测试账号。 */
/** 快速登录用的种子账号(密码由后端用户表校验;这里仅用于一键填充)。 */
export const TEST_ACCOUNTS: readonly {
readonly username: string;
readonly password: string;
readonly role: AuthRole;
readonly aliases: readonly string[];
}[] = [
{ username: '销售账号', password: '123456', role: '商务/销售', aliases: ['sales'] },
{ username: '风控账号', password: '123456', role: '风控', aliases: ['risk'] },
{ username: '管理账号', password: '123456', role: '管理层', aliases: ['mgmt'] },
{ username: '销售账号', password: '123456', role: '商务/销售' },
{ username: '风控账号', password: '123456', role: '风控' },
{ username: '管理账号', password: '123456', role: '管理层' },
{ username: '系统账号', password: '123456', role: '系统管理员' },
];
/** 认证状态。 */
export interface AuthState {
readonly isAuthenticated: boolean;
readonly user: { username: string; role: AuthRole } | null;
readonly user: { username: string; role: AuthRole; displayName?: string } | null;
readonly error: string | null;
login(username: string, password: string): boolean;
login(username: string, password: string): Promise<boolean>;
logout(): void;
clearError(): void;
}
const STORAGE_KEY = 'risk-agent-auth';
function loadFromStorage(): { isAuthenticated: boolean; user: { username: string; role: AuthRole } | null } {
function loadFromStorage(): { isAuthenticated: boolean; user: AuthState['user'] } {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return { isAuthenticated: false, user: null };
@@ -47,7 +47,7 @@ function loadFromStorage(): { isAuthenticated: boolean; user: { username: string
}
}
function saveToStorage(user: { username: string; role: AuthRole } | null): void {
function saveToStorage(user: AuthState['user']): void {
if (user === null) {
localStorage.removeItem(STORAGE_KEY);
} else {
@@ -62,30 +62,29 @@ export const useAuthStore = create<AuthState>((set) => ({
user: initial.user,
error: null,
login: (username, password) => {
const account = TEST_ACCOUNTS.find(
(a) => (a.username === username || a.aliases.includes(username)) && a.password === password,
);
if (account === undefined) {
set({ error: '用户名或密码错误' });
login: async (username, password) => {
try {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
});
const d = await res.json().catch(() => ({}));
if (!res.ok) {
set({ error: typeof d.error === 'string' ? d.error : '用户名或密码错误' });
return false;
}
const role = d.role as AuthRole;
const user = { username: String(d.username ?? username), role, ...(d.displayName ? { displayName: String(d.displayName) } : {}) };
saveToStorage(user);
if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token);
else localStorage.removeItem('risk-agent-token');
set({ isAuthenticated: true, user, error: null });
return true;
} catch {
set({ error: '无法连接服务器,请稍后重试' });
return false;
}
const user = { username: account.username, role: account.role };
saveToStorage(user);
// 生产 RBAC:向后端换取 JWT 并保存(演示模式后端返回空 token,不影响本地登录)。
void fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: account.username, role: account.role }),
})
.then((r) => r.json())
.then((d: { token?: string }) => {
if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token);
else localStorage.removeItem('risk-agent-token');
})
.catch(() => undefined);
set({ isAuthenticated: true, user, error: null });
return true;
},
logout: () => {