feat: add customer authentication and scoped RBAC

This commit is contained in:
lingniu
2026-07-16 13:58:28 +08:00
parent 6d6c9ce534
commit a1195fb97d
28 changed files with 1738 additions and 97 deletions

View File

@@ -0,0 +1,137 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { AdminUser, CustomerUserInput } from '../../api/types';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
];
type Draft = {
username: string;
displayName: string;
password: string;
status: 'enabled' | 'disabled';
customerRef: string;
tenantRef: string;
menuKeys: string[];
vehicleVins: string[];
};
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleVins: [] };
function draftFromUser(user?: AdminUser): Draft {
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleVins: [] };
return { username: user.username, displayName: user.displayName, password: '', status: user.status, customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], vehicleVins: [...user.vehicleVins] };
}
function formatTime(value?: string) {
if (!value) return '尚未登录';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
export default function UsersPage() {
const queryClient = useQueryClient();
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
const [selectedID, setSelectedID] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
const [vehicleKeyword, setVehicleKeyword] = useState('');
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
const [bulkVINs, setBulkVINs] = useState('');
const [feedback, setFeedback] = useState('');
useEffect(() => {
if (!creating && selected) setDraft(draftFromUser(selected));
}, [creating, selected]);
const candidates = useQuery({
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
queryFn: ({ signal }) => api.vehicles(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
enabled: deferredVehicleKeyword.length >= 1,
staleTime: 30_000
});
const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]);
const save = useMutation({
mutationFn: async () => {
const input: CustomerUserInput = { displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, vehicleVins: draft.vehicleVins };
if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password });
if (!selected) throw new Error('请先选择客户账号');
return api.updateCustomerUser(selected.id, input);
},
onSuccess: async (result) => {
setFeedback(creating ? '客户账号已创建' : '账号与权限已更新');
setCreating(false);
setSelectedID(result.id);
setDraft((value) => ({ ...value, password: '' }));
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
});
const startCreate = () => {
setCreating(true);
setSelectedID(null);
setDraft(draftFromUser());
setVehicleKeyword('');
setBulkVINs('');
setFeedback('');
};
const selectCustomer = (user: AdminUser) => {
setCreating(false);
setSelectedID(user.id);
setDraft(draftFromUser(user));
setVehicleKeyword('');
setBulkVINs('');
setFeedback('');
};
const toggleVIN = (vin: string) => setDraft((value) => ({ ...value, vehicleVins: value.vehicleVins.includes(vin) ? value.vehicleVins.filter((item) => item !== vin) : [...value.vehicleVins, vin].sort() }));
const addBulkVINs = () => {
const next = bulkVINs.split(/[\s,;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
setDraft((value) => ({ ...value, vehicleVins: [...new Set([...value.vehicleVins, ...next])].sort() }));
setBulkVINs('');
};
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
return <div className="v2-user-admin">
<header className="v2-user-admin-heading">
<div><h2></h2><p> 30 </p></div>
<button type="button" onClick={startCreate}></button>
</header>
<div className="v2-user-admin-grid">
<aside className="v2-user-list">
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span></span></div>
{users.isPending ? <p className="v2-user-empty"></p> : customers.length === 0 ? <p className="v2-user-empty"></p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicleVins.length} </small></span>
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
</button>)}
</aside>
<main className="v2-user-editor">
{!creating && !selected ? <div className="v2-user-editor-empty"><strong></strong><p></p><button type="button" onClick={startCreate}></button></div> : <form onSubmit={submit}>
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `最近登录:${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><input type="checkbox" checked={draft.status === 'enabled'} onChange={(event) => setDraft((value) => ({ ...value, status: event.target.checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label></div>
<section><h4></h4><div className="v2-user-fields">
<label><span></span><input required disabled={!creating} value={draft.username} onChange={(event) => setDraft((value) => ({ ...value, username: event.target.value }))} placeholder="例如 customer-huadong" /></label>
<label><span></span><input required value={draft.displayName} onChange={(event) => setDraft((value) => ({ ...value, displayName: event.target.value }))} placeholder="显示在平台右上角" /></label>
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><input required={creating} type="password" autoComplete="new-password" value={draft.password} onChange={(event) => setDraft((value) => ({ ...value, password: event.target.value }))} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
</div></section>
<section><h4> <small></small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
<section><div className="v2-vehicle-permission-heading"><h4> <small> {draft.vehicleVins.length} </small></h4>{draft.vehicleVins.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleVins: [] }))}></button> : null}</div>
<div className="v2-vehicle-permission-tools"><label><span> VIN </span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span> VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}></button></span></label></div>
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p></p> : (candidates.data?.items ?? []).length === 0 ? <p></p> : candidates.data?.items.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={`${vehicle.vin}-${vehicle.protocol}`} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
{draft.vehicleVins.length ? <div className="v2-assigned-vins">{draft.vehicleVins.map((vin) => <button type="button" key={vin} onClick={() => toggleVIN(vin)}>{vin}<span>×</span></button>)}</div> : <p className="v2-user-empty"></p>}
</section>
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
</form>}
</main>
</div>
</div>;
}