feat(platform): save reusable customer views

This commit is contained in:
lingniu
2026-07-05 19:38:30 +08:00
parent 2e0b13dfb5
commit e1126a6fcc
4 changed files with 203 additions and 2 deletions

View File

@@ -3,7 +3,7 @@ import { Toast } from '@douyinfe/semi-ui';
import { api } from './api/client'; import { api } from './api/client';
import type { QualityNotificationPlan, VehicleSourceConsistency, VehicleServiceOverview, VehicleServiceStatus } from './api/types'; import type { QualityNotificationPlan, VehicleSourceConsistency, VehicleServiceOverview, VehicleServiceStatus } from './api/types';
import { buildAppHash, parseAppHash } from './domain/appRoute'; import { buildAppHash, parseAppHash } from './domain/appRoute';
import { AppShell, type PageKey } from './layout/AppShell'; import { AppShell, type CustomerView, type PageKey } from './layout/AppShell';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { History } from './pages/History'; import { History } from './pages/History';
import { Mileage } from './pages/Mileage'; import { Mileage } from './pages/Mileage';
@@ -14,6 +14,8 @@ import { Realtime } from './pages/Realtime';
import { VehicleDetail } from './pages/VehicleDetail'; import { VehicleDetail } from './pages/VehicleDetail';
import { Vehicles } from './pages/Vehicles'; import { Vehicles } from './pages/Vehicles';
const customerViewsStorageKey = 'lingniu.customerViews.v1';
export default function App() { export default function App() {
const initialRoute = parseAppHash(window.location.hash); const initialRoute = parseAppHash(window.location.hash);
const initialVehicleKey = initialRoute.keyword || ''; const initialVehicleKey = initialRoute.keyword || '';
@@ -38,6 +40,7 @@ export default function App() {
initialRoute.page === 'quality' || initialRoute.page === 'alert-events' ? qualityFiltersFromRoute(initialRoute) : {} initialRoute.page === 'quality' || initialRoute.page === 'alert-events' ? qualityFiltersFromRoute(initialRoute) : {}
); );
const [customerTimeWindow, setCustomerTimeWindow] = useState<Record<string, string>>(timeWindowFiltersFromRoute(initialRoute)); const [customerTimeWindow, setCustomerTimeWindow] = useState<Record<string, string>>(timeWindowFiltersFromRoute(initialRoute));
const [customerViews, setCustomerViews] = useState<CustomerView[]>(readCustomerViews);
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null); const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
const [activeAlertRuleCount, setActiveAlertRuleCount] = useState<number | null>(null); const [activeAlertRuleCount, setActiveAlertRuleCount] = useState<number | null>(null);
const [p0AlertRuleCount, setP0AlertRuleCount] = useState<number | null>(null); const [p0AlertRuleCount, setP0AlertRuleCount] = useState<number | null>(null);
@@ -543,6 +546,48 @@ export default function App() {
} }
}; };
const saveCustomerView = () => {
const keyword = (activeVin || analysisVin).trim();
if (!keyword) {
Toast.warning('请先查询一辆车');
return;
}
const protocol = activeProtocol.trim();
const timeWindow = currentCustomerTaskTimeWindow();
const view: CustomerView = {
id: [keyword, protocol, timeWindow.dateFrom ?? '', timeWindow.dateTo ?? ''].join('|'),
label: currentVehicleLabel || keyword,
keyword,
protocol,
...timeWindow
};
setCustomerViews((prev) => {
const next = [view, ...prev.filter((item) => item.id !== view.id)].slice(0, 5);
writeCustomerViews(next);
return next;
});
Toast.success('已保存客户视图');
};
const openCustomerView = (view: CustomerView) => {
const keyword = view.keyword.trim();
if (!keyword) {
return;
}
const protocol = view.protocol?.trim() ?? '';
const timeWindow = {
...(view.dateFrom ? { dateFrom: view.dateFrom } : {}),
...(view.dateTo ? { dateTo: view.dateTo } : {})
};
setActiveVin(keyword);
setAnalysisVin(keyword);
setActiveProtocol(protocol);
setCurrentVehicleLabel(view.label || keyword);
setCustomerTimeWindow(timeWindow);
setActivePage('detail');
replaceHash('detail', keyword, protocol);
};
const updateVehicleDetailQuery = (keyword: string, protocol?: string) => { const updateVehicleDetailQuery = (keyword: string, protocol?: string) => {
const nextKeyword = keyword.trim(); const nextKeyword = keyword.trim();
const nextProtocol = protocol?.trim() ?? ''; const nextProtocol = protocol?.trim() ?? '';
@@ -576,12 +621,27 @@ export default function App() {
}; };
return ( return (
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} activeAlertRuleCount={activeAlertRuleCount} p0AlertRuleCount={p0AlertRuleCount} platformRelease={platformRelease} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} customerTimeWindow={customerTimeWindow} onCustomerTimeWindowChange={setCustomerTimeWindow} onCopyCustomerScope={copyCustomerScope} onChange={navigatePage} onCustomerTask={openCustomerTask} onVehicleSearch={openVehicle}> <AppShell activePage={activePage} linkIssueCount={linkIssueCount} activeAlertRuleCount={activeAlertRuleCount} p0AlertRuleCount={p0AlertRuleCount} platformRelease={platformRelease} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} customerTimeWindow={customerTimeWindow} customerViews={customerViews} onCustomerTimeWindowChange={setCustomerTimeWindow} onSaveCustomerView={saveCustomerView} onOpenCustomerView={openCustomerView} onCopyCustomerScope={copyCustomerScope} onChange={navigatePage} onCustomerTask={openCustomerTask} onVehicleSearch={openVehicle}>
{pages[activePage]} {pages[activePage]}
</AppShell> </AppShell>
); );
} }
function readCustomerViews(): CustomerView[] {
try {
const parsed = JSON.parse(window.localStorage.getItem(customerViewsStorageKey) || '[]');
return Array.isArray(parsed)
? parsed.filter((item): item is CustomerView => Boolean(item?.id && item?.label && item?.keyword)).slice(0, 5)
: [];
} catch {
return [];
}
}
function writeCustomerViews(views: CustomerView[]) {
window.localStorage.setItem(customerViewsStorageKey, JSON.stringify(views.slice(0, 5)));
}
function timeWindowFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> { function timeWindowFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return timeWindowFiltersFromValues(route.filters ?? {}); return timeWindowFiltersFromValues(route.filters ?? {});
} }

View File

@@ -18,6 +18,15 @@ import { isAMapConfigured } from '../config/appConfig';
export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality'; export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality';
export type CustomerView = {
id: string;
label: string;
keyword: string;
protocol?: string;
dateFrom?: string;
dateTo?: string;
};
const navGroups = [ const navGroups = [
{ {
title: '实时运营', title: '实时运营',
@@ -141,7 +150,10 @@ export function AppShell({
currentVehicleLabel, currentVehicleLabel,
currentVehicleConsistency, currentVehicleConsistency,
customerTimeWindow, customerTimeWindow,
customerViews,
onCustomerTimeWindowChange, onCustomerTimeWindowChange,
onSaveCustomerView,
onOpenCustomerView,
onCopyCustomerScope, onCopyCustomerScope,
onChange, onChange,
onCustomerTask, onCustomerTask,
@@ -157,7 +169,10 @@ export function AppShell({
currentVehicleLabel?: string; currentVehicleLabel?: string;
currentVehicleConsistency?: VehicleSourceConsistency; currentVehicleConsistency?: VehicleSourceConsistency;
customerTimeWindow?: Record<string, string>; customerTimeWindow?: Record<string, string>;
customerViews?: CustomerView[];
onCustomerTimeWindowChange?: (filters: Record<string, string>) => void; onCustomerTimeWindowChange?: (filters: Record<string, string>) => void;
onSaveCustomerView?: () => void;
onOpenCustomerView?: (view: CustomerView) => void;
onCopyCustomerScope?: () => void; onCopyCustomerScope?: () => void;
onChange: (page: PageKey) => void; onChange: (page: PageKey) => void;
onCustomerTask?: (page: PageKey) => void; onCustomerTask?: (page: PageKey) => void;
@@ -172,6 +187,7 @@ export function AppShell({
const selectedPage = activePage === 'quality' ? 'alert-events' : activePage; const selectedPage = activePage === 'quality' ? 'alert-events' : activePage;
const dateFrom = customerTimeWindow?.dateFrom ?? ''; const dateFrom = customerTimeWindow?.dateFrom ?? '';
const dateTo = customerTimeWindow?.dateTo ?? ''; const dateTo = customerTimeWindow?.dateTo ?? '';
const savedViews = customerViews ?? [];
const applyTimeWindowPreset = (days: number) => { const applyTimeWindowPreset = (days: number) => {
onCustomerTimeWindowChange?.({ onCustomerTimeWindowChange?.({
@@ -335,6 +351,30 @@ export function AppShell({
> >
</Button> </Button>
<Button
size="small"
aria-label="顶部保存客户视图"
onClick={onSaveCustomerView}
>
</Button>
{savedViews.length > 0 ? (
<div className="vp-topbar-saved-views" aria-label="客户视图">
{savedViews.slice(0, 3).map((view) => {
const rangeText = view.dateFrom || view.dateTo ? `${view.dateFrom || '未指定'}${view.dateTo || '未指定'}` : '实时范围';
return (
<button
key={view.id}
type="button"
aria-label={`客户视图 ${view.label} ${rangeText}`}
onClick={() => onOpenCustomerView?.(view)}
>
{view.label}
</button>
);
})}
</div>
) : null}
<Button <Button
size="small" size="small"
aria-label={`顶部${alertLabel}`} aria-label={`顶部${alertLabel}`}

View File

@@ -353,6 +353,36 @@ body {
color: var(--vp-primary); color: var(--vp-primary);
} }
.vp-topbar-saved-views {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 260px;
overflow: hidden;
}
.vp-topbar-saved-views button {
max-width: 118px;
height: 28px;
padding: 0 8px;
border: 1px solid rgba(22, 119, 255, 0.18);
border-radius: 8px;
background: rgba(22, 119, 255, 0.06);
color: var(--vp-primary);
cursor: pointer;
font-size: 12px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vp-topbar-saved-views button:hover,
.vp-topbar-saved-views button:focus-visible {
background: #ffffff;
outline: none;
}
.vp-topbar-release { .vp-topbar-release {
color: var(--vp-text-subtle); color: var(--vp-text-subtle);
font-size: 12px; font-size: 12px;

View File

@@ -5,6 +5,7 @@ import App from '../App';
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.useRealTimers(); vi.useRealTimers();
window.localStorage.clear();
window.history.replaceState(null, '', '/'); window.history.replaceState(null, '', '/');
delete window.__LINGNIU_APP_CONFIG__; delete window.__LINGNIU_APP_CONFIG__;
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -6742,6 +6743,76 @@ test('topbar copies customer vehicle service scope links', async () => {
expect(copied).toContain('告警通知http://localhost:3000/#/alert-events?keyword=VIN-SHARE-SCOPE&protocol=JT808&dateFrom=2026-06-29&dateTo=2026-07-05'); expect(copied).toContain('告警通知http://localhost:3000/#/alert-events?keyword=VIN-SHARE-SCOPE&protocol=JT808&dateFrom=2026-06-29&dateTo=2026-07-05');
}); });
test('topbar saves and restores reusable customer vehicle views', async () => {
vi.setSystemTime(new Date('2026-07-05T10:00:00+08:00'));
window.history.replaceState(null, '', '/#/dashboard');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/vehicle-service/overview')) {
return {
ok: true,
json: async () => ({
data: {
vin: 'VIN-SAVED-SCOPE',
plate: '粤A复用窗',
primaryProtocol: 'GB32960',
protocols: ['GB32960'],
coverageStatus: 'online',
sourceCount: 1,
onlineSourceCount: 1,
realtimeCount: 1,
historyCount: 8,
rawCount: 20,
mileageCount: 3,
qualityIssueCount: 0
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/ops/health')) {
return {
ok: true,
json: async () => ({
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
return {
ok: true,
json: async () => ({
data: { items: [], total: 0, limit: 10, offset: 0 },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: '顶部时间窗 近7天' }));
fireEvent.change(screen.getByPlaceholderText('搜索 VIN / 车牌 / 手机号'), { target: { value: '粤A复用窗' } });
fireEvent.click(screen.getByRole('button', { name: '查询车辆' }));
expect(await screen.findByText('粤A复用窗 / VIN-SAVED-SCOPE')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '顶部保存客户视图' }));
expect(screen.getByRole('button', { name: '客户视图 粤A复用窗 / VIN-SAVED-SCOPE 2026-06-29 至 2026-07-05' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '顶部时间窗 清空' }));
fireEvent.click(screen.getByRole('button', { name: '顶部地图态势' }));
expect(window.location.hash).toBe('#/map?keyword=VIN-SAVED-SCOPE&protocol=GB32960');
fireEvent.click(screen.getByRole('button', { name: '客户视图 粤A复用窗 / VIN-SAVED-SCOPE 2026-06-29 至 2026-07-05' }));
expect(screen.getByLabelText('顶部时间窗开始')).toHaveValue('2026-06-29');
expect(screen.getByLabelText('顶部时间窗结束')).toHaveValue('2026-07-05');
fireEvent.click(screen.getByRole('button', { name: '顶部统计查询' }));
expect(window.location.hash).toBe('#/mileage?keyword=VIN-SAVED-SCOPE&protocol=GB32960&dateFrom=2026-06-29&dateTo=2026-07-05');
});
test('shows and clears current history filters while keeping vehicle scope', async () => { test('shows and clears current history filters while keeping vehicle scope', async () => {
window.history.replaceState(null, '', '/#/history-query?keyword=VIN-HISTORY-001&protocol=JT808&dateFrom=2026-07-01+00%3A00%3A00&dateTo=2026-07-01+23%3A59%3A59&includeFields=true&fields=jt808.location.longitude%2Cjt808.location.latitude&tab=raw'); window.history.replaceState(null, '', '/#/history-query?keyword=VIN-HISTORY-001&protocol=JT808&dateFrom=2026-07-01+00%3A00%3A00&dateTo=2026-07-01+23%3A59%3A59&includeFields=true&fields=jt808.location.longitude%2Cjt808.location.latitude&tab=raw');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {