feat: add customer authentication and scoped RBAC
This commit is contained in:
137
vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
Normal file
137
vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user