多用户与角色:新增系统管理员 + 用户管理
- 新增 users 表(scrypt 口令哈希)与持久化层,启动兜底种子账号 - 登录改为后端用户表校验账号密码;JWT 带角色;保留无DB演示回退 - 新增系统管理员角色 + 用户管理页(增删改/改角色/启停/重置密码) - 用户管理端点按 系统管理员 角色强制校验(RBAC) - 各角色可建任意多个账号(多销售/多风控/多管理) - 更新登录页快速登录与首屏快照
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* 多用户与角色:用户账号表。
|
||||
* - role: 商务/销售 | 风控 | 管理层 | 系统管理员
|
||||
* - password_hash: scrypt 派生(格式 salt:hash),不存明文
|
||||
* - 默认账号由应用启动时 ensureSeedUsers 兜底创建(首次为空时)
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS users;`);
|
||||
};
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export type AuthRole = '商务/销售' | '风控' | '管理层';
|
||||
export type AuthRole = '商务/销售' | '风控' | '管理层' | '系统管理员';
|
||||
|
||||
export interface AuthPayload {
|
||||
username: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from './redlineRules.js';
|
||||
export * from './customers.js';
|
||||
export * from './minWages.js';
|
||||
export * from './drafts.js';
|
||||
export * from './users.js';
|
||||
export * from './settings.js';
|
||||
export * from './regionRates.js';
|
||||
export * from './rejectReasons.js';
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 用户账号持久化(多用户 + 角色)。
|
||||
*
|
||||
* 密码用 Node scrypt 派生(salt:hash),不依赖外部库;校验用 timingSafeEqual 防时序攻击。
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type UserRole = '商务/销售' | '风控' | '管理层' | '系统管理员';
|
||||
|
||||
export const USER_ROLES: readonly UserRole[] = ['商务/销售', '风控', '管理层', '系统管理员'];
|
||||
|
||||
/** 对外用户记录(不含密码哈希)。 */
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** scrypt 派生口令哈希,返回 `salt:hash`。 */
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/** 校验明文口令与存储哈希是否匹配。 */
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (salt === undefined || hash === undefined || salt === '' || hash === '') return false;
|
||||
const derived = scryptSync(password, salt, 64);
|
||||
const expected = Buffer.from(hash, 'hex');
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
function mapRow(r: Record<string, unknown>): UserRecord {
|
||||
return {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
displayName: r.display_name != null ? String(r.display_name) : null,
|
||||
role: String(r.role) as UserRole,
|
||||
active: r.active === true,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** 列出全部用户(不含密码)。 */
|
||||
export async function listUsers(pool: pg.Pool): Promise<UserRecord[]> {
|
||||
const res = await pool.query(
|
||||
'SELECT id, username, display_name, role, active, created_at FROM users ORDER BY role, username',
|
||||
);
|
||||
return (res.rows as Array<Record<string, unknown>>).map(mapRow);
|
||||
}
|
||||
|
||||
/** 按用户名取用户(含密码哈希,仅供登录校验内部使用)。 */
|
||||
export async function getUserByUsername(
|
||||
pool: pg.Pool,
|
||||
username: string,
|
||||
): Promise<(UserRecord & { passwordHash: string }) | null> {
|
||||
const res = await pool.query(
|
||||
'SELECT id, username, display_name, role, active, created_at, password_hash FROM users WHERE username = $1',
|
||||
[username],
|
||||
);
|
||||
const r = (res.rows as Array<Record<string, unknown>>)[0];
|
||||
if (!r) return null;
|
||||
return { ...mapRow(r), passwordHash: String(r.password_hash) };
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/** 新增用户。用户名已存在则抛错。 */
|
||||
export async function createUser(pool: pg.Pool, input: CreateUserInput): Promise<UserRecord> {
|
||||
const id = `user-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const res = await pool.query(
|
||||
`INSERT INTO users(id, username, display_name, password_hash, role, active)
|
||||
VALUES($1,$2,$3,$4,$5,true)
|
||||
RETURNING id, username, display_name, role, active, created_at`,
|
||||
[id, input.username, input.displayName ?? null, hashPassword(input.password), input.role],
|
||||
);
|
||||
return mapRow((res.rows as Array<Record<string, unknown>>)[0]!);
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
displayName?: string | null;
|
||||
role?: UserRole;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/** 更新用户的显示名/角色/启用状态。 */
|
||||
export async function updateUser(pool: pg.Pool, id: string, patch: UpdateUserInput): Promise<UserRecord | null> {
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
let i = 1;
|
||||
if (patch.displayName !== undefined) { sets.push(`display_name = $${i++}`); vals.push(patch.displayName); }
|
||||
if (patch.role !== undefined) { sets.push(`role = $${i++}`); vals.push(patch.role); }
|
||||
if (patch.active !== undefined) { sets.push(`active = $${i++}`); vals.push(patch.active); }
|
||||
if (sets.length === 0) {
|
||||
const cur = await pool.query('SELECT id, username, display_name, role, active, created_at FROM users WHERE id=$1', [id]);
|
||||
const r = (cur.rows as Array<Record<string, unknown>>)[0];
|
||||
return r ? mapRow(r) : null;
|
||||
}
|
||||
sets.push('updated_at = now()');
|
||||
vals.push(id);
|
||||
const res = await pool.query(
|
||||
`UPDATE users SET ${sets.join(', ')} WHERE id = $${i} RETURNING id, username, display_name, role, active, created_at`,
|
||||
vals,
|
||||
);
|
||||
const r = (res.rows as Array<Record<string, unknown>>)[0];
|
||||
return r ? mapRow(r) : null;
|
||||
}
|
||||
|
||||
/** 重置/修改用户密码。 */
|
||||
export async function setUserPassword(pool: pg.Pool, id: string, password: string): Promise<void> {
|
||||
await pool.query('UPDATE users SET password_hash=$1, updated_at=now() WHERE id=$2', [hashPassword(password), id]);
|
||||
}
|
||||
|
||||
/** 删除用户。 */
|
||||
export async function deleteUser(pool: pg.Pool, id: string): Promise<void> {
|
||||
await pool.query('DELETE FROM users WHERE id=$1', [id]);
|
||||
}
|
||||
|
||||
/** 统计用户数。 */
|
||||
export async function countUsers(pool: pg.Pool): Promise<number> {
|
||||
const res = await pool.query('SELECT count(*)::int AS n FROM users');
|
||||
return Number((res.rows as Array<{ n: number }>)[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/** 首次启动兜底:用户表为空时创建默认账号(密码 123456,请尽快修改)。 */
|
||||
export async function ensureSeedUsers(pool: pg.Pool): Promise<void> {
|
||||
if ((await countUsers(pool)) > 0) return;
|
||||
const seeds: CreateUserInput[] = [
|
||||
{ username: '系统账号', displayName: '系统管理员', password: '123456', role: '系统管理员' },
|
||||
{ username: '管理账号', displayName: '默认管理层', password: '123456', role: '管理层' },
|
||||
{ username: '风控账号', displayName: '默认风控', password: '123456', role: '风控' },
|
||||
{ username: '销售账号', displayName: '默认销售', password: '123456', role: '商务/销售' },
|
||||
];
|
||||
for (const s of seeds) {
|
||||
await createUser(pool, s).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
+98
-4
@@ -62,6 +62,16 @@ import {
|
||||
getDraft,
|
||||
upsertDraft,
|
||||
deleteDraft,
|
||||
listUsers,
|
||||
getUserByUsername,
|
||||
createUser,
|
||||
updateUser,
|
||||
setUserPassword,
|
||||
deleteUser,
|
||||
verifyPassword,
|
||||
ensureSeedUsers,
|
||||
USER_ROLES,
|
||||
type UserRole,
|
||||
getSetting,
|
||||
setSetting,
|
||||
loadAllRegionRates,
|
||||
@@ -126,14 +136,32 @@ app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* 登录签发 JWT(生产)。演示模式(未配置 AUTH_SECRET)下返回空 token,前端沿用本地登录。
|
||||
* 登录:有数据库时按 用户名+密码 校验用户表;无数据库时回退演示模式(仅按角色签发)。
|
||||
* 返回 JWT(未配置 AUTH_SECRET 时 token 为空,前端仍可凭返回的角色进入演示)。
|
||||
*/
|
||||
app.post('/api/auth/login', async (c) => {
|
||||
const body = await c.req.json<{ username?: string; role?: string }>().catch(() => ({}) as { username?: string; role?: string });
|
||||
const body = await c.req.json<{ username?: string; password?: string; role?: string }>().catch(() => ({}) as { username?: string; password?: string; role?: string });
|
||||
const username = (body.username ?? '').trim();
|
||||
const password = body.password ?? '';
|
||||
|
||||
if (username === '') {
|
||||
return c.json({ error: '请输入用户名' }, 400);
|
||||
}
|
||||
|
||||
// 有数据库:以用户表为准校验账号密码。
|
||||
if (pool !== null) {
|
||||
const user = await getUserByUsername(pool, username);
|
||||
if (user === null || !user.active || !verifyPassword(password, user.passwordHash)) {
|
||||
return c.json({ error: '用户名或密码错误,或账号已停用' }, 401);
|
||||
}
|
||||
const token = issueToken(user.username, user.role as AuthRole);
|
||||
return c.json({ token, username: user.username, role: user.role, displayName: user.displayName });
|
||||
}
|
||||
|
||||
// 无数据库(演示模式):按传入角色签发,兼容旧行为。
|
||||
const role = body.role as AuthRole | undefined;
|
||||
if (username === '' || (role !== '商务/销售' && role !== '风控' && role !== '管理层')) {
|
||||
return c.json({ error: '用户名或角色无效' }, 400);
|
||||
if (role !== '商务/销售' && role !== '风控' && role !== '管理层' && role !== '系统管理员') {
|
||||
return c.json({ error: '演示模式需提供合法角色' }, 400);
|
||||
}
|
||||
const token = issueToken(username, role);
|
||||
return c.json({ token, username, role });
|
||||
@@ -1193,6 +1221,71 @@ app.delete('/api/drafts/:id', async (c) => {
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 用户管理(系统管理员):多用户与角色维护。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 列出全部用户(系统管理员)。 */
|
||||
app.get('/api/users', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json([]);
|
||||
return c.json(await listUsers(pool));
|
||||
});
|
||||
|
||||
/** 新增用户(系统管理员)。 */
|
||||
app.post('/api/users', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
const body = await c.req.json<{ username?: string; displayName?: string; password?: string; role?: string }>();
|
||||
const username = (body.username ?? '').trim();
|
||||
const password = body.password ?? '';
|
||||
const role = body.role as UserRole | undefined;
|
||||
if (username === '' || password.length < 6) {
|
||||
return c.json({ error: '用户名必填,密码至少 6 位' }, 400);
|
||||
}
|
||||
if (role === undefined || !USER_ROLES.includes(role)) {
|
||||
return c.json({ error: '非法角色' }, 400);
|
||||
}
|
||||
const exists = await getUserByUsername(pool, username);
|
||||
if (exists !== null) {
|
||||
return c.json({ error: '用户名已存在' }, 409);
|
||||
}
|
||||
const user = await createUser(pool, { username, displayName: body.displayName ?? null, password, role });
|
||||
return c.json(user);
|
||||
});
|
||||
|
||||
/** 更新用户显示名/角色/启用状态(系统管理员)。 */
|
||||
app.put('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
const id = c.req.param('id') as string;
|
||||
const body = await c.req.json<{ displayName?: string | null; role?: string; active?: boolean }>();
|
||||
const patch: { displayName?: string | null; role?: UserRole; active?: boolean } = {};
|
||||
if (body.displayName !== undefined) patch.displayName = body.displayName;
|
||||
if (body.role !== undefined) {
|
||||
if (!USER_ROLES.includes(body.role as UserRole)) return c.json({ error: '非法角色' }, 400);
|
||||
patch.role = body.role as UserRole;
|
||||
}
|
||||
if (body.active !== undefined) patch.active = body.active;
|
||||
const updated = await updateUser(pool, id, patch);
|
||||
if (updated === null) return c.json({ error: '用户不存在' }, 404);
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
/** 重置用户密码(系统管理员)。 */
|
||||
app.post('/api/users/:id/password', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
const id = c.req.param('id') as string;
|
||||
const body = await c.req.json<{ password?: string }>();
|
||||
if ((body.password ?? '').length < 6) return c.json({ error: '密码至少 6 位' }, 400);
|
||||
await setUserPassword(pool, id, body.password as string);
|
||||
return c.json({ reset: true });
|
||||
});
|
||||
|
||||
/** 删除用户(系统管理员)。 */
|
||||
app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
await deleteUser(pool, c.req.param('id') as string);
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/region-rates/engine-defaults
|
||||
* 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。
|
||||
@@ -2127,6 +2220,7 @@ async function start(): Promise<void> {
|
||||
const arc = await pg.loadArchived();
|
||||
for (const [id, v] of arc) archivedById.set(id, v);
|
||||
calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null;
|
||||
await ensureSeedUsers(pool);
|
||||
console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`);
|
||||
} catch (err) {
|
||||
console.error('PostgreSQL 连接失败,回退到进程内存储:', err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
+31
-32
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user