163 lines
12 KiB
TypeScript
163 lines
12 KiB
TypeScript
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 });
|
||
}
|
||
|
||
function uniqueVehicles<T extends { vin: string; plate: string }>(vehicles: T[]) {
|
||
const byVIN = new Map<string, T>();
|
||
for (const vehicle of vehicles) {
|
||
const vin = vehicle.vin.trim().toUpperCase();
|
||
const current = byVIN.get(vin);
|
||
if (!vin || (current?.plate && !vehicle.plate)) continue;
|
||
byVIN.set(vin, vehicle);
|
||
}
|
||
return [...byVIN.values()];
|
||
}
|
||
|
||
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 [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
||
const [feedback, setFeedback] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (!creating && selected) {
|
||
setDraft(draftFromUser(selected));
|
||
setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate])));
|
||
}
|
||
}, [creating, selected]);
|
||
|
||
const candidates = useQuery({
|
||
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
|
||
queryFn: ({ signal }) => api.vehicleCoverage(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 candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]);
|
||
useEffect(() => {
|
||
if (candidateVehicles.length === 0) return;
|
||
setVehicleLabels((current) => {
|
||
const next = { ...current };
|
||
for (const vehicle of candidateVehicles) next[vehicle.vin] = vehicle.plate;
|
||
return next;
|
||
});
|
||
}, [candidateVehicles]);
|
||
|
||
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('');
|
||
setVehicleLabels({});
|
||
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> : candidateVehicles.length === 0 ? <p>没有匹配车辆</p> : candidateVehicles.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={vehicle.vin} 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) => { const plate = vehicleLabels[vin]; return <button type="button" key={vin} aria-label={`移除 ${plate || vin}`} onClick={() => toggleVIN(vin)}><span><b>{plate || '未登记车牌'}</b><small>{vin}</small></span><i>×</i></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>;
|
||
}
|