feat: add customer authentication and scoped RBAC
This commit is contained in:
@@ -12,19 +12,22 @@ import {
|
||||
IconUser,
|
||||
IconExit
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { hasMenu } from '../auth/session';
|
||||
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
||||
|
||||
const navigation = [
|
||||
{ to: '/monitor', label: '全局监控', icon: IconHome },
|
||||
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
|
||||
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
|
||||
{ to: '/statistics', label: '里程查询', icon: IconBarChartHStroked },
|
||||
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
|
||||
{ to: '/access', label: '接入管理', icon: IconBox }
|
||||
{ to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconMapPin },
|
||||
{ to: '/history', menu: 'history', label: '历史数据', icon: IconBarChartHStroked },
|
||||
{ to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartHStroked },
|
||||
{ to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm },
|
||||
{ to: '/access', menu: 'access', label: '接入管理', icon: IconBox },
|
||||
{ to: '/users', menu: 'users', label: '账号管理', icon: IconUser }
|
||||
];
|
||||
|
||||
const pageNames: Record<string, string> = {
|
||||
@@ -35,7 +38,8 @@ const pageNames: Record<string, string> = {
|
||||
statistics: '里程查询',
|
||||
alerts: '告警中心',
|
||||
access: '接入管理',
|
||||
operations: '运维质量'
|
||||
operations: '运维质量',
|
||||
users: '账号管理'
|
||||
};
|
||||
|
||||
const pageHelp: Record<string, { summary: string; tips: string[] }> = {
|
||||
@@ -46,7 +50,8 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
|
||||
statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] },
|
||||
alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] },
|
||||
access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] },
|
||||
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] }
|
||||
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] },
|
||||
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
|
||||
};
|
||||
|
||||
function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) {
|
||||
@@ -72,8 +77,9 @@ export function AppShell() {
|
||||
const activeRoutePath = `/${section}`;
|
||||
const { session, logout } = usePlatformSession();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches);
|
||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
|
||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role];
|
||||
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 680px)');
|
||||
@@ -91,24 +97,61 @@ export function AppShell() {
|
||||
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
|
||||
<div className="v2-topbar-actions">
|
||||
<button type="button" aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><IconHelpCircle /></button>
|
||||
<span className="v2-current-user"><IconUser /><b>{session.name}</b><small>{roleLabel}</small></span>
|
||||
<button type="button" className="v2-current-user" title="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /><b>{session.name}</b><small>{roleLabel}</small></button>
|
||||
<button type="button" className="v2-password-mobile" aria-label="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /></button>
|
||||
<button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="v2-content"><Outlet /></main>
|
||||
</div>
|
||||
{helpOpen ? <div id="v2-context-help"><ContextHelp section={section} onClose={() => setHelpOpen(false)} /></div> : null}
|
||||
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged: () => void }) {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (newPassword !== confirmPassword) { setError('两次输入的新密码不一致'); return; }
|
||||
setPending(true); setError('');
|
||||
try {
|
||||
await api.changePassword({ currentPassword, newPassword });
|
||||
onChanged();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '密码修改失败');
|
||||
} finally { setPending(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !pending) onClose(); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [onClose, pending]);
|
||||
return <div className="v2-password-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget && !pending) onClose(); }}><form className="v2-password-dialog" role="dialog" aria-modal="true" aria-labelledby="v2-password-title" onSubmit={submit}>
|
||||
<header><div><h2 id="v2-password-title">修改登录密码</h2><p>修改成功后,所有设备需要重新登录。</p></div><button type="button" aria-label="关闭修改密码" onClick={onClose}>×</button></header>
|
||||
<label><span>当前密码</span><input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} /></label>
|
||||
<label><span>新密码</span><input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>确认新密码</span><input required minLength={10} type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></label>
|
||||
{error ? <em role="alert">{error}</em> : null}
|
||||
<footer><button type="button" onClick={onClose} disabled={pending}>取消</button><button type="submit" disabled={pending || !currentPassword || !newPassword || !confirmPassword}>{pending ? '正在修改…' : '确认修改'}</button></footer>
|
||||
</form></div>;
|
||||
}
|
||||
|
||||
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]];
|
||||
const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], { to: '/operations', label: '运维质量', icon: IconSetting }];
|
||||
const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], navigation[7], { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }];
|
||||
|
||||
function MobileNavigation() {
|
||||
const location = useLocation();
|
||||
const { session } = usePlatformSession();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreActive = mobileMoreNavigation.some((item) => location.pathname.startsWith(item.to));
|
||||
const primary = mobilePrimaryNavigation.filter((item) => hasMenu(session, item.menu));
|
||||
const more = mobileMoreNavigation.filter((item) => hasMenu(session, item.menu));
|
||||
const moreActive = more.some((item) => location.pathname.startsWith(item.to));
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
useEffect(() => setMoreOpen(false), [location.pathname]);
|
||||
useEffect(() => {
|
||||
@@ -120,17 +163,19 @@ function MobileNavigation() {
|
||||
|
||||
const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
|
||||
return <>
|
||||
{moreOpen ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong>更多功能</strong><span>数据分析与系统管理</span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{mobileMoreNavigation.map(link)}</nav></section></div> : null}
|
||||
{moreOpen && more.length ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong>更多功能</strong><span>数据分析与系统管理</span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{more.map(link)}</nav></section></div> : null}
|
||||
<nav className="v2-mobile-navigation" aria-label="主导航">
|
||||
{mobilePrimaryNavigation.map(link)}
|
||||
<button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span>更多</span></button>
|
||||
{primary.map(link)}
|
||||
{more.length ? <button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span>更多</span></button> : null}
|
||||
</nav>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const { session } = usePlatformSession();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
const visibleNavigation = navigation.filter((item) => hasMenu(session, item.menu));
|
||||
|
||||
return (
|
||||
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
|
||||
@@ -139,17 +184,17 @@ function Sidebar() {
|
||||
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<nav className="v2-navigation" aria-label="主导航">
|
||||
{navigation.map(({ to, label, icon: Icon }) => (
|
||||
{visibleNavigation.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} aria-label={label} title={collapsed ? label : undefined} onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
|
||||
<Icon size="large" />
|
||||
<span className="v2-nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
|
||||
{hasMenu(session, 'operations') ? <NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
|
||||
<IconSetting size="large" />
|
||||
<span className="v2-nav-label">运维质量</span>
|
||||
</NavLink>
|
||||
</NavLink> : null}
|
||||
<button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
|
||||
<IconChevronLeft />
|
||||
<span>收起</span>
|
||||
|
||||
Reference in New Issue
Block a user