fix(web): activate contextual help and filter status

This commit is contained in:
lingniu
2026-07-16 07:33:55 +08:00
parent d1764b3d66
commit 25e8427882
5 changed files with 66 additions and 3 deletions

View File

@@ -41,3 +41,19 @@ test('reschedules likely route preloads when the active module changes', async (
cleanup();
expect(secondCleanup).toHaveBeenCalledTimes(1);
});
test('opens contextual help for the active module and closes it without navigating', () => {
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
<Routes><Route element={<AppShell />}><Route path="*" element={<span></span>} /></Route></Routes>
</MemoryRouter>);
const help = screen.getByRole('button', { name: '帮助' });
expect(help).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(help);
expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效');
expect(help).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭帮助' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByText('页面内容')).toBeInTheDocument();
});

View File

@@ -37,11 +37,40 @@ const pageNames: Record<string, string> = {
operations: '运维质量'
};
const pageHelp: Record<string, { summary: string; tips: string[] }> = {
monitor: { summary: '查看全车队实时位置、状态和最新上报。', tips: ['车牌、协议和状态筛选会自动生效,无需再次提交。', '拖动地图会暂停选中车辆跟随,点击“跟随车辆”可恢复。', '文字地址按需解析,避免实时刷新持续消耗地图 API。'] },
vehicles: { summary: '按车牌或 VIN 查询车辆档案和实时遥测。', tips: ['先搜索车辆,再进入详情查看接入来源与最新数据。', '实时遥测按固定周期更新,离开页面后会自动停止。'] },
tracks: { summary: '按车辆和时间范围回放历史轨迹。', tips: ['先提交查询条件,再使用播放轴定位具体时刻。', '手动拖动地图会暂停跟随,避免操作与播放动画冲突。'] },
history: { summary: '查询车辆原始历史数据并导出证据。', tips: ['最多同时查询 5 台车辆,缩小时间范围可提高响应速度。', '导出任务在后台执行,完成前可继续使用其他页面。'] },
statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] },
alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] },
access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] },
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] }
};
function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) {
const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] };
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [onClose]);
return <div className="v2-help-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<aside className="v2-help-panel" role="dialog" aria-modal="true" aria-labelledby="v2-help-title">
<header><div><small></small><h2 id="v2-help-title">{pageNames[section] ?? '车辆数据中台'}</h2></div><button type="button" autoFocus aria-label="关闭帮助" onClick={onClose}><span aria-hidden="true">×</span></button></header>
<p>{content.summary}</p>
<ol>{content.tips.map((tip, index) => <li key={tip}><span>{index + 1}</span>{tip}</li>)}</ol>
<footer><kbd>Esc</kbd><span></span></footer>
</aside>
</div>;
}
export function AppShell() {
const location = useLocation();
const section = location.pathname.split('/')[1] || 'monitor';
const activeRoutePath = `/${section}`;
const { session, logout } = usePlatformSession();
const [helpOpen, setHelpOpen] = useState(false);
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
@@ -52,13 +81,14 @@ export function AppShell() {
<header className="v2-topbar">
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
<div className="v2-topbar-actions">
<button type="button" aria-label="帮助"><IconHelpCircle /></button>
<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" 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}
</div>
);
}

View File

@@ -93,6 +93,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('有实时位置')).toBeInTheDocument();
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
fireEvent.click(firstVehicle);

View File

@@ -310,7 +310,7 @@ export default function MonitorPage() {
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh /></button>
<button type="button" className="v2-primary-button"><IconFilter /></button>
<span className="v2-filter-live-status" role="status" title="搜索和筛选条件修改后自动生效"><IconFilter /></span>
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin /></button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList /></button></div>
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode /></button>
</section>

View File

@@ -44,6 +44,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-topbar-actions { display: flex; align-items: center; gap: 4px; }
.v2-topbar-actions button { display: grid; width: 34px; height: 34px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: #64748b; cursor: pointer; }
.v2-topbar-actions button:hover { background: #f1f5f9; }
.v2-help-backdrop { position: fixed; z-index: 1200; inset: 0; background: rgba(15,23,42,.12); }
.v2-help-panel { position: absolute; top: 58px; right: 18px; width: min(390px, calc(100vw - 28px)); border: 1px solid rgba(255,255,255,.9); border-radius: 14px; background: #fff; padding: 18px; box-shadow: 0 24px 70px rgba(15,23,42,.22); }
.v2-help-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.v2-help-panel > header small { color: var(--v2-blue); font-size: 10px; font-weight: 800; letter-spacing: .08em; }
.v2-help-panel h2 { margin: 4px 0 0; color: #17253a; font-size: 19px; }
.v2-help-panel > header button { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 8px; background: #f3f6fa; color: #627086; cursor: pointer; }
.v2-help-panel > p { margin: 15px 0; color: #59677c; font-size: 12px; line-height: 1.7; }
.v2-help-panel ol { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
.v2-help-panel li { display: grid; grid-template-columns: 24px minmax(0,1fr); align-items: start; gap: 9px; border-radius: 8px; background: #f7f9fc; padding: 10px; color: #435168; font-size: 11px; line-height: 1.65; }
.v2-help-panel li > span { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 50%; background: var(--v2-blue-soft); color: var(--v2-blue); font-size: 10px; font-weight: 800; }
.v2-help-panel > footer { display: flex; align-items: center; gap: 6px; margin-top: 14px; color: #8996a8; font-size: 10px; }
.v2-help-panel kbd { border: 1px solid #dce4ef; border-radius: 4px; background: #fff; padding: 2px 5px; color: #59677c; font-family: inherit; }
.v2-current-user { display: flex; align-items: center; gap: 6px; margin: 0 5px; color: #59677c; font-size: 11px; }.v2-current-user b { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-current-user small, .v2-role-badge { border-radius: 10px; background: var(--v2-blue-soft); padding: 2px 6px; color: var(--v2-blue); font-size: 9px; }
.v2-role-notice { margin: 6px 0; border-radius: 6px; background: #f6f8fb; padding: 8px; color: var(--v2-muted); font-size: 8px; line-height: 1.5; }
.v2-content { min-width: 0; min-height: 0; flex: 1; overflow: auto; }
@@ -81,6 +93,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-batch-search-dialog > footer button { min-width: 92px; }
.v2-batch-search-dialog > footer button:disabled { opacity: .45; cursor: not-allowed; }
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
.v2-filter-live-status { display: flex; height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid #d9e8fb; border-radius: 7px; background: #f5f9ff; padding: 0 11px; color: #3568a9; font-size: 11px; font-weight: 700; white-space: nowrap; }
.v2-filter-live-status svg { color: var(--v2-blue); }
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
.v2-secondary-button { border: 1px solid #dce4ef; background: #fff; color: #59677c; }
@@ -1107,13 +1121,14 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-topbar h1 { font-size: 17px; }
.v2-topbar-actions { gap: 0; }
.v2-topbar-actions button { width: 40px; height: 40px; }
.v2-help-panel { top: 48px; right: 7px; padding: 16px; }
.v2-current-user { display: none; }
.v2-content { overscroll-behavior: contain; }
.v2-monitor-page { height: auto; min-height: 100%; gap: 8px; overflow: visible; padding: 8px; }
.v2-filterbar { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 9px; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-search-field { grid-column: 1 / -1; height: 44px; }
.v2-search-field input, .v2-filterbar select { font-size: 16px; }
.v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 44px; }
.v2-filterbar select, .v2-primary-button, .v2-secondary-button, .v2-filter-live-status { height: 44px; }
.v2-primary-button, .v2-secondary-button { padding: 0 10px; font-size: 12px; }
.v2-monitor-mode { height: 44px; }
.v2-monitor-mode button { flex: 1; font-size: 12px; }