多用户与角色:新增系统管理员 + 用户管理
- 新增 users 表(scrypt 口令哈希)与持久化层,启动兜底种子账号 - 登录改为后端用户表校验账号密码;JWT 带角色;保留无DB演示回退 - 新增系统管理员角色 + 用户管理页(增删改/改角色/启停/重置密码) - 用户管理端点按 系统管理员 角色强制校验(RBAC) - 各角色可建任意多个账号(多销售/多风控/多管理) - 更新登录页快速登录与首屏快照
This commit is contained in:
@@ -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') }}>
|
||||
用户名
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user