feat: sync vehicle monitoring and accumulated platform updates

Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
lingniu
2026-10-26 18:05:59 +08:00
parent 8b32ae2988
commit d6b957dfc8
290 changed files with 28238 additions and 1647 deletions
@@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from 'vitest';
import { API_QUERY_TIMEOUT_MS, api } from './client';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken, setAuthCredentials } from '../v2/auth/session';
function boundedRequest(init: Record<string, unknown> = {}) {
return { ...init, signal: expect.any(AbortSignal) };
@@ -21,6 +21,33 @@ test('authenticated requests use the session-only bearer token', async () => {
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
});
test('platform requests forward the exact OneOS token and client ID, then drop both for local credentials', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
setAuthCredentials('oneos-token-unchanged', 'oneos-client-id');
await api.session();
const firstHeaders = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(firstHeaders.get('Authorization')).toBe('Bearer oneos-token-unchanged');
expect(firstHeaders.get('X-OneOS-Client-ID')).toBe('oneos-client-id');
setAccessToken('local-token');
await api.session();
const secondHeaders = new Headers(fetchMock.mock.calls[1][1]?.headers);
expect(secondHeaders.get('Authorization')).toBe('Bearer local-token');
expect(secondHeaders.has('X-OneOS-Client-ID')).toBe(false);
});
test('OneOS public login and captcha never receive the previous identity headers', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { captchaEnabled: false } }) } as Response);
setAuthCredentials('previous-token', 'previous-client');
await api.oneOSCaptcha();
await api.oneOSLogin({ username: 'next', password: 'password' });
for (const [, init] of fetchMock.mock.calls) {
const headers = new Headers(init?.headers);
expect(headers.has('Authorization')).toBe(false);
expect(headers.has('X-OneOS-Client-ID')).toBe(false);
}
});
test('history export downloads use bearer authentication and retain the server filename', async () => {
setAccessToken('customer-export-token');
const payload = new Blob(['export'], { type: 'text/csv' });
@@ -1,3 +1,5 @@
import type { CustomerMileageResponse, CustomerMileageRules } from './customerMileage';
import type { OperationComplianceDetail, OperationComplianceOptions, OperationComplianceResponse } from './operationCompliance';
import type {
ApiEnvelope,
AdminUser,
@@ -72,6 +74,11 @@ import type {
SessionInfo,
LoginResponse,
OneOSExchangeResponse,
AuthProviders,
OneOSPasswordCredentials,
OneOSPendingIdentity,
OneOSAssetPage,
OneOSCaptcha,
CustomerUserInput,
CustomerUserBatchItem,
CustomerUserBatchResult,
@@ -93,7 +100,7 @@ import type {
VehicleSourcePolicyUpdate,
VehicleRow
} from './types';
import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session';
import { getAuthCredentials, notifyUnauthorizedSession } from '../v2/auth/session';
export type RawFrameQuery = {
keyword?: string;
@@ -165,14 +172,14 @@ function requestTimeoutError() {
return error;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAccessToken();
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
async function request<T>(path: string, init?: RequestInit, anonymous = false): Promise<T> {
const credentials = anonymous ? { accessToken: '', clientId: '' } : getAuthCredentials();
const requestInit = credentials.accessToken ? { ...init, headers: withAuthorization(init?.headers, credentials.accessToken, credentials.clientId) } : init;
const timeout = queryTimeout(requestInit);
try {
const response = await fetch(path, timeout?.init ?? requestInit);
if (!response.ok) {
if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession(token);
if (response.status === 401 && credentials.accessToken && path !== '/api/v2/session') notifyUnauthorizedSession(credentials.accessToken, credentials.clientId);
throw new Error(await responseErrorMessage(response));
}
const envelope = (await response.json()) as ApiEnvelope<T>;
@@ -189,13 +196,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
async function requestBlob(path: string, signal?: AbortSignal, requestInit?: RequestInit, fallbackFilename = 'history-export.csv') {
const token = getAccessToken();
const init: RequestInit = token
? { ...requestInit, signal, headers: withAuthorization(requestInit?.headers, token) }
const credentials = getAuthCredentials();
const init: RequestInit = credentials.accessToken
? { ...requestInit, signal, headers: withAuthorization(requestInit?.headers, credentials.accessToken, credentials.clientId) }
: { ...requestInit, signal };
const response = await fetch(path, init);
if (!response.ok) {
if (response.status === 401 && token) notifyUnauthorizedSession(token);
if (response.status === 401 && credentials.accessToken) notifyUnauthorizedSession(credentials.accessToken, credentials.clientId);
throw new Error(await responseErrorMessage(response));
}
const encodedFilename = response.headers.get('X-Export-Name')?.trim();
@@ -213,9 +220,11 @@ async function requestBlob(path: string, signal?: AbortSignal, requestInit?: Req
};
}
function withAuthorization(headers: HeadersInit | undefined, token: string) {
function withAuthorization(headers: HeadersInit | undefined, token: string, clientId = '') {
const authorized = new Headers(headers);
authorized.set('Authorization', `Bearer ${token}`);
if (clientId) authorized.set('X-OneOS-Client-ID', clientId);
else authorized.delete('X-OneOS-Client-ID');
return authorized;
}
@@ -261,10 +270,29 @@ function withTraceID(message: string, traceID?: string) {
}
export const api = {
customerMileage: (params: URLSearchParams, signal?: AbortSignal) => request<CustomerMileageResponse>(`/api/v2/customer-mileage?${params.toString()}`, withSignal(undefined, signal)),
operationCompliance: (params: URLSearchParams, signal?: AbortSignal) => request<OperationComplianceResponse>(`/api/v2/operation-compliance?${params.toString()}`, withSignal(undefined, signal)),
operationComplianceOptions: (signal?: AbortSignal) => request<OperationComplianceOptions>('/api/v2/operation-compliance/options', withSignal(undefined, signal)),
operationComplianceDetail: (params: URLSearchParams, signal?: AbortSignal) => request<OperationComplianceDetail>(`/api/v2/operation-compliance/detail?${params.toString()}`, withSignal(undefined, signal)),
saveCustomerMileageRules: (rules: CustomerMileageRules) => request<CustomerMileageRules>('/api/v2/customer-mileage/rules', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rules) }),
vehicleScope: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/auth/vehicle-scope', withSignal(undefined, signal)),
session: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/session', withSignal(undefined, signal)),
authProviders: (signal?: AbortSignal) => request<AuthProviders>('/api/v2/auth/providers', withSignal(undefined, signal), true),
oneOSCaptcha: () => request<OneOSCaptcha>('/api/v2/auth/oneos/captcha', undefined, true),
oneOSLogin: (credentials: OneOSPasswordCredentials) => request<LoginResponse>('/api/v2/auth/oneos/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials)
}, true),
oneOSPending: () => request<OneOSPendingIdentity>('/api/v2/auth/oneos/pending', undefined, true),
oneOSAssetVehicles: (params: URLSearchParams, signal?: AbortSignal) => request<OneOSAssetPage>(`/api/v2/auth/oneos/vehicles?${params.toString()}`, withSignal(undefined, signal)),
oneOSConfirm: (csrfToken: string) => request<LoginResponse>('/api/v2/auth/oneos/confirm', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrfToken })
}, true),
oneOSCancel: (csrfToken: string) => request<{ cancelled: boolean }>('/api/v2/auth/oneos/cancel', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrfToken })
}, true),
login: (credentials: { username: string; password: string }) => request<LoginResponse>('/api/v2/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials)
}),
}, true),
exchangeOneOSTicket: (ticket: string) => request<OneOSExchangeResponse>('/api/v2/auth/oneos/exchange', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket })
}),
@@ -385,6 +413,9 @@ export const api = {
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined),
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
mileageVehicleOptions: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<Pick<VehicleCoverageRow, 'vin' | 'plate' | 'brandName' | 'modelName'>>>(`/api/v2/statistics/vehicle-options?${params.toString()}`, withSignal(undefined, signal)),
mileageVehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<Pick<VehicleRow, 'vin' | 'plate'>>>(`/api/v2/statistics/vehicles?${params.toString()}`, withSignal(undefined, signal)),
mileageVehicleResolve: (query: VehicleOverviewBatchQuery) => request<Page<Pick<VehicleServiceOverview, 'vin' | 'plate'>>>('/api/v2/statistics/vehicle-resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }),
vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined),
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
vehicleBusinessFilters: (signal?: AbortSignal) => request<VehicleBusinessFilters>('/api/vehicles/business-filters', withSignal(undefined, signal)),
@@ -0,0 +1,26 @@
export type CustomerMileageRules = {
version: number; lowMileageKm: number; targetMileageKm: number;
deviationKm: number; deviationPercent: number; minimumGpsKm: number; includeToday: boolean;
};
export type CustomerVehicleDay = {
restricted?: boolean; vin: string; plate: string; customer: string; department: string; responsiblePerson: string;
hydrogenCoveragePercent?: number | null; hydrogenObservedKm?: number | null; hydrogenSource?: string;
date: string; gpsKm: number | null; hydrogenKm: number | null;
gpsStatus: string; hydrogenStatus: string; differenceKm: number | null; differencePercent: number | null;
alert: string; provisional: boolean; qualityReason?: string;
};
export type CustomerMileageDay = {
date: string; vehicleCount: number; gpsVehicleCount: number; hydrogenVehicleCount: number;
gpsKm: number | null; hydrogenKm: number | null; suspectedChargingCount: number;
dataIssueCount: number; lowMileageCount: number; provisional: boolean;
};
export type CustomerMileageGroup = {
customer: string; vehicleCount: number; departments: string[]; responsiblePeople: string[];
days: CustomerMileageDay[]; vehicles: CustomerVehicleDay[];
};
export type CustomerMileageResponse = {
hydrogenJobs?: {date: string; status: string; completedAt: string; attempts?: number}[];
groups: CustomerMileageGroup[]; rules: CustomerMileageRules; dates: string[];
assignmentSource: string; assignmentObservedAt: string; assignmentStale: boolean;
assignmentMissingCount?: number; excludedUnassignedCount: number; canConfigure: boolean;
};
@@ -0,0 +1,20 @@
export type OperationComplianceStatus = 'passed' | 'failed' | 'insufficient';
export type OperationMileageSource = 'gps' | 'odometer' | 'hydrogen';
export type OperationComplianceOptions = { customers: string[]; departments: string[]; responsiblePeople: string[] };
export type OperationComplianceRow = {
vin: string; plate: string; customer: string; department: string; responsiblePerson: string;
mileageKm: number | null; status: OperationComplianceStatus;
validDays: number; expectedDays: number; authorizedDays: number; invalidDays: number; missingDays: number;
incomplete: boolean; reasons: string[];
};
export type OperationComplianceResponse = {
items: OperationComplianceRow[]; total: number; limit: number; offset: number;
dateFrom: string; dateTo: string; targetKm: number; mileageSource: OperationMileageSource;
summary: { total: number; passed: number; failed: number; insufficient: number; passRate: number | null };
assignmentSource: string; assignmentObservedAt: string; assignmentStale: boolean; assignmentMissingCount: number;
};
export type OperationComplianceDetail = {
vehicle: OperationComplianceRow;
dateFrom: string; dateTo: string; targetKm: number; mileageSource: OperationMileageSource;
days: { date: string; mileageKm: number | null; protocol: string; status: 'valid' | 'missing' | 'invalid' | 'restricted'; reason: string }[];
};
@@ -5,12 +5,16 @@ export interface ApiEnvelope<T> {
}
export interface SessionInfo {
readOnly?: boolean;
dataSource?: string;
vehicleScopeSource?: string;
subjectId?: string;
name: string;
username?: string;
role: 'viewer' | 'operator' | 'admin' | 'customer';
userType?: 'viewer' | 'operator' | 'admin' | 'customer';
authProvider?: 'local' | 'legacy-token' | 'disabled' | string;
authorizationStatus?: 'loading' | 'pending' | 'asset-catalog' | 'ready';
menuKeys?: string[];
vehicleCount?: number;
customerRef?: string;
@@ -23,6 +27,7 @@ export interface SessionInfo {
export interface LoginResponse {
accessToken: string;
clientId?: string;
expiresAt: string;
session: Omit<SessionInfo, 'authMode'>;
}
@@ -31,6 +36,63 @@ export interface OneOSExchangeResponse extends LoginResponse {
returnTo: string;
}
export interface AuthProviders {
oneos: {
enabled: boolean;
passwordEnabled: boolean;
ssoEnabled: boolean;
authorizationReady: boolean;
devIntegration?: boolean;
};
local: { enabled: boolean };
}
export interface OneOSPasswordCredentials {
username: string;
password: string;
code?: string;
uuid?: string;
}
export interface OneOSPendingIdentity {
identity: {
username: string;
displayName: string;
tenantId: string;
departmentName?: string;
};
csrfToken: string;
expiresAt: string;
}
export interface OneOSAssetVehicle {
vin: string;
plateNumber: string;
brand: string;
brandName: string;
model: string;
modelName: string;
businessDepartment: string;
operationStatus: string;
vehicleSource: string;
}
export interface OneOSAssetPage {
records: OneOSAssetVehicle[];
total: number;
current: number;
size: number;
telemetryAvailable: false;
historyAvailable: false;
source: 'oneos-asset-catalog';
}
export interface OneOSCaptcha {
captchaEnabled: boolean;
uuid?: string;
img?: string;
}
export interface AdminUser {
id: number;
username: string;
@@ -115,8 +177,9 @@ export interface MonitorSummary {
idleVehicles: number;
alertVehicles: number;
unknownVehicles: number;
activeToday: number;
frameToday: number;
activityDataAvailable?: boolean;
activeToday?: number;
frameToday?: number;
alertDataAvailable: boolean;
truncated: boolean;
asOf: string;
@@ -525,6 +588,7 @@ export interface VehicleIdentityResolution {
}
export interface VehicleDetail {
historyIncluded?: boolean;
vin: string;
lookupKey: string;
lookupResolved: boolean;
@@ -836,6 +900,7 @@ export interface RealtimeLocationRow {
}
export interface VehicleRealtimeRow {
vehicleNumber?: string;
vin: string;
plate: string;
phone: string;
@@ -858,6 +923,10 @@ export interface VehicleRealtimeRow {
speedKmh: number;
socAvailable?: boolean;
socPercent: number;
hydrogenPressureMpa?: number | null;
hydrogenMassKg?: number | null;
hydrogenMassSource?: 'REPORTED' | 'ESTIMATED';
hydrogenConsumptionKgPer100Km?: number | null;
mileageAvailable?: boolean;
totalMileageKm: number;
todayMileageAvailable?: boolean;
@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,50 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
success: "border-transparent bg-success/10 text-success",
warning: "border-transparent bg-warning/10 text-warning",
danger: "border-transparent bg-destructive/10 text-destructive",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,61 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }>(function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}, ref) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
})
export { Button, buttonVariants }
@@ -0,0 +1,29 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import { SearchIcon } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command label="搜索功能名称" className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
const DialogOverlay = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(function DialogOverlay({
className,
...props
}, ref) {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
})
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
@@ -0,0 +1,248 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,61 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NativeSelect({
className,
size = "default",
...props
}: Omit<React.ComponentProps<"select">, "size"> & { size?: "sm" | "default" }) {
return (
<div
className="group/native-select relative w-fit has-[select:disabled]:opacity-50"
data-slot="native-select-wrapper"
>
<select
data-slot="native-select"
data-size={size}
className={cn(
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1 dark:bg-input/30 dark:hover:bg-input/50",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
<ChevronDownIcon
className="pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 text-muted-foreground opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Progress as ProgressPrimitive } from "radix-ui"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
value={value}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,187 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,141 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
const SheetOverlay = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>>(function SheetOverlay({
className,
...props
}, ref) {
return (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
})
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Tabs as TabsPrimitive } from "radix-ui"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,81 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,46 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Toggle as TogglePrimitive } from "radix-ui"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -11,6 +11,9 @@ import {
afterEach(() => {
delete window.__LINGNIU_APP_CONFIG__;
delete window.AMapLoader;
delete window.AMapUI;
delete window.AMapUIProtocol;
document.querySelector('script[src="https://webapi.amap.com/ui/1.1/main.js?v=1.1.1"]')?.remove();
document.querySelector('script[src="https://webapi.amap.com/loader.js"]')?.remove();
});
@@ -71,3 +74,32 @@ test('removes a failed loader script so a later request can recover', async () =
window.AMapLoader = { load: vi.fn().mockResolvedValue(amap) };
await expect(loadAMap(['AMap.ToolBar'])).resolves.toBe(amap);
});
test('resolves the local security proxy to the absolute URL required by the SDK', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key', amapSecurityServiceHost: '/_AMapService' };
window.AMapLoader = { load: vi.fn().mockResolvedValue({}) };
await loadAMap(['AMap.Scale']);
expect(window._AMapSecurityConfig).toEqual({ serviceHost: `${window.location.origin}/_AMapService` });
});
test('waits for the base SDK before district UI and retries a failed SDK initialization', async () => {
vi.resetModules();
const { loadAMapDistrictExplorer } = await import('./amap');
let completeSDK!: (value: AMapLike) => void;
const sdk = new Promise<AMapLike>((resolve) => { completeSDK = resolve; });
const load = vi.fn().mockRejectedValueOnce(new Error('SDK unavailable')).mockReturnValueOnce(sdk);
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load };
const uiScript = () => document.querySelector<HTMLScriptElement>('script[src="https://webapi.amap.com/ui/1.1/main.js?v=1.1.1"]');
await expect(loadAMapDistrictExplorer()).rejects.toThrow('SDK unavailable');
expect(uiScript()).toBeNull();
const district = loadAMapDistrictExplorer();
await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2));
expect(uiScript()).toBeNull();
completeSDK({} as AMapLike);
await vi.waitFor(() => expect(uiScript()).not.toBeNull());
class DistrictExplorer {}
window.AMapUI = { loadUI: (_modules, callback) => callback(DistrictExplorer) };
uiScript()!.dispatchEvent(new Event('load'));
await expect(district).resolves.toBe(DistrictExplorer);
});
@@ -181,6 +181,8 @@ function loadScript(src: string) {
}
function loadAMapUIScript() {
// AMapUI otherwise inherits HTTP from the local development page for modules.
window.AMapUIProtocol = 'https:';
const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1';
return new Promise<void>((resolve, reject) => {
if (window.AMapUI) {
@@ -316,7 +318,7 @@ export function loadAMap(plugins: AMapPlugin[] = ['AMap.Scale']) {
return cached;
}
if (config.securityServiceHost) {
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
window._AMapSecurityConfig = { serviceHost: new URL(config.securityServiceHost, window.location.origin).href };
} else if (config.securityJsCode) {
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
}
@@ -340,6 +342,8 @@ export function loadAMap(plugins: AMapPlugin[] = ['AMap.Scale']) {
}
export async function loadAMapDistrictExplorer(): Promise<AMapDistrictExplorerConstructor> {
// Province aggregation can start before the map effect finishes loading the SDK.
await loadAMap([]);
await loadAMapUIScript();
if (!window.AMapUI) {
throw new Error('高德行政区组件不可用');
@@ -0,0 +1,3 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
+2 -4
View File
@@ -7,10 +7,8 @@ import './v2/styles/workspace.css';
import './v2/styles/foundation.css';
import './v2/styles/experience.css';
import './v2/styles/event-center.css';
import './v2/styles/apple-workspace.css';
import './v2/styles/apple-data-pages.css';
import './v2/styles/apple-governance.css';
import './v2/styles/apple-map-pages.css';
import './v2/styles/workspace-theme.css';
import './v2/styles/monitor-list.css';
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
@@ -101,3 +101,8 @@ document.createRange = () => ({
}
})
} as unknown as Range);
// JSDOM does not implement scrolling; cmdk uses this to reveal keyboard selection.
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = () => undefined;
}
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from '@douyinfe/semi-ui';
import { Card } from '@douyinfe/semi-ui';
import zhCN from '@douyinfe/semi-ui/lib/es/locale/source/zh_CN';
import { useEffect, type ReactNode } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
@@ -40,13 +41,21 @@ function PlatformReadySignal() {
}
function defaultPath(menuKeys: string[]) {
const first = ['monitor', 'vehicles', 'tracks', 'statistics'].find((key) => menuKeys.includes(key));
const first = ['monitor', 'vehicles', 'tracks', 'statistics', 'customer-mileage', 'operation-compliance'].find((key) => menuKeys.includes(key));
return first ? `/${first}` : '/monitor';
}
function OneOSLocalDataUnavailable({ menu }: { menu: string }) {
const { session } = usePlatformSession();
const labels: Record<string, string> = { monitor: '全局监控', tracks: '轨迹回放', history: '历史数据', statistics: '里程查询', alerts: '事件中心', access: '接入管理', operations: '运维质量', users: '账号管理' };
const label = labels[menu] ?? '业务数据';
return <Card className="v2-not-found" bodyStyle={{ padding: 24 }}><section role="status"><h2>{label}:等待连接业务数据</h2><p>{session.role === 'admin' ? 'OneOS 管理员权限已生效,已开通中台全部菜单和操作权限。' : '当前页面权限已开通。'}</p><p>本地开发环境目前仅连接 OneOS 车辆资产目录,尚未连接中台业务数据库和服务。连接后可使用本页面;当前不展示模拟业务数据。</p></section></Card>;
}
function ProtectedRoute({ menu, children }: { menu: string; children: ReactNode }) {
const { session } = usePlatformSession();
if (!hasMenu(session, menu)) return <Navigate to={defaultPath(session.menuKeys ?? [])} replace />;
if (!hasMenu(session, menu)) return <Navigate to={session.authorizationStatus === 'asset-catalog' ? '/vehicles' : defaultPath(session.menuKeys ?? [])} replace />;
if (session.authorizationStatus === 'asset-catalog' && menu !== 'vehicles') return <OneOSLocalDataUnavailable menu={menu} />;
return children;
}
@@ -64,6 +73,8 @@ export function AppV2() {
<Route path="/vehicles/:vin?" element={<ProtectedRoute menu="vehicles"><RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" /></ProtectedRoute>} />
<Route path="/tracks" element={<ProtectedRoute menu="tracks"><RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" /></ProtectedRoute>} />
<Route path="/history" element={<ProtectedRoute menu="history"><RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" /></ProtectedRoute>} />
<Route path="/customer-mileage" element={<ProtectedRoute menu="customer-mileage"><RoutePage page={RoutePages.CustomerMileage} recreatePage={RoutePageFactories.CustomerMileage} label="客户运营里程" /></ProtectedRoute>} />
<Route path="/operation-compliance" element={<ProtectedRoute menu="operation-compliance"><RoutePage page={RoutePages.OperationCompliance} recreatePage={RoutePageFactories.OperationCompliance} label="车辆运营达标统计" /></ProtectedRoute>} />
<Route path="/statistics" element={<ProtectedRoute menu="statistics"><RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" /></ProtectedRoute>} />
<Route path="/access" element={<ProtectedRoute menu="access"><RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" /></ProtectedRoute>} />
<Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="事件中心" /></ProtectedRoute>} />
@@ -81,5 +92,5 @@ export function AppV2() {
function SessionIndex() {
const { session } = usePlatformSession();
return <Navigate to={defaultPath(session.menuKeys ?? [])} replace />;
return <Navigate to={session.authorizationStatus === 'asset-catalog' ? '/vehicles' : defaultPath(session.menuKeys ?? [])} replace />;
}
@@ -1,11 +1,16 @@
import { QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { getAccessToken, getAuthCredentials, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken, setAuthCredentials } from './session';
import { AuthGate, usePlatformSession } from './AuthGate';
const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn(), exchangeOneOSTicket: vi.fn() }));
vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout, exchangeOneOSTicket: mocks.exchangeOneOSTicket } }));
const mocks = vi.hoisted(() => ({ session: vi.fn(), vehicleScope: vi.fn(), login: vi.fn(), logout: vi.fn(), authProviders: vi.fn(), oneOSCaptcha: vi.fn(), oneOSLogin: vi.fn(), oneOSPending: vi.fn(), oneOSConfirm: vi.fn(), oneOSCancel: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
beforeEach(() => {
mocks.authProviders.mockResolvedValue({ oneos: { enabled: false, passwordEnabled: false, authorizationReady: false }, local: { enabled: true } });
mocks.oneOSCaptcha.mockResolvedValue({ captchaEnabled: false });
});
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
const { session, logout } = usePlatformSession();
@@ -21,25 +26,30 @@ function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
afterEach(() => {
cleanup();
mocks.session.mockReset();
mocks.vehicleScope.mockReset();
mocks.login.mockReset();
mocks.logout.mockReset();
mocks.exchangeOneOSTicket.mockReset();
mocks.authProviders.mockReset();
mocks.oneOSCaptcha.mockReset();
mocks.oneOSLogin.mockReset();
mocks.oneOSPending.mockReset();
mocks.oneOSConfirm.mockReset();
mocks.oneOSCancel.mockReset();
mocks.logout.mockResolvedValue({ loggedOut: true });
window.sessionStorage.clear();
window.history.replaceState({}, '', '/');
});
test('exchanges a OneOS callback ticket without exposing it after the page is mounted', async () => {
test('refuses an unprocessed callback and removes sensitive query data without browser exchange', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.exchangeOneOSTicket.mockRejectedValue(new Error('票据已过期'));
window.history.replaceState({}, '', '/auth/oneos/callback?ticket=single-use-ticket-value');
window.history.replaceState({}, '', '/auth/oneos/callback?code=single-use-ticket-value');
render(<QueryClientProvider client={client}><AuthGate><div>受保护工作台</div></AuthGate></QueryClientProvider>);
await waitFor(() => expect(mocks.exchangeOneOSTicket).toHaveBeenCalledWith('single-use-ticket-value'));
expect(window.location.pathname).toBe('/auth/oneos/callback');
expect(window.location.pathname).toBe('/auth/oneos/complete');
expect(window.location.search).toBe('');
expect(await screen.findByText('票据已过期')).toBeInTheDocument();
expect(screen.getByText(/登录跳转需要由平台服务器处理/)).toBeInTheDocument();
expect(mocks.oneOSPending).not.toHaveBeenCalled();
expect(mocks.session).not.toHaveBeenCalled();
});
@@ -129,14 +139,14 @@ test('treats login and logout as complete query and mutation cache boundaries',
expect(window.sessionStorage.length).toBe(0);
});
test('exposes only account and password login on the public sign-in screen', async () => {
test('shows a separate local migration login while OneOS is unavailable', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.session.mockRejectedValue(new Error('未登录'));
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={() => undefined} /></AuthGate></QueryClientProvider>);
expect(await screen.findByRole('form', { name: '账号密码登录' })).toBeInTheDocument();
expect(screen.getByText('使用管理员为你开通的账号和密码登录。')).toBeInTheDocument();
expect(await screen.findByRole('form', { name: '中台账号登录' })).toBeInTheDocument();
expect(screen.getByText('使用原中台账号登录,保留原有菜单、车辆及历史数据授权。')).toBeInTheDocument();
expect(screen.getByPlaceholderText('请输入用户名')).toHaveAttribute('autocomplete', 'username');
expect(screen.getByPlaceholderText('请输入密码')).toHaveAttribute('autocomplete', 'current-password');
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
@@ -146,6 +156,185 @@ test('exposes only account and password login on the public sign-in screen', asy
expect(screen.getByRole('button', { name: '登录工作台' })).toBeDisabled();
});
test('OneOS password failure never falls back to local login', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.session.mockRejectedValue(new Error('未登录'));
mocks.authProviders.mockResolvedValue({ oneos: { enabled: true, passwordEnabled: true, ssoEnabled: false, authorizationReady: true }, local: { enabled: true } });
mocks.oneOSLogin.mockRejectedValue(new Error('OneOS 账号或密码错误'));
render(<QueryClientProvider client={client}><AuthGate><div>工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByRole('form', { name: '中台账号登录' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'OneOS 账号' }));
fireEvent.change(screen.getByPlaceholderText('请输入用户名'), { target: { value: 'user' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'wrong' } });
await waitFor(() => expect(screen.getByRole('button', { name: '使用 OneOS 登录' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '使用 OneOS 登录' }));
expect(await screen.findByRole('alert')).toHaveTextContent('OneOS 账号或密码错误');
expect(mocks.oneOSLogin).toHaveBeenCalledWith({ username: 'user', password: 'wrong' });
expect(mocks.login).not.toHaveBeenCalled();
expect(getAccessToken()).toBe('');
});
test('successful OneOS password login stores the exact shared token and client ID', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.session.mockImplementation(async () => {
if (getAuthCredentials().accessToken === 'exact-oneos-token') return { name: 'OneOS 用户', role: 'customer', authMode: 'enforce', authProvider: 'oneos', menuKeys: ['vehicles'] };
throw new Error('未登录');
});
mocks.authProviders.mockResolvedValue({ oneos: { enabled: true, passwordEnabled: true, ssoEnabled: false, authorizationReady: true }, local: { enabled: true } });
mocks.oneOSLogin.mockResolvedValue({ accessToken: 'exact-oneos-token', clientId: 'web-client', expiresAt: new Date().toISOString(), session: { name: 'OneOS 用户', role: 'customer' } });
render(<QueryClientProvider client={client}><AuthGate><div>已进入工作台</div></AuthGate></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: 'OneOS 账号' }));
fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'oneos-user' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'password' } });
await waitFor(() => expect(screen.getByRole('button', { name: '使用 OneOS 登录' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '使用 OneOS 登录' }));
expect(await screen.findByText('已进入工作台')).toBeInTheDocument();
expect(getAuthCredentials()).toEqual({ accessToken: 'exact-oneos-token', clientId: 'web-client' });
expect(mocks.login).not.toHaveBeenCalled();
});
test('dev OneOS verifies a real identity while authorization is pending without rendering business pages', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.authProviders.mockResolvedValue({ oneos: { enabled: true, passwordEnabled: true, ssoEnabled: false, authorizationReady: false, devIntegration: true }, local: { enabled: false } });
mocks.session.mockImplementation(async () => {
if (getAuthCredentials().accessToken !== 'dev-oneos-token') throw new Error('未登录');
return { name: '开发用户', username: 'dev-user', tenantRef: 'dev-tenant', role: 'customer', userType: 'customer', authProvider: 'oneos', authorizationStatus: 'pending', menuKeys: [], vehicleCount: 0, authMode: 'enforce' };
});
mocks.oneOSLogin.mockResolvedValue({ accessToken: 'dev-oneos-token', clientId: 'dev-client', expiresAt: new Date().toISOString(), session: { name: '开发用户', role: 'customer', authorizationStatus: 'pending' } });
mocks.logout.mockResolvedValue({ loggedOut: true });
render(<QueryClientProvider client={client}><AuthGate><div>业务工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByRole('form', { name: 'OneOS 账号登录' })).toHaveTextContent('连接 OneOS / 本地中台');
expect(screen.getByText(/SSO 当前不可用/)).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('请输入用户名'), { target: { value: 'dev-user' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'real-password' } });
await waitFor(() => expect(screen.getByRole('button', { name: '使用 OneOS 登录' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '使用 OneOS 登录' }));
const pending = await screen.findByLabelText('OneOS 接入状态');
expect(pending).toHaveTextContent('OneOS 身份已验证');
expect(pending).toHaveTextContent('开发用户');
expect(pending).toHaveTextContent('dev-user');
expect(pending).toHaveTextContent('dev-tenant');
expect(pending).toHaveTextContent('中台尚未取得页面与车辆授权');
expect(pending).toHaveTextContent('当前仅完成身份验证。页面、车辆权限及 SSO 尚未接通。');
expect(screen.queryByText('业务工作台')).not.toBeInTheDocument();
expect(getAuthCredentials()).toEqual({ accessToken: 'dev-oneos-token', clientId: 'dev-client' });
const verifiedCalls = mocks.session.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '刷新校验' }));
await waitFor(() => expect(mocks.session.mock.calls.length).toBeGreaterThan(verifiedCalls));
expect(screen.queryByText('业务工作台')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '退出统一登录' }));
expect(await screen.findByRole('form', { name: 'OneOS 账号登录' })).toBeInTheDocument();
expect(getAuthCredentials()).toEqual({ accessToken: '', clientId: '' });
});
test('failed live revalidation clears a pending dev OneOS identity instead of keeping stale session data', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthCredentials('expired-dev-token', 'dev-client');
mocks.authProviders.mockResolvedValue({ oneos: { enabled: true, passwordEnabled: true, ssoEnabled: false, authorizationReady: false, devIntegration: true }, local: { enabled: false } });
mocks.session.mockResolvedValueOnce({ name: '开发用户', username: 'dev-user', tenantRef: 'dev-tenant', role: 'customer', authProvider: 'oneos', authorizationStatus: 'pending', menuKeys: [], authMode: 'enforce' }).mockRejectedValue(new Error('OneOS 会话已失效'));
render(<QueryClientProvider client={client}><AuthGate><div>业务工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByLabelText('OneOS 接入状态')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '刷新校验' }));
expect(await screen.findByRole('form', { name: 'OneOS 账号登录' })).toBeInTheDocument();
expect(getAuthCredentials()).toEqual({ accessToken: '', clientId: '' });
expect(screen.queryByText('业务工作台')).not.toBeInTheDocument();
});
test('server exchanged identity requires explicit confirmation before session replacement', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
window.history.replaceState({}, '', '/auth/oneos/complete');
setAccessToken('existing-session');
mocks.oneOSPending.mockResolvedValue({ identity: { username: 'u123', displayName: '张三', tenantId: 'tenant-a' }, csrfToken: 'confirm-once', expiresAt: new Date().toISOString() });
mocks.oneOSConfirm.mockRejectedValue(new Error('尚未分配车辆中台权限'));
render(<QueryClientProvider client={client}><AuthGate><div>工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByText('张三')).toBeInTheDocument();
expect(screen.getByText('tenant-a')).toBeInTheDocument();
expect(getAccessToken()).toBe('existing-session');
expect(mocks.oneOSConfirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认切换并进入' }));
expect(await screen.findByRole('alert')).toHaveTextContent('尚未分配车辆中台权限');
expect(mocks.oneOSConfirm).toHaveBeenCalledWith('confirm-once');
expect(getAccessToken()).toBe('existing-session');
});
test('cancelling a pending OneOS identity preserves the existing token and client ID', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
window.history.replaceState({}, '', '/auth/oneos/complete');
setAuthCredentials('current-oneos-token', 'current-client');
mocks.oneOSPending.mockResolvedValue({ identity: { username: 'other', displayName: '另一用户', tenantId: 'tenant-a' }, csrfToken: 'cancel-once', expiresAt: new Date().toISOString() });
mocks.oneOSCancel.mockResolvedValue({ cancelled: true });
mocks.session.mockResolvedValue({ name: 'current-user', role: 'viewer', authMode: 'enforce', authProvider: 'oneos', menuKeys: ['vehicles'] });
render(<QueryClientProvider client={client}><AuthGate><div>当前用户工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByText('另一用户')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(await screen.findByText('当前用户工作台')).toBeInTheDocument();
expect(mocks.oneOSCancel).toHaveBeenCalledWith('cancel-once');
expect(mocks.oneOSConfirm).not.toHaveBeenCalled();
expect(getAuthCredentials()).toEqual({ accessToken: 'current-oneos-token', clientId: 'current-client' });
});
test('confirming a server exchanged identity replaces both shared credentials and clears old cache', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(['old-user-data'], { vin: 'OLD' });
window.history.replaceState({}, '', '/auth/oneos/complete');
setAuthCredentials('old-token', 'old-client');
mocks.oneOSPending.mockResolvedValue({ identity: { username: 'new', displayName: '新用户', tenantId: 'tenant-a' }, csrfToken: 'confirm-once', expiresAt: new Date().toISOString() });
mocks.oneOSConfirm.mockResolvedValue({ accessToken: 'new-exact-token', clientId: 'new-client', expiresAt: new Date().toISOString(), session: { name: '新用户', role: 'customer' } });
mocks.session.mockImplementation(async () => {
if (getAccessToken() === 'new-exact-token') return { name: '新用户', role: 'customer', authMode: 'enforce', authProvider: 'oneos', menuKeys: ['vehicles'] };
throw new Error('未登录');
});
render(<QueryClientProvider client={client}><AuthGate><div>新用户工作台</div></AuthGate></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: '确认切换并进入' }));
expect(await screen.findByText('新用户工作台')).toBeInTheDocument();
expect(getAuthCredentials()).toEqual({ accessToken: 'new-exact-token', clientId: 'new-client' });
expect(client.getQueryData(['old-user-data'])).toBeUndefined();
expect(window.location.pathname).toBe('/vehicles');
});
test('OneOS captcha image, code, and uuid are bound to one password attempt', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.session.mockRejectedValue(new Error('未登录'));
mocks.authProviders.mockResolvedValue({ oneos: { enabled: true, passwordEnabled: true, ssoEnabled: false, authorizationReady: true }, local: { enabled: false } });
mocks.oneOSCaptcha.mockResolvedValue({ captchaEnabled: true, uuid: 'captcha-id', img: 'aGVsbG8=' });
mocks.oneOSLogin.mockRejectedValue(new Error('验证码错误'));
render(<QueryClientProvider client={client}><AuthGate><div>工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByAltText('OneOS 图形验证码')).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=');
fireEvent.change(screen.getByPlaceholderText('请输入用户名'), { target: { value: 'oneos-user' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'wrong' } });
fireEvent.change(screen.getByPlaceholderText('请输入验证码'), { target: { value: '1234' } });
fireEvent.click(screen.getByRole('button', { name: '使用 OneOS 登录' }));
expect(await screen.findByRole('alert')).toHaveTextContent('验证码错误');
expect(mocks.oneOSLogin).toHaveBeenCalledWith({ username: 'oneos-user', password: 'wrong', code: '1234', uuid: 'captcha-id' });
expect(screen.getByPlaceholderText('请输入验证码')).toHaveValue('');
expect(mocks.oneOSCaptcha).toHaveBeenCalledTimes(2);
});
test('expired server callback has a clean URL and no session replacement', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
window.history.replaceState({}, '', '/auth/oneos/complete?error=ONEOS_LOGIN_FAILED');
render(<QueryClientProvider client={client}><AuthGate><div>工作台</div></AuthGate></QueryClientProvider>);
expect(await screen.findByRole('alert')).toHaveTextContent('跳转可能已过期');
expect(window.location.search).toBe('');
expect(mocks.oneOSPending).not.toHaveBeenCalled();
expect(getAccessToken()).toBe('');
});
test('silently clears an expired stored session without exposing token errors on the account login page', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAccessToken('expired-session-token');
@@ -153,7 +342,7 @@ test('silently clears an expired stored session without exposing token errors on
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={() => undefined} /></AuthGate></QueryClientProvider>);
expect(await screen.findByRole('form', { name: '账号密码登录' })).toBeInTheDocument();
expect(await screen.findByRole('form', { name: '中台账号登录' })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByText(/访问令牌/)).not.toBeInTheDocument();
await waitFor(() => expect(getAccessToken()).toBe(''));
@@ -177,3 +366,54 @@ test('an expired protected request returns to login and removes the active user
expect(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined();
expect(getAccessToken()).toBe('');
});
test('both providers stay available and native login retains its own session', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.authProviders.mockResolvedValue({ oneos: { enabled: true, passwordEnabled: true, authorizationReady: true, devIntegration: true }, local: { enabled: true } });
mocks.session.mockImplementation(async () => {
if (getAuthCredentials().accessToken === 'native-session') return { name: '中台客户', authProvider: 'local', role: 'customer', menuKeys: ['vehicles'], vehicleCount: 1, authMode: 'enforce' };
throw new Error('未登录');
});
mocks.login.mockResolvedValue({ accessToken: 'native-session', session: { name: '中台客户', authProvider: 'local', role: 'customer', menuKeys: ['vehicles'], vehicleCount: 1 } });
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={() => undefined} /></AuthGate></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: '中台账号' }));
expect(await screen.findByRole('form', { name: '中台账号登录' })).toHaveTextContent('保留原有菜单、车辆及历史数据授权');
fireEvent.change(screen.getByPlaceholderText('请输入用户名'), { target: { value: 'customer-original' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'OriginalPassword1!' } });
fireEvent.click(screen.getByRole('button', { name: '登录工作台' }));
expect(await screen.findByText('中台客户')).toBeInTheDocument();
expect(mocks.oneOSLogin).not.toHaveBeenCalled();
expect(getAuthCredentials()).toEqual({ accessToken: 'native-session', clientId: '' });
});
test('loads vehicle permissions after login and retries without resubmitting credentials', async () => {
mocks.session.mockRejectedValue(new Error('未登录'));
mocks.login.mockResolvedValue({accessToken:'split-login-token',expiresAt:new Date().toISOString(),session:{name:'customer',role:'customer',authorizationStatus:'loading'}});
let failScope!: (error: Error) => void;
mocks.vehicleScope.mockReturnValueOnce(new Promise((_,reject)=>{failScope=reject;}));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><AuthGate><div>车辆工作台已就绪</div></AuthGate></QueryClientProvider>);
fireEvent.change(await screen.findByPlaceholderText('请输入用户名'),{target:{value:'customer'}});
fireEvent.change(screen.getByPlaceholderText('请输入密码'),{target:{value:'password'}});
fireEvent.click(screen.getByRole('button',{name:'登录工作台'}));
expect(await screen.findByText('登录成功,正在加载车辆权限')).toBeInTheDocument();
expect(screen.queryByText('车辆工作台已就绪')).not.toBeInTheDocument();
failScope(new Error('车辆服务暂不可用'));
expect(await screen.findByText('车辆权限加载失败')).toBeInTheDocument();
expect(getAccessToken()).toBe('split-login-token');
mocks.vehicleScope.mockResolvedValueOnce({name:'customer',role:'customer',authMode:'enforce',authorizationStatus:'ready'});
fireEvent.click(screen.getByRole('button',{name:'重新加载车辆权限'}));
expect(await screen.findByText('车辆工作台已就绪')).toBeInTheDocument();
expect(mocks.login).toHaveBeenCalledTimes(1);
expect(mocks.vehicleScope).toHaveBeenCalledTimes(2);
});
test('distinguishes missing OneOS entry permission from unavailable authorization integration', async () => {
mocks.session.mockResolvedValue({name:'ordinary',username:'ordinary',role:'customer',authProvider:'oneos',authorizationStatus:'pending',vehicleScopeSource:'oneos-vehicle-query',menuKeys:[],authMode:'enforce'});
const client = new QueryClient({defaultOptions:{queries:{retry:false}}});
render(<QueryClientProvider client={client}><AuthGate><div>车辆业务数据</div></AuthGate></QueryClientProvider>);
expect(await screen.findByText('账号尚未开通中台权限')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('等待管理员授权');
expect(screen.queryByText('车辆业务数据')).not.toBeInTheDocument();
expect(screen.queryByText('等待授权接口接通')).not.toBeInTheDocument();
});
@@ -1,10 +1,12 @@
import { IconAlertTriangle, IconLock, IconSafe, IconShield, IconUser } from '@douyinfe/semi-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Banner, Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useRef, useState, createContext } from 'react';
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
import { api } from '../../api/client';
import type { LoginResponse, OneOSPendingIdentity } from '../../api/types';
import { PasswordInput } from '../shared/PasswordInput';
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAuthCredentials } from './session';
import './oneos-auth.css';
const { Title, Text } = Typography;
const TRACE_ID_SUFFIX = /\s*\(traceId:\s*([^)]+)\)\s*$/i;
@@ -34,7 +36,26 @@ const authAssurances = [
}
];
function AuthLoadingState() {
function LoginFeedback({ pending }: { pending: boolean }) {
const [slow, setSlow] = useState(false);
useEffect(() => {
setSlow(false);
if (!pending) return;
const timer = window.setTimeout(() => setSlow(true), 4000);
return () => window.clearTimeout(timer);
}, [pending]);
return <div className={`v2-login-feedback${pending ? ' is-pending' : ''}`}>
{pending ? <div role="status" aria-live="polite" className="v2-login-progress">
<div className="v2-login-progress-copy"><span className="v2-login-orbit" aria-hidden="true" /><div>
<strong>{slow ? '登录仍在处理中' : '正在验证账号身份'}</strong>
<span>{slow ? '响应比平时稍慢,请稍候,无需重复提交。' : '完成后自动进入工作台,请稍候。'}</span>
</div></div>
<div className="v2-login-progress-track" aria-hidden="true"><i /></div>
</div> : <div className="v2-login-idle-note"><IconLock /><span>登录后按账号隔离菜单、车辆和历史数据权限</span></div>}
</div>;
}
function AuthLoadingState({ scope = false, failed = false, retry, logout }: { scope?: boolean; failed?: boolean; retry?: () => void; logout?: () => void } = {}) {
return <main className="v2-auth-screen is-session-loading">
<Card className="v2-auth-card v2-auth-loading" bodyStyle={{ padding: 0 }} aria-label="正在恢复平台会话">
<div className="v2-auth-loading-inner">
@@ -43,57 +64,107 @@ function AuthLoadingState() {
<Tag color="blue" prefixIcon={<IconSafe />}>安全会话</Tag>
</header>
<div className="v2-auth-loading-status" role="status" aria-live="polite">
<span><Spin size="large" /></span>
<span>{failed ? <IconAlertTriangle /> : <Spin size="large" />}</span>
<div>
<Title heading={4}>正在验证登录状态</Title>
<Text type="tertiary">正在恢复账号、菜单与车辆权限,完成后会自动进入工作台。</Text>
<Title heading={4}>{scope ? (failed ? '车辆权限加载失败' : '登录成功,正在加载车辆权限') : '正在验证登录状态'}</Title>
<Text type="tertiary">{scope ? (failed ? '身份已验证,请重试加载车辆权限。' : '正在按登录账号准备可访问的车辆,完成后自动进入工作台。') : '正在恢复账号、菜单与车辆权限,完成后会自动进入工作台。'}</Text>
</div>
</div>
<div className="v2-auth-loading-skeleton" aria-hidden="true"><i /><i /><i /></div>
{scope && failed ? <div className="v2-oneos-actions"><Button onClick={retry} theme="solid" type="primary">重新加载车辆权限</Button><Button onClick={logout}>返回登录</Button></div> : null}
<footer><IconLock /><Text type="tertiary" size="small">会话仅保存在当前浏览器,切换账号时会清理用户数据缓存。</Text></footer>
</div>
</Card>
</main>;
}
function OneOSCallback() {
const started = useRef(false);
function OneOSAuthorizationPending({ session, ssoEnabled, refreshing, onRefresh, onLogout }: {
session: PlatformSession;
ssoEnabled: boolean;
refreshing: boolean;
onRefresh: () => void;
onLogout: () => void;
}) {
const missingAccess = session.vehicleScopeSource === 'oneos-vehicle-query';
return <main className="v2-auth-screen is-session-loading">
<Card className="v2-auth-card v2-auth-loading" bodyStyle={{ padding: 0 }} aria-label="OneOS 接入状态">
<div className="v2-auth-loading-inner v2-oneos-pending">
<header className="v2-auth-loading-header"><img src="/brand-logo.svg" alt="羚牛智能" /><Tag color="blue" prefixIcon={<IconSafe />}>OneOS 身份已验证</Tag></header>
<Title heading={4}>{missingAccess ? '账号尚未开通中台权限' : '车辆数据授权待接通'}</Title>
<Text type="secondary">{missingAccess ? '账号登录成功,请联系管理员在 OneOS 中为你的角色开通车辆数据中台。' : '真实 OneOS 账号已验证。中台尚未取得页面与车辆授权,当前不会展示业务数据。'}</Text>
<dl><div><dt>用户</dt><dd>{session.name}</dd></div><div><dt>账号</dt><dd>{session.username || '—'}</dd></div><div><dt>租户</dt><dd>{session.tenantRef || '—'}</dd></div></dl>
<div className="v2-oneos-pending-notice" role="status"><strong>{missingAccess ? '等待管理员授权' : '等待授权接口接通'}</strong><span>{missingAccess ? '开通中台入口、所需页面及查询权限后,点击刷新校验即可继续。车辆范围仍按你的账号授权。' : ssoEnabled ? '当前仅完成身份验证。页面、车辆权限尚未接通。' : '当前仅完成身份验证。页面、车辆权限及 SSO 尚未接通。'}</span></div>
<div className="v2-oneos-actions"><Button theme="solid" type="primary" loading={refreshing} disabled={refreshing} onClick={onRefresh}>刷新校验</Button><Button onClick={onLogout}>退出统一登录</Button></div>
</div>
</Card>
</main>;
}
function OneOSCompletion({ onComplete, onReturn }: { onComplete: (result: LoginResponse) => void; onReturn: () => void }) {
const [pending, setPending] = useState<OneOSPendingIdentity | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const errorPresentation = authErrorPresentation(error);
useEffect(() => {
if (started.current) return;
started.current = true;
const params = new URLSearchParams(window.location.search);
const ticket = params.get('ticket')?.trim() ?? '';
window.history.replaceState({}, document.title, '/auth/oneos/callback');
if (!ticket) {
setError('登录票据缺失,请从 OneOS 重新进入车辆数据中台。');
const callbackError = new URLSearchParams(window.location.search).get('error');
window.history.replaceState({}, document.title, '/auth/oneos/complete');
if (callbackError) {
setError('OneOS 登录未完成。跳转可能已过期或无法验证,请从 OneOS 重新进入。');
setLoading(false);
return;
}
void api.exchangeOneOSTicket(ticket).then((result) => {
setAccessToken(result.accessToken);
window.location.replace(result.returnTo || '/vehicles');
}).catch((reason) => {
clearAccessToken();
setError(reason instanceof Error ? reason.message : 'OneOS 登录未完成,请重新进入。');
let active = true;
void api.oneOSPending().then((value) => {
if (active) setPending(value);
}).catch(() => {
if (active) setError('待确认身份已过期或不存在,请从 OneOS 重新进入。');
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, []);
const confirm = async () => {
if (!pending || busy) return;
setBusy(true);
setError('');
try {
const result = await api.oneOSConfirm(pending.csrfToken);
if (!result.clientId) throw new Error('OneOS 会话信息不完整,请重新登录。');
onComplete(result);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '身份确认失败,请从 OneOS 重新进入。');
setPending(null);
} finally {
setBusy(false);
}
};
const cancel = async () => {
if (pending) {
setBusy(true);
try {
await api.oneOSCancel(pending.csrfToken);
} catch {
setError('取消身份切换失败,请重试。');
setBusy(false);
return;
}
}
onReturn();
};
return <main className="v2-auth-screen is-session-loading">
<Card className="v2-auth-card v2-auth-loading" bodyStyle={{ padding: 0 }} aria-label="正在登录车辆数据中台" data-support-trace={errorPresentation.traceId || undefined}>
<Card className="v2-auth-card v2-auth-loading" bodyStyle={{ padding: 0 }} aria-label="确认 OneOS 身份">
<div className="v2-auth-loading-inner">
<header className="v2-auth-loading-header">
<img src="/brand-logo.svg" alt="羚牛智能" />
<Tag color={error ? 'red' : 'blue'} prefixIcon={error ? <IconAlertTriangle /> : <IconSafe />}>OneOS 安全登录</Tag>
</header>
<div className="v2-auth-loading-status" role="status" aria-live="polite">
{!error ? <span><Spin size="large" /></span> : <span><IconAlertTriangle size="extra-large" /></span>}
<div>
<Title heading={4}>{error ? '登录未完成' : '正在同步账号与车辆权限'}</Title>
<Text type={error ? 'danger' : 'tertiary'}>{error ? errorPresentation.message : '正在核验一次性票据,完成后会自动进入车辆查询。'}</Text>
</div>
</div>
{error ? <Button theme="solid" type="primary" onClick={() => window.location.assign('/')}>返回登录页</Button> : <div className="v2-auth-loading-skeleton" aria-hidden="true"><i /><i /><i /></div>}
<footer><IconLock /><Text type="tertiary" size="small">OneOS 登录令牌不会写入地址,票据使用一次后立即失效。</Text></footer>
<header className="v2-auth-loading-header"><img src="/brand-logo.svg" alt="羚牛智能" /><Tag color={error ? 'red' : 'blue'} prefixIcon={<IconSafe />}>OneOS 身份确认</Tag></header>
{loading ? <div className="v2-auth-loading-status" role="status"><Spin size="large" /><div><Title heading={4}>正在读取待确认身份</Title><Text type="tertiary">请稍候。</Text></div></div> : null}
{!loading && pending ? <div className="v2-oneos-confirm">
<Title heading={4}>确认进入车辆数据中台</Title>
<Text type="secondary">OneOS 请求以以下身份登录。确认后会结束当前中台会话并切换账号。</Text>
<dl><div><dt>用户</dt><dd>{pending.identity.displayName || pending.identity.username}</dd></div><div><dt>账号</dt><dd>{pending.identity.username}</dd></div><div><dt>租户</dt><dd>{pending.identity.tenantId}</dd></div>{pending.identity.departmentName ? <div><dt>部门</dt><dd>{pending.identity.departmentName}</dd></div> : null}</dl>
<div className="v2-oneos-actions"><Button theme="solid" type="primary" loading={busy} disabled={busy} onClick={confirm}>确认切换并进入</Button><Button disabled={busy} onClick={cancel}>取消</Button></div>
</div> : null}
{error ? <div role="alert" className="v2-oneos-error"><IconAlertTriangle /><Text type="danger">{authErrorPresentation(error).message}</Text></div> : null}
{!loading && !pending ? <Button theme="solid" type="primary" onClick={onReturn}>返回登录页</Button> : null}
<footer><IconLock /><Text type="tertiary" size="small">确认身份后,平台仍按已分配的菜单和车辆权限开放功能。</Text></footer>
</div>
</Card>
</main>;
@@ -128,21 +199,49 @@ export function usePlatformSession() {
}
export function AuthGate({ children }: { children: ReactNode }) {
const isOneOSCallback = window.location.pathname === '/auth/oneos/callback';
const [authPath, setAuthPath] = useState(() => window.location.pathname);
const isOneOSCompletion = authPath === '/auth/oneos/complete';
const isUnprocessedCallback = authPath === '/auth/oneos/callback';
const queryClient = useQueryClient();
const [tokenVersion, setTokenVersion] = useState(0);
const [provider, setProvider] = useState<'oneos' | 'local'>('local');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [captchaCode, setCaptchaCode] = useState('');
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState('');
const [scopePending, setScopePending] = useState(false);
const session = useQuery({
queryKey: ['platform-session', tokenVersion],
queryFn: ({ signal }) => api.session(signal),
queryFn: ({ signal }) => scopePending ? api.vehicleScope(signal) : api.session(signal),
retry: false,
staleTime: Infinity,
enabled: !isOneOSCallback
enabled: !isOneOSCompletion && !isUnprocessedCallback
});
const providers = useQuery({
queryKey: ['auth-providers'],
queryFn: ({ signal }) => api.authProviders(signal),
retry: false,
staleTime: 60_000,
enabled: !isOneOSCompletion && !isUnprocessedCallback
});
const available = providers.data;
const oneosReady = Boolean(available?.oneos.enabled && available.oneos.passwordEnabled && (available.oneos.authorizationReady || available.oneos.devIntegration));
const localReady = Boolean(available?.local.enabled);
const selectedProvider = provider === 'oneos' && !oneosReady && localReady ? 'local' : provider === 'local' && !localReady && oneosReady ? 'oneos' : provider;
const devOneOSLogin = Boolean(available?.oneos.devIntegration && selectedProvider === 'oneos');
const captcha = useQuery({
queryKey: ['oneos-captcha'],
queryFn: () => api.oneOSCaptcha(),
retry: false,
staleTime: 0,
enabled: oneosReady && selectedProvider === 'oneos'
});
const captchaRequired = Boolean(captcha.data?.captchaEnabled);
const captchaImage = captcha.data?.img;
const captchaImageSrc = captchaImage && (/^data:image\/(png|jpeg|gif|webp);base64,/i.test(captchaImage)
? captchaImage : /^[A-Za-z0-9+/=]+$/.test(captchaImage) ? `data:image/png;base64,${captchaImage}` : '');
const clearClientSession = useCallback(() => {
void queryClient.cancelQueries();
queryClient.clear();
@@ -152,6 +251,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
clearClientSession();
setPassword('');
setAttempted(false);
setScopePending(false);
setLoginError('');
setTokenVersion((value) => value + 1);
}, [clearClientSession]);
@@ -163,44 +263,85 @@ export function AuthGate({ children }: { children: ReactNode }) {
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
}, [finishLogout]);
useEffect(() => {
if (!session.isError || loginPending || loginError || !getAccessToken()) return;
clearAccessToken();
setAttempted(false);
}, [loginError, loginPending, session.isError]);
if (!session.isError || scopePending || loginPending || loginError || !getAccessToken()) return;
if (session.data) finishLogout();
else {
clearAccessToken();
setAttempted(false);
}
}, [finishLogout, scopePending, loginError, loginPending, session.data, session.isError]);
const login = async (event: FormEvent) => {
event.preventDefault();
if (loginPending) return;
setAttempted(true);
setLoginError('');
setLoginPending(true);
try {
const result = await api.login({ username: username.trim(), password });
setAccessToken(result.accessToken);
const result = selectedProvider === 'oneos'
? await api.oneOSLogin({ username: username.trim(), password, ...(captchaRequired ? { code: captchaCode.trim(), uuid: captcha.data?.uuid } : {}) })
: await api.login({ username: username.trim(), password });
if (selectedProvider === 'oneos' && !result.clientId) throw new Error('OneOS 会话信息不完整,请重新登录。');
setAuthCredentials(result.accessToken, result.clientId);
setScopePending(result.session.authorizationStatus === 'loading');
setPassword('');
setCaptchaCode('');
clearClientSession();
setTokenVersion((value) => value + 1);
} catch (error) {
clearAccessToken();
setLoginError(error instanceof Error ? error.message : '登录失败,请稍后重试');
if (selectedProvider === 'oneos') {
setCaptchaCode('');
void captcha.refetch();
}
} finally {
setLoginPending(false);
}
};
if (isOneOSCallback) {
return <OneOSCallback />;
if (isUnprocessedCallback) {
window.history.replaceState({}, document.title, '/auth/oneos/complete');
return <main className="v2-auth-screen is-session-loading"><Card className="v2-auth-card v2-auth-loading"><div className="v2-auth-loading-inner"><Title heading={4}>OneOS 跳转尚未完成</Title><Text type="danger">登录跳转需要由平台服务器处理,请从 OneOS 重新进入。</Text><Button onClick={() => { window.history.replaceState({}, '', '/'); window.dispatchEvent(new PopStateEvent('popstate')); setAuthPath('/'); }}>返回登录页</Button></div></Card></main>;
}
if (isOneOSCompletion) {
return <OneOSCompletion onComplete={(result) => {
clearClientSession();
setAuthCredentials(result.accessToken, result.clientId);
setScopePending(result.session.authorizationStatus === 'loading');
setPassword('');
setAttempted(true);
window.history.replaceState({}, document.title, '/vehicles');
window.dispatchEvent(new PopStateEvent('popstate'));
setAuthPath('/vehicles');
setTokenVersion((value) => value + 1);
}} onReturn={() => {
window.history.replaceState({}, document.title, '/');
window.dispatchEvent(new PopStateEvent('popstate'));
setAuthPath('/');
}} />;
}
if (scopePending && (session.isPending || session.isError)) {
return <AuthLoadingState scope failed={session.isError} retry={() => { void session.refetch(); }} logout={finishLogout} />;
}
if (session.isPending) {
return <AuthLoadingState />;
}
if (session.isError && session.data) {
return <AuthLoadingState />;
}
if (session.data?.authProvider === 'oneos' && session.data.authorizationStatus === 'pending') {
return <OneOSAuthorizationPending session={session.data} ssoEnabled={Boolean(available?.oneos.ssoEnabled)} refreshing={session.isFetching} onRefresh={() => { void session.refetch(); }} onLogout={logout} />;
}
if (!session.data) {
const error = loginError || (!loginPending && attempted && session.error && !getAccessToken() ? session.error.message : '');
return <div className="v2-auth-screen">
return <div className="v2-auth-screen v2-login-screen">
<section className="v2-auth-intro" aria-labelledby="v2-auth-intro-title">
<header>
<img src="/brand-logo.svg" alt="羚牛智能" />
<Tag color="blue" prefixIcon={<IconSafe />}>车辆数据可信入口</Tag>
</header>
<Title id="v2-auth-intro-title" heading={1}>让每一辆车的数据<br />清晰、可控、可信</Title>
<Text>统一查看实时状态、车辆轨迹与里程数据。账号权限由管理员按车辆和功能精确分配。</Text>
<Text>统一查看实时状态、车辆轨迹与里程数据。功能和车辆权限以当前账号实际授权为准。</Text>
<ul className="v2-auth-assurances" aria-label="平台身份能力">
{authAssurances.map((item) => <li key={item.title}>
<span aria-hidden="true">{item.icon}</span>
@@ -210,22 +351,28 @@ export function AuthGate({ children }: { children: ReactNode }) {
<footer><IconLock /><Text>账号、车辆与菜单权限全程隔离</Text></footer>
</section>
<Card className="v2-auth-card" bodyStyle={{ padding: 0 }}>
<form className="v2-auth-form" onSubmit={login} aria-label="账号密码登录">
<form className="v2-auth-form v2-login-form" aria-busy={loginPending} onSubmit={login} aria-label={selectedProvider === 'oneos' ? 'OneOS 账号登录' : '中台账号登录'}>
<header className="v2-auth-form-heading">
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
<Tag color="blue" prefixIcon={<IconUser />}>账号密码登录</Tag>
<Tag color="blue" prefixIcon={<IconUser />}>{selectedProvider === 'oneos' ? 'OneOS 统一身份' : '中台账号'}</Tag>
<Title heading={3}>登录车辆数据中台</Title>
<Text type="secondary">使用管理员为你开通的账号和密码登录。</Text>
<Text type="secondary">{devOneOSLogin ? (available?.oneos.authorizationReady ? '连接 OneOS / 本地中台。车辆范围来自 OneOS 车辆管理,操作权限按账号授权。' : '连接 OneOS / 本地中台。可验证真实账号;车辆和页面权限接通前仅显示接入状态。') : selectedProvider === 'oneos' ? '使用 OneOS 账号登录,权限由统一身份系统管理。' : '使用原中台账号登录,保留原有菜单、车辆及历史数据授权。'}</Text>
</header>
{oneosReady && localReady ? <div className="v2-oneos-provider-switch" role="group" aria-label="登录方式"><Button disabled={loginPending} htmlType="button" type={selectedProvider === 'oneos' ? 'primary' : 'tertiary'} onClick={() => { setProvider('oneos'); setLoginError(''); setPassword(''); }}>OneOS 账号</Button><Button disabled={loginPending} htmlType="button" type={selectedProvider === 'local' ? 'primary' : 'tertiary'} onClick={() => { setProvider('local'); setLoginError(''); setPassword(''); }}>中台账号</Button></div> : null}
<div className="v2-auth-fields">
<label><span>用户名</span><Input autoFocus required autoComplete="username" prefix={<IconUser aria-hidden="true" />} value={username} onChange={setUsername} placeholder="请输入用户名" size="large" /></label>
<label><span>密码</span><PasswordInput aria-label="登录密码" required autoComplete="current-password" prefix={<IconLock aria-hidden="true" />} visibilityLabel="登录密码" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
<label><span>用户名</span><Input disabled={loginPending} autoFocus required autoComplete="username" prefix={<IconUser aria-hidden="true" />} value={username} onChange={setUsername} placeholder="请输入用户名" size="large" /></label>
<label><span>密码</span><PasswordInput disabled={loginPending} aria-label="登录密码" required autoComplete="current-password" prefix={<IconLock aria-hidden="true" />} visibilityLabel="登录密码" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
{selectedProvider === 'oneos' && captchaRequired ? <label><span>验证码</span><div className="v2-oneos-captcha-row"><Input disabled={loginPending} required value={captchaCode} onChange={setCaptchaCode} placeholder="请输入验证码" size="large" />{captchaImageSrc ? <img src={captchaImageSrc} alt="OneOS 图形验证码" /> : null}<Button disabled={loginPending} htmlType="button" type="tertiary" onClick={() => { setCaptchaCode(''); void captcha.refetch(); }}>换一张</Button></div></label> : null}
</div>
{selectedProvider === 'oneos' && captcha.isError ? <Banner type="warning" bordered closeIcon={null} title="验证码服务暂不可用" description="请稍后重试或联系管理员。" /> : null}
{selectedProvider === 'oneos' && oneosReady && !available?.oneos.ssoEnabled ? <Text type="tertiary" className="v2-oneos-local-note">{devOneOSLogin ? 'SSO 当前不可用;请使用当前接入的 OneOS 账号密码登录。' : 'OneOS 跳转登录暂不可用,请使用账号密码登录。'}</Text> : null}
{!oneosReady && !localReady ? <Banner type="warning" bordered closeIcon={null} title="登录暂不可用" description={providers.isError ? '无法获取登录服务状态,请稍后重试。' : 'OneOS 认证或权限服务尚未就绪,请联系管理员。'} /> : null}
{selectedProvider === 'local' && !oneosReady ? <Text type="tertiary" className="v2-oneos-local-note">OneOS 登录暂不可用;中台账号需单独选择,不会自动尝试同名账号。</Text> : null}
{error ? <AuthErrorMessage message={error} /> : null}
<Button block htmlType="submit" loading={loginPending} disabled={loginPending || !username.trim() || !password} theme="solid" type="primary" size="large">
{loginPending ? '正在登录…' : '登录工作台'}
<Button block htmlType="submit" loading={loginPending} disabled={loginPending || !(selectedProvider === 'oneos' ? oneosReady && captcha.isSuccess : localReady) || !username.trim() || !password || (selectedProvider === 'oneos' && captchaRequired && (!captchaCode.trim() || !captcha.data?.uuid))} theme="solid" type="primary" size="large">
{loginPending ? '正在登录…' : selectedProvider === 'oneos' ? '使用 OneOS 登录' : '登录工作台'}
</Button>
<div className="v2-auth-trust-note"><IconLock /><Text type="tertiary" size="small">登录后按账号隔离菜单、车辆和本地缓存</Text></div>
<LoginFeedback pending={loginPending} />
</form>
</Card>
</div>;
@@ -0,0 +1,233 @@
.v2-oneos-provider-switch {
display: flex;
gap: 8px;
margin-top: 22px;
}
.v2-oneos-provider-switch .semi-button {
flex: 1;
}
.v2-oneos-local-note {
display: block;
margin-top: 16px;
font-size: 12px;
line-height: 1.6;
}
.v2-oneos-captcha-row {
display: flex;
align-items: center;
gap: 8px;
}
.v2-oneos-captcha-row > .semi-input-wrapper {
min-width: 80px;
flex: 1;
}
.v2-oneos-captcha-row > img {
width: 112px;
height: 48px;
object-fit: contain;
border: 1px solid #dbe4ef;
border-radius: 8px;
}
.v2-oneos-captcha-row > .semi-button {
flex: 0 0 auto;
}
.v2-oneos-confirm {
display: grid;
gap: 14px;
}
.v2-oneos-confirm > h4.semi-typography {
margin: 0;
}
.v2-oneos-confirm > .semi-typography {
line-height: 1.6;
}
.v2-oneos-confirm dl {
display: grid;
gap: 8px;
margin: 0;
padding: 16px;
border: 1px solid #dbe4ef;
border-radius: 12px;
background: #f8fbff;
}
.v2-oneos-confirm dl > div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
}
.v2-oneos-confirm dt {
color: #66768d;
}
.v2-oneos-confirm dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #243950;
font-weight: 650;
}
.v2-oneos-actions {
display: flex;
gap: 10px;
}
.v2-oneos-actions .semi-button {
min-height: 40px;
}
.v2-oneos-error {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 14px 0;
}
.v2-oneos-pending {
display: grid;
gap: 16px;
}
.v2-oneos-pending > h4.semi-typography,
.v2-oneos-pending > .semi-typography {
margin: 0;
}
.v2-oneos-pending dl {
display: grid;
gap: 8px;
margin: 0;
padding: 16px;
border: 1px solid #dbe4ef;
border-radius: 12px;
background: #f8fbff;
}
.v2-oneos-pending dl > div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
}
.v2-oneos-pending dt { color: #66768d; }
.v2-oneos-pending dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #243950; font-weight: 650; }
.v2-oneos-pending-notice {
display: grid;
gap: 6px;
padding: 14px 16px;
border: 1px solid #f1dbaa;
border-radius: 10px;
background: #fffaf0;
color: #744b13;
line-height: 1.5;
}
.v2-oneos-pending-notice strong { font-weight: 650; }
@media (max-width: 480px) {
.v2-oneos-actions { flex-direction: column; }
.v2-oneos-captcha-row { flex-wrap: wrap; }
}
/* Keep the login fields in one stable column, including short viewports. */
:root .v2-auth-screen .v2-login-form {
display: flex;
flex-direction: column;
height: auto;
gap: 0;
padding: 32px;
}
:root .v2-login-form .v2-auth-form-heading {
border: 0;
padding: 0;
justify-items: center;
}
:root .v2-login-form .v2-auth-form-heading > h3 + .semi-typography {
max-width: 340px;
text-align: center;
font-size: 12px;
line-height: 1.65;
}
:root .v2-login-form .v2-oneos-provider-switch {
margin-top: 24px;
padding: 4px;
gap: 4px;
border-radius: 12px;
background: #f2f4f7;
}
:root .v2-login-form .v2-oneos-provider-switch .semi-button {
height: 36px;
border-radius: 8px;
font-size: 13px;
}
:root .v2-login-form > .v2-auth-fields {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
gap: 20px;
margin-top: 24px;
}
:root .v2-login-form .v2-auth-fields > label {
min-width: 0;
margin: 0;
gap: 8px;
font-size: 13px;
line-height: 20px;
}
:root .v2-login-form .v2-auth-fields .semi-input-wrapper {
height: 48px;
border-radius: 10px;
}
:root .v2-login-form .v2-auth-fields .semi-input { font-size: 14px; }
:root .v2-login-form > .semi-button-primary {
height: 48px;
flex-shrink: 0;
margin-top: 24px;
font-size: 14px;
}
:root .v2-login-form > .v2-oneos-local-note { margin-top: 16px; text-align: left; }
.v2-login-feedback { min-height: 82px; padding-top: 16px; box-sizing: border-box; }
.v2-login-idle-note { display: flex; justify-content: center; align-items: center; gap: 6px; color: #858b96; font-size: 11px; line-height: 20px; padding-top: 10px; }
.v2-login-progress-copy { display: flex; align-items: center; gap: 12px; }
.v2-login-progress-copy > div { display: grid; gap: 4px; }
.v2-login-progress-copy strong { color: #344155; font-size: 12px; font-weight: 600; line-height: 18px; }
.v2-login-progress-copy div > span { color: #778294; font-size: 11px; line-height: 17px; }
.v2-login-orbit { width: 22px; height: 22px; flex: 0 0 auto; border: 2px solid #deebfb; border-top-color: #0071e3; border-radius: 50%; animation: login-orbit 1s linear infinite; }
.v2-login-progress-track { margin-top: 12px; height: 3px; overflow: hidden; background: #edf2f8; border-radius: 3px; }
.v2-login-progress-track i { display: block; width: 40%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #afd3fc, #0071e3); animation: login-travel 1.8s ease-in-out infinite; }
@keyframes login-orbit { to { transform: rotate(360deg); } }
@keyframes login-travel { from { transform: translateX(-110%); } to { transform: translateX(360%); } }
@media (max-width: 600px) {
:root .v2-auth-screen .v2-login-form { padding: 24px; }
:root .v2-login-form .v2-auth-fields .semi-input { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.v2-login-orbit, .v2-login-progress-track i { animation: none; }
}
:root .v2-login-form .v2-auth-form-heading { margin: 0; background: transparent; }
:root .v2-login-form > .semi-button { height: 48px; margin-top: 24px; font-size: 14px; }
@media (prefers-reduced-motion: reduce) {
.v2-auth-loading-skeleton > i { animation: none; }
}
:root .v2-auth-screen.v2-login-screen { padding: 24px; }
:root .v2-login-screen .v2-login-form { padding: 28px; }
.v2-login-feedback { min-height: 70px; }
@media (max-width: 600px) {
:root .v2-auth-screen.v2-login-screen { padding: 16px; }
:root .v2-login-screen .v2-login-form { padding: 24px; }
}
@@ -4,10 +4,12 @@ import {
canOperate,
clearAccessToken,
getAccessToken,
getAuthCredentials,
hasMenu,
notifyUnauthorizedSession,
PLATFORM_UNAUTHORIZED_EVENT,
setAccessToken,
setAuthCredentials,
type PlatformSession,
} from './session';
@@ -24,6 +26,16 @@ test('access token is scoped to the browser session and can be cleared', () => {
expect(getAccessToken()).toBe('');
});
test('OneOS token and client ID are stored and cleared as one browser session', () => {
setAuthCredentials(' shared-oneos-token ', ' shared-client ');
expect(getAuthCredentials()).toEqual({ accessToken: 'shared-oneos-token', clientId: 'shared-client' });
expect(window.sessionStorage.length).toBe(1);
setAccessToken('local-token');
expect(getAuthCredentials()).toEqual({ accessToken: 'local-token', clientId: '' });
clearAccessToken();
expect(getAuthCredentials()).toEqual({ accessToken: '', clientId: '' });
});
test('role helpers follow the server permission hierarchy', () => {
expect(canOperate({ name: 'v', role: 'viewer', authMode: 'enforce' })).toBe(false);
expect(canOperate({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(true);
@@ -42,6 +54,14 @@ test('an administrator can have account management removed explicitly', () => {
expect(hasMenu(session, 'users')).toBe(false);
});
test('OneOS sessions with absent, empty, or unknown menu grants cannot open a page', () => {
const base = { name: 'oneos-user', role: 'admin', authMode: 'enforce', authProvider: 'oneos' } as PlatformSession;
expect(hasMenu(base, 'vehicles')).toBe(false);
expect(hasMenu({ ...base, menuKeys: [] }, 'vehicles')).toBe(false);
expect(hasMenu({ ...base, menuKeys: ['unrecognized:permission'] }, 'vehicles')).toBe(false);
expect(hasMenu({ ...base, menuKeys: ['vehicles'] }, 'vehicles')).toBe(true);
});
test('invalidates only the currently active rejected token', () => {
const unauthorized = vi.fn();
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
@@ -56,3 +76,17 @@ test('invalidates only the currently active rejected token', () => {
window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
});
test('a delayed 401 for an old client ID cannot erase a switched identity', () => {
setAuthCredentials('same-token', 'new-client');
expect(notifyUnauthorizedSession('same-token', 'old-client')).toBe(false);
expect(getAuthCredentials()).toEqual({ accessToken: 'same-token', clientId: 'new-client' });
});
test('read-only real-data mode keeps navigation but disables writes', () => {
const session = { name: 'admin', role: 'admin', authMode: 'enforce', readOnly: true, menuKeys: ['monitor', 'operations'] } as PlatformSession;
expect(hasMenu(session, 'operations')).toBe(true);
expect(canOperate(session)).toBe(false);
expect(canAdminister(session)).toBe(false);
});
@@ -1,39 +1,60 @@
const TOKEN_KEY = 'vehicle-platform.access-token';
export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized';
type StoredCredentials = { accessToken: string; clientId: string };
export type PlatformSession = SessionInfo;
export function getAccessToken() {
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
return getAuthCredentials().accessToken;
}
export function setAccessToken(token: string) {
const normalized = token.trim();
if (normalized) window.sessionStorage.setItem(TOKEN_KEY, normalized);
else window.sessionStorage.removeItem(TOKEN_KEY);
setAuthCredentials(token);
}
export function getAuthCredentials(): StoredCredentials {
const stored = window.sessionStorage.getItem(TOKEN_KEY);
if (!stored) return { accessToken: '', clientId: '' };
try {
const value = JSON.parse(stored) as StoredCredentials;
if (typeof value.accessToken === 'string' && typeof value.clientId === 'string') return value;
} catch { /* Previous local sessions stored the token directly. */ }
return { accessToken: stored, clientId: '' };
}
export function setAuthCredentials(token: string, clientId = '') {
const accessToken = token.trim();
if (!accessToken) {
clearAccessToken();
return;
}
window.sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ accessToken, clientId: clientId.trim() } satisfies StoredCredentials));
}
export function clearAccessToken() {
window.sessionStorage.removeItem(TOKEN_KEY);
}
export function notifyUnauthorizedSession(rejectedToken: string) {
if (!rejectedToken || getAccessToken() !== rejectedToken) return false;
export function notifyUnauthorizedSession(rejectedToken: string, rejectedClientId?: string) {
const current = getAuthCredentials();
if (!rejectedToken || current.accessToken !== rejectedToken || (rejectedClientId !== undefined && current.clientId !== rejectedClientId)) return false;
clearAccessToken();
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
return true;
}
export function canOperate(session: PlatformSession) {
return session.role === 'operator' || session.role === 'admin';
return !session.readOnly && (session.role === 'operator' || session.role === 'admin');
}
export function hasMenu(session: PlatformSession, menu: string) {
if (session.menuKeys) return session.menuKeys.includes(menu);
if (Array.isArray(session.menuKeys)) return session.menuKeys.includes(menu);
if (session.authProvider && session.authProvider !== 'local' && session.authProvider !== 'legacy-token') return false;
return session.role !== 'customer';
}
export function canAdminister(session: PlatformSession) {
return session.role === 'admin';
return !session.readOnly && session.role === 'admin';
}
import type { SessionInfo } from '../../api/types';
@@ -4,8 +4,9 @@ import { createMileageWorkbook } from './mileageWorkbook';
afterEach(() => vi.unstubAllGlobals());
test('creates a styled numeric mileage workbook with formulas and frozen panes', async () => {
test('creates a styled hydrogen workbook with formulas and frozen panes', async () => {
const workbook = await createMileageWorkbook({
metricView: 'hydrogen',
dateFrom: '2026-07-13',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
@@ -89,6 +90,7 @@ test('terminates the workbook worker when an active export is cancelled', async
vi.stubGlobal('Worker', WorkerMock);
const controller = new AbortController();
const exportPromise = downloadMileageWorkbook({
metricView: 'hydrogen',
dateFrom: '2026-07-13',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
@@ -152,3 +154,40 @@ test('hydrogen export opens the hydrogen sheet with geography in the first five
expect(workbook.worksheets[0].name).toBe('氢能明细');
expect(Array.from(workbook.worksheets[0].getRow(1).values as unknown[]).slice(1, 6)).toEqual(['日期', '车牌', '当天所在省', '当天所在市', '大区']);
});
test('GPS mileage exports omit the unrelated energy worksheet and preserve numeric totals', async () => {
const workbook = await createMileageWorkbook({
metricView: 'mileage', dateFrom: '2025-01-01', dateTo: '2025-12-31',
dates: ['2025-01-01', '2025-12-31'],
vehicles: [{ vin: 'VIN001', plate: '沪A12345' }],
mileageRows: [{ vin: 'VIN001', date: '2025-01-01', dailyMileageKm: 0 }, { vin: 'VIN001', date: '2025-12-31', dailyMileageKm: 123.5 }],
sources: [{ protocol: 'JT808', label: 'GPS', mileageType: 'GPS 里程' }]
});
expect(workbook.worksheets.map(sheet => sheet.name)).toEqual(['里程查询']);
const { Workbook } = await import('exceljs');
const restored = new Workbook();
await restored.xlsx.load(await workbook.xlsx.writeBuffer());
expect(restored.worksheets[0].getCell('E7').value).toBe(0);
expect(restored.worksheets[0].getCell('F7').value).toBe(123.5);
expect(restored.worksheets[0].getCell('G7').value).toEqual({ formula: 'SUM(E7:F7)', result: 123.5 });
});
test('passes only mileage fields to the worker for a GPS export and surfaces early failures', async () => {
let worker: WorkerMock;
class WorkerMock {
constructor() { worker = this; }
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
postMessage = vi.fn();
terminate = vi.fn();
}
vi.stubGlobal('Worker', WorkerMock);
const stream = createMileageExportStream({ dateFrom: '2025-01-01', dateTo: '2025-12-31', dates: [], sources: [] });
stream.appendRows([{ vin: 'VIN001', date: '2025-01-01', dailyMileageKm: 12, hydrogenQualityStatus: 'NO_DATA' }]);
expect(worker!.postMessage.mock.calls[1][0].rows).toEqual([{ vin: 'VIN001', date: '2025-01-01', dailyMileageKm: 12 }]);
worker!.onerror!({ message: '生成失败' } as ErrorEvent);
expect(() => stream.appendRows([])).toThrow('生成失败');
await expect(stream.finish([])).rejects.toThrow('生成失败');
expect(worker!.terminate).toHaveBeenCalledTimes(1);
});
@@ -67,13 +67,18 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
reject(new Error(event.data.message));
return;
}
const buffer = event.data.buffer;
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
resolveFinish?.();
dispose();
try {
const buffer = event.data.buffer;
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
resolveFinish?.();
dispose();
} catch (error) {
reject(error instanceof Error ? error : new Error('Excel 文件下载失败'));
}
};
worker.onerror = (event) => reject(new Error(event.message || 'Excel 工作线程执行失败'));
worker.onmessageerror = () => reject(new Error('Excel 工作线程数据传输失败,请重试'));
signal?.addEventListener('abort', cancel, { once: true });
let finishVehicles: MileageExportVehicle[] = [];
const post = (message: MileageWorkerRequest) => worker.postMessage(message);
@@ -81,10 +86,13 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
return {
appendRows(rows) {
if (disposed || finishing) return;
if (disposed) throw earlyError ?? abortError();
if (finishing) throw new Error('里程导出已经进入生成阶段');
post({
type: 'rows',
rows: rows.map((row) => ({
rows: rows.map((row) => input.metricView !== 'hydrogen' ? ({
vin: row.vin, date: row.date, dailyMileageKm: row.dailyMileageKm
}) : ({
province: row.province, city: row.city, region: row.region, locationTime: row.locationTime, locationStatus: row.locationStatus,
vin: row.vin,
date: row.date,
@@ -23,7 +23,11 @@ workerScope.onmessage = async (event) => {
return;
}
if (event.data.type === 'rows') {
mileageRows.push(...event.data.rows);
for (const row of event.data.rows) {
mileageRows.push(baseInput?.metricView === 'hydrogen' ? row : {
vin: row.vin, date: row.date, dailyMileageKm: row.dailyMileageKm
});
}
return;
}
try {
@@ -93,6 +93,15 @@ export async function createMileageWorkbook(input: MileageExportInput) {
if (!rowsByVin.has(row.vin)) rowsByVin.set(row.vin, new Map());
rowsByVin.get(row.vin)!.set(row.date, row);
}
// ExcelJS retains every cell until ZIP serialization. Share immutable style
// objects instead of allocating fonts, fills and borders per vehicle-day.
const bodyStyles = [0, 1].map((parity) => Array.from({ length: 5 }, (_, kind) => ({
font: { name: kind === 1 ? 'Consolas' : 'Microsoft YaHei', size: kind === 1 ? 9 : 10, color: { argb: kind === 3 ? 'FF1D4E89' : 'FF34445A' }, bold: kind === 0 || kind === 3 },
alignment: { vertical: 'middle' as const, horizontal: kind <= 1 || kind === 4 ? 'left' as const : 'right' as const },
fill: { type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: kind === 3 ? (parity ? 'FFEDF5FF' : 'FFF4F8FE') : (parity ? 'FFF9FBFD' : 'FFFFFFFF') } },
border: { bottom: { style: 'thin' as const, color: { argb: 'FFE6ECF3' } } },
...((kind === 2 || kind === 3) ? { numFmt: '#,##0.0" km"' } : {})
})));
input.vehicles.forEach((vehicle, index) => {
const rowNumber = firstDataRow + index;
const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map<string, MileageWorkbookRow>();
@@ -102,17 +111,13 @@ export async function createMileageWorkbook(input: MileageExportInput) {
row.values = [vehicle.plate || '未绑定', vehicle.vin, vehicle.brandName || '待维护', vehicle.modelName || '待维护', ...dailyValues, null];
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 4 ? 'left' : 'right' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
const kind = columnNumber === totalColumn ? 3 : columnNumber >= firstDateColumn ? 2 : columnNumber === 2 ? 1 : columnNumber === 1 ? 0 : 4;
cell.style = bodyStyles[index % 2][kind];
});
const dateStart = excelColumn(firstDateColumn);
const dateEnd = excelColumn(lastDateColumn);
const totalCell = sheet.getCell(rowNumber, totalColumn);
totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total };
totalCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFEDF5FF' : 'FFF4F8FE' } };
row.commit();
});
@@ -135,7 +140,11 @@ export async function createMileageWorkbook(input: MileageExportInput) {
}
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
const hydrogenSheet = primaryHydrogenSheet ?? workbook.addWorksheet('氢能明细');
// A GPS annual report must not allocate a 24-column energy row for every
// vehicle-day (300,000 days otherwise become 7.2 million extra cells).
if (input.metricView !== 'hydrogen') return workbook;
const hydrogenSheet = primaryHydrogenSheet!;
hydrogenSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1, activeCell: 'C2', showGridLines: false }];
hydrogenSheet.properties.defaultRowHeight = 21;
const hydrogenHeaders = [
@@ -0,0 +1,31 @@
import { expect, test } from 'vitest';
import { monitorReportTime } from './monitorReportTime';
const now = Date.parse('2026-10-26T15:00:00+08:00');
test.each([
['2026-10-26 15:00:00', '实时', 'realtime'],
['2026-10-26 14:00:01', '实时', 'realtime'],
['2026-10-26 14:00:00', '1小时前', 'recent'],
['2026-10-26 12:00:00', '3小时前', 'recent'],
['2026-10-25 23:59:59', '昨天', 'recent'],
['2026-10-25 00:00:00', '昨天', 'recent'],
['2026-10-24 23:59:59', '2天前', 'stale'],
['2026-10-01 00:00:00', '25天前', 'stale'],
['', '—', 'stale'],
['invalid', '—', 'stale']
])('formats %s as %s in Beijing time', (value, label, tone) => {
expect(monitorReportTime(value, now)).toMatchObject({ label, tone });
});
test('normalizes ISO and SQL timestamps into the same exact hover time', () => {
for (const value of ['2026-10-26 12:34:56', '2026-10-26T04:34:56.123Z', '2026-10-26T12:34:56+08:00']) {
expect(monitorReportTime(value, now)).toMatchObject({ label: '2小时前', title: '2026-10-26 12:34:56' });
}
});
test('uses calendar days at midnight and month/year boundaries', () => {
expect(monitorReportTime('2026-10-25 23:59:59', Date.parse('2026-10-26T00:00:00+08:00')).label).toBe('昨天');
expect(monitorReportTime('2025-12-31 23:59:59', Date.parse('2026-01-01T00:00:00+08:00')).label).toBe('昨天');
expect(monitorReportTime(undefined, now).title).toBeUndefined();
});
@@ -0,0 +1,19 @@
import { formatShanghaiDateTime } from './formatters';
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
const SHANGHAI_OFFSET = 8 * HOUR;
export function monitorReportTime(value?: string, now = Date.now()) {
const source = value?.trim() ?? '';
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(source);
// The realtime API's zone-less SQL timestamps are already Beijing time.
const timestamp = source ? Date.parse(zoned ? source : `${source.replace(' ', 'T')}+08:00`) : NaN;
if (!Number.isFinite(timestamp)) return { label: '—', tone: 'stale', title: undefined, dateTime: undefined };
const dateTime = new Date(timestamp).toISOString();
const days = Math.floor((now + SHANGHAI_OFFSET) / DAY) - Math.floor((timestamp + SHANGHAI_OFFSET) / DAY);
const hours = Math.floor(Math.max(0, now - timestamp) / HOUR);
const label = days > 1 ? `${days}天前` : days === 1 ? '昨天' : hours > 0 ? `${hours}小时前` : '实时';
const tone = label === '实时' ? 'realtime' : days <= 1 ? 'recent' : 'stale';
return { label, tone, title: formatShanghaiDateTime(dateTime), dateTime };
}
@@ -183,7 +183,7 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor
return { summary, vehicles, map, selectedVehicle };
}
export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, activelyTracked = false) {
export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, activelyTracked = false, alertsAllowed = true) {
const enabled = Boolean(vin);
const longitude = vehicle?.longitude;
const latitude = vehicle?.latitude;
@@ -199,9 +199,9 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
const activeAlerts = useQuery({
queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin],
queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal),
enabled,
enabled: enabled && alertsAllowed,
staleTime: 10_000,
refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false,
refetchInterval: enabled && alertsAllowed && activelyTracked ? MONITOR_REFRESH.alerts : false,
gcTime: QUERY_MEMORY.highVolumeGcTime,
...LIVE_QUERY_POLICY
});
@@ -0,0 +1,28 @@
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { useWorkspacePreference } from './useWorkspacePreference';
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.localStorage.clear(); });
const options = ['comfortable', 'compact'] as const;
test('restores a valid display preference across remounts', () => {
const first = renderHook(() => useWorkspacePreference('density', 'comfortable', options));
act(() => first.result.current[1]('compact'));
first.unmount();
const second = renderHook(() => useWorkspacePreference('density', 'comfortable', options));
expect(second.result.current[0]).toBe('compact');
});
test('ignores an unsupported stored preference', () => {
window.localStorage.setItem('vehicle-platform.ui.density', 'invalid');
const hook = renderHook(() => useWorkspacePreference('density', 'comfortable', options));
expect(hook.result.current[0]).toBe('comfortable');
});
test('keeps controls usable when browser storage is blocked', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); });
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
const hook = renderHook(() => useWorkspacePreference('density', 'comfortable', options));
act(() => hook.result.current[1]('compact'));
expect(hook.result.current[0]).toBe('compact');
});
@@ -0,0 +1,17 @@
import { useState } from 'react';
/** Presentation preferences contain no account or vehicle information. */
export function useWorkspacePreference<T extends string>(key: string, fallback: T, allowed: readonly T[]) {
const storageKey = `vehicle-platform.ui.${key}`;
const [value, setValue] = useState<T>(() => {
try {
const stored = window.localStorage.getItem(storageKey) as T;
return allowed.includes(stored) ? stored : fallback;
} catch { return fallback; }
});
const update = (next: T) => {
setValue(next);
try { window.localStorage.setItem(storageKey, next); } catch { /* Private browsing may disable storage. */ }
};
return [value, update] as const;
}
@@ -177,7 +177,7 @@ test('validates password policy while typing and submits only a complete secure
expect(auth.logout).toHaveBeenCalledTimes(1);
});
test('uses the compact mobile navigation and opens secondary modules in a Semi SideSheet', () => {
test('uses the compact mobile navigation and opens secondary modules in a shadcn Sheet', () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(max-width: 680px)',
@@ -205,7 +205,7 @@ test('uses the compact mobile navigation and opens secondary modules in a Semi S
fireEvent.click(screen.getByRole('button', { name: '更多功能' }));
const moreSheet = screen.getByRole('dialog', { name: '更多功能' });
expect(moreSheet.closest('.semi-sidesheet')).toHaveClass('v2-workspace-action-sidesheet', 'v2-mobile-more-sidesheet');
expect(moreSheet.closest('.business-sheet')).toHaveClass('v2-workspace-action-sidesheet', 'v2-mobile-more-sidesheet');
expect(moreSheet).toHaveTextContent('数据分析、业务处置与平台治理');
expect(screen.getByRole('navigation', { name: '分析与处置' })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '平台治理' })).toBeInTheDocument();
@@ -216,7 +216,9 @@ test('uses the compact mobile navigation and opens secondary modules in a Semi S
expect(screen.getByRole('link', { name: '接入管理' })).toHaveTextContent('核对身份、协议与差异');
expect(screen.getByRole('link', { name: '账号管理' })).toHaveTextContent('管理客户与车辆权限');
expect(screen.getByRole('link', { name: '运维质量' })).toHaveTextContent('诊断链路与数据质量');
expect(moreSheet.querySelectorAll('.v2-mobile-more-item-copy')).toHaveLength(5);
expect(screen.getByRole('link', { name: '客户运营里程' })).toHaveTextContent('比较客户车辆每日 GPS 与纯氢里程');
expect(screen.getByRole('link', { name: '车辆运营达标统计' })).toHaveTextContent('按区间累计里程核查车辆运营达标');
expect(moreSheet.querySelectorAll('.v2-mobile-more-item-copy')).toHaveLength(7);
fireEvent.click(screen.getByLabelText('关闭更多功能'));
expect(screen.getByRole('button', { name: '更多功能' })).toHaveAttribute('aria-expanded', 'false');
@@ -268,3 +270,28 @@ test('automatically collapses the Semi sidebar at tablet width', () => {
expect(screen.queryByRole('button', { name: '收起侧栏' })).not.toBeInTheDocument();
expect(document.querySelector('.v2-sidebar')).toHaveClass('is-collapsed');
});
test('command search respects customer menus and opens a permitted page', async () => {
auth.session = { name: '客户甲', role: 'customer', userType: 'customer', menuKeys: ['monitor', 'vehicles'] };
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
</MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '搜索页面与功能' }));
expect(screen.getByRole('dialog', { name: '快速导航' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /账号管理/ })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '搜索功能名称' }), { target: { value: '车辆查询' } });
fireEvent.click(await screen.findByRole('option', { name: /车辆查询/ }));
await waitFor(() => expect(screen.getByRole('heading', { name: '车辆查询' })).toBeInTheDocument());
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Ctrl K opens quick navigation but does not interrupt an open help panel', () => {
render(<MemoryRouter future={ROUTER_FUTURE}><Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes></MemoryRouter>);
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(screen.getByRole('dialog', { name: '快速导航' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(screen.queryByRole('dialog', { name: '快速导航' })).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '全局监控' })).toBeInTheDocument();
});
@@ -1,3 +1,8 @@
import { Button as BusinessButton } from '@/components/ui/button';
import { PanelLeftClose, PanelLeftOpen, Rows3, Rows4 } from 'lucide-react';
import { WorkspaceCommand } from './WorkspaceCommand';
import { cn } from '@/lib/utils';
import { useWorkspacePreference } from '../hooks/useWorkspacePreference';
import {
IconAlarm,
IconBarChartVStroked,
@@ -18,9 +23,9 @@ import {
IconUserGroup,
IconExit
} from '@douyinfe/semi-icons';
import { Avatar, Banner, Button, Card, Dropdown, Layout, Nav, Tag, Typography } from '@douyinfe/semi-ui';
import { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar, Banner, Button, Card, Dropdown, Layout, Tag, Typography } from '@douyinfe/semi-ui';
import { type CSSProperties, FormEvent, useEffect, useLayoutEffect, 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';
@@ -40,12 +45,16 @@ const navigation = [
{ to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconRoute, group: 'workspace' },
{ to: '/history', menu: 'history', label: '历史数据', icon: IconHistory, group: 'workspace' },
{ to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartVStroked, group: 'workspace' },
{ to: '/customer-mileage', menu: 'customer-mileage', label: '客户运营里程', icon: IconBarChartVStroked, group: 'workspace' },
{ to: '/operation-compliance', menu: 'operation-compliance', label: '车辆运营达标统计', icon: IconBarChartVStroked, group: 'workspace' },
{ to: '/alerts', menu: 'alerts', label: '事件中心', icon: IconAlarm, group: 'governance' },
{ to: '/access', menu: 'access', label: '接入管理', icon: IconConnectionPoint1, group: 'governance' },
{ to: '/users', menu: 'users', label: '账号管理', icon: IconUserGroup, group: 'governance' }
];
const pageNames: Record<string, string> = {
'operation-compliance': '车辆运营达标统计',
'customer-mileage': '客户运营里程',
monitor: '全局监控',
vehicles: '车辆查询',
tracks: '轨迹回放',
@@ -58,6 +67,8 @@ const pageNames: Record<string, string> = {
};
const pageHelp: Record<string, { summary: string; tips: string[] }> = {
'operation-compliance': { summary: '按车辆查看区间里程、每日明细与运营达标情况。', tips: ['客户、部门、负责人支持模糊搜索多选;同类取或,不同条件取且。', '默认统计最近30个完整自然日,设定达标里程默认3000 km。数据源默认 GPS,也可选择仪表或纯氢里程;修改后点击查询生效。', '点击车牌查看每天的里程、采用来源和数据状态。仪表整合 GB32960 与宇通 MQTT,同车同日不重复累加。', '按当前归属统计历史里程。缺失和未授权日期不补为零;数据不足车辆独立列出,达标率只以可判定车辆为分母。'] },
'customer-mileage': { summary: '按客户查看每日 GPS、纯氢里程及车辆偏差。', tips: ['选择日期范围、客户、部门或负责人后点击查询。无客户归属车辆不计入。', '点击客户和日期查看每辆车的明细;颜色阈值与偏差规则由管理员配置。', '纯氢首次计算在后台完成,页面自动更新。偏差仅作核查线索,需要结合采样质量与充电记录判断。'] },
monitor: { summary: '查看全车队实时位置、状态和最新上报。', tips: ['车牌、协议和状态筛选会自动生效,无需再次提交。', '拖动地图会暂停选中车辆跟随,点击“跟随车辆”可恢复。', '文字地址按需解析,避免实时刷新持续消耗地图 API。'] },
vehicles: { summary: '按车牌或 VIN 查询车辆档案和实时遥测。', tips: ['先搜索车辆,再进入详情查看接入来源与最新数据。', '实时遥测按固定周期更新,离开页面后会自动停止。'] },
tracks: { summary: '按车辆和时间范围回放历史轨迹。', tips: ['先提交查询条件,再使用播放轴定位具体时刻。', '手动拖动地图会暂停跟随,避免操作与播放动画冲突。'] },
@@ -66,7 +77,7 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
alerts: { summary: '统一处理车辆事件与自动化。', tips: ['事件流包含异常、状态变化和业务结果,只有待处理事件需要人工介入。', '自动化按“事件、条件、动作”组织,协议来源与动作策略彼此独立。', '处置前核对原始事件、自动化版本与执行轨迹。'] },
access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] },
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] },
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用管理员明确分配的菜单。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
};
function ContextHelp({ section, visible, mobile, onClose }: { section: string; visible: boolean; mobile: boolean; onClose: () => void }) {
@@ -120,8 +131,8 @@ function AccountMenu({
<Tag color="blue" size="small">{roleLabel}</Tag>
</Dropdown.Title>
<Dropdown.Divider />
<Dropdown.Item icon={<IconLock />} onClick={onPassword}>修改登录密码</Dropdown.Item>
<Dropdown.Item type="danger" icon={<IconExit />} onClick={onLogout}>退出登录</Dropdown.Item>
{session.authProvider === 'oneos' ? <Dropdown.Item disabled icon={<IconLock />}>密码由 OneOS 管理</Dropdown.Item> : <Dropdown.Item icon={<IconLock />} onClick={onPassword}>修改登录密码</Dropdown.Item>}
<Dropdown.Item type="danger" icon={<IconExit />} onClick={onLogout}>{session.authProvider === 'oneos' ? '退出统一登录' : '退出登录'}</Dropdown.Item>
</Dropdown.Menu>;
}
@@ -134,6 +145,12 @@ export function AppShell() {
const [accountOpen, setAccountOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const mobileLayout = useMobileLayout();
const [density, setDensity] = useWorkspacePreference('density', 'compact', ['comfortable', 'compact'] as const);
// Portal-mounted inspectors and menus use the same density as the workspace.
useLayoutEffect(() => {
document.documentElement.dataset.workspaceDensity = density;
return () => { delete document.documentElement.dataset.workspaceDensity; };
}, [density]);
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role];
const pageSummary = pageHelp[section]?.summary ?? '统一查看车辆数据、运行状态与业务证据。';
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
@@ -156,7 +173,8 @@ export function AppShell() {
}, [location.pathname]);
return (
<Layout className="v2-shell">
<Layout className="v2-shell business-shell" data-density={density}>
<a className="business-skip-link" href="#workspace-content">跳至主要内容</a>
{mobileLayout ? <MobileNavigation /> : <Sidebar activePath={activeRoutePath} />}
<Layout className="v2-main">
<Header className="v2-topbar">
@@ -168,6 +186,14 @@ export function AppShell() {
</span>
</div>
<div className="v2-topbar-actions">
<WorkspaceCommand />
{!mobileLayout ? <BusinessButton variant="outline" className="workspace-density-toggle"
aria-label={density === 'comfortable' ? '切换为紧凑显示' : '切换为舒适显示'}
title={density === 'comfortable' ? '切换为紧凑显示' : '切换为舒适显示'}
aria-pressed={density === 'compact'}
onClick={() => setDensity(density === 'comfortable' ? 'compact' : 'comfortable')}>
{density === 'comfortable' ? <Rows3 /> : <Rows4 />}<span>{density === 'comfortable' ? '舒适' : '紧凑'}</span>
</BusinessButton> : null}
<Button className="v2-help-trigger" theme="light" type="tertiary" icon={<IconHelpCircle />} aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><span className="v2-help-trigger-label">页面帮助</span></Button>
<Dropdown
trigger="click"
@@ -197,7 +223,9 @@ export function AppShell() {
</Dropdown>
</div>
</Header>
<Content className={`v2-content is-section-${section}${section === 'tracks' ? ' is-track-workspace' : ''}`}><Outlet /></Content>
{session.dataSource === 'ecs-readonly' ? <Banner type="info" closeIcon={null} description="真实数据只读联调 · OneOS 管理员已验证 · 查询来自现有中台,生产写操作已关闭" /> : null}
{session.dataSource === 'production-local' ? <Banner type="warning" closeIcon={null} description="本地后端已连接生产库 · 写入立即生效 · 车辆范围按当前登录账号授权" /> : null}
<main id="workspace-content" tabIndex={-1} className={`v2-content is-section-${section}${section === 'tracks' ? ' is-track-workspace' : ''}`}><Outlet /></main>
</Layout>
<ContextHelp section={section} visible={helpOpen} mobile={mobileLayout} onClose={() => setHelpOpen(false)} />
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
@@ -272,7 +300,7 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
</WorkspaceDialog>;
}
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]];
const mobilePrimaryNavigation = navigation.filter(item => ['monitor', 'vehicles', 'tracks', 'statistics'].includes(item.menu));
const operationsNavigation = { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting, group: 'governance' };
const mobileMoreGroups = [
{
@@ -280,8 +308,10 @@ const mobileMoreGroups = [
title: '分析与处置',
description: '历史证据与事件自动化',
items: [
{ ...navigation[3], description: '按时间回溯原始上报' },
{ ...navigation[5], description: '统一查看事件与执行轨迹' }
{ ...navigation.find(item => item.menu === 'history')!, description: '按时间回溯原始上报' },
{ ...navigation.find(item => item.menu === 'customer-mileage')!, description: '比较客户车辆每日 GPS 与纯氢里程' },
{ ...navigation.find(item => item.menu === 'operation-compliance')!, description: '按区间累计里程核查车辆运营达标' },
{ ...navigation.find(item => item.menu === 'alerts')!, description: '统一查看事件与执行轨迹' }
]
},
{
@@ -289,8 +319,8 @@ const mobileMoreGroups = [
title: '平台治理',
description: '接入、账号与运维质量',
items: [
{ ...navigation[6], description: '核对身份、协议与差异' },
{ ...navigation[7], description: '管理客户与车辆权限' },
{ ...navigation.find(item => item.menu === 'access')!, description: '核对身份、协议与差异' },
{ ...navigation.find(item => item.menu === 'users')!, description: '管理客户与车辆权限' },
{ ...operationsNavigation, description: '诊断链路与数据质量' }
]
}
@@ -350,8 +380,8 @@ function MobileNavigation() {
function Sidebar({ activePath }: { activePath: string }) {
const { session } = usePlatformSession();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const [sidebarMode, setSidebarMode] = useWorkspacePreference('sidebar', 'expanded', ['expanded', 'collapsed'] as const);
const collapsed = sidebarMode === 'collapsed';
const compactLayout = useMobileLayout(1100);
const effectiveCollapsed = collapsed || compactLayout;
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
@@ -359,38 +389,34 @@ function Sidebar({ activePath }: { activePath: string }) {
...navigation.filter((item) => hasMenu(session, item.menu)),
...(hasMenu(session, 'operations') ? [operationsNavigation] : [])
];
let previousGroup = '';
const items = visibleNavigation.map(({ to, label, icon: Icon, group }) => {
const startsGroup = group !== previousGroup;
previousGroup = group;
return {
itemKey: to,
text: label,
icon: <Icon size="large" />,
className: startsGroup ? `v2-nav-group-start is-${group}` : undefined,
link: to,
linkOptions: {
'aria-label': label,
title: effectiveCollapsed ? label : undefined,
onClick: (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); navigate(to); },
onPointerEnter: () => warmRoute(to),
onFocus: () => warmRoute(to),
onPointerDown: () => warmRoute(to)
}
};
});
return (
<Sider className={`v2-sidebar${effectiveCollapsed ? ' is-collapsed' : ''}`} aria-label="主导航">
<div className="v2-brand" aria-label="羚牛智能车辆数据中台">
<img className="v2-brand-logo" src="/brand-logo.svg" alt="羚牛智能" />
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
</div>
<Nav className="v2-navigation" aria-label="主导航" items={items} selectedKeys={[activePath]} isCollapsed={effectiveCollapsed} tooltipShowDelay={300} tooltipHideDelay={300} />
{!compactLayout ? <Button className="v2-collapse" theme="borderless" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
<IconChevronLeft />
<span>收起</span>
</Button> : null}
<Sider className={cn('v2-sidebar', 'business-sidebar', effectiveCollapsed && 'is-collapsed')} aria-label="主导航">
<div className="v2-brand" aria-label="羚牛智能车辆数据中台">
<img className="v2-brand-logo" src="/brand-logo.svg" alt="羚牛智能" />
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
</div>
<div className="business-workspace-label"><span>车辆数据中台</span><small>VEHICLE DATA PLATFORM</small></div>
<nav className="business-navigation" aria-label="工作空间导航">
{(['workspace', 'governance'] as const).map((group) => {
const links = visibleNavigation.filter((item) => item.group === group);
if (!links.length) return null;
return <div className="business-nav-group" key={group}>
<span className="business-nav-label">{group === 'workspace' ? '车辆工作台' : '平台治理'}</span>
{links.map(({ to, label, icon: Icon }, index) => <BusinessButton
key={to} asChild variant={activePath === to ? 'secondary' : 'ghost'}
className={cn('business-nav-link', activePath === to && 'is-active', index === 0 && `v2-nav-group-start is-${group}`)}
><NavLink to={to} aria-label={label} title={effectiveCollapsed ? label : undefined}
onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)}>
<Icon data-icon="inline-start" /><span>{label}</span>
</NavLink></BusinessButton>)}
</div>;
})}
</nav>
<div className="business-sidebar-footer">
{!compactLayout ? <BusinessButton variant="ghost" className="business-collapse" onClick={() => setSidebarMode(collapsed ? 'expanded' : 'collapsed')} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
{collapsed ? <PanelLeftOpen data-icon="inline-start" /> : <PanelLeftClose data-icon="inline-start" />}<span>{collapsed ? '展开' : '收起侧栏'}</span>
</BusinessButton> : null}
</div>
</Sider>
);
}
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, ArrowUpRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { usePlatformSession } from '../auth/AuthGate';
import { hasMenu } from '../auth/session';
import { preloadRoute, shouldPreloadRouteOnIntent } from '../routing/routeModules';
const entries = [
['monitor', '全局监控', '实时位置 · 在线状态', 'monitor map'],
['vehicles', '车辆查询', '车辆档案 · 实时遥测', 'vehicles vin search'],
['tracks', '轨迹回放', '历史位置 · 行驶回放', 'tracks replay'],
['history', '历史数据', '原始数据 · 导出任务', 'history export tasks'],
['statistics', '里程查询', '每日里程 · 车队统计', 'statistics mileage'],
['customer-mileage', '客户运营里程', '客户车辆 · 纯氢偏差预警', 'customer mileage hydrogen charging'],
['operation-compliance', '车辆运营达标统计', 'GPS / 仪表 / 纯氢 · 每日明细 · 运营达标', 'operation compliance target mileage gps odometer hydrogen 纯氢'],
['alerts', '事件中心', '异常处置 · 自动化', 'alerts events automation'],
['access', '接入管理', '协议覆盖 · 身份差异', 'access protocols'],
['users', '账号管理', '客户账号 · 授权范围', 'users permissions'],
['operations', '运维质量', '服务健康 · 数据质量', 'operations health']
];
export function WorkspaceCommand() {
const [open, setOpen] = useState(false);
const { session } = usePlatformSession();
const navigate = useNavigate();
const returnFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
// Do not stack a command palette over an in-progress approval or form.
if (document.querySelector('[role="dialog"]') && !open) return;
event.preventDefault();
if (!open) returnFocus.current = document.activeElement as HTMLElement;
setOpen((value) => !value);
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
useEffect(() => {
if (!open) returnFocus.current?.focus();
}, [open]);
return <>
<Button variant="outline" className="business-command-trigger" aria-label="搜索页面与功能" aria-haspopup="dialog" onClick={(event) => { returnFocus.current = event.currentTarget; setOpen(true); }}>
<Search data-icon="inline-start" /><span>搜索功能</span><kbd>⌘ K</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen} title="快速导航" description="搜索可访问的功能,使用方向键选择,回车打开。" className="business-command-dialog" showCloseButton={false}>
<CommandInput aria-labelledby={undefined} aria-label="搜索功能名称" placeholder="搜索页面或功能…" />
<CommandList>
<CommandEmpty>没有匹配的功能,请尝试其他关键词。</CommandEmpty>
<CommandGroup heading="工作空间">
{entries.filter(([menu]) => hasMenu(session, menu)).map(([path, label, description, keywords]) => <CommandItem
key={path} value={`${label} ${description} ${keywords}`}
onMouseEnter={() => { if (shouldPreloadRouteOnIntent()) void preloadRoute(`/${path}`); }}
onSelect={() => { setOpen(false); navigate(`/${path}`); }}
><span className="business-command-copy"><strong>{label}</strong><small>{description}</small></span><ArrowUpRight /></CommandItem>)}
</CommandGroup>
</CommandList>
<div className="business-command-footer"><span><kbd>↑</kbd><kbd>↓</kbd> 选择</span><span><kbd>↵</kbd> 打开</span><span><kbd>Esc</kbd> 关闭</span></div>
</CommandDialog>
</>;
}
@@ -129,14 +129,15 @@ test('removes old access rows immediately when the vehicle filter scope changes'
expect(screen.getByLabelText('接入管理操作')).toHaveClass('v2-workspace-command-bar', 'v2-access-command-bar', 'has-identity');
expect(screen.getByLabelText('接入管理操作').querySelector('.v2-workspace-command-icon.semi-avatar')).toBeInTheDocument();
expect(view.container.querySelector('.v2-access-discovery-shell')).toBeInTheDocument();
for (const className of ['v2-access-filter-card-v3', 'v2-access-table-v3']) {
expect(screen.getByRole('region', { name: '接入筛选' })).toHaveClass('governance-query-bar');
for (const className of ['v2-access-table-v3']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(screen.getByRole('navigation', { name: '接入差异筛选' })).toHaveClass('v2-access-status-tabs');
const accessMetrics = screen.getByRole('list', { name: '接入差异概览' });
expect(accessMetrics.closest('.v2-access-metric-rail')).toHaveClass('semi-card', 'is-queue');
expect(within(accessMetrics).getAllByRole('listitem')).toHaveLength(2);
expect(within(accessMetrics).getByRole('button', { name: '筛选需关注,共 0 辆' })).toHaveClass('v2-workspace-metric-action');
expect(accessMetrics.closest('.v2-access-metric-rail')).toHaveClass('governance-status-strip');
expect(within(accessMetrics).getAllByRole('listitem')).toHaveLength(6);
expect(within(accessMetrics).getByRole('button', { name: '筛选需关注,共 0 辆' })).toHaveAttribute('aria-pressed', 'false');
expect(within(accessMetrics).getByRole('button', { name: '筛选全部车辆,共 2 辆' })).toHaveAttribute('aria-pressed', 'true');
const protocolCoverage = screen.getByLabelText('真实协议来源概览');
expect(protocolCoverage).toHaveTextContent('真实来源覆盖');
@@ -144,11 +145,12 @@ test('removes old access rows immediately when the vehicle filter scope changes'
expect(protocolCoverage).toHaveTextContent('808');
expect(protocolCoverage).toHaveTextContent('宇通');
expect(screen.getByRole('heading', { name: '接入差异清单', level: 5 })).toBeInTheDocument();
expect(screen.getByText('当前范围:全部主车辆 · 缺席协议仅表示当前未发现')).toBeInTheDocument();
expect(screen.getByText('当前范围:全部主车辆')).toBeInTheDocument();
const differenceHeader = screen.getByRole('columnheader', { name: '差异结论' });
expect(differenceHeader).toHaveClass('semi-table-cell-fixed-left', 'v2-access-difference-column');
expect(screen.queryByRole('columnheader', { name: '真实来源' })).not.toBeInTheDocument();
expect(screen.getByText('已接来源状态正常')).toBeInTheDocument();
expect(screen.queryByText('已接来源状态正常')).not.toBeInTheDocument();
expect(screen.getByText('1 个来源')).toBeInTheDocument();
const filterActions = view.container.querySelector<HTMLElement>('.v2-access-filter-actions');
expect(filterActions).toBeInTheDocument();
expect(within(filterActions!).getByRole('button', { name: '查询' })).toBeInTheDocument();
@@ -306,8 +308,8 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
expect(filterTrigger).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(filterTrigger);
const filterDialog = await screen.findByRole('dialog', { name: '接入车辆筛选' });
expect(document.querySelector('.v2-access-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-access-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(76dvh, 640px)' });
expect(document.querySelector('.v2-access-mobile-filter-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-access-mobile-filter-sidesheet')).toHaveStyle({ height: 'min(76dvh, 640px)' });
expect(within(filterDialog).getByRole('textbox', { name: '车辆' })).toBeInTheDocument();
expect(within(filterDialog).getByRole('combobox', { name: '接入状态' })).toBeInTheDocument();
expect(within(filterDialog).getByRole('combobox', { name: '真实协议' })).toBeInTheDocument();
@@ -321,7 +323,7 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
fireEvent.click(within(filterDialog).getByRole('button', { name: '应用并查询' }));
await waitFor(() => expect(mocks.accessVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ connectionState: 'attention' }), expect.anything()));
expect(screen.getByRole('button', { name: /修改接入筛选:已启用 1 项/ })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('dialog', { name: '接入车辆筛选' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(screen.queryByRole('dialog', { name: '接入车辆筛选' })).not.toBeInTheDocument();
action = await screen.findByRole('button', { name: '查看 粤A00001 接入详情' });
const toolsButton = screen.getByRole('button', { name: '打开接入管理工具' });
expect(toolsButton).toHaveTextContent('工具');
@@ -365,9 +367,9 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
expect(within(dialog).getByRole('button', { name: '收起 JT808 完整证据' })).toHaveAttribute('aria-expanded', 'true');
expect(mocks.accessVehicles).toHaveBeenCalledTimes(vehicleRequestsBeforeEvidenceToggle);
expect(mocks.accessSummary).toHaveBeenCalledTimes(summaryRequestsBeforeEvidenceToggle);
expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-access-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(84dvh, 740px)' });
expect(document.querySelector('.v2-access-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('部分来源异常');
expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveStyle({ height: 'min(84dvh, 740px)' });
expect(document.querySelector('.v2-access-detail-sidesheet .business-dialog-heading > [data-slot="badge"]')).toHaveTextContent('部分来源异常');
fireEvent.click(within(dialog).getByRole('button', { name: '关闭车辆接入详情' }));
expect(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
@@ -376,8 +378,8 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
const governanceButton = screen.getByRole('button', { name: /接入治理/ });
fireEvent.click(governanceButton);
const governanceDialog = await screen.findByRole('dialog', { name: '接入治理配置' });
expect(document.querySelector('.v2-access-governance-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-config-sidesheet');
expect(document.querySelector('.v2-access-governance-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(document.querySelector('.v2-access-governance-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-config-sidesheet');
expect(document.querySelector('.v2-access-governance-sidesheet')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(within(governanceDialog).getByText('接入治理工作台')).toBeInTheDocument();
expect(within(governanceDialog).getByRole('list', { name: '接入治理配置摘要' })).toBeInTheDocument();
fireEvent.click(within(governanceDialog).getByRole('button', { name: '关闭接入治理配置' }));
@@ -1,3 +1,4 @@
import './governance-workflows.css';
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconClose, IconConnectionPoint1, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Input, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -12,8 +13,7 @@ import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFil
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from './GovernanceStatusStrip';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -168,9 +168,10 @@ const ProtocolState = memo(function ProtocolState({
}
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
{protocolLabel ? <b className="v2-access-protocol-label">{protocolLabel}</b> : null}
<span><i />{label}</span>
<strong>{status?.connected ? <AccessTime value={status.latestReceivedAt} compact /> : '—'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
{status?.connected ? <>
<span className="v2-access-protocol-status-line"><span><i />{label}</span>{status.provider ? <small title={status.provider}>{status.provider}</small> : null}</span>
<strong><AccessTime value={status.latestReceivedAt} compact /></strong>
</> : <span className="v2-access-protocol-absent">未发现来源</span>}
</div>;
});
@@ -185,9 +186,10 @@ function connectionTagColor(state: AccessVehicleRow['connectionState']) {
const ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) {
const summary = accessIssueSummary(row);
return <div className={`v2-access-connection is-${row.connectionState}`}>
<ConnectionTag row={row} />
<span title={summary}>{summary}</span>
<small>{row.actualProtocols.length ? `${row.actualProtocols.length} 个真实来源 · ${row.actualProtocols.join(' / ')}` : '当前没有真实来源证据'}</small>
<div className="v2-access-connection-line"><ConnectionTag row={row} /><small>{row.actualProtocols.length} 个来源</small></div>
{row.connectionState === 'healthy'
? <span title={row.actualProtocols.join(' / ')}>{row.actualProtocols.join(' / ')}</span>
: row.connectionState !== 'not_connected' ? <span title={summary}>{summary}</span> : null}
</div>;
});
@@ -237,7 +239,7 @@ const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN,
dataSource={rows}
rowKey="vin"
pagination={false}
empty={null}
empty={<></>}
onRow={(row) => row ? detailTriggerRow({
className: [`is-state-${row.connectionState}`, selectedVIN === row.vin ? 'is-selected' : ''].filter(Boolean).join(' '),
expanded: selectedVIN === row.vin,
@@ -574,6 +576,10 @@ export default function AccessPage() {
tone: 'warning',
emphasis: 'primary'
}),
accessMetric('来源正常', summary?.healthyVehicles ?? 0, 'healthy', { tone: 'success' }),
accessMetric('来源异常', summary?.degradedVehicles ?? 0, 'degraded', { tone: 'warning' }),
accessMetric('尚无来源', summary?.neverReported ?? 0, 'not_connected', { tone: 'neutral' }),
accessMetric('资料待维护', summary?.masterDataIncompleteVehicles ?? 0, 'master_data', { tone: 'neutral' }),
accessMetric('全部车辆', summary?.totalVehicles ?? 0, '', {
note: mobileLayout ? '权威主车辆' : '权威主车辆口径',
tone: 'neutral',
@@ -596,7 +602,7 @@ export default function AccessPage() {
<label><span id="access-oem-label">车辆品牌</span><Select aria-labelledby="access-oem-label" value={draft.oem} onChange={(value) => setDraft({ ...draft, oem: String(value) })} optionList={[{ value: '', label: '全部品牌' }, ...(summary?.oems.map((item) => ({ value: item.name, label: item.name })) ?? [])]} /></label>
</>;
return <div className="v2-access-page v2-access-page-v3">
return <div className="v2-access-page v2-access-page-v3 governance-workspace">
<div className="v2-access-discovery-shell">
<WorkspaceCommandBar
className="v2-access-command-bar"
@@ -654,16 +660,7 @@ export default function AccessPage() {
</MobileFilterSheetSection>
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-access-filter-card-v3"
title="接入筛选"
description="车辆、接入状态、真实协议与品牌"
mobileSummary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部主车辆'}
expanded={!filtersCollapsed}
status={activeFilterCount ? `${activeFilterCount} 项已启用` : '全部主车辆'}
statusColor={activeFilterCount ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
</div> : <section className="governance-query-bar" aria-label="接入筛选">
<form className="v2-access-filter-v3" onSubmit={submit}>
{filterFields}
<div className="v2-access-filter-actions">
@@ -671,7 +668,7 @@ export default function AccessPage() {
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}>重置</Button>
</div>
</form>
</WorkspaceFilterPanel>}
</section>}
</div>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
@@ -680,7 +677,7 @@ export default function AccessPage() {
<WorkspacePanelHeader
tone="primary"
title="接入差异清单"
description={`当前范围:${scopeLabels[criteria.connectionState] || '全部主车辆'} · 缺席协议仅表示当前未发现`}
description={`当前范围:${scopeLabels[criteria.connectionState] || '全部主车辆'}`}
actionsClassName="v2-access-table-actions"
actions={mobileLayout ? null : <Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}>导出当前页</Button>}
/>
@@ -223,7 +223,7 @@ test('keeps creation contextual to the automation workspace', async () => {
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /自动化/, hidden: true })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument();
const nameInput = screen.getByLabelText('规则名称');
@@ -629,7 +629,8 @@ test('clears old alert rows and inspector evidence when the event scope changes'
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
for (const className of ['v2-alert-filter-card', 'v2-alert-table-card']) {
expect(screen.getByRole('region', { name: '筛选事件' })).toHaveClass('governance-query-bar');
for (const className of ['v2-alert-table-card']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-alert-navigation')).toBeInTheDocument();
@@ -751,9 +752,9 @@ test('renders only selectable Semi alert cards on mobile', async () => {
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('dialog', { name: '事件详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-alert-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(90dvh, 800px)' });
expect(document.querySelector('.v2-alert-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('待处理');
expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveStyle({ height: 'min(90dvh, 800px)' });
expect(document.querySelector('.v2-alert-detail-sidesheet .business-dialog-heading > [data-slot="badge"]')).toHaveTextContent('待处理');
expect(document.querySelector('.v2-alert-detail-sidesheet .v2-alert-inspector-heading')).not.toBeInTheDocument();
const mobileDisposition = document.querySelector('.v2-alert-detail-sidesheet .v2-alert-disposition-card');
const mobileProgress = document.querySelector('.v2-alert-detail-sidesheet .v2-alert-progress-card');
@@ -808,8 +809,8 @@ test('uses one focused Semi bottom SideSheet for the complete mobile event filte
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: '事件筛选' });
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(within(dialog).getByRole('textbox', { name: /搜索/ })).toBeInTheDocument();
expect(within(dialog).getByRole('combobox', { name: '协议来源' })).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('开始时间')).toBeInTheDocument();
@@ -986,8 +987,8 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid
expect(within(detail).getByRole('button', { name: /复制/ })).toBeInTheDocument();
fireEvent.click(within(detail).getByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' });
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveStyle({ height: 'min(96dvh, 920px)' });
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument();
expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
@@ -1015,7 +1016,7 @@ test('opens a lightweight mobile rule detail before editing in a Semi bottom Sid
fireEvent.click(within(editor).getByRole('button', { name: '关闭事件自动化编辑' }));
fireEvent.click(within(await screen.findByRole('dialog', { name: '确认放弃自动化草稿' })).getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument());
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
expect(ruleItem).toHaveAttribute('aria-expanded', 'false');
const newRule = screen.getByRole('button', { name: /创建自动化/ });
@@ -1070,8 +1071,8 @@ test('opens automation version history from the mobile rule detail', async () =>
const detail = await screen.findByRole('dialog', { name: '自动化详情' });
fireEvent.click(within(detail).getByRole('button', { name: /版本历史/ }));
const history = await screen.findByRole('dialog', { name: '自动化版本历史' });
expect(document.querySelector('.v2-alert-rule-revision-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-alert-rule-revision-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(94dvh, 860px)' });
expect(document.querySelector('.v2-alert-rule-revision-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-alert-rule-revision-sidesheet')).toHaveStyle({ height: 'min(94dvh, 860px)' });
expect(within(history).getByText('当前 v3')).toBeInTheDocument();
});
@@ -1224,7 +1225,7 @@ test('protects an unpublished automation draft before closing or leaving the pag
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '夜间测试超速规则' } });
expect(within(editor).getByRole('status')).toHaveTextContent('草稿未保存');
expect(document.querySelector('.v2-alert-rule-editor-dialog .v2-workspace-dialog-title > .semi-tag')).toHaveTextContent('草稿未保存');
expect(document.querySelector('.v2-alert-rule-editor-dialog .business-dialog-heading > [data-slot="badge"]')).toHaveTextContent('草稿未保存');
const unload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
@@ -1237,12 +1238,12 @@ test('protects an unpublished automation draft before closing or leaving the pag
fireEvent.click(within(confirmation).getByRole('button', { name: '继续编辑' }));
expect(screen.getByRole('dialog', { name: '事件自动化编辑' })).toBeInTheDocument();
await waitFor(() => expect(confirmation).toHaveClass('semi-modal-content-animate-hide'));
await waitFor(() => expect(confirmation).not.toBeInTheDocument());
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
await waitFor(() => expect(confirmation).not.toHaveClass('semi-modal-content-animate-hide'));
fireEvent.click(within(confirmation).getByRole('button', { name: '放弃草稿' }));
const reopenedConfirmation = await screen.findByRole('dialog', { name: '确认放弃自动化草稿' });
fireEvent.click(within(reopenedConfirmation).getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument());
expect(editor).toHaveClass('semi-modal-content-animate-hide');
expect(editor).not.toBeInTheDocument();
expect(mocks.saveAlertRuleV2).not.toHaveBeenCalled();
});
@@ -1,3 +1,4 @@
import './governance-workflows.css';
import { IconAlarm, IconArrowLeft, IconArrowRight, IconBell, IconBox, IconChevronRight, IconClock, IconClose, IconCopy, IconFilter, IconHistory, IconMapPin, IconPlus, IconRefresh, IconSearch, IconSetting, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, Checkbox, Collapse, Descriptions, Input, Select, Switch, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -16,7 +17,6 @@ import { WorkspaceDateTimeRange, workspaceDateTimePresetWindow } from '../shared
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceConfirmDialog, WorkspaceDialog } from '../shared/WorkspaceDialog';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { VehicleQuickSelector } from '../shared/VehicleQuickSelector';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
@@ -356,7 +356,7 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac
dataSource={rows}
rowKey="id"
pagination={false}
empty={<span className="v2-table-empty-silent" aria-hidden="true" />}
empty={<></>}
onRow={(event) => event ? detailTriggerRow({
className: [`is-severity-${event.severity}`, selectedID === event.id ? 'is-selected' : ''].filter(Boolean).join(' '),
expanded: selectedID === event.id,
@@ -538,16 +538,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, editable,
</MobileFilterSheetSection>
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-alert-filter-card"
title="筛选事件"
description="搜索、关注级别、执行状态与高级范围"
mobileSummary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部事件'}
expanded={!filtersCollapsed}
status={activeFilterCount ? `${activeFilterCount} 项已启用` : '全部事件'}
statusColor={activeFilterCount ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
</div> : <section className="governance-query-bar" aria-label="筛选事件">
<form className="v2-alert-filter v2-alert-filter-primary" onSubmit={submit}>
{coreFilterFields}
<Button className="v2-alert-more-filter" theme="light" htmlType="button" icon={<IconFilter />} aria-haspopup="dialog" aria-controls="v2-alert-advanced-filters" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen(true)}>更多筛选{advancedFilterCount ? ` · ${advancedFilterCount}` : ''}</Button>
@@ -555,7 +546,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, editable,
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}>重置</Button>
<Button className="v2-alert-refresh-button" theme="borderless" htmlType="button" icon={<IconRefresh />} loading={events.isFetching} aria-label="刷新事件流" onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])} />
</form>
</WorkspaceFilterPanel>}
</section>}
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '事件汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
<nav className="v2-event-status-tabs" aria-label="事件执行状态">{
[
@@ -1587,8 +1578,8 @@ function NotificationsWorkspace({ editable, showHealth, view, onViewChange }: {
{showHealth ? <section className={`v2-alert-delivery-health${deliveryHealth.data?.deadLetter ? ' has-dead-letter' : ''}`} aria-label="通知队列健康">
<header><span><strong>发送队列</strong><small>{deliveryHealth.isPending ? '正在读取真实队列状态' : deliveryHealth.isError ? '队列状态暂不可用' : `每 ${ALERT_LIVE_REFRESH_MS / 1000} 秒更新 · ${deliveryHealth.data?.asOf ? formatAlertTime(deliveryHealth.data.asOf) : '等待首次采样'}`}</small></span><Button theme="borderless" size="small" icon={<IconRefresh />} loading={deliveryHealth.isFetching} aria-label="刷新通知队列健康" onClick={() => deliveryHealth.refetch()} /></header>
{deliveryHealth.isError ? <InlineError message={deliveryHealth.error instanceof Error ? deliveryHealth.error.message : '通知队列健康读取失败'} onRetry={() => deliveryHealth.refetch()} /> : <div className="v2-alert-delivery-health-metrics">
<span><small>待发送</small><strong>{deliveryHealth.data?.queued ?? '—'}</strong></span>
<span><small>发送失败</small><strong>{deliveryHealth.data?.failed ?? '—'}</strong></span>
<Button theme="borderless" type="tertiary" aria-label="查看待发送通知" aria-pressed={deliveryFilter === 'queued'} onClick={() => onViewChange({ deliveryFilter: 'queued', offset: 0, selectedID: undefined })}><small>待发送</small><strong>{deliveryHealth.data?.queued ?? '—'}</strong></Button>
<Button theme="borderless" type="tertiary" aria-label="查看发送失败通知" aria-pressed={deliveryFilter === 'failed'} onClick={() => onViewChange({ deliveryFilter: 'failed', offset: 0, selectedID: undefined })}><small>发送失败</small><strong>{deliveryHealth.data?.failed ?? '—'}</strong></Button>
<span className={deliveryHealth.data?.deadLetter ? 'is-danger' : ''}><small>需人工处置</small><strong>{deliveryHealth.data?.deadLetter ?? '—'}</strong></span>
<span><small>正在处理</small><strong>{deliveryHealth.data?.activeLeases ?? '—'}</strong></span>
</div>}
@@ -1605,7 +1596,7 @@ function NotificationsWorkspace({ editable, showHealth, view, onViewChange }: {
{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
{retryReceipt ? <section className={`v2-delivery-retry-receipt is-${alertNotificationDeliveryState(retryReceipt.notification)}`} role="status" aria-label="通知重试回执"><IconTickCircle /><span><strong>{alertNotificationDeliveryState(retryReceipt.notification) === 'delivered' ? '站内通知已恢复送达' : '通知重试已进入发送队列'}</strong><small>第 {retryReceipt.receipt.attemptCount} 次尝试 · 回执 #{retryReceipt.receipt.id} · {retryReceipt.receipt.actor}{retryReceipt.idempotent ? ' · 已复用原请求' : ''}</small></span><Button theme="borderless" icon={<IconClose />} aria-label="关闭通知重试回执" onClick={() => setRetryReceipt(undefined)} /></section> : null}
<div className="v2-alert-delivery-table-wrap">
{notifications.isPending ? <PanelLoading className="v2-alert-notification-loading" title="正在读取通知记录" description="正在检索完整通知范围及送达状态。" /> : items.length ? mobileLayout ? <div className="v2-alert-delivery-mobile-list">{items.map((item) => <Button key={item.id} theme="borderless" className="v2-alert-delivery-mobile-row" aria-pressed={selectedID === item.id} onClick={() => onViewChange({ selectedID: item.id })}><span><strong>{item.title}</strong><small>{channelLabel(item.channel)} · <AlertTime value={item.createdAt} /></small></span><em className={`is-${alertNotificationDeliveryState(item)}`}>{notificationDeliveryLabel(item)}</em><IconChevronRight /></Button>)}</div> : <Table className="v2-alert-delivery-table" columns={columns} dataSource={items} rowKey="id" pagination={false} empty={null} onRow={(item) => item ? detailTriggerRow({ className: selectedID === item.id ? 'is-selected' : '', expanded: selectedID === item.id, label: `查看 ${item.title} 送达详情`, onOpen: () => onViewChange({ selectedID: item.id }) }) : ({})} /> : !notifications.isError ? filtered ? <PanelEmpty className="v2-alert-notification-empty" compact tone="primary" icon={<IconSearch />} title={emptyTitle} description="已检查完整通知范围;可清除搜索或切换送达状态。" action={<Button theme="light" type="primary" onClick={() => onViewChange({ search: '', deliveryFilter: 'all', selectedID: undefined })}>查看全部通知</Button>} /> : <PanelEmpty className="v2-alert-notification-empty" compact tone="primary" icon={<IconBell />} title="暂无通知记录" description="事件触发通知动作后,送达结果会在这里形成审计记录。" /> : null}
{notifications.isPending ? <PanelLoading className="v2-alert-notification-loading" title="正在读取通知记录" description="正在检索完整通知范围及送达状态。" /> : items.length ? mobileLayout ? <div className="v2-alert-delivery-mobile-list">{items.map((item) => <Button key={item.id} theme="borderless" className="v2-alert-delivery-mobile-row" aria-pressed={selectedID === item.id} onClick={() => onViewChange({ selectedID: item.id })}><span><strong>{item.title}</strong><small>{channelLabel(item.channel)} · <AlertTime value={item.createdAt} /></small></span><em className={`is-${alertNotificationDeliveryState(item)}`}>{notificationDeliveryLabel(item)}</em><IconChevronRight /></Button>)}</div> : <Table className="v2-alert-delivery-table" columns={columns} dataSource={items} rowKey="id" pagination={false} empty={<></>} onRow={(item) => item ? detailTriggerRow({ className: selectedID === item.id ? 'is-selected' : '', expanded: selectedID === item.id, label: `查看 ${item.title} 送达详情`, onOpen: () => onViewChange({ selectedID: item.id }) }) : ({})} /> : !notifications.isError ? filtered ? <PanelEmpty className="v2-alert-notification-empty" compact tone="primary" icon={<IconSearch />} title={emptyTitle} description="已检查完整通知范围;可清除搜索或切换送达状态。" action={<Button theme="light" type="primary" onClick={() => onViewChange({ search: '', deliveryFilter: 'all', selectedID: undefined })}>查看全部通知</Button>} /> : <PanelEmpty className="v2-alert-notification-empty" compact tone="primary" icon={<IconBell />} title="暂无通知记录" description="事件触发通知动作后,送达结果会在这里形成审计记录。" /> : null}
</div>
<footer className="v2-alert-notification-pagination"><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 条 · 本页 ${items.length.toLocaleString('zh-CN')} 条`} disabled={notifications.isFetching} onPageChange={(next) => onViewChange({ offset: (next - 1) * limit, selectedID: undefined })} pageSize={limit} pageSizeLabel="每页通知数" onPageSizeChange={(next) => onViewChange({ limit: next, offset: 0, selectedID: undefined })} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
</div>
@@ -1802,7 +1793,7 @@ export default function AlertsPage() {
...(admin ? [{ key: 'rules' as const, label: '自动化', count: ruleLibrary.data?.summary.current || rules.data?.length || undefined }] : []),
{ key: 'notifications' as const, label: '通知记录' }
];
return <div className={`v2-alert-page v2-event-center is-${activeTab}`}>
return <div className={`v2-alert-page v2-event-center governance-workspace is-${activeTab}`}>
<MonitorReturnBar />
<nav className="v2-alert-navigation" aria-label="事件中心工作区"><SegmentedTabs className="v2-alert-tabs" variant="line" ariaLabel="事件中心分类" value={activeTab} items={tabs} onChange={setTab} /></nav>
{activeTab === 'events' && automationRunReturn ? <AutomationRunContextBar context={automationRunReturn} vin={filters.keyword} onReturn={returnToAutomation} /> : null}
@@ -1,5 +1,6 @@
import { IconSearch } from '@douyinfe/semi-icons';
import { TextArea } from '@douyinfe/semi-ui';
import { Textarea } from '@/components/ui/textarea';
import { FieldGroup, Field, FieldLabel } from '@/components/ui/field';
import { useMemo, useState } from 'react';
import { MAX_MONITOR_SEARCH_TERMS, parseMonitorSearchTerms } from '../hooks/useMonitorData';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
@@ -38,10 +39,10 @@ export default function BatchVehicleSearchDialog({ initialValue, mobile, onApply
primaryAction={{ label: `应用搜索(${terms.length})`, ariaLabel: `应用搜索(${terms.length})`, disabled: !terms.length, icon: <IconSearch />, onClick: () => onApply(terms.join(',')) }}
onCancel={onClose}
>
<div className="v2-batch-search-dialog">
<label htmlFor="batch-vehicle-search">每行一个车牌,也支持空格、逗号或分号分隔</label>
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
<div className="v2-batch-search-summary" role="status"><span>已识别 <strong>{terms.length}</strong> 辆,重复项已自动去除</span><em>最多 {MAX_MONITOR_SEARCH_TERMS} 辆</em></div>
</div>
<FieldGroup className="v2-batch-search-dialog"><Field>
<FieldLabel htmlFor="batch-vehicle-search">每行一个车牌,也支持空格、逗号或分号分隔</FieldLabel>
<Textarea id="batch-vehicle-search" autoFocus value={draft} onChange={(event) => setDraft(event.target.value)} rows={10} placeholder={'粤A12345\n粤B67890\n粤C24680'} />
</Field><div className="v2-batch-search-summary" role="status"><span>已识别 <strong>{terms.length}</strong> 辆,重复项已自动去除</span><em>最多 {MAX_MONITOR_SEARCH_TERMS} 辆</em></div>
</FieldGroup>
</WorkspaceSideSheet>;
}
@@ -0,0 +1,177 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { api } from '../../api/client';
import type { CustomerMileageResponse } from '../../api/customerMileage';
import CustomerMileagePage from './CustomerMileagePage';
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
function fixture(): CustomerMileageResponse {
return { rules: { version: 3, lowMileageKm: 50, targetMileageKm: 200, deviationKm: 30, deviationPercent: 20, minimumGpsKm: 50, includeToday: false }, canConfigure: true, assignmentSource: 'oneos-live', assignmentObservedAt: '2026-09-30T01:00:00Z', assignmentStale: false, excludedUnassignedCount: 3, dates: ['2026-09-29'], groups: [{ customer: '客户甲', vehicleCount: 2, departments: ['业务一部'], responsiblePeople: ['负责人甲'], days: [{ date: '2026-09-29', vehicleCount: 2, gpsVehicleCount: 2, hydrogenVehicleCount: 1, gpsKm: 250, hydrogenKm: 100, suspectedChargingCount: 1, dataIssueCount: 1, lowMileageCount: 0, provisional: false }], vehicles: [{ vin: 'LNX00000000000001', plate: '沪A12345', customer: '客户甲', department: '业务一部', responsiblePerson: '负责人甲', date: '2026-09-29', gpsKm: 200, hydrogenKm: 100, gpsStatus: 'on-target', hydrogenStatus: 'below-target', differenceKm: 100, differencePercent: 50, alert: 'suspected-charging', provisional: false }, { vin: 'LNX00000000000002', plate: '沪A67890', customer: '客户甲', department: '', responsiblePerson: '', date: '2026-09-29', gpsKm: 50, hydrogenKm: null, gpsStatus: 'below-target', hydrogenStatus: 'missing', differenceKm: null, differencePercent: null, alert: 'missing-data', provisional: false }] }] };
}
function mount() { return render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}><CustomerMileagePage /></QueryClientProvider>); }
test('preserves missing measurements and filters per-vehicle alerts', async () => {
vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345');
const row = screen.getByText('沪A67890').closest('tr')!; expect(row).toHaveTextContent('无数据'); expect(within(row).getAllByText('—').length).toBe(2); expect(within(screen.getByRole('dialog', { name: '客户甲' })).getByText('覆盖 2 / 1 / 2')).toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '筛选预警' }), { target: { value: 'suspected-charging' } }); expect(screen.queryByText('沪A67890')).not.toBeInTheDocument(); expect(screen.getByText('沪A12345')).toBeInTheDocument();
});
test('submits dates and customer filters, rejecting inverted date range', async () => {
const request = vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345');
fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-01-10' } }); fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-01-01' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); expect(screen.getByRole('alert')).toHaveTextContent('有效日期范围'); expect(request).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-01-31' } }); fireEvent.change(screen.getByPlaceholderText('搜索客户名称'), { target: { value: '客户甲' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); expect(request.mock.calls[1][0].get('dateFrom')).toBe('2026-01-10'); expect(request.mock.calls[1][0].get('customer')).toBe('客户甲');
});
test('saves versioned rules then refetches evaluated data', async () => {
const request = vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); const save = vi.spyOn(api, 'saveCustomerMileageRules').mockImplementation(async r => ({ ...r, version: 4 })); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345'); fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.click(screen.getByRole('button', { name: '预警规则' })); fireEvent.change(await screen.findByLabelText('偏差绝对值下限(km)'), { target: { value: '80' } }); fireEvent.click(screen.getByRole('button', { name: '保存并重新计算' })); await waitFor(() => expect(save).toHaveBeenCalled()); expect(save.mock.calls[0][0]).toMatchObject({ version: 3, deviationKm: 80 }); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('readers inspect rules without edit controls', async () => {
const response = fixture(); response.canConfigure = false; vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345'); fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.click(screen.getByRole('button', { name: '查看规则' })); expect(await screen.findByLabelText('达标里程(km)')).toBeDisabled(); expect(screen.queryByRole('button', { name: '保存并重新计算' })).not.toBeInTheDocument();
});
test('GZEV source replaces telemetry jobs and calculation notices', async () => {
const response = fixture(); response.hydrogenJobs = [{ date: '2026-09-29', status: 'pending', attempts: 0, completedAt: '' }];
vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount();
await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' });
expect(screen.queryByText(/正在后台计算/)).not.toBeInTheDocument();
expect(screen.getByText(/hyRunMileage/)).toBeInTheDocument();
});
test('compares customers before opening details and keeps the selected customer and date together', async () => {
const response = fixture();
response.dates.push('2026-09-28');
const first = response.groups[0];
first.days.push({ ...first.days[0], date: '2026-09-28', gpsKm: 20, suspectedChargingCount: 0 });
first.vehicles.push({ ...first.vehicles[0], date: '2026-09-28', plate: '沪A前一天' });
response.groups.push({ ...first, customer: '客户乙', days: first.days.map(d => ({ ...d, suspectedChargingCount: 0, dataIssueCount: 0 })), vehicles: first.vehicles.map(v => ({ ...v, plate: '沪B乙客户', customer: '客户乙' })) });
vi.spyOn(api, 'customerMileage').mockResolvedValue(response);
mount();
const cell = await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByText('客户乙')).toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: '疑似充电' }));
expect(screen.queryByText('客户乙')).not.toBeInTheDocument();
fireEvent.click(cell);
expect(await screen.findByText('沪A12345')).toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '明细日期' }), { target: { value: '2026-09-28' } });
expect(screen.getByText('沪A前一天')).toBeInTheDocument();
expect(screen.queryByText('沪A12345')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' }));
fireEvent.click(screen.getByRole('radio', { name: '全部客户' }));
expect(screen.getByText('客户乙')).toBeInTheDocument();
});
test('paginates the customer overview and sorts the complete result', async () => {
const response = fixture();
const first = response.groups[0];
response.groups = Array.from({ length: 16 }, (_, index) => ({ ...first, customer: `客户${String(index).padStart(2, '0')}`, vehicleCount: index + 1 }));
vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount();
await screen.findByText('客户00');
expect(screen.queryByText('客户15')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '下一页客户' }));
expect(screen.getByText('客户15')).toBeInTheDocument();
expect(screen.queryByText('客户00')).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '客户排序' }), { target: { value: 'vehicles' } });
expect(screen.getByText('客户15')).toBeInTheDocument();
expect(screen.queryByText('客户00')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '上一页客户' })).toBeDisabled();
});
test('resets dates and all customer filters to the initial query', async () => {
const request = vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture());
mount();
await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' });
const initial = request.mock.calls[0][0];
fireEvent.change(screen.getByLabelText('客户'), { target: { value: '客户甲' } });
fireEvent.change(screen.getByLabelText('部门'), { target: { value: '业务一部' } });
fireEvent.change(screen.getByLabelText('业务负责人'), { target: { value: '负责人甲' } });
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-01-01' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByRole('button', { name: '重置' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(screen.getByLabelText('客户')).toHaveValue('');
expect(screen.getByLabelText('部门')).toHaveValue('');
expect(screen.getByLabelText('业务负责人')).toHaveValue('');
expect(screen.getByLabelText('开始日期')).toHaveValue(initial.get('dateFrom'));
expect(screen.getByLabelText('结束日期')).toHaveValue(initial.get('dateTo'));
fireEvent.click(screen.getByRole('button', { name: '快捷日期' }));
expect(screen.getByRole('radio', { name: '近 7 天' })).toBeChecked();
});
test('shows deviation and below-target counts independently without counting missing data', async () => {
vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount();
const cell = await screen.findByRole('button', {name:'查看客户甲 2026-09-29车辆明细'});
expect(within(cell).getByText('偏差 1')).toBeInTheDocument();
expect(within(cell).getByText('未达标 2')).toBeInTheDocument();
fireEvent.click(cell);
expect(screen.getByText('GPS 达标 · 纯氢 未达标')).toBeInTheDocument();
expect(screen.getByText('GPS 未达标 · 纯氢 无数据,无法判断')).toBeInTheDocument();
});
test('drills into interval totals and then daily details', async () => {
vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount();
fireEvent.click(await screen.findByRole('button', {name:'查看客户甲区间累计明细'}));
expect(await screen.findByRole('table',{name:'车辆区间累计'})).toHaveTextContent('沪A12345');
expect(screen.getByRole('table',{name:'车辆区间累计'})).toHaveTextContent('有数据 0 天');
fireEvent.click(screen.getByRole('button',{name:'2026-09-29'}));
expect(await screen.findByRole('table',{name:'客户车辆里程明细'})).toBeInTheDocument();
});
test('filters customer interval GPS totals with greater less and equal', async () => {
vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount();
await screen.findByRole('button',{name:'查看客户甲区间累计明细'});
fireEvent.click(screen.getByRole('button',{name:'区间里程条件'}));
fireEvent.change(screen.getByRole('spinbutton',{name:'区间 GPS 里程阈值'}),{target:{value:'250'}});
expect(screen.queryByRole('button',{name:'查看客户甲区间累计明细'})).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox',{name:'区间 GPS 里程比较'}),{target:{value:'eq'}});
expect(screen.getByRole('button',{name:'查看客户甲区间累计明细'})).toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox',{name:'区间 GPS 里程比较'}),{target:{value:'lt'}});
fireEvent.change(screen.getByRole('spinbutton',{name:'区间 GPS 里程阈值'}),{target:{value:'251'}});
expect(screen.getByRole('button',{name:'查看客户甲区间累计明细'})).toBeInTheDocument();
fireEvent.click(screen.getByRole('button',{name:'清除'}));
expect(screen.getByRole('spinbutton',{name:'区间 GPS 里程阈值'})).toHaveValue(null);
});
test('filters individual vehicles by GPS mileage in interval and daily details', async () => {
vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount();
fireEvent.click(await screen.findByRole('button',{name:'查看客户甲区间累计明细'}));
fireEvent.change(screen.getByRole('spinbutton',{name:'单车 GPS 里程阈值'}),{target:{value:'100'}});
expect(screen.getByText('沪A12345')).toBeInTheDocument();
expect(screen.queryByText('沪A67890')).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox',{name:'单车 GPS 里程比较'}),{target:{value:'lt'}});
expect(screen.queryByText('沪A12345')).not.toBeInTheDocument();
expect(screen.getByText('沪A67890')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button',{name:'2026-09-29'}));
fireEvent.change(screen.getByRole('spinbutton',{name:'单车 GPS 里程阈值'}),{target:{value:'100'}});
expect(screen.getByText('沪A12345')).toBeInTheDocument();
expect(screen.queryByText('沪A67890')).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox',{name:'单车 GPS 里程比较'}),{target:{value:'lt'}});
expect(screen.getByText('沪A67890')).toBeInTheDocument();
expect(screen.queryByText('沪A12345')).not.toBeInTheDocument();
});
test('offers searchable dropdowns, keeps all options after filtering, and submits partial names', async () => {
const response = fixture();
response.groups.push({ ...response.groups[0], customer: '客户乙', departments: ['业务二部'], responsiblePeople: ['负责人乙'] });
const request = vi.spyOn(api, 'customerMileage').mockImplementation(async params => ({ ...response, groups: params.get('customer') ? response.groups.slice(0, 1) : response.groups }));
mount(); await screen.findByText('客户乙');
fireEvent.click(screen.getByRole('button', { name: '展开客户选项' }));
expect(within(screen.getByRole('listbox', { name: '客户选项' })).getAllByRole('option').map(option => option.textContent)).toEqual(['客户甲', '客户乙']);
fireEvent.change(screen.getByRole('combobox', { name: '客户' }), { target: { value: '甲' } });
expect(within(screen.getByRole('listbox', { name: '客户选项' })).getAllByRole('option').map(option => option.textContent)).toEqual(['客户甲']);
fireEvent.keyDown(screen.getByRole('combobox', { name: '客户' }), { key: 'ArrowDown' });
fireEvent.keyDown(screen.getByRole('combobox', { name: '客户' }), { key: 'Enter' });
expect(screen.getByLabelText('客户')).toHaveValue('客户甲'); expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('部门'), { target: { value: '一部' } });
expect(screen.getByRole('option', { name: '业务一部' })).toBeInTheDocument();
fireEvent.blur(screen.getByLabelText('部门'));
fireEvent.change(screen.getByLabelText('业务负责人'), { target: { value: '甲' } });
expect(screen.getByRole('option', { name: '负责人甲' })).toBeInTheDocument();
fireEvent.blur(screen.getByLabelText('业务负责人'));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
expect(request.mock.calls[1][0].get('department')).toBe('一部'); expect(request.mock.calls[1][0].get('responsiblePerson')).toBe('甲');
fireEvent.click(screen.getByRole('button', { name: '展开客户选项' }));
expect(screen.getByRole('option', { name: '客户乙' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清除客户' })); expect(screen.getByLabelText('客户')).toHaveValue('');
});
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useState } from 'react';
import { Popover } from 'radix-ui';
import { MileageFilterPanel, MileageMetric } from './MileageWorkspace';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Maximize2, Minimize2, ChevronRight, Database, Info, RefreshCw, RotateCcw, Search, Settings2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { CustomerMileageSearch } from './CustomerMileageSearch';
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { api } from '../../api/client';
import type { CustomerMileageGroup, CustomerMileageRules } from '../../api/customerMileage';
import { MileageIntervalDetails, MileageDetails, MileageEmpty, MileageMatrix, MileageNotice, MileagePagination, MileageRuleEditor, isBelowTarget, total } from './CustomerMileageViews';
import './customer-mileage.css';
function dateOffset(days: number) {
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date());
const value = (type: string) => parts.find(part => part.type === type)?.value;
const date = new Date(`${value('year')}-${value('month')}-${value('day')}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
const defaultParams = () => new URLSearchParams({ dateFrom: dateOffset(-7), dateTo: dateOffset(-1) }).toString();
const emptyGroups: CustomerMileageGroup[] = [];
const countDeviations = (group: CustomerMileageGroup) => group.days.reduce((sum, day) => sum + day.suspectedChargingCount, 0);
const countIssues = (group: CustomerMileageGroup) => group.days.reduce((sum, day) => sum + day.dataIssueCount, 0);
export default function CustomerMileagePage() {
const [from, setFrom] = useState(() => dateOffset(-7));
const [to, setTo] = useState(() => dateOffset(-1));
const [customer, setCustomer] = useState('');
const [department, setDepartment] = useState('');
const [person, setPerson] = useState('');
const [params, setParams] = useState(defaultParams);
const [selected, setSelected] = useState('');
const [day, setDay] = useState('');
const [formError, setFormError] = useState('');
const [ruleDraft, setRuleDraft] = useState<CustomerMileageRules | null>(null);
const [saved, setSaved] = useState(false);
const [dateMenuOpen, setDateMenuOpen] = useState(false);
const [overviewFilter, setOverviewFilter] = useState('all');
const [listFullscreen, setListFullscreen] = useState(false);
useEffect(() => {
if (!listFullscreen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') setListFullscreen(false); };
window.addEventListener('keydown', escape);
return () => { document.body.style.overflow = previous; window.removeEventListener('keydown', escape); };
}, [listFullscreen]);
const [gpsOperator, setGpsOperator] = useState('gt');
const [gpsThreshold, setGpsThreshold] = useState('');
const [sort, setSort] = useState('deviation');
const [customerPage, setCustomerPage] = useState(0);
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['customer-mileage', params],
queryFn: ({ signal }) => api.customerMileage(new URLSearchParams(params), signal),
staleTime: 60_000, retry: 1,
});
const data = query.data;
const groups = data?.groups ?? emptyGroups;
const [filterOptions, setFilterOptions] = useState({ customers: [] as string[], departments: [] as string[], people: [] as string[] });
useEffect(() => {
const filters = new URLSearchParams(params);
if (!data || ['customer', 'department', 'responsiblePerson'].some(key => filters.get(key))) return;
const unique = (values: string[]) => [...new Set(values.filter(value => value.trim()))].sort((a, b) => a.localeCompare(b, 'zh-CN'));
setFilterOptions({ customers: unique(data.groups.map(group => group.customer)), departments: unique(data.groups.flatMap(group => group.departments)), people: unique(data.groups.flatMap(group => group.responsiblePeople)) });
}, [data, params]);
const active = groups.find(group => group.customer === selected);
const overviewGroups = useMemo(() => {
const values = groups.map(group => ({ group, deviations: countDeviations(group), issues: countIssues(group), belowTarget: group.vehicles.filter(vehicle => !vehicle.restricted && (isBelowTarget(vehicle.gpsStatus) || isBelowTarget(vehicle.hydrogenStatus))).length, gps: total(group, 'gpsKm') }));
return values.filter(value => {
if (gpsThreshold === '') return true;
const threshold = Number(gpsThreshold);
if (!Number.isFinite(threshold) || threshold < 0 || value.gps == null) return false;
const mileage = Math.round(value.gps * 1000), limit = Math.round(threshold * 1000);
return gpsOperator === 'gt' ? mileage > limit : gpsOperator === 'lt' ? mileage < limit : mileage === limit;
}).filter(value => overviewFilter === 'all' || (overviewFilter === 'deviation' ? value.deviations > 0 : overviewFilter === 'below-target' ? value.belowTarget > 0 : value.issues > 0))
.sort((a, b) => {
const difference = sort === 'deviation' ? b.deviations - a.deviations : sort === 'issues' ? b.issues - a.issues : sort === 'vehicles' ? b.group.vehicleCount - a.group.vehicleCount : sort === 'gps' ? (b.gps ?? -1) - (a.gps ?? -1) : 0;
return difference || a.group.customer.localeCompare(b.group.customer, 'zh-CN');
}).map(value => value.group);
}, [groups, overviewFilter, sort, gpsOperator, gpsThreshold]);
const overviewPage = Math.min(customerPage, Math.max(0, Math.ceil(overviewGroups.length / 15) - 1));
const visibleGroups = useMemo(() => overviewGroups.slice(overviewPage * 15, (overviewPage + 1) * 15), [overviewGroups, overviewPage]);
const saveRules = useMutation({ mutationFn: api.saveCustomerMileageRules, onSuccess: async () => {
setRuleDraft(null); setSaved(true); await queryClient.invalidateQueries({ queryKey: ['customer-mileage'] });
} });
const belowTarget = groups.flatMap(group => group.vehicles).filter(vehicle => !vehicle.restricted && (isBelowTarget(vehicle.gpsStatus) || isBelowTarget(vehicle.hydrogenStatus))).length;
const vehicles = groups.reduce((sum, group) => sum + group.vehicleCount, 0);
const comparable = groups.reduce((sum, group) => sum + group.vehicles.filter(vehicle => vehicle.gpsKm != null && vehicle.hydrogenKm != null && !vehicle.qualityReason).length, 0);
const suspicious = groups.reduce((sum, group) => sum + countDeviations(group), 0);
const missing = groups.reduce((sum, group) => sum + countIssues(group), 0);
const applied = new URLSearchParams(params);
const range = [7, 30, 90].find(days => from === dateOffset(-days) && to === dateOffset(-1));
function apply(dateFrom = from, dateTo = to) {
if (!dateFrom || !dateTo || dateFrom > dateTo || dateTo > dateOffset(0) || (Date.parse(dateTo) - Date.parse(dateFrom)) / 86_400_000 > 365) {
setFormError('请选择不晚于今天、最多 366 天的有效日期范围'); return false;
}
setFormError(''); setSelected(''); setDay(''); setCustomerPage(0);
setParams(new URLSearchParams({ dateFrom, dateTo, customer: customer.trim(), department: department.trim(), responsiblePerson: person.trim() }).toString());
return true;
}
function reset() {
setFrom(dateOffset(-7)); setTo(dateOffset(-1)); setCustomer(''); setDepartment(''); setPerson('');
setSelected(''); setDay(''); setCustomerPage(0); setOverviewFilter('all'); setGpsThreshold(''); setFormError(''); setParams(defaultParams());
}
const dirty = Boolean(customer || department || person || from !== dateOffset(-7) || to !== dateOffset(-1));
const method = data ? <details className="cm-method" id="cm-method" onKeyDown={event => { if (event.key === 'Escape') event.currentTarget.open = false; }}><summary><Database aria-hidden="true" />数据口径<ChevronRight className="cm-method-chevron" aria-hidden="true" /></summary><div className="mileage-method-body"><Separator />
<p>按 OneOS 当前客户归属汇总历史里程;非历史交付归属。{data.assignmentObservedAt ? `归属更新:${new Date(data.assignmentObservedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` : '尚无 OneOS 归属同步记录。'}{data.assignmentSource === 'oneos-synced' ? ' 使用最近同步归属,仅包含已同步车辆。' : ''}{data.assignmentStale ? ' 归属可能过期,请由有权限的 OneOS 用户刷新本页。' : ''} 已排除 {data.excludedUnassignedCount} 辆无客户车辆。{data.assignmentMissingCount ? ` 另有 ${data.assignmentMissingCount} 辆授权车辆尚未同步归属,暂未计入。` : ''}</p>
<p>缺失值为 —;合计仅含有数据车辆;颜色分档用于车辆明细。GPS 使用 JT808 日里程;纯氢使用广州平台(GZEV)每日统计接口的氢能源行驶里程(hyRunMileage),按 VIN 与北京时间自然日匹配。源数据为空或尚未采集时显示 —,不以遥测推算或零值替代。里程偏差仅作核查线索,不等同于已证实充电行驶。</p>
</div></details> : null;
return <main className="cm-page" aria-label="客户运营里程">
<MileageFilterPanel className="cm-filters" label="客户里程筛选" summary={`${applied.get('dateFrom')} 至 ${applied.get('dateTo')}${applied.get('customer') ? ` · ${applied.get('customer')}` : ' · 全部客户'}${applied.get('department') ? ` · ${applied.get('department')}` : ''}${applied.get('responsiblePerson') ? ` · ${applied.get('responsiblePerson')}` : ''}`} onApply={() => apply()}>
<FieldGroup className="cm-filter-fields">
<Field data-invalid={!!formError}><FieldLabel htmlFor="cm-date-from">开始日期</FieldLabel><Input id="cm-date-from" type="date" value={from} max={dateOffset(0)} aria-invalid={!!formError} onChange={event => setFrom(event.target.value)} /></Field>
<Field data-invalid={!!formError}><FieldLabel htmlFor="cm-date-to">结束日期</FieldLabel><Input id="cm-date-to" type="date" value={to} max={dateOffset(0)} aria-invalid={!!formError} onChange={event => setTo(event.target.value)} /></Field>
<Field><FieldLabel htmlFor="cm-customer">客户</FieldLabel><CustomerMileageSearch id="cm-customer" label="客户" placeholder="搜索客户名称" value={customer} options={filterOptions.customers} onChange={setCustomer} /></Field>
<Field><FieldLabel htmlFor="cm-department">部门</FieldLabel><CustomerMileageSearch id="cm-department" label="部门" placeholder="选择或搜索部门" value={department} options={filterOptions.departments} onChange={setDepartment} /></Field>
<Field><FieldLabel htmlFor="cm-person">业务负责人</FieldLabel><CustomerMileageSearch id="cm-person" label="业务负责人" placeholder="选择或搜索负责人" value={person} options={filterOptions.people} onChange={setPerson} /></Field>
<div className="cm-actions">
<Button type="submit" disabled={query.isFetching} className="cm-query-button"><Search aria-hidden="true" />查询</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="重置" title="重置" disabled={!dirty || query.isFetching} onClick={reset}><RotateCcw aria-hidden="true" /></Button>
<Button type="button" variant="outline" size="icon-sm" aria-label="刷新数据" title="刷新数据" disabled={query.isFetching} onClick={() => void query.refetch()}><RefreshCw aria-hidden="true" /></Button>
<Popover.Root open={dateMenuOpen} onOpenChange={setDateMenuOpen}><Popover.Trigger asChild><Button type="button" variant="outline" size="sm" aria-label="快捷日期">{range ? `近 ${range} 天` : '快捷日期'}</Button></Popover.Trigger><Popover.Portal><Popover.Content className="mileage-toolbar-popover" align="end" sideOffset={6} aria-label="快捷日期范围">
<p>北京时间自然日 · 不含无客户车辆</p>
<ToggleGroup type="single" variant="outline" size="sm" value={range ? String(range) : ''} aria-label="快捷日期范围" onValueChange={value => {
if (!value) return;
const start = dateOffset(-Number(value)); const end = dateOffset(-1);
setFrom(start); setTo(end); apply(start, end); setDateMenuOpen(false);
}}>{[7, 30, 90].map(days => <ToggleGroupItem key={days} value={String(days)}>近 {days} 天</ToggleGroupItem>)}</ToggleGroup>
</Popover.Content></Popover.Portal></Popover.Root>
<Button type="button" variant="ghost" size="icon-sm" aria-label={data?.canConfigure ? '预警规则' : '查看规则'} title={data?.canConfigure ? '预警规则' : '查看规则'} disabled={!data} onClick={() => { setSaved(false); saveRules.reset(); setRuleDraft(data?.rules ?? null); }}><Settings2 aria-hidden="true" /></Button>
</div>
</FieldGroup>
</MileageFilterPanel>
{formError ? <MileageNotice tone="error">{formError}</MileageNotice> : null}
{saved ? <MileageNotice tone="success">规则已保存,当前查询已按新规则重新计算。</MileageNotice> : null}
{query.isError ? <div role="alert" className="cm-panel"><MileageEmpty title="暂时无法加载客户里程" action={<Button variant="outline" onClick={() => void query.refetch()}><RefreshCw data-icon="inline-start" aria-hidden="true" />重新加载</Button>}>{query.error instanceof Error ? query.error.message : '请稍后重试'}</MileageEmpty></div> : !data ?
<section className="cm-panel cm-loading" role="status" aria-label="正在加载客户里程"><p><Info aria-hidden="true" />正在匹配客户车辆与每日里程…</p><div className="cm-loading-metrics">{[0, 1, 2, 3].map(value => <Skeleton key={value} className="h-16 w-full" />)}</div>{[0, 1, 2, 3].map(value => <Skeleton key={value} className="h-12 w-full" />)}</section> : <>
{data.assignmentStale ? <MileageNotice tone="error">归属可能过期,当前使用已同步归属;可由有权限的 OneOS 用户更新。</MileageNotice> : null}
<section className="cm-summary" aria-label="客户运营概况">
<MileageMetric label="当前客户" value={groups.length} unit="家" description="所选条件下的客户数量" />
<MileageMetric label="运营车辆" value={vehicles} unit="辆" description="当前授权且有客户归属" />
<MileageMetric label="疑似充电行驶" value={comparable ? suspicious : '—'} unit="车辆日" description={comparable ? `可比较 ${comparable} 车辆日 · 双阈值预警` : '暂无可比较数据,不能判断是否存在偏差'} />
<MileageMetric label="未达标" value={belowTarget} unit="车辆日" description={`GPS 或纯氢低于 ${data.rules.targetMileageKm} km · 不含缺失值`} />
<MileageMetric label="需核查数据" value={missing} unit="车辆日" description="缺失、质量问题或纯氢高于 GPS" />
</section>
{!groups.length ? <section className="cm-panel"><MileageEmpty title="没有符合条件的客户车辆">请调整客户、部门或负责人筛选;只有当前授权范围内且在 OneOS 填有客户的车辆会计入。</MileageEmpty><div className="cm-overview-footer">{method}</div></section> :
<section className={`cm-overview${listFullscreen ? ' cm-overview-fullscreen' : ''}`} aria-label="客户每日里程总览">
<div className="cm-overview-command">
<div className="cm-overview-copy"><h2>客户每日里程</h2><p title="每格为客户当日合计,点击查看车辆明细">{applied.get('dateFrom')} — {applied.get('dateTo')} · km</p></div>
<div className="cm-overview-tools">
<ToggleGroup type="single" variant="outline" size="sm" value={overviewFilter} aria-label="客户关注范围" onValueChange={value => { if (value) { setOverviewFilter(value); setCustomerPage(0); } }}>
<ToggleGroupItem value="all">全部客户</ToggleGroupItem><ToggleGroupItem value="deviation">疑似充电</ToggleGroupItem><ToggleGroupItem value="below-target">未达标</ToggleGroupItem><ToggleGroupItem value="issues">数据待核查</ToggleGroupItem>
</ToggleGroup>
<Popover.Root><Popover.Trigger asChild><Button type="button" size="sm" variant="outline" aria-label="区间里程条件">{gpsThreshold !== '' ? `GPS ${gpsOperator === 'gt' ? '>' : gpsOperator === 'lt' ? '<' : '='} ${gpsThreshold}` : '里程条件'}</Button></Popover.Trigger><Popover.Portal><Popover.Content className="mileage-toolbar-popover" align="end" sideOffset={6} aria-label="区间里程条件">
<div className="cm-gps-filter"><label htmlFor="cm-gps-operator">区间 GPS 里程</label><NativeSelect id="cm-gps-operator" aria-label="区间 GPS 里程比较" value={gpsOperator} onChange={event => { setGpsOperator(event.target.value); setCustomerPage(0); }}><NativeSelectOption value="gt">大于</NativeSelectOption><NativeSelectOption value="lt">小于</NativeSelectOption><NativeSelectOption value="eq">等于</NativeSelectOption></NativeSelect><Input type="number" min="0" step="0.001" aria-label="区间 GPS 里程阈值" placeholder="不限" value={gpsThreshold} onChange={event => { setGpsThreshold(event.target.value); setCustomerPage(0); }} /><span>km</span>{gpsThreshold !== '' ? <Button size="sm" variant="ghost" onClick={() => setGpsThreshold('')}>清除</Button> : null}</div>
</Popover.Content></Popover.Portal></Popover.Root>
<div className="cm-sort"><label htmlFor="cm-sort">排序</label><NativeSelect id="cm-sort" aria-label="客户排序" size="sm" value={sort} onChange={event => { setSort(event.target.value); setCustomerPage(0); }}><NativeSelectOption value="deviation">偏差数量优先</NativeSelectOption><NativeSelectOption value="issues">数据核查数量优先</NativeSelectOption><NativeSelectOption value="vehicles">车辆数量优先</NativeSelectOption><NativeSelectOption value="gps">GPS 里程优先</NativeSelectOption><NativeSelectOption value="name">客户名称</NativeSelectOption></NativeSelect></div>
<Button type="button" variant="outline" size="icon-sm" aria-label={listFullscreen ? '退出全屏' : '全屏列表'} title={listFullscreen ? '退出全屏' : '全屏列表'} aria-pressed={listFullscreen} onClick={() => setListFullscreen(value => !value)}>{listFullscreen ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}</Button>
</div>
</div>
<MileageMatrix groups={visibleGroups} dates={data.dates} onChoose={(name, date) => { setSelected(name); setDay(date); }} />
{!overviewGroups.length ? <MileageEmpty title="当前范围没有这类客户" action={<Button variant="outline" onClick={() => setOverviewFilter('all')}>查看全部客户</Button>}>可切换关注范围查看其他客户。</MileageEmpty> : null}
<div className="cm-overview-footer"><MileagePagination customers count={overviewGroups.length} page={overviewPage} pageSize={15} onPage={setCustomerPage} />{method}</div>
</section>}
{active && !day ? <MileageIntervalDetails group={active} onClose={() => setSelected('')} onChooseDay={setDay} /> : active ? <MileageDetails key={`${active.customer}/${day}`} group={active} initialDay={day} rules={data.rules} onClose={() => setSelected('')} /> : null}
</>}
{ruleDraft ? <MileageRuleEditor draft={ruleDraft} onDraft={setRuleDraft} canConfigure={data?.canConfigure ?? false} pending={saveRules.isPending} error={saveRules.isError ? (saveRules.error instanceof Error ? saveRules.error.message : '规则保存失败') : ''} onClose={() => { if (!saveRules.isPending) setRuleDraft(null); }} onSave={() => saveRules.mutate(ruleDraft)} /> : null}
</main>;
}
@@ -0,0 +1,46 @@
import { useEffect, useId, useRef, useState } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
export function CustomerMileageSearch({ id, label, placeholder, value, options, onChange }: {
id: string; label: string; placeholder: string; value: string; options: string[]; onChange: (value: string) => void;
}) {
const listId = useId();
const container = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [active, setActive] = useState(-1);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => { if (event.target instanceof Node && !container.current?.contains(event.target)) setOpen(false); };
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [open]);
const matches = options.filter(option => option.toLocaleLowerCase().includes(keyword.trim().toLocaleLowerCase()));
function choose(option: string) { onChange(option); container.current?.querySelector('input')?.focus(); setOpen(false); setActive(-1); }
return <div ref={container} className="cm-search" onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}>
<InputGroup>
<InputGroupInput id={id} role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={open ? listId : undefined} aria-activedescendant={open && active >= 0 && matches[active] ? `${listId}-${active}` : undefined} autoComplete="off" placeholder={placeholder} value={value}
onFocus={() => { setKeyword(''); setActive(-1); setOpen(true); }}
onChange={event => { onChange(event.target.value); setKeyword(event.target.value); setActive(-1); setOpen(true); }}
onKeyDown={event => {
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault(); setOpen(true);
const next = matches.length ? active < 0 ? (event.key === 'ArrowDown' ? 0 : matches.length - 1) : (active + (event.key === 'ArrowDown' ? 1 : -1) + matches.length) % matches.length : -1;
setActive(next); document.getElementById(`${listId}-${next}`)?.scrollIntoView?.({ block: 'nearest' });
}
if (event.key === 'Enter') { if (open && active >= 0 && matches[active]) { event.preventDefault(); choose(matches[active]); } else setOpen(false); }
}} />
<InputGroupAddon align="inline-end">
{value ? <Button type="button" variant="ghost" size="icon-sm" aria-label={`清除${label}`} onMouseDown={event => event.preventDefault()} onClick={() => { onChange(''); setKeyword(''); setActive(-1); container.current?.querySelector('input')?.focus(); setOpen(false); }}><X aria-hidden="true" /></Button> : null}
<Button type="button" variant="ghost" size="icon-sm" aria-label={`${open ? '收起' : '展开'}${label}选项`} aria-expanded={open} onMouseDown={event => event.preventDefault()} onClick={() => { setKeyword(''); setActive(-1); setOpen(!open); }}><ChevronDown aria-hidden="true" /></Button>
</InputGroupAddon>
</InputGroup>
{open ? <div className="cm-search-menu"><div id={listId} role="listbox" aria-label={`${label}选项`}>
{matches.map((option, index) => <div key={option} id={`${listId}-${index}`} role="option" aria-selected={value === option} data-active={index === active} onMouseDown={event => event.preventDefault()} onClick={() => choose(option)}><span>{option}</span>{value === option ? <Check aria-hidden="true" /> : null}</div>)}
{!matches.length ? <p role="status">{options.length ? '没有匹配选项,可直接按关键词查询' : '暂无可选项,可直接输入关键词查询'}</p> : null}
</div><p className="cm-search-hint">可选择下拉项,或输入关键词后查询</p></div> : null}
</div>;
}
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, Database, Info, Search, Settings2, X } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { cn } from '@/lib/utils';
import type { CustomerMileageGroup, CustomerMileageRules, CustomerVehicleDay } from '../../api/customerMileage';
const number = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
const weekday = new Intl.DateTimeFormat('zh-CN', { weekday: 'short', timeZone: 'Asia/Shanghai' });
export function MileageSourceTag({ source }: { source: 'gps' | 'hydrogen' }) {
return <span className={`cm-source-tag cm-source-${source}`}>{source === 'gps' ? 'GPS' : '氢'}</span>;
}
export const km = (value: number | null) => value == null ? '—' : number.format(value);
export const total = (group: CustomerMileageGroup, field: 'gpsKm' | 'hydrogenKm') => {
const values = group.days.map(day => day[field]).filter((value): value is number => value != null);
return values.length ? values.reduce((sum, value) => sum + value, 0) : null;
};
const alerts: Record<string, string> = {
restricted: '不在历史授权范围', normal: '偏差正常', 'suspected-charging': '疑似充电行驶',
'missing-data': '数据缺失', 'data-quality': '数据待核查', 'reverse-deviation': '纯氢高于 GPS', provisional: '当日暂估'
};
const bands: Record<string, string> = { restricted: '未授权日期', low: '低里程', 'below-target': '未达标', 'on-target': '达标', missing: '无数据' };
function bandVariant(status: string) {
return status === 'low' ? 'danger' : status === 'below-target' ? 'warning' : status === 'on-target' ? 'success' : 'secondary';
}
function alertVariant(status: string) {
return status === 'suspected-charging' ? 'danger' : status === 'normal' ? 'success' : status === 'missing-data' || status === 'data-quality' || status === 'reverse-deviation' ? 'warning' : 'secondary';
}
export const isBelowTarget = (status: string) => status === 'low' || status === 'below-target';
export const targetConclusion = (vehicle: CustomerVehicleDay) => {
if (vehicle.restricted) return '未授权日期,不判断达标';
const label = (name: string, status: string) => `${name}${isBelowTarget(status) ? '未达标' : status === 'on-target' ? '达标' : '无数据,无法判断'}`;
return `${label('GPS ', vehicle.gpsStatus)} · ${label('纯氢 ', vehicle.hydrogenStatus)}`;
};
export function MileageNotice({ children, tone = 'info' }: { children: ReactNode; tone?: 'info' | 'error' | 'success' }) {
const Icon = tone === 'error' ? AlertCircle : tone === 'success' ? CheckCircle2 : Info;
return <Alert role={tone === 'error' ? 'alert' : 'status'} variant={tone === 'error' ? 'destructive' : 'default'} className={cn('cm-notice', `cm-notice-${tone}`)}>
<Icon aria-hidden="true" /><AlertDescription>{children}</AlertDescription>
</Alert>;
}
export function MileageEmpty({ title, children, action }: { title: string; children?: ReactNode; action?: ReactNode }) {
return <Empty className="cm-empty">
<EmptyHeader><EmptyMedia variant="icon"><Database aria-hidden="true" /></EmptyMedia><EmptyTitle>{title}</EmptyTitle><EmptyDescription>{children}</EmptyDescription></EmptyHeader>
{action ? <EmptyContent>{action}</EmptyContent> : null}
</Empty>;
}
export function MileagePagination({ count, page, pageSize, onPage, customers = false }: { count: number; page: number; pageSize: number; onPage: (page: number) => void; customers?: boolean }) {
const pages = Math.max(1, Math.ceil(count / pageSize));
return <div className="cm-pagination" aria-label={customers ? '客户分页' : '车辆分页'}>
<span>共 {count} {customers ? '家客户' : '辆'} · 每页 {pageSize} {customers ? '家' : '辆'}</span>
<div><Button variant="outline" size="sm" disabled={page === 0} onClick={() => onPage(page - 1)}><ChevronLeft data-icon="inline-start" aria-hidden="true" />{customers ? '上一页客户' : '上一页'}</Button>
<span className="cm-page-number" aria-label={`第 ${page + 1} 页,共 ${pages} 页`}>{page + 1} / {pages}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= pages} onClick={() => onPage(page + 1)}>{customers ? '下一页客户' : '下一页'}<ChevronRight data-icon="inline-end" aria-hidden="true" /></Button></div>
</div>;
}
export function MileageMatrix({ groups, dates, onChoose }: { groups: CustomerMileageGroup[]; dates: string[]; onChoose: (customer: string, date: string) => void }) {
const scroll = useRef<HTMLDivElement>(null);
useEffect(() => { if (scroll.current) scroll.current.scrollTop = 0; }, [groups, dates]);
const indexed = useMemo(() => groups.map(group => ({ group, days: new Map(group.days.map(day => [day.date, day])), gps: total(group, 'gpsKm'), hydrogen: total(group, 'hydrogenKm'), targets: new Map(group.days.map(day => { const rows = group.vehicles.filter(vehicle => vehicle.date === day.date && !vehicle.restricted); return [day.date, rows.filter(vehicle => isBelowTarget(vehicle.gpsStatus) || isBelowTarget(vehicle.hydrogenStatus)).length]; })) })), [groups]);
return <div className="cm-matrix-scroll" ref={scroll} tabIndex={0} aria-label="客户与日期里程表,可横向滚动">
<Table className="cm-matrix" aria-label="客户每日里程">
<TableHeader><TableRow>
<TableHead scope="col" className="cm-customer-column">客户 / 车辆</TableHead>
<TableHead scope="col" className="cm-total-column">区间累计<small>GPS / 纯氢 · km</small></TableHead>
{dates.map(date => <TableHead scope="col" key={date} className="cm-date-column">{date.slice(5).replace('-', '/')}<small>{weekday.format(new Date(`${date}T00:00:00+08:00`))}</small></TableHead>)}
</TableRow></TableHeader>
<TableBody>{indexed.map(({ group, days, gps, hydrogen, targets }) => <TableRow key={group.customer}>
<TableHead scope="row" className="cm-customer-column"><span className="cm-customer-identity"><strong title={group.customer}>{group.customer}</strong><Badge variant="secondary" className="cm-vehicle-count">车辆 {group.vehicleCount} 辆</Badge></span><small className="cm-customer-owner" title={`${group.departments.join(' / ') || '未填部门'} · ${group.responsiblePeople.join('、') || '未填负责人'}`}>{group.departments.join(' / ') || '未填部门'} · {group.responsiblePeople.join('、') || '未填负责人'}</small></TableHead>
<TableCell className="cm-total-column"><button className="cm-total-drill" aria-label={`查看${group.customer}区间累计明细`} onClick={() => onChoose(group.customer, '')}><div className="cm-mileage-pair"><span><MileageSourceTag source="gps" />{km(gps)}</span><span><MileageSourceTag source="hydrogen" />{km(hydrogen)}</span></div></button></TableCell>
{dates.map(date => {
const day = days.get(date);
const target = targets.get(date);
return <TableCell className="cm-date-cell" key={date}>{day ?
<button className={cn('cm-day-cell', day.suspectedChargingCount > 0 && 'has-alert')} aria-label={`查看${group.customer} ${date}车辆明细`} onClick={() => onChoose(group.customer, date)}>
<span className="cm-daily-lines">
<span className="cm-daily-line"><span className="cm-daily-value"><MileageSourceTag source="gps" />{km(day.gpsKm)}{day.gpsKm != null ? ' km' : ''}</span><Badge variant={target ? 'warning' : 'secondary'} title="GPS 或纯氢低于达标阈值,同一车辆只计一次;缺失值不计入" className="cm-day-status">未达标 {target ?? 0}</Badge></span>
<span className="cm-daily-line"><span className="cm-daily-value"><MileageSourceTag source="hydrogen" />{km(day.hydrogenKm)}{day.hydrogenKm != null ? ' km' : ''}</span><Badge variant={day.suspectedChargingCount ? 'danger' : 'secondary'} className={cn('cm-day-status', day.suspectedChargingCount > 0 && 'cm-deviation')}>偏差 {day.suspectedChargingCount}</Badge></span>
</span>
{day.provisional ? <small>当日暂估</small> : null}
</button> : <span className="cm-cell-unavailable">—</span>}</TableCell>;
})}
</TableRow>)}</TableBody>
</Table>
</div>;
}
function MileageDialog({ children, title, description, label, closeLabel, onClose, busy = false, rules = false, footer }: {
children: ReactNode; title: string; description: string; label: string; closeLabel: string; onClose: () => void; busy?: boolean; rules?: boolean; footer?: ReactNode;
}) {
const returnFocus = useRef(document.activeElement instanceof HTMLElement ? document.activeElement : null);
return <Dialog open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent onCloseAutoFocus={event => { event.preventDefault(); returnFocus.current?.focus(); }} aria-label={label} showCloseButton={false} className={cn('cm-dialog', rules ? 'cm-rules-dialog' : 'cm-detail-dialog')} onEscapeKeyDown={event => { if (busy) event.preventDefault(); }} onInteractOutside={event => { if (busy) event.preventDefault(); }}>
<DialogHeader><DialogTitle>{title}</DialogTitle><DialogDescription>{description}</DialogDescription></DialogHeader>
<DialogClose asChild><Button variant="ghost" size="icon-sm" disabled={busy} aria-label={closeLabel} className="cm-dialog-close"><X aria-hidden="true" /></Button></DialogClose>
<div className="cm-dialog-body">{children}</div>
{footer ? <DialogFooter>{footer}</DialogFooter> : null}
</DialogContent>
</Dialog>;
}
function matchesGPS(value: number | null, operator: string, threshold: string) {
if (threshold === '') return true;
const limit = Number(threshold);
if (value == null || !Number.isFinite(limit) || limit < 0) return false;
const actual = Math.round(value * 1000), expected = Math.round(limit * 1000);
return operator === 'gt' ? actual > expected : operator === 'lt' ? actual < expected : actual === expected;
}
function VehicleGPSFilter({ operator, threshold, onOperator, onThreshold }: { operator: string; threshold: string; onOperator: (value: string) => void; onThreshold: (value: string) => void }) {
return <div className="cm-gps-filter"><span>单车 GPS 里程</span><NativeSelect aria-label="单车 GPS 里程比较" value={operator} onChange={event => onOperator(event.target.value)}><NativeSelectOption value="gt">大于</NativeSelectOption><NativeSelectOption value="lt">小于</NativeSelectOption><NativeSelectOption value="eq">等于</NativeSelectOption></NativeSelect><Input type="number" min="0" step="0.001" aria-label="单车 GPS 里程阈值" placeholder="不限" value={threshold} onChange={event => onThreshold(event.target.value)} /><span>km</span>{threshold !== '' ? <Button variant="ghost" size="sm" onClick={() => onThreshold('')}>清除</Button> : null}</div>;
}
export function MileageDetails({ group, initialDay, rules, onClose }: { group: CustomerMileageGroup; initialDay: string; rules: CustomerMileageRules; onClose: () => void }) {
const [day, setDay] = useState(initialDay);
const [alertFilter, setAlertFilter] = useState('all');
const [search, setSearch] = useState('');
const [gpsOperator, setGpsOperator] = useState('gt');
const [gpsThreshold, setGpsThreshold] = useState('');
const [page, setPage] = useState(0);
const current = group.days.find(item => item.date === day) ?? group.days[group.days.length - 1];
const rows = useMemo(() => group.vehicles.filter(vehicle => vehicle.date === current?.date && matchesGPS(vehicle.gpsKm, gpsOperator, gpsThreshold) && (alertFilter === 'all' || (alertFilter === 'below-target' ? !vehicle.restricted && (isBelowTarget(vehicle.gpsStatus) || isBelowTarget(vehicle.hydrogenStatus)) : vehicle.alert === alertFilter)) && `${vehicle.vin} ${vehicle.plate}`.toLowerCase().includes(search.toLowerCase())), [group, current?.date, alertFilter, search, gpsOperator, gpsThreshold]);
const visiblePage = Math.min(page, Math.max(0, Math.ceil(rows.length / 30) - 1));
return <MileageDialog title={group.customer} description={`${current?.date ?? ''} · ${group.vehicleCount} 辆车 · 负责人:${group.responsiblePeople.join('、') || '未填写'}`} label="客户车辆明细" closeLabel="关闭车辆明细" onClose={onClose}>
<div className="cm-detail-summary">
<Field><FieldLabel htmlFor="cm-detail-date">查看日期</FieldLabel><NativeSelect id="cm-detail-date" aria-label="明细日期" value={current?.date ?? ''} onChange={event => { setDay(event.target.value); setPage(0); }}>{group.days.map(item => <NativeSelectOption value={item.date} key={item.date}>{item.date}{item.provisional ? ' · 暂估' : ''}</NativeSelectOption>)}</NativeSelect></Field>
<div><span>GPS 里程</span><strong>{km(current?.gpsKm ?? null)} <small>km</small></strong></div><div><span>纯氢里程</span><strong>{km(current?.hydrogenKm ?? null)} <small>km</small></strong></div><div><span>数据覆盖</span><p>覆盖 {current?.gpsVehicleCount ?? 0} / {current?.hydrogenVehicleCount ?? 0} / {current?.vehicleCount ?? 0}</p><small>GPS / 纯氢 / 可查看车辆</small></div>
</div>
<div className="cm-detail-toolbar"><div><h3>车辆明细</h3><p>未达标按每车每日里程判断(含低里程),GPS 与纯氢分别评价;缺失数据不判断达标。偏差独立比较。</p></div><div className="cm-detail-controls">
<NativeSelect aria-label="筛选预警" value={alertFilter} onChange={event => { setAlertFilter(event.target.value); setPage(0); }}><NativeSelectOption value="all">全部状态</NativeSelectOption><NativeSelectOption value="below-target">未达标(GPS 或纯氢)</NativeSelectOption>{Object.entries(alerts).map(([key, value]) => <NativeSelectOption key={key} value={key}>{value}</NativeSelectOption>)}</NativeSelect>
<InputGroup><InputGroupInput aria-label="搜索车牌或 VIN" placeholder="车牌 / VIN" value={search} onChange={event => { setSearch(event.target.value); setPage(0); }} /><InputGroupAddon><Search aria-hidden="true" /></InputGroupAddon></InputGroup>
</div></div>
<VehicleGPSFilter operator={gpsOperator} threshold={gpsThreshold} onOperator={value => { setGpsOperator(value); setPage(0); }} onThreshold={value => { setGpsThreshold(value); setPage(0); }} />
<div className="cm-legend"><Badge variant="danger">低于 {rules.lowMileageKm} km</Badge><Badge variant="warning">{rules.lowMileageKm}–{rules.targetMileageKm} km</Badge><Badge variant="success">≥ {rules.targetMileageKm} km</Badge><span>— 无数据</span></div>
<div className="cm-vehicle-table"><Table aria-label="客户车辆里程明细"><TableHeader><TableRow><TableHead scope="col">车辆 / 归属</TableHead><TableHead scope="col"><MileageSourceTag source="gps" /> 里程</TableHead><TableHead scope="col"><MileageSourceTag source="hydrogen" /> 里程</TableHead><TableHead scope="col">GPS − 纯氢</TableHead><TableHead scope="col">达标与偏差结论</TableHead></TableRow></TableHeader>
<TableBody>{rows.slice(visiblePage * 30, (visiblePage + 1) * 30).map(vehicle => <TableRow key={vehicle.vin}>
<TableCell><strong>{vehicle.plate || '未登记车牌'}</strong><small className="cm-vin">{vehicle.vin}</small><small>{vehicle.department || '未填部门'} · {vehicle.responsiblePerson || '未填负责人'}</small></TableCell>
<TableCell><Badge variant={bandVariant(vehicle.gpsStatus)} title={bands[vehicle.gpsStatus]}>{km(vehicle.gpsKm)}{vehicle.gpsKm != null ? ' km' : ''}</Badge><small>{bands[vehicle.gpsStatus]}</small></TableCell>
<TableCell><Badge variant={bandVariant(vehicle.hydrogenStatus)} title={bands[vehicle.hydrogenStatus]}>{km(vehicle.hydrogenKm)}{vehicle.hydrogenKm != null ? ' km' : ''}</Badge><small>{bands[vehicle.hydrogenStatus]}</small>{vehicle.hydrogenCoveragePercent != null ? <small title={vehicle.hydrogenSource}>模式覆盖 {km(vehicle.hydrogenCoveragePercent)}%</small> : null}</TableCell>
<TableCell>{km(vehicle.differenceKm)}{vehicle.differenceKm != null ? ' km' : ''}<small>{vehicle.differencePercent != null ? `${km(vehicle.differencePercent)}%` : '比例不可用'}</small></TableCell>
<TableCell><small>{targetConclusion(vehicle)}</small><Badge variant={alertVariant(vehicle.alert)}>{alerts[vehicle.alert] ?? vehicle.alert}</Badge>{vehicle.qualityReason ? <small>{vehicle.qualityReason}</small> : null}{vehicle.alert === 'suspected-charging' ? <small>请核对充电记录、采样完整性与运营情况</small> : null}</TableCell>
</TableRow>)}</TableBody>
</Table>{!rows.length ? <MileageEmpty title="没有符合筛选条件的车辆">请调整状态或车牌、VIN 筛选。</MileageEmpty> : null}</div>
<MileagePagination count={rows.length} page={visiblePage} pageSize={30} onPage={setPage} />
</MileageDialog>;
}
export function MileageIntervalDetails({ group, onClose, onChooseDay }: { group: CustomerMileageGroup; onClose: () => void; onChooseDay: (date: string) => void }) {
const [search, setSearch] = useState('');
const [gpsOperator, setGpsOperator] = useState('gt');
const [gpsThreshold, setGpsThreshold] = useState('');
const vehicles = useMemo(() => {
const rows = new Map<string, { vin: string; plate: string; gps: number | null; hydrogen: number | null; gpsDays: number; hydrogenDays: number }>();
for (const day of group.vehicles) {
if (day.restricted) continue;
const row = rows.get(day.vin) ?? { vin: day.vin, plate: day.plate, gps: null, hydrogen: null, gpsDays: 0, hydrogenDays: 0 };
if (day.gpsKm != null) { row.gps = (row.gps ?? 0) + day.gpsKm; row.gpsDays++; }
if (day.hydrogenKm != null) { row.hydrogen = (row.hydrogen ?? 0) + day.hydrogenKm; row.hydrogenDays++; }
rows.set(day.vin, row);
}
return [...rows.values()];
}, [group]);
const dates = group.days.map(day => day.date);
return <MileageDialog title={`${group.customer} · 区间累计`} description={`${dates[0] ?? ''} — ${dates[dates.length - 1] ?? ''} · 仅合计有数据日期,缺失值不计作零`} label="客户区间累计明细" closeLabel="关闭区间明细" onClose={onClose}>
<div className="cm-detail-summary"><div><span>GPS 区间累计</span><strong>{km(total(group, 'gpsKm'))} km</strong></div><div><span>纯氢区间累计</span><strong>{km(total(group, 'hydrogenKm'))} km</strong></div></div>
<Input aria-label="搜索区间车牌或 VIN" placeholder="车牌 / VIN" value={search} onChange={event => setSearch(event.target.value)} />
<VehicleGPSFilter operator={gpsOperator} threshold={gpsThreshold} onOperator={setGpsOperator} onThreshold={setGpsThreshold} />
<Table aria-label="车辆区间累计"><TableHeader><TableRow><TableHead>车辆</TableHead><TableHead><MileageSourceTag source="gps" /> 累计 km</TableHead><TableHead><MileageSourceTag source="hydrogen" /> 累计 km</TableHead></TableRow></TableHeader><TableBody>{vehicles.filter(row => matchesGPS(row.gps, gpsOperator, gpsThreshold) && `${row.plate} ${row.vin}`.toLowerCase().includes(search.toLowerCase())).map(row => <TableRow key={row.vin}><TableCell>{row.plate || '未登记车牌'}<small className="cm-vin">{row.vin}</small></TableCell><TableCell>{km(row.gps)}<small>有数据 {row.gpsDays} 天</small></TableCell><TableCell>{km(row.hydrogen)}<small>有数据 {row.hydrogenDays} 天</small></TableCell></TableRow>)}</TableBody></Table>
<h3>每日明细</h3><div className="cm-interval-dates">{dates.map(date => <Button key={date} variant="outline" onClick={() => onChooseDay(date)}>{date}</Button>)}</div>
</MileageDialog>;
}
const ruleFields = [
['lowMileageKm', '低里程上限(km)'], ['targetMileageKm', '达标里程(km)'],
['deviationKm', '偏差绝对值下限(km)'], ['deviationPercent', '偏差比例下限(%)'],
['minimumGpsKm', '参与偏差判断的最低 GPS 里程(km)']
] as const;
export function MileageRuleEditor({ draft, onDraft, canConfigure, pending, error, onSave, onClose }: {
draft: CustomerMileageRules; onDraft: (draft: CustomerMileageRules) => void; canConfigure: boolean; pending: boolean; error: string; onSave: () => void; onClose: () => void;
}) {
const fields = (start: number, end: number) => ruleFields.slice(start, end).map(([key, label]) => <Field key={key} data-disabled={!canConfigure || pending}>
<FieldLabel htmlFor={`cm-rule-${key}`}>{label}</FieldLabel><Input id={`cm-rule-${key}`} type="number" min={0} max={key === 'deviationPercent' ? 100 : 3000} step="0.1" disabled={!canConfigure || pending} value={draft[key]} onChange={event => onDraft({ ...draft, [key]: event.target.value === '' ? 0 : Number(event.target.value) })} />
</Field>);
return <MileageDialog rules title="客户运营预警规则" description="保存后对所选日期重新计算,所有使用者共享当前租户规则。" label="客户运营预警规则" closeLabel="关闭规则" busy={pending} onClose={onClose} footer={<><Button variant="outline" disabled={pending} onClick={onClose}>取消</Button>{canConfigure ? <Button type="submit" form="cm-rule-form" disabled={pending}><Settings2 data-icon="inline-start" aria-hidden="true" />{pending ? '正在保存…' : '保存并重新计算'}</Button> : null}</>}>
<form id="cm-rule-form" onSubmit={event => { event.preventDefault(); if (canConfigure && !pending) onSave(); }}>
<FieldGroup className="cm-rule-groups">
<FieldSet><FieldLegend>每日里程颜色分档</FieldLegend><FieldDescription>分别应用于每辆车的 GPS 与纯氢里程。</FieldDescription><FieldGroup className="cm-rule-fields">{fields(0, 2)}</FieldGroup></FieldSet>
<FieldSet><FieldLegend>偏差核查阈值</FieldLegend><FieldDescription>GPS − 纯氢同时达到绝对值和比例阈值才提示疑似充电。比例 = 差值 ÷ GPS。</FieldDescription><FieldGroup className="cm-rule-fields">{fields(2, 5)}</FieldGroup></FieldSet>
<Field orientation="horizontal" data-disabled={!canConfigure || pending}><Checkbox id="cm-include-today" checked={draft.includeToday} disabled={!canConfigure || pending} onCheckedChange={checked => onDraft({ ...draft, includeToday: checked === true })} /><FieldLabel htmlFor="cm-include-today">当天暂估里程也参与告警</FieldLabel></Field>
</FieldGroup>
{error ? <MileageNotice tone="error">{error}</MileageNotice> : null}
</form>
</MileageDialog>;
}
@@ -0,0 +1,19 @@
import { Button } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
import type { WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
export type { WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
/** Operational counts belong beside the queue, rather than in dashboard cards. */
export function WorkspaceMetricRail({ ariaLabel, className, items, context }: {
variant: 'queue'; ariaLabel: string; className: string;
items: WorkspaceQueueMetricRailItem[]; context?: ReactNode;
}) {
return <div className={`governance-status-strip ${className}`}>
<div role="list" aria-label={ariaLabel} className="governance-status-items">
{items.map((item) => <span role="listitem" key={item.label} className={`governance-status-item is-${item.tone ?? 'neutral'}${item.active ? ' is-active' : ''}`}>
{item.onClick ? <Button theme="borderless" type="tertiary" aria-label={item.ariaLabel} aria-pressed={item.active} onClick={item.onClick} title={typeof item.note === 'string' ? item.note : undefined}><span>{item.label}</span><strong>{item.value}</strong></Button> : <><span>{item.label}</span><strong>{item.value}</strong>{item.note ? <small>{item.note}</small> : null}</>}
</span>)}
</div>
{context ? <div className="governance-status-context">{context}</div> : null}
</div>;
}
@@ -698,8 +698,8 @@ test('uses one focused Semi bottom SideSheet for the complete mobile history que
expect(trigger).toHaveAttribute('aria-expanded', 'true');
const dialog = await screen.findByRole('dialog', { name: '历史查询范围' });
expect(document.querySelector('.v2-history-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-history-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 700px)' });
expect(document.querySelector('.v2-history-filter-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-history-filter-sidesheet')).toHaveStyle({ height: 'min(82dvh, 700px)' });
const querySummary = within(dialog).getByRole('list', { name: '历史查询范围摘要' });
expect(querySummary).toHaveTextContent('车辆范围待选择最多 5 台车辆');
expect(querySummary).toHaveTextContent('时间范围');
@@ -722,7 +722,7 @@ test('uses one focused Semi bottom SideSheet for the complete mobile history que
expect((calls[calls.length - 1]?.[0] as URLSearchParams | undefined)?.get('keywords')).toBe('MOBILEVIN');
});
expect(screen.getByRole('button', { name: '修改查询范围:1 辆 · 位置数据' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('dialog', { name: '历史查询范围' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(screen.queryByRole('dialog', { name: '历史查询范围' })).not.toBeInTheDocument();
});
test('renders only selectable Semi history cards on mobile', async () => {
@@ -760,9 +760,9 @@ test('renders only selectable Semi history cards on mobile', async () => {
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-history-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 720px)' });
expect(document.querySelector('.v2-history-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('正常');
expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveStyle({ height: 'min(82dvh, 720px)' });
expect(document.querySelector('.v2-history-detail-sidesheet .business-dialog-heading > [data-slot="badge"]')).toHaveTextContent('正常');
expect(await screen.findByText('MOBILEVIN-evidence')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据详情' }));
expect(action).toHaveAttribute('aria-pressed', 'false');
@@ -770,11 +770,11 @@ test('renders only selectable Semi history cards on mobile', async () => {
expect(document.body.style.overflow).not.toBe('hidden');
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
const toolsButton = screen.getByRole('button', { name: '打开历史数据工具' });
let toolsButton = screen.getByRole('button', { name: '打开历史数据工具' });
fireEvent.click(screen.getByRole('button', { name: '打开导出任务中心' }));
expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-task-sidesheet');
expect(document.querySelector('.v2-history-export-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(90dvh, 780px)' });
expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-task-sidesheet');
expect(document.querySelector('.v2-history-export-sidesheet')).toHaveStyle({ height: 'min(90dvh, 780px)' });
await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1));
expect(screen.getByRole('list', { name: '历史数据导出任务摘要' })).toHaveTextContent('当前任务00 个生成中可下载00 个需处理审计归档0清理前可移回');
expect(screen.getByRole('button', { name: '刷新导出任务' })).toBeInTheDocument();
@@ -788,10 +788,18 @@ test('renders only selectable Semi history cards on mobile', async () => {
fireEvent.click(toolsButton);
fireEvent.click(screen.getByRole('menuitem', { name: /字段设置/ }));
expect(await screen.findByRole('dialog', { name: '列显示设置' })).toBeInTheDocument();
expect(document.querySelector('.v2-history-column-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-history-column-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(68dvh, 580px)' });
expect(document.querySelector('.v2-history-column-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-history-column-sidesheet')).toHaveStyle({ height: 'min(68dvh, 580px)' });
fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' }));
toolsButton = screen.getByRole('button', { name: '打开历史数据工具' });
expect(toolsButton).toHaveAttribute('aria-expanded', 'false');
// Semi's portal layout fallback delivers visibility after up to 50 ms.
// Flush that real callback and ensure a dismissed menu stays dismissed.
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 80)); });
expect(toolsButton).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('menuitem', { name: /字段设置/ })).not.toBeInTheDocument();
fireEvent.click(toolsButton);
expect(await screen.findByRole('menuitem', { name: /字段设置/ })).toBeInTheDocument();
});
test('opens desktop export tasks directly without requiring a current history query', async () => {
@@ -1349,9 +1357,9 @@ test('moves a mobile export from the filter sheet into the focused task workspac
fireEvent.click(within(filter).getByRole('button', { name: /创建导出/ }));
const tasks = await screen.findByRole('dialog', { name: '历史数据导出任务' });
expect(tasks.closest('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(tasks.closest('.v2-history-export-sidesheet')).toHaveClass('business-sheet-bottom');
expect(await within(tasks).findByText('移动端历史导出')).toBeInTheDocument();
expect(document.querySelector('.v2-history-filter-sidesheet .semi-sidesheet-inner')).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(document.querySelector('.v2-history-filter-sidesheet')).not.toBeInTheDocument();
});
test('keeps low-frequency desktop table controls in one working tools menu', async () => {
@@ -1,6 +1,8 @@
import '../styles/data-workflows.css';
import { Progress } from '@/components/ui/progress';
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { IconAlertTriangle, IconArrowLeft, IconBox, IconCalendar, IconChevronRight, IconClose, IconDelete, IconDownload, IconFilter, IconHistory, IconLock, IconMapPin, IconMore, IconRefresh, IconSafe, IconSearch, IconSetting, IconTickCircle, IconUser } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Input, Progress, Select, Switch, Table, Tag, TextArea, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Input, Select, Switch, Table, Tag, TextArea, Typography } from '@douyinfe/semi-ui';
import { FormEvent, KeyboardEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -24,7 +26,6 @@ import {
} from '../shared/WorkspaceDateTimeRange';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceConfirmDialog, WorkspaceDialog } from '../shared/WorkspaceDialog';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
@@ -1126,7 +1127,7 @@ function ExportJobsPanel({ query, view, onViewChange, showHeader = true }: { que
<small className={`v2-export-job-retention${expiring ? ' is-expiring' : ''}`}>{expiring ? '24 小时内到期 · ' : ''}文件保留 {job.retentionDays ?? 7} 天{job.status === 'completed' && job.expiresAt ? <> · <PlatformTime value={job.expiresAt} /> 到期</> : null}</small>
{job.archivedAt ? <small className="v2-export-job-archive-audit">{job.archivedBy || '当前账号'}归档 · <PlatformTime value={job.archivedAt} /></small> : null}
</div>
{job.status === 'running' ? <Progress className="v2-export-job-progress" percent={job.progress} showInfo /> : null}
{job.status === 'running' ? <Progress className="v2-export-job-progress" value={job.progress} aria-label={`导出进度 ${job.progress}%`} /> : null}
{!batchMode && isHistoryExportRecoverable(job) ? <RebuildHistoryExportButton job={job} alreadyQueued={activeRequestSignatures.has(historyExportRequestSignature(historyExportRequestFromJob(job)))} crossAccount={crossAccount} currentAccountLabel={currentAccountLabel} disabled={pageTransition} /> : null}
</Card>;
})}</CardGroup>}</div>
@@ -1595,10 +1596,25 @@ export default function HistoryPage() {
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [desktopQueryCollapsed, setDesktopQueryCollapsed] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
const toolsOpenIntent = useRef(false);
const [toolsEpoch, setToolsEpoch] = useState(0);
const closeTools = useCallback(() => {
toolsOpenIntent.current = false;
setToolsOpen(false);
// Dispose Semi's pending portal layout when an action leaves this menu.
setToolsEpoch((epoch) => epoch + 1);
}, []);
const onToolsVisibleChange = useCallback((visible: boolean) => {
// Semi emits visibility after portal layout. A late opening notification
// must not reopen a menu whose action has already opened a detail sheet.
if (!visible) { toolsOpenIntent.current = false; setToolsOpen(false); }
else if (toolsOpenIntent.current) setToolsOpen(true);
}, []);
const requestTools = () => { toolsOpenIntent.current = true; };
const exportJobsOpen = routeView.tool === 'exports';
useEffect(() => {
if (columnSettingsOpen) setToolsOpen(false);
}, [columnSettingsOpen]);
if (columnSettingsOpen) closeTools();
}, [columnSettingsOpen, closeTools]);
useEffect(() => {
if (previousRouteCriteriaSignature.current === routeCriteriaSignature) return;
previousRouteCriteriaSignature.current = routeCriteriaSignature;
@@ -1883,11 +1899,11 @@ export default function HistoryPage() {
const exportRequest: HistoryExportRequest = { keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv', retentionDays: exportRetentionDays };
const openCreatedExport = (_job: HistoryExportJob) => {
setFiltersCollapsed(true);
setToolsOpen(false);
closeTools();
updateHistoryTool(true);
};
return <div className="v2-history-page">
return <div className="v2-history-page v2-data-workflow">
<MonitorReturnBar />
{eventContext ? <HistoryEventContextBar context={eventContext} /> : null}
<div className="v2-history-discovery-shell" data-query-collapsed={!mobileLayout && desktopQueryCollapsed}>
@@ -1994,16 +2010,7 @@ export default function HistoryPage() {
{exportAllowed ? <section className="v2-history-mobile-export-action" aria-label="导出当前历史查询"><span><strong>导出当前结果</strong><small>按已应用范围、当前字段和所选保留期生成 CSV</small></span><HistoryExportCreateControls disabled={!resultRows.length} request={exportRequest} retentionDays={exportRetentionDays} savingPreference={preferencesMutation.isPending} preferenceError={preferencesMutation.isError} onRetentionChange={changeExportRetentionDays} onCreated={openCreatedExport} /></section> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large">只读</Tag>}
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-history-filter-card"
title="查询范围"
description="车辆、时间、数据类型与协议来源"
mobileSummary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
expanded={!filtersCollapsed}
status={keywords.length ? `${keywords.length} 辆已选` : '待选择车辆'}
statusColor={keywords.length ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
</div> : <Card className="v2-history-filter-card" bodyStyle={{ padding: 0 }} aria-label="查询范围">
<form id="v2-history-query-panel" className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
{historyFilterFields}
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!canApplyDraft}>查询</Button><Button className="v2-secondary-button" theme="light" onClick={reset}>重置</Button>{!exportAllowed ? <Tag className="v2-history-permission-tag" color="grey" type="light" size="large">只读</Tag> : null}</div>
@@ -2011,16 +2018,9 @@ export default function HistoryPage() {
{draftValidation ? <p className="v2-history-validation" role="alert">{draftValidation}</p> : null}
<HistoryQueryPlanBar plan={draftPlan} pageSize={limit} canExport={exportAllowed} />
</form>
</WorkspaceFilterPanel>}
</Card>}
</div>
{keywords.length && appliedPlan.scaled && (mobileLayout ? !mobileFiltersOpen : desktopQueryCollapsed) ? <HistoryQueryPlanBar plan={appliedPlan} pageSize={limit} canExport={exportAllowed} applied /> : null}
{keywords.length ? <WorkspaceMetricRail
variant="queue"
className="v2-history-summary-rail"
ariaLabel="历史数据查询统计信息"
items={historyMetrics}
/> : null}
{keywords.length ? <HistoryVehicleQueryReceipt summary={resultSummary} /> : null}
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className={`v2-history-workspace${selectedRow && !mobileLayout ? ' is-inspector-open' : ''}`}>
<div className={`v2-history-main${trendExpanded ? ' has-expanded-trend' : ''}`}>
@@ -2034,37 +2034,46 @@ export default function HistoryPage() {
actions={mobileLayout ? keywords.length ? <>
<Button className={`v2-history-table-trend v2-workspace-mobile-tool-button is-muted${trendExpanded ? ' is-expanded' : ''}`} theme="light" aria-label={trendExpanded ? '收起趋势' : '展开趋势'} aria-expanded={trendExpanded} icon={<IconChevronRight />} onClick={() => setTrendExpanded((value) => !value)}>{trendExpanded ? '收起' : '趋势'}</Button>
<Dropdown
key={toolsEpoch}
trigger="click"
position="bottomRight"
visible={toolsOpen}
onVisibleChange={setToolsOpen}
onVisibleChange={onToolsVisibleChange}
contentClassName="v2-workspace-mobile-tools-menu"
render={<Dropdown.Menu>
<Dropdown.Item icon={<IconSetting />} onClick={() => { setToolsOpen(false); setColumnSettingsOpen(true); }}>字段设置</Dropdown.Item>
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setToolsOpen(false); void dataQuery.refetch(); }}>刷新数据</Dropdown.Item>
<Dropdown.Item icon={<IconSetting />} onClick={() => { closeTools(); setColumnSettingsOpen(true); }}>字段设置</Dropdown.Item>
<Dropdown.Item icon={<IconRefresh />} onClick={() => { closeTools(); void dataQuery.refetch(); }}>刷新数据</Dropdown.Item>
</Dropdown.Menu>}
>
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" aria-label="打开历史数据工具" aria-haspopup="menu" aria-expanded={toolsOpen} icon={<IconMore />}>工具</Button>
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" aria-label="打开历史数据工具" onClick={requestTools} aria-haspopup="menu" aria-expanded={toolsOpen} icon={<IconMore />}>工具</Button>
</Dropdown>
</> : null : <>
<Button className={`v2-history-table-trend${trendExpanded ? ' is-expanded' : ''}`} theme="borderless" aria-label={trendExpanded ? '收起趋势' : '展开趋势'} aria-expanded={trendExpanded} icon={<IconChevronRight />} onClick={() => setTrendExpanded((value) => !value)}>{trendExpanded ? '收起' : '趋势'}</Button>
<Button theme="borderless" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} aria-controls="v2-history-column-settings" icon={<IconSetting />} onClick={() => setColumnSettingsOpen((value) => !value)}>字段</Button>
{exportAllowed ? <HistoryExportCreateControls disabled={!resultRows.length} request={exportRequest} retentionDays={exportRetentionDays} savingPreference={preferencesMutation.isPending} preferenceError={preferencesMutation.isError} onRetentionChange={changeExportRetentionDays} onCreated={openCreatedExport} /> : null}
<Dropdown
key={toolsEpoch}
trigger="click"
position="bottomRight"
visible={toolsOpen}
onVisibleChange={setToolsOpen}
onVisibleChange={onToolsVisibleChange}
contentClassName="v2-history-tools-menu"
render={<Dropdown.Menu>
<Dropdown.Item icon={<IconSetting />} onClick={() => { setToolsOpen(false); setDensity((value) => value === 'compact' ? 'comfortable' : 'compact'); }}>{density === 'compact' ? '切换为舒适密度' : '切换为紧凑密度'}</Dropdown.Item>
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setToolsOpen(false); void dataQuery.refetch(); }}>刷新数据</Dropdown.Item>
<Dropdown.Item icon={<IconSetting />} onClick={() => { closeTools(); setDensity((value) => value === 'compact' ? 'comfortable' : 'compact'); }}>{density === 'compact' ? '切换为舒适密度' : '切换为紧凑密度'}</Dropdown.Item>
<Dropdown.Item icon={<IconRefresh />} onClick={() => { closeTools(); void dataQuery.refetch(); }}>刷新数据</Dropdown.Item>
</Dropdown.Menu>}
>
<Button className="v2-history-more-button" theme="borderless" aria-label="打开历史数据更多操作" aria-haspopup="menu" aria-expanded={toolsOpen} icon={<IconMore />}>更多</Button>
<Button className="v2-history-more-button" theme="borderless" aria-label="打开历史数据更多操作" onClick={requestTools} aria-haspopup="menu" aria-expanded={toolsOpen} icon={<IconMore />}>更多</Button>
</Dropdown>
</>}
/>
{keywords.length ? <WorkspaceMetricRail
variant="queue"
className="v2-history-summary-rail"
ariaLabel="历史数据查询统计信息"
items={historyMetrics}
/> : null}
{keywords.length ? <HistoryVehicleQueryReceipt summary={resultSummary} /> : null}
<ColumnVisibilityPanel visible={columnSettingsOpen} category={criteria.category} metrics={allMetrics} visibleKeys={visibleKeys} savedViews={savedFieldViews} activeViewName={activeFieldViewName} preferenceSyncing={preferencesMutation.isPending} preferenceError={preferencesMutation.isError || preferencesQuery.isError} preferenceSynced={Boolean(preferencesQuery.data)} onToggle={toggleMetric} onApplyView={setVisibleMetrics} onSaveView={saveFieldView} onDeleteView={deleteFieldView} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(defaultVisibleKeys)} onClose={() => setColumnSettingsOpen(false)} />
{criteria.category === 'raw' && rawSegments.length ? <section className="v2-history-raw-segments" aria-label="原始报文类型筛选">
<div><strong>按报文内容分段</strong><small>与车辆查询共用字段分类;一条报文包含多个数据块时可出现在多个分段。</small></div>
@@ -0,0 +1,44 @@
import { useId, useRef, useState, type ReactNode } from 'react';
import { Filter, Info } from 'lucide-react';
import { Popover } from 'radix-ui';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useMobileLayout } from '../hooks/useMobileLayout';
import './mileage-workspace.css';
export function MileageFilterPanel({ label, summary, className, onApply, children }: {
label: string; summary: string; className: string; onApply: () => boolean | void; children: ReactNode;
}) {
const mobile = useMobileLayout(760);
const [expanded, setExpanded] = useState(false);
const mobileBar = useRef<HTMLDivElement>(null);
const id = useId();
return <form className={className} aria-label={label} onSubmit={event => {
event.preventDefault();
if (onApply() !== false) {
setExpanded(false);
if (mobile) mobileBar.current?.querySelector('button')?.focus();
}
}}>
{mobile ? <div className="mileage-mobile-filter-bar" ref={mobileBar}><span title={summary}>{summary}</span><Button type="button" variant="outline" size="sm" aria-expanded={expanded} aria-controls={id} onClick={() => setExpanded(value => !value)}><Filter aria-hidden="true" />{expanded ? '收起筛选' : '筛选'}</Button></div> : null}
<div id={id} className="mileage-filter-body" hidden={mobile && !expanded}>{children}</div>
</form>;
}
export function MileageHint({ label, children }: { label: string; children: ReactNode }) {
const [expanded, setExpanded] = useState(false);
const [hovered, setHovered] = useState(false);
return <Popover.Root open={expanded} onOpenChange={value => { setExpanded(value); setHovered(false); }}>
<TooltipProvider delayDuration={150}><Tooltip open={!expanded && hovered} onOpenChange={setHovered}>
<TooltipTrigger asChild><Popover.Trigger asChild><button type="button" className="mileage-hint" aria-label={label}><Info aria-hidden="true" /></button></Popover.Trigger></TooltipTrigger>
<TooltipContent className="mileage-hint-content" side="bottom">{children}</TooltipContent>
</Tooltip></TooltipProvider>
<Popover.Portal><Popover.Content className="mileage-hint-popover" aria-label={`${label}详情`} side="bottom" sideOffset={5}>{children}</Popover.Content></Popover.Portal>
</Popover.Root>;
}
export function MileageMetric({ label, value, unit, description, className = '' }: {
label: string; value: ReactNode; unit?: string; description: ReactNode; className?: string;
}) {
return <article className={className}><span>{label}<MileageHint label={`${label}说明`}>{description}</MileageHint></span><strong>{value}{unit ? <small> {unit}</small> : null}</strong></article>;
}
@@ -25,6 +25,9 @@ const monitorDataArgsSpy = vi.hoisted(() => vi.fn());
const qrToDataURLSpy = vi.hoisted(() => vi.fn());
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
const monitorFleetFixture = vi.hoisted(() => ({ total: 2 }));
const monitorAuthFixture = vi.hoisted(() => ({
role: 'admin', authProvider: 'local', menuKeys: undefined as string[] | undefined
}));
const monitorSummaryFixture = vi.hoisted(() => ({
totalVehicles: 2,
locationVehicles: 2,
@@ -72,6 +75,7 @@ vi.mock('../map/FleetMap', () => ({
}));
vi.mock('qrcode', () => ({ default: { toDataURL: qrToDataURLSpy } }));
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: monitorAuthFixture }) }));
vi.mock('../hooks/useMonitorData', () => ({
MAX_MONITOR_SEARCH_TERMS: 100,
@@ -126,6 +130,9 @@ afterEach(() => {
monitorSummaryFixture.totalVehicles = 2;
monitorSummaryFixture.truncated = false;
monitorFleetFixture.total = 2;
monitorAuthFixture.role = 'admin';
monitorAuthFixture.authProvider = 'local';
monitorAuthFixture.menuKeys = undefined;
monitorMap.truncated = false;
monitorMap.total = 2;
vehicleCardFixture.detail = undefined;
@@ -285,7 +292,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByRole('button', { name: '查看总里程全部来源' })).toHaveClass('semi-button', 'v2-workspace-metric-action');
expect(screen.getByRole('button', { name: '查看全部位置来源' })).toHaveClass('semi-button', 'v2-detail-source-link');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true, true);
expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length;
@@ -359,6 +366,30 @@ test('restores URL-backed monitor context and carries it into every vehicle work
}
});
test('keeps scoped alert count visible without linking an unauthorized OneOS user to alerts', async () => {
monitorAuthFixture.role = 'customer';
monitorAuthFixture.authProvider = 'oneos';
monitorAuthFixture.menuKeys = ['monitor', 'vehicles', 'tracks', 'statistics'];
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
const alertsMetric = screen.getByText('告警车辆').closest('[role="listitem"]');
expect(alertsMetric).toBeInTheDocument();
expect(alertsMetric).toHaveTextContent('告警车辆');
expect(screen.queryByRole('button', { name: /打开事件中心/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }));
const actions = await screen.findByRole('navigation', { name: '车辆快捷操作' });
expect(within(actions).getByRole('link', { name: '轨迹回放' })).toBeInTheDocument();
expect(within(actions).getByRole('link', { name: '里程查询' })).toBeInTheDocument();
expect(within(actions).queryByRole('link', { name: '事件流' })).not.toBeInTheDocument();
expect(within(actions).queryByRole('link', { name: '历史数据' })).not.toBeInTheDocument();
expect(within(actions).queryByRole('link', { name: '质量差异' })).not.toBeInTheDocument();
expect(vehicleCardArgsSpy).toHaveBeenCalledWith(vehicles[0].vin, vehicles[0], true, false);
expect(screen.getByText('无告警查看权限')).toBeInTheDocument();
expect(screen.queryByText('无当前业务告警')).not.toBeInTheDocument();
});
test('turns the fleet summary into consistent status filters and clears stale vehicle selection', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
@@ -386,7 +417,7 @@ test('keeps the current map selection visible when reviewing the realtime list',
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
expect(await screen.findByTitle('当前地图选中,点击返回地图')).toHaveClass('is-selected');
expect(view.container.querySelector('.v2-monitor-table .semi-table-row.is-selected')).toBeInTheDocument();
expect(view.container.querySelector('.business-data-table [data-state="selected"]')).toBeInTheDocument();
});
test('shows active filter count and clears all filter fields in one action', () => {
@@ -416,20 +447,23 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
const mapMode = within(modeGroup).getByRole('tab', { name: /地图/ });
const listMode = within(modeGroup).getByRole('tab', { name: /列表/ });
expect(mapMode).toHaveAttribute('aria-selected', 'true');
expect(mapMode).toHaveClass('is-active');
expect(mapMode).toHaveAttribute('data-state', 'active');
expect(listMode).toHaveAttribute('aria-selected', 'false');
fireEvent.click(listMode);
expect(mapMode).toHaveAttribute('aria-selected', 'false');
expect(listMode).toHaveAttribute('aria-selected', 'true');
expect(listMode).toHaveClass('is-active');
expect(listMode).toHaveAttribute('data-state', 'active');
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '车辆实时列表', level: 5 })).toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-table-panel')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-monitor-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.business-data-table')).toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-table-scroll > table')).not.toBeInTheDocument();
expect(screen.getByText('覆盖全部授权车辆;缺失值显示“—”,地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(realtime.mock.calls[realtime.mock.calls.length - 1]?.[0]?.get('sort')).toBe('identity');
expect(realtime.mock.calls[realtime.mock.calls.length - 1]?.[0]?.get('sort')).toBe('hydrogenPressureMpa');
expect(realtime.mock.calls[realtime.mock.calls.length - 1]?.[0]?.get('includeEnergy')).toBe('1');
expect(realtime.mock.calls[realtime.mock.calls.length - 1]?.[0]?.get('sortOrder')).toBe('asc');
expect(screen.getByRole('columnheader', { name: '氢气压力(MPa)' })).toHaveAttribute('aria-sort', 'ascending');
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
@@ -437,7 +471,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
expect(view.container.querySelector('.v2-monitor-protocol .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(screen.queryByText('推荐来源')).not.toBeInTheDocument();
expect(screen.getByText('当日里程')).toBeInTheDocument();
expect(screen.getByText('今日里程')).toBeInTheDocument();
expect(screen.getByText('协议来源')).toBeInTheDocument();
expect(screen.queryByText('最新上报')).not.toBeInTheDocument();
expect(reverseGeocode).not.toHaveBeenCalled();
@@ -457,6 +491,27 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
view.unmount();
});
test('sorts speed, report time, mileage, SOC and pressure in both directions, resets pagination, and preserves the chosen order in links', async () => {
const realtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 120, limit: 20, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter initialEntries={['/monitor?mode=list&listLimit=20&listOffset=100']} future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
await screen.findByRole('button', { name: '电 SOC(%)' });
expect(realtime.mock.calls[realtime.mock.calls.length - 1]?.[0]?.get('offset')).toBe('100');
for (const [label, key] of [['速度', 'speedKmh'], ['上报时间', 'lastSeen'], ['今日里程', 'todayMileageKm'], ['总里程', 'totalMileageKm'], ['电 SOC(%)', 'socPercent'], ['氢气压力(MPa)', 'hydrogenPressureMpa']]) {
for (const order of ['asc', 'desc']) {
fireEvent.click(screen.getByRole('button', { name: label }));
await waitFor(() => {
const params = realtime.mock.calls[realtime.mock.calls.length - 1]?.[0];
expect(params?.get('sort')).toBe(key);
expect(params?.get('sortOrder')).toBe(order);
expect(params?.get('offset')).toBe('0');
});
expect(screen.getByRole('columnheader', { name: label })).toHaveAttribute('aria-sort', order === 'asc' ? 'ascending' : 'descending');
}
}
await waitFor(() => expect(screen.getByRole('link', { name: '查看 粤A12345 档案' })).toHaveAttribute('href', expect.stringContaining('listSort%3DhydrogenPressureMpa%26listSortOrder%3Ddesc')));
});
test('shows one recovery state instead of stacking an empty state below a failed list query', async () => {
vi.spyOn(api, 'vehicleRealtime').mockRejectedValue(new Error('当前账号无权查看该车辆'));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -614,8 +669,9 @@ test('mounts only the mobile list representation and removes its viewport listen
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
const mobileCard = view.container.querySelector('.v2-monitor-mobile-card');
expect(mobileCard).toHaveAttribute('aria-label', '粤A12345 实时数据');
expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('速度42 km/h当日里程18.6 km总里程1,234 km协议来源JT/T 808');
expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary > div')).toHaveLength(4);
expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('速度42 km/h今日里程18.6 km总里程1,234 km协议来源JT/T 808');
expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary:not(.v2-monitor-mobile-energy) > div')).toHaveLength(4);
expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-energy > div')).toHaveLength(4);
expect(mobileCard?.querySelector('.v2-monitor-mobile-location')).toHaveTextContent('113.260000, 23.130000解析位置');
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('定位');
@@ -1,26 +1,32 @@
import './fleet-workflows.css';
import { Button as BusinessButton } from '@/components/ui/button';
import { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton } from '@/components/ui/input-group';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectItem } from '@/components/ui/select';
import { FieldGroup, Field } from '@/components/ui/field';
import { Search, X, SlidersHorizontal } from 'lucide-react';
import {
IconChevronLeft, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Input, Select, Spin, Table, Tag } from '@douyinfe/semi-ui';
import { Button, Card, Spin, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { hasMenu } from '../auth/session';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { VehicleNumberLabel } from '../shared/VehicleNumberLabel';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { ProtocolTag } from '../shared/ProtocolTag';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { formatNumber, statusLabel, vehicleStatus } from '../domain/monitor';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn, type MonitorListSort, type MonitorSortOrder } from '../routing/monitorContext';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
@@ -29,58 +35,11 @@ const MONITOR_VIEW_ITEMS = [
{ key: 'map', label: '地图', icon: <IconMapPin aria-hidden="true" /> },
{ key: 'list', label: '列表', icon: <IconList aria-hidden="true" /> }
] as const;
const MonitorVehicleTable = lazy(() => import('./MonitorVehicleTable'));
const VehicleDetailCard = lazy(() => import('./MonitorVehicleDetailCard'));
const MonitorCoverageWarning = lazy(() => import('./MonitorCoverageWarning'));
const BatchVehicleSearchDialog = lazy(() => import('./BatchVehicleSearchDialog'));
type AddressCoordinate = { longitude: number; latitude: number; key: string };
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
&& Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
&& (vehicle.longitude !== 0 || vehicle.latitude !== 0);
}
function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
}
function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
}
function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtimeRow & { todayMileageKm: number } {
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
}
function vehicleProtocols(vehicle: VehicleRealtimeRow): string[] {
const runtimeValue = (vehicle as VehicleRealtimeRow & { protocols?: unknown }).protocols;
const normalized: string[] = [];
const seen = new Set<string>();
if (Array.isArray(runtimeValue)) {
for (const value of runtimeValue) {
if (typeof value !== 'string') continue;
const protocol = value.trim();
if (!protocol || seen.has(protocol)) continue;
seen.add(protocol);
normalized.push(protocol);
}
}
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
if (!normalized.length && primaryProtocol) normalized.push(primaryProtocol);
return normalized;
}
function vehiclePrimaryProtocol(vehicle: VehicleRealtimeRow, sourceProtocols = vehicleProtocols(vehicle)) {
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
return primaryProtocol || sourceProtocols[0] || '';
}
function vehicleProtocolSignature(vehicle: VehicleRealtimeRow) {
return vehicleProtocols(vehicle).join('|');
}
function formatSupportCount(value: number) {
const absolute = Math.abs(value);
if (absolute >= 100_000_000) return `${Number((value / 100_000_000).toFixed(1))}亿`;
@@ -88,122 +47,6 @@ function formatSupportCount(value: number) {
return formatNumber(value);
}
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
}
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
if (!hasRealtimeLocation(vehicle) || !Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
const longitude = Number(vehicle.longitude.toFixed(4));
const latitude = Number(vehicle.latitude.toFixed(4));
return { longitude, latitude, key: `${longitude.toFixed(4)},${latitude.toFixed(4)}` };
}
const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehicle: VehicleRealtimeRow }) {
const current = addressCoordinate(vehicle);
const [requested, setRequested] = useState<AddressCoordinate>();
const addressQuery = useQuery({
queryKey: ['monitor', 'list-address', requested?.key],
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
longitude: requested!.longitude.toFixed(4),
latitude: requested!.latitude.toFixed(4)
}), signal),
enabled: Boolean(requested),
staleTime: 6 * 60 * 60_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchOnWindowFocus: false,
retry: 1
});
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty">暂无实时位置</span>;
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德地址解析,不随实时数据刷新" onClick={() => setRequested(current)}>解析位置</Button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><Spin size="small" />解析中</span>;
if (addressQuery.isError) return <Button className="v2-monitor-address-action is-error" size="small" theme="light" type="danger" aria-label={`${vehicle.plate || vehicle.vin}位置解析失败,重试`} onClick={() => void addressQuery.refetch()}>解析失败,重试</Button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</Button> : null}</div>;
});
type MonitorMobileVehicleCardProps = {
row: VehicleRealtimeRow;
selected: boolean;
onSelect: (vin: string) => void;
};
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, selected, onSelect }: MonitorMobileVehicleCardProps) {
const identity = row.plate || '未绑定车牌';
const location = hasRealtimeLocation(row);
const sourceProtocols = vehicleProtocols(row);
const primaryProtocol = vehiclePrimaryProtocol(row, sourceProtocols);
return <Card className={`v2-monitor-mobile-card${selected ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据${selected ? ',当前地图选中' : ''}`}>
<header className="v2-monitor-mobile-card-header">
<div className="v2-monitor-mobile-identity"><strong>{identity}</strong><span>{row.vin}</span></div>
<div className="v2-monitor-card-heading-actions">
<Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${identity}`} onClick={() => onSelect(row.vin)}>定位</Button>
</div>
</header>
<dl className="v2-monitor-mobile-primary">
<div><dt>速度</dt><dd>{hasRealtimeSpeed(row) ? <>{formatNumber(row.speedKmh, 1)}<small> km/h</small></> : '—'}</dd></div>
<div><dt>当日里程</dt><dd className="is-today">{hasTodayMileage(row) ? <>{formatNumber(row.todayMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? <>{formatNumber(row.totalMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt>协议来源</dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={primaryProtocol} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>{sourceProtocols.length} 路</small> : null}</span></dd></div>
</dl>
<div className={`v2-monitor-mobile-location${location ? '' : ' is-unavailable'}`}>
<span className="v2-monitor-mobile-coordinate"><IconMapPin aria-hidden="true" />{location ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : <small>暂无经纬度</small>}</span>
<MonitorAddressCell vehicle={row} />
</div>
</Card>;
}, (previous, next) => {
const before = previous.row;
const after = next.row;
return previous.onSelect === next.onSelect
&& previous.selected === next.selected
&& before.vin === after.vin
&& before.plate === after.plate
&& before.speedAvailable === after.speedAvailable
&& before.speedKmh === after.speedKmh
&& before.todayMileageAvailable === after.todayMileageAvailable
&& before.todayMileageKm === after.todayMileageKm
&& before.mileageAvailable === after.mileageAvailable
&& before.totalMileageKm === after.totalMileageKm
&& before.primaryProtocol === after.primaryProtocol
&& vehicleProtocolSignature(before) === vehicleProtocolSignature(after)
&& before.locationAvailable === after.locationAvailable
&& before.longitude === after.longitude
&& before.latitude === after.latitude;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, error, mobile, selectedVin, mobileScrollTop, onMobileScroll, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; error: boolean; mobile: boolean; selectedVin: string; mobileScrollTop: number; onMobileScroll: (scrollTop: number) => void; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const mobileCardsRef = useRef<HTMLDivElement>(null);
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className={`v2-monitor-vehicle-action${row.vin === selectedVin ? ' is-selected' : ''}`} theme="borderless" type="tertiary" title={row.vin === selectedVin ? '当前地图选中,点击返回地图' : '在地图中定位'} onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => {
const sourceProtocols = vehicleProtocols(row);
return <div className="v2-monitor-protocol"><ProtocolTag protocol={vehiclePrimaryProtocol(row, sourceProtocols)} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>+{sourceProtocols.length - 1} 路</small> : null}</div>;
} },
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable">—</span> },
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> }
], [onSelect, selectedVin]);
useLayoutEffect(() => {
if (!mobile || !mobileCardsRef.current) return;
mobileCardsRef.current.scrollTop = mobileScrollTop;
}, [mobile, mobileScrollTop, selectedVin]);
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-monitor-list-header"
title="车辆实时列表"
description={mobile ? undefined : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
meta={`${total.toLocaleString('zh-CN')} 辆`}
/>
{!mobile ? <div className={`v2-monitor-table-scroll${error ? ' is-error' : ''}`}><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} onRow={(row) => row ? ({ className: row.vin === selectedVin ? 'is-selected' : '' }) : ({})} />{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '保留当前列表,完成后平滑替换。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div ref={mobileCardsRef} className="v2-monitor-mobile-cards" tabIndex={0} aria-label="车辆实时列表" onScroll={(event) => onMobileScroll(event.currentTarget.scrollTop)}>{rows.map((row) => <MonitorMobileVehicleCard row={row} selected={row.vin === selectedVin} onSelect={onSelect} key={row.vin} />)}{loading && !rows.length ? <PanelLoading className="v2-monitor-table-loading" compact /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>;
}
function MobileEntry({ mobile, onClose }: { mobile: boolean; onClose: () => void }) {
const [qr, setQr] = useState('');
@@ -276,7 +119,7 @@ const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { v
return (
<Button theme="borderless" type="tertiary" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
<i className={`v2-status-dot is-${status}`} />
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
<span className="v2-vehicle-identity"><span className="v2-map-vehicle-heading"><strong>{vehicle.plate || '未绑定车牌'}</strong><VehicleNumberLabel value={vehicle.vehicleNumber} /></span><small>{vehicle.vin}</small></span>
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
</Button>
);
@@ -285,6 +128,8 @@ const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { v
const MemoFleetMap = memo(FleetMap);
export default function MonitorPage() {
const { session } = usePlatformSession();
const canViewAlerts = hasMenu(session, 'alerts');
const [routeParams, setRouteParams] = useSearchParams();
const navigate = useNavigate();
const pageRef = useRef<HTMLDivElement>(null);
@@ -295,6 +140,8 @@ export default function MonitorPage() {
const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
const [listOffset, setListOffset] = useState(initialContext.listOffset);
const [listLimit, setListLimit] = useState(initialContext.listLimit);
const [listSort, setListSort] = useState<MonitorListSort>(initialContext.listSort ?? 'hydrogenPressureMpa');
const [listSortOrder, setListSortOrder] = useState<MonitorSortOrder>(initialContext.listSortOrder ?? 'asc');
const [keyword, setKeyword] = useState(initialContext.keyword);
const [batchSearchOpen, setBatchSearchOpen] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
@@ -314,19 +161,27 @@ export default function MonitorPage() {
}, []);
const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]);
const monitorReturn = useMemo(() => buildMonitorPath({
mode, keyword, protocol, status, selectedVin, detailOpen, viewport, listOffset, listLimit
}), [detailOpen, keyword, listLimit, listOffset, mode, protocol, selectedVin, status, viewport]);
mode, keyword, protocol, status, selectedVin, detailOpen, viewport, listOffset, listLimit, listSort, listSortOrder
}), [detailOpen, keyword, listLimit, listOffset, listSort, listSortOrder, mode, protocol, selectedVin, status, viewport]);
useEffect(() => {
const next = new URL(monitorReturn, 'https://vehicle-platform.invalid').searchParams;
if (next.toString() !== routeParams.toString()) setRouteParams(next, { replace: true });
}, [monitorReturn, routeParams, setRouteParams]);
const listScope = useMemo(() => monitorFilterScope(filters), [filters]);
const listScope = useMemo(() => `${monitorFilterScope(filters)}&sort=${listSort}&sortOrder=${listSortOrder}`, [filters, listSort, listSortOrder]);
const listParams = useMemo(() => {
const params = monitorQueryParams(filters, listLimit);
params.set('offset', String(listOffset));
params.set('sort', 'identity');
params.set('sort', listSort);
params.set('sortOrder', listSortOrder);
params.set('includeEnergy', '1');
return params;
}, [filters, listLimit, listOffset]);
}, [filters, listLimit, listOffset, listSort, listSortOrder]);
const changeListSort = useCallback((field: Exclude<MonitorListSort, 'identity'>) => {
setListSortOrder(listSort === field && listSortOrder === 'asc' ? 'desc' : 'asc');
setListSort(field);
setListOffset(0);
listScrollTopRef.current = 0;
}, [listSort, listSortOrder]);
const trackedVin = mode === 'map' ? selectedVin : '';
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, trackedVin, mode === 'map', mode === 'map');
const realtimeListQuery = useQuery<Page<VehicleRealtimeRow>>({
@@ -351,6 +206,7 @@ export default function MonitorPage() {
for (const vehicle of activeBatchRows) {
identities.add(vehicle.vin.toLocaleUpperCase());
if (vehicle.plate) identities.add(vehicle.plate.toLocaleUpperCase());
if (vehicle.vehicleNumber) identities.add(vehicle.vehicleNumber.toLocaleUpperCase());
}
const missing = searchTerms.filter((term) => !identities.has(term));
return { matched: searchTerms.length - missing.length, missing };
@@ -526,8 +382,8 @@ export default function MonitorPage() {
note: alertVehicleCount == null ? '数据未开放' : '需要关注',
tone: alertVehicleCount ? 'danger' : 'neutral',
emphasis: 'primary',
ariaLabel: alertVehicleCount == null ? '打开事件中心' : `打开事件中心核对告警车辆,共 ${formatNumber(alertVehicleCount)} 辆`,
onClick: () => navigate(withMonitorReturn('/alerts?status=unprocessed', monitorReturn))
ariaLabel: canViewAlerts ? alertVehicleCount == null ? '打开事件中心' : `打开事件中心核对告警车辆,共 ${formatNumber(alertVehicleCount)} 辆` : undefined,
onClick: canViewAlerts ? () => navigate(withMonitorReturn('/alerts?status=unprocessed', monitorReturn)) : undefined
}
];
const mapCoverage = map.data?.mode === 'provinces'
@@ -541,15 +397,16 @@ export default function MonitorPage() {
const lastSyncAt = vehicles.dataUpdatedAt ? new Date(vehicles.dataUpdatedAt) : undefined;
return (
<div ref={pageRef} className={`v2-monitor-page is-${mode}-mode`}>
<div ref={pageRef} className={`fleet-workflow v2-monitor-page is-${mode}-mode`}>
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
<div id="monitor-filter-fields" className={`v2-monitor-filter-fields${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} aria-label="筛选条件">
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<Input
<FieldGroup id="monitor-filter-fields" className={`v2-monitor-filter-fields${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} aria-label="筛选条件">
<Field className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<InputGroup>
<InputGroupAddon><Search /></InputGroupAddon>
<InputGroupInput
aria-label="搜索车辆"
prefix={<IconSearch />}
value={keyword}
onChange={(value) => { setKeyword(value); setListOffset(0); clearSelection(); }}
onChange={(event) => { setKeyword(event.target.value); setListOffset(0); clearSelection(); }}
onPaste={(event) => {
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
if (pastedTerms.length <= 1) return;
@@ -558,17 +415,24 @@ export default function MonitorPage() {
setListOffset(0);
clearSelection();
}}
placeholder="车牌 / VIN;可批量粘贴车牌"
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}>批量</Button>}
placeholder="车牌 / VIN / 车辆编号"
/>
<InputGroupAddon align="inline-end"><InputGroupButton onClick={() => setBatchSearchOpen(true)}>批量</InputGroupButton></InputGroupAddon>
</InputGroup>
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
</div>
</Field>
<span className="v2-sr-only" id="monitor-protocol-filter-label">协议</span>
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); clearSelection(); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
<Select value={protocol || 'all'} onValueChange={(value) => { setProtocol(value === 'all' ? '' : value); setListOffset(0); clearSelection(); }}>
<SelectTrigger aria-labelledby="monitor-protocol-filter-label"><SelectValue /></SelectTrigger>
<SelectContent position="popper"><SelectGroup>{protocols.map((item) => <SelectItem key={item || 'all'} value={item || 'all'}>{item || '全部协议'}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<span className="v2-sr-only" id="monitor-status-filter-label">在线状态</span>
<Select value={status} onChange={(value) => applyStatusFilter(String(value))} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
<Button className="v2-filter-reset" icon={<IconRefresh />} disabled={activeFilterCount === 0} onClick={clearFilters}>清空</Button>
</div>
<Select value={status || 'all'} onValueChange={(value) => applyStatusFilter(value === 'all' ? '' : value)}>
<SelectTrigger aria-labelledby="monitor-status-filter-label"><SelectValue /></SelectTrigger>
<SelectContent position="popper"><SelectGroup>{statuses.map((item) => <SelectItem key={item || 'all'} value={item || 'all'}>{item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态'}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<BusinessButton variant="ghost" className="business-filter-reset" disabled={activeFilterCount === 0} onClick={clearFilters}><X data-icon="inline-start" />清空</BusinessButton>
</FieldGroup>
<div className="v2-monitor-filter-actions" aria-label="监控操作">
{mobileLayout ? <Button
className={`v2-monitor-filter-toggle${activeFilterCount ? ' has-filters' : ''}`}
@@ -609,7 +473,7 @@ export default function MonitorPage() {
className="v2-monitor-summary-rail"
items={monitorMetrics}
context={<div className="v2-monitor-summary-support">
<span title={`今日上报 ${formatNumber(summary.data?.frameToday ?? 0)} 条`}><small>今日上报</small><strong>{formatSupportCount(summary.data?.frameToday ?? 0)}<i>条</i></strong><em>数据活跃度</em></span>
<span title={summary.data?.activityDataAvailable === false ? '当前授权车辆范围的上报统计暂不可用' : `今日上报 ${formatNumber(summary.data?.frameToday ?? 0)} 条`}><small>今日上报</small><strong>{summary.data?.activityDataAvailable === false ? '—' : <>{formatSupportCount(summary.data?.frameToday ?? 0)}<i>条</i></>}</strong><em>{summary.data?.activityDataAvailable === false ? '范围内统计暂不可用' : '数据活跃度'}</em></span>
<Button className={`v2-monitor-summary-support-action${status === 'no_location' ? ' is-active' : ''}`} theme="borderless" type="tertiary" aria-label={`筛选无实时位置车辆,共 ${formatNumber(summary.data?.noLocationVehicles ?? 0)} 辆`} aria-pressed={status === 'no_location'} onClick={() => applyStatusFilter('no_location')}><small>无实时位置</small><strong>{formatNumber(summary.data?.noLocationVehicles ?? 0)}<i>辆</i></strong><em>辅助排查</em></Button>
</div>}
/>
@@ -642,6 +506,7 @@ export default function MonitorPage() {
<Suspense fallback={<Card className="v2-vehicle-detail v2-detail-loading-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在加载车辆详情" description="车辆状态和最新上报即将就绪。" /></Card>}>
<VehicleDetailCard
vehicle={selected}
session={session}
monitorReturn={monitorReturn}
onCollapse={collapseDetail}
onClear={clearDetail}
@@ -658,7 +523,7 @@ export default function MonitorPage() {
</Button>
</Card>
) : null}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} error={realtimeListQuery.isError} mobile={mobileLayout} selectedVin={selectedVin} mobileScrollTop={listScrollTopRef.current} onMobileScroll={(scrollTop) => { listScrollTopRef.current = scrollTop; }} onSelect={selectListVehicle} onPage={(page) => { listScrollTopRef.current = 0; setListOffset((page - 1) * listLimit); }} onLimit={(next) => { listScrollTopRef.current = 0; setListLimit(next); setListOffset(0); }} />}
</section> : <Suspense fallback={<PanelLoading title="正在打开车辆列表" />}><MonitorVehicleTable sort={listSort} sortOrder={listSortOrder} onSort={changeListSort} rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} error={realtimeListQuery.isError} mobile={mobileLayout} selectedVin={selectedVin} mobileScrollTop={listScrollTopRef.current} onMobileScroll={(scrollTop) => { listScrollTopRef.current = scrollTop; }} onSelect={selectListVehicle} onPage={(page) => { listScrollTopRef.current = 0; setListOffset((page - 1) * listLimit); }} onLimit={(next) => { listScrollTopRef.current = 0; setListLimit(next); setListOffset(0); }} /></Suspense>}
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }} aria-label="实时数据状态">
<div className="v2-event-sync">
@@ -3,6 +3,7 @@ import { Button, Card, Tag } from '@douyinfe/semi-ui';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { VehicleRealtimeRow } from '../../api/types';
import { hasMenu, type PlatformSession } from '../auth/session';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { protocolSourceLabel } from '../domain/protocols';
import { formatTelemetryValue } from '../domain/telemetry';
@@ -34,19 +35,22 @@ function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
export default function MonitorVehicleDetailCard({
vehicle,
session,
monitorReturn,
onCollapse,
onClear,
collapseLabel = '收起车辆详情'
}: {
vehicle: VehicleRealtimeRow;
session: PlatformSession;
monitorReturn: string;
onCollapse: () => void;
onClear: () => void;
collapseLabel?: string;
}) {
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
const canViewAlerts = hasMenu(session, 'alerts');
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true, canViewAlerts);
const detail = card.detail.data;
const activeAlerts = card.activeAlerts.data;
const telemetry = card.telemetry?.data;
@@ -72,13 +76,13 @@ export default function MonitorVehicleDetailCard({
const availableProtocols = detail?.sources?.length ? detail.sources : vehicle.protocols;
const alertCount = activeAlerts?.total ?? 0;
const workflowLinks = [
{ label: '单车详情', path: `/vehicles/${encodedVin}` },
{ label: '轨迹回放', path: `/tracks?vin=${encodedVin}` },
{ label: '历史数据', path: `/history?vin=${encodedVin}` },
{ label: '里程查询', path: `/statistics?vins=${encodedVin}` },
{ label: alertCount > 0 ? `事件流 ${alertCount}` : '事件流', path: `/alerts?keyword=${encodedVin}`, attention: alertCount > 0 },
{ label: '质量差异', path: `/operations?reconcileKeyword=${encodedVin}` }
];
{ label: '单车详情', path: `/vehicles/${encodedVin}`, menu: 'vehicles' },
{ label: '轨迹回放', path: `/tracks?vin=${encodedVin}`, menu: 'tracks' },
{ label: '历史数据', path: `/history?vin=${encodedVin}`, menu: 'history' },
{ label: '里程查询', path: `/statistics?vins=${encodedVin}`, menu: 'statistics' },
{ label: alertCount > 0 ? `事件流 ${alertCount}` : '事件流', path: `/alerts?keyword=${encodedVin}`, attention: alertCount > 0, menu: 'alerts' },
{ label: '质量差异', path: `/operations?reconcileKeyword=${encodedVin}`, menu: 'operations' }
].filter((item) => hasMenu(session, item.menu));
const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
@@ -170,7 +174,7 @@ export default function MonitorVehicleDetailCard({
</span>
<span className={alertCount > 0 ? 'has-alerts' : ''}>
<small>当前告警</small>
<strong>{formatNumber(alertCount)}<em>条</em></strong>
<strong>{canViewAlerts ? formatNumber(alertCount) : '—'}{canViewAlerts ? <em>条</em> : null}</strong>
</span>
</div>}
/>
@@ -189,7 +193,7 @@ export default function MonitorVehicleDetailCard({
<div><dt>可用协议</dt><dd className="v2-detail-protocols">{availableProtocols.length ? availableProtocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} compact />) : <ProtocolTag compact unknownLabel="未知协议" />}</dd></div>
<div><dt>接入供应商</dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
<div><dt>来源覆盖</dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
<div><dt>告警状态</dt><dd>{!canViewAlerts ? '无告警查看权限' : latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
@@ -0,0 +1,115 @@
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import type { VehicleRealtimeRow } from '../../api/types';
import MonitorVehicleTable from './MonitorVehicleTable';
import { ROUTER_FUTURE } from '../routing/routerConfig';
afterEach(cleanup);
test.each([false, true])('shows only configured vehicle number labels and updates them (mobile=%s)', (mobile) => {
const props = tableProps(mobile);
const view = render(<Harness props={{ ...props, rows: [{ ...vehicle, vehicleNumber: '01Q' }] }} />);
expect(screen.getByLabelText('车辆编号 01Q')).toHaveTextContent(/^01Q$/);
expect(screen.getByTitle('车辆编号:01Q')).toBeVisible();
view.rerender(<Harness props={{ ...props, rows: [{ ...vehicle, vehicleNumber: '备用车110Q' }] }} />);
expect(screen.queryByLabelText('车辆编号 01Q')).not.toBeInTheDocument();
expect(screen.getByLabelText('车辆编号 备用车110Q')).toBeInTheDocument();
view.rerender(<Harness props={{ ...props, rows: [{ ...vehicle, vehicleNumber: undefined }] }} />);
expect(view.container.querySelector('.v2-monitor-vehicle-number')).toBeNull();
});
const vehicle = {
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['GB32960'], primaryProtocol: 'GB32960',
locationAvailable: false, speedAvailable: false, mileageAvailable: false,
socAvailable: true, socPercent: 65.4, hydrogenPressureMpa: 32.5, hydrogenMassKg: 14.32,
hydrogenConsumptionKgPer100Km: 8.76
} as VehicleRealtimeRow;
function tableProps(mobile: boolean): ComponentProps<typeof MonitorVehicleTable> {
return {
rows: [vehicle], total: 1, page: 1, totalPages: 1, limit: 50, loading: false, error: false,
sort: 'identity', sortOrder: 'asc', onSort: vi.fn(), mobile, selectedVin: '', mobileScrollTop: 0, onMobileScroll: vi.fn(), onSelect: vi.fn(), onPage: vi.fn(), onLimit: vi.fn()
};
}
function Harness({ props }: { props: ComponentProps<typeof MonitorVehicleTable> }) {
return <QueryClientProvider client={new QueryClient()}><MemoryRouter future={ROUTER_FUTURE}><MonitorVehicleTable {...props} /></MemoryRouter></QueryClientProvider>;
}
test('shows reported SOC and hydrogen values with units in the desktop list', () => {
render(<Harness props={tableProps(false)} />);
for (const name of ['上报时间', '电 SOC(%)', '氢气压力(MPa)', '剩余氢量(Kg)', '百公里氢耗(kg/100km)']) {
expect(screen.getByRole('columnheader', { name })).toBeInTheDocument();
}
for (const value of ['65.4', '32.5', '14.32', '8.76']) expect(screen.getByText(value)).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(14);
const remainingCell = within(screen.getAllByRole('row')[1]).getAllByRole('cell')[6];
expect(remainingCell).toHaveTextContent('32.5');
expect(within(screen.getAllByRole('row')[1]).getAllByRole('cell')[7]).toHaveTextContent('14.32 Kg');
expect(screen.queryByRole('button', { name: '剩余氢量(Kg)' })).not.toBeInTheDocument();
});
test.each([false, true])('distinguishes missing data from zero and refreshes energy values (mobile=%s)', (mobile) => {
const props = tableProps(mobile);
const view = render(<Harness props={props} />);
const energyCells = () => mobile
? Array.from(view.container.querySelectorAll('.v2-monitor-mobile-energy dd'))
: within(screen.getAllByRole('row')[1]).getAllByRole('cell').slice(5, 9);
expect(energyCells().map((cell) => cell.textContent)).toEqual(['65.4', '32.5', '14.32 Kg', '8.76']);
view.rerender(<Harness props={{ ...props, rows: [{ ...vehicle, socPercent: 0, hydrogenPressureMpa: 0, hydrogenMassKg: 0, hydrogenConsumptionKgPer100Km: 0 }] }} />);
expect(energyCells().map((cell) => cell.textContent)).toEqual(['0', '0', '0 Kg', '0']);
view.rerender(<Harness props={{ ...props, rows: [{ ...vehicle, socAvailable: false, hydrogenPressureMpa: undefined, hydrogenMassKg: null, hydrogenConsumptionKgPer100Km: Number.NaN }] }} />);
expect(energyCells().map((cell) => cell.textContent)).toEqual(['—', '—', '— Kg', '—']);
});
test.each([false, true])('labels pressure-derived mass as estimated (mobile=%s)', (mobile) => {
const props = tableProps(mobile);
const view = render(<Harness props={{ ...props, rows: [{ ...vehicle, hydrogenMassSource: 'ESTIMATED' }] }} />);
expect(screen.getByText('Kg · 估算')).toBeInTheDocument();
expect(screen.getByTitle('根据氢气压力、温度和该车气瓶总容积估算')).toHaveTextContent('14.32');
view.rerender(<Harness props={{ ...props, rows: [{ ...vehicle, hydrogenMassSource: 'REPORTED' }] }} />);
expect(screen.queryByText('Kg · 估算')).not.toBeInTheDocument();
});
test.each([false, true])('refreshes report time and exact hover timestamp (mobile=%s)', (mobile) => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-26T15:00:00+08:00'));
try {
const props = tableProps(mobile);
const view = render(<Harness props={{ ...props, rows: [{ ...vehicle, lastSeen: '2026-10-26 14:59:30' }] }} />);
expect(screen.getByTitle('2026-10-26 14:59:30')).toHaveTextContent('实时');
expect(screen.getByTitle('2026-10-26 14:59:30')).toHaveClass('is-realtime');
view.rerender(<Harness props={{ ...props, rows: [{ ...vehicle, lastSeen: '2026-10-26 14:00:30' }] }} />);
expect(screen.getByTitle('2026-10-26 14:00:30')).toHaveTextContent('实时');
act(() => vi.advanceTimersByTime(60_000));
expect(screen.getByTitle('2026-10-26 14:00:30')).toHaveTextContent('1小时前');
expect(screen.getByTitle('2026-10-26 14:00:30')).toHaveClass('is-recent');
expect(screen.getByTitle('2026-10-26 14:00:30')).toHaveAttribute('datetime', '2026-10-26T06:00:30.000Z');
view.unmount();
} finally {
vi.useRealTimers();
}
});
test.each([false, true])('opens exact report time on click and closes with Escape (mobile=%s)', async (mobile) => {
const view = render(<Harness props={{ ...tableProps(mobile), rows: [{ ...vehicle, lastSeen: '2026-10-26T06:59:30Z' }] }} />);
const trigger = screen.getByRole('button', { name: '查看上报时间:2026-10-26 14:59:30' });
fireEvent.click(trigger);
const detail = await screen.findByRole('dialog', { name: '上报时间详情' });
expect(within(detail).getByText('2026-10-26 14:59:30')).toBeVisible();
expect(within(detail).getByText('上报时间(北京时间)')).toBeVisible();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '上报时间详情' })).not.toBeInTheDocument();
fireEvent.click(trigger);
fireEvent.click(screen.getByRole('button', { name: '关闭上报时间详情' }));
expect(screen.queryByRole('dialog', { name: '上报时间详情' })).not.toBeInTheDocument();
view.rerender(<Harness props={{ ...tableProps(mobile), rows: [{ ...vehicle, lastSeen: '' }] }} />);
expect(screen.queryByRole('button', { name: /查看上报时间/ })).not.toBeInTheDocument();
});
@@ -0,0 +1,257 @@
import { Popover } from 'radix-ui';
import { Link, useLocation } from 'react-router-dom';
import { withMonitorReturn } from '../routing/monitorContext';
import { IconMapPin, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Spin } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from '@/components/ui/table';
import { api } from '../../api/client';
import type { VehicleRealtimeRow } from '../../api/types';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { TablePagination } from '../shared/TablePagination';
import { ProtocolTag } from '../shared/ProtocolTag';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { formatNumber, vehicleStatus, statusLabel } from '../domain/monitor';
import { VehicleNumberLabel } from '../shared/VehicleNumberLabel';
import { monitorReportTime } from '../domain/monitorReportTime';
import { QUERY_MEMORY } from '../queryPolicy';
import type { MonitorListSort, MonitorSortOrder } from '../routing/monitorContext';
type SortableMonitorField = Exclude<MonitorListSort, 'identity'>;
type MonitorSortProps = { sort: MonitorListSort; sortOrder: MonitorSortOrder; onSort: (field: SortableMonitorField) => void };
function MonitorSortButton({ field, label, sort, sortOrder, onSort }: MonitorSortProps & { field: SortableMonitorField; label: string }) {
const active = sort === field;
const nextOrder = active && sortOrder === 'asc' ? '降序' : '升序';
return <button type="button" className="v2-monitor-sort-button" aria-pressed={active} title={`${field === 'hydrogenPressureMpa' ? '按 MPa ' : ''}点击${nextOrder}排序`} onClick={() => onSort(field)}>
<span>{label}</span><span aria-hidden="true">{active ? sortOrder === 'asc' ? '↑' : '↓' : '↕'}</span>
</button>;
}
function ReportTime({ value, now }: { value?: string; now: number }) {
const time = monitorReportTime(value, now);
const label = <time className={`v2-monitor-report-time is-${time.tone}`} dateTime={time.dateTime} title={time.title}>{time.label}</time>;
if (!time.dateTime) return label;
return <Popover.Root>
<Popover.Trigger asChild><button type="button" className="v2-monitor-report-trigger" aria-label={`查看上报时间:${time.title}`}>{label}</button></Popover.Trigger>
<Popover.Portal><Popover.Content className="v2-monitor-report-popover" sideOffset={6} collisionPadding={12} aria-label="上报时间详情">
<div><span>上报时间(北京时间)</span><Popover.Close aria-label="关闭上报时间详情">×</Popover.Close></div>
<time dateTime={time.dateTime}>{time.title}</time>
</Popover.Content></Popover.Portal>
</Popover.Root>;
}
type AddressCoordinate = { longitude: number; latitude: number; key: string };
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
&& Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
&& (vehicle.longitude !== 0 || vehicle.latitude !== 0);
}
function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
}
function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
}
function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtimeRow & { todayMileageKm: number } {
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
}
function energyValue(value: number | null | undefined, maximumFractionDigits = 1) {
return value != null && Number.isFinite(value) && value >= 0 ? formatNumber(value, maximumFractionDigits) : '—';
}
function socValue(vehicle: VehicleRealtimeRow) {
const available = vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
return available && vehicle.socPercent <= 100 ? energyValue(vehicle.socPercent) : '—';
}
function HydrogenRemainingValue({ vehicle }: { vehicle: VehicleRealtimeRow }) {
const estimated = vehicle.hydrogenMassSource === 'ESTIMATED' && energyValue(vehicle.hydrogenMassKg, 2) !== '—';
return <span className="v2-monitor-hydrogen-value">
<span title="根据氢气压力、温度和该车气瓶总容积估算"><strong>{energyValue(vehicle.hydrogenMassKg, 2)}</strong><small> Kg{estimated ? ' · 估算' : ''}</small></span>
</span>;
}
function vehicleProtocols(vehicle: VehicleRealtimeRow): string[] {
const runtimeValue = (vehicle as VehicleRealtimeRow & { protocols?: unknown }).protocols;
const normalized: string[] = [];
const seen = new Set<string>();
if (Array.isArray(runtimeValue)) {
for (const value of runtimeValue) {
if (typeof value !== 'string') continue;
const protocol = value.trim();
if (!protocol || seen.has(protocol)) continue;
seen.add(protocol);
normalized.push(protocol);
}
}
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
if (!normalized.length && primaryProtocol) normalized.push(primaryProtocol);
return normalized;
}
function vehiclePrimaryProtocol(vehicle: VehicleRealtimeRow, sourceProtocols = vehicleProtocols(vehicle)) {
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
return primaryProtocol || sourceProtocols[0] || '';
}
function vehicleProtocolSignature(vehicle: VehicleRealtimeRow) {
return vehicleProtocols(vehicle).join('|');
}
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
if (!hasRealtimeLocation(vehicle) || !Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
const longitude = Number(vehicle.longitude.toFixed(4));
const latitude = Number(vehicle.latitude.toFixed(4));
return { longitude, latitude, key: `${longitude.toFixed(4)},${latitude.toFixed(4)}` };
}
const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehicle: VehicleRealtimeRow }) {
const current = addressCoordinate(vehicle);
const [requested, setRequested] = useState<AddressCoordinate>();
const addressQuery = useQuery({
queryKey: ['monitor', 'list-address', requested?.key],
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
longitude: requested!.longitude.toFixed(4),
latitude: requested!.latitude.toFixed(4)
}), signal),
enabled: Boolean(requested),
staleTime: 6 * 60 * 60_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchOnWindowFocus: false,
retry: 1
});
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty">暂无实时位置</span>;
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德地址解析,不随实时数据刷新" onClick={() => setRequested(current)}>解析位置</Button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><Spin size="small" />解析中</span>;
if (addressQuery.isError) return <Button className="v2-monitor-address-action is-error" size="small" theme="light" type="danger" aria-label={`${vehicle.plate || vehicle.vin}位置解析失败,重试`} onClick={() => void addressQuery.refetch()}>解析失败,重试</Button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</Button> : null}</div>;
});
type MonitorMobileVehicleCardProps = {
now: number;
row: VehicleRealtimeRow;
selected: boolean;
onSelect: (vin: string) => void;
};
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, selected, onSelect, now }: MonitorMobileVehicleCardProps) {
const identity = row.plate || '未绑定车牌';
const location = hasRealtimeLocation(row);
const sourceProtocols = vehicleProtocols(row);
const primaryProtocol = vehiclePrimaryProtocol(row, sourceProtocols);
return <Card className={`v2-monitor-mobile-card${selected ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据${selected ? ',当前地图选中' : ''}`}>
<header className="v2-monitor-mobile-card-header">
<div className="v2-monitor-mobile-identity"><div className="v2-monitor-vehicle-heading"><strong>{identity}</strong><VehicleNumberLabel value={row.vehicleNumber} /></div><span>{row.vin}</span></div>
<div className="v2-monitor-card-heading-actions">
<Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${identity}`} onClick={() => onSelect(row.vin)}>定位</Button>
</div>
</header>
<div className="v2-monitor-mobile-report-time"><span>上报时间</span><ReportTime value={row.lastSeen} now={now} /></div>
<dl className="v2-monitor-mobile-primary">
<div><dt>速度</dt><dd>{hasRealtimeSpeed(row) ? <>{formatNumber(row.speedKmh, 1)}<small> km/h</small></> : '—'}</dd></div>
<div><dt>今日里程</dt><dd className="is-today">{hasTodayMileage(row) ? <>{formatNumber(row.todayMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? <>{formatNumber(row.totalMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt>协议来源</dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={primaryProtocol} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>{sourceProtocols.length} 路</small> : null}</span></dd></div>
</dl>
<dl className="v2-monitor-mobile-primary v2-monitor-mobile-energy">
<div><dt>电 SOC(%)</dt><dd>{socValue(row)}</dd></div>
<div><dt>氢气压力(MPa)</dt><dd>{energyValue(row.hydrogenPressureMpa, 2)}</dd></div>
<div><dt>剩余氢量(Kg)</dt><dd><HydrogenRemainingValue vehicle={row} /></dd></div>
<div><dt>百公里氢耗</dt><dd>{energyValue(row.hydrogenConsumptionKgPer100Km, 2)}</dd><small className="v2-monitor-energy-unit">kg/100km</small></div>
</dl>
<div className={`v2-monitor-mobile-location${location ? '' : ' is-unavailable'}`}>
<span className="v2-monitor-mobile-coordinate"><IconMapPin aria-hidden="true" />{location ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : <small>暂无经纬度</small>}</span>
<MonitorAddressCell vehicle={row} />
</div>
</Card>;
}, (previous, next) => {
const before = previous.row;
const after = next.row;
return previous.onSelect === next.onSelect
&& previous.now === next.now
&& before.lastSeen === after.lastSeen
&& previous.selected === next.selected
&& before.vin === after.vin
&& before.plate === after.plate
&& before.vehicleNumber === after.vehicleNumber
&& before.speedAvailable === after.speedAvailable
&& before.speedKmh === after.speedKmh
&& before.todayMileageAvailable === after.todayMileageAvailable
&& before.todayMileageKm === after.todayMileageKm
&& before.mileageAvailable === after.mileageAvailable
&& before.totalMileageKm === after.totalMileageKm
&& before.socAvailable === after.socAvailable
&& before.socPercent === after.socPercent
&& before.hydrogenPressureMpa === after.hydrogenPressureMpa
&& before.hydrogenMassKg === after.hydrogenMassKg
&& before.hydrogenMassSource === after.hydrogenMassSource
&& before.hydrogenConsumptionKgPer100Km === after.hydrogenConsumptionKgPer100Km
&& before.primaryProtocol === after.primaryProtocol
&& vehicleProtocolSignature(before) === vehicleProtocolSignature(after)
&& before.locationAvailable === after.locationAvailable
&& before.longitude === after.longitude
&& before.latitude === after.latitude;
});
export default function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, error, mobile, selectedVin, mobileScrollTop, onMobileScroll, onSelect, onPage, onLimit, sort, sortOrder, onSort }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; error: boolean; mobile: boolean; selectedVin: string; mobileScrollTop: number; onMobileScroll: (scrollTop: number) => void; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void } & MonitorSortProps) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(timer);
}, []);
const mobileCardsRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const monitorReturn = `${location.pathname}${location.search}`;
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 200, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className={`v2-monitor-vehicle-action${row.vin === selectedVin ? ' is-selected' : ''}`} theme="borderless" type="tertiary" title={row.vin === selectedVin ? '当前地图选中,点击返回地图' : '在地图中定位'} onClick={() => onSelect(row.vin)}><div className="v2-monitor-vehicle-heading"><strong>{row.plate || '未绑定车牌'}</strong><VehicleNumberLabel value={row.vehicleNumber} /></div><span>{row.vin}</span></Button> },
{ title: '状态', dataIndex: 'online', width: 76, render: (_value: boolean, row: VehicleRealtimeRow) => <span className={`fleet-status is-${vehicleStatus(row)}`}>{statusLabel(vehicleStatus(row))}</span> },
{ title: '速度', dataIndex: 'speedKmh', sortField: 'speedKmh' as const, width: 76, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
{ title: '今日里程', dataIndex: 'todayMileageKm', sortField: 'todayMileageKm' as const, width: 90, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '总里程', dataIndex: 'totalMileageKm', sortField: 'totalMileageKm' as const, width: 100, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '电 SOC(%)', dataIndex: 'socPercent', sortField: 'socPercent' as const, width: 100, render: (_value: number, row: VehicleRealtimeRow) => <strong className="v2-monitor-live-value">{socValue(row)}</strong> },
{ title: '氢气压力(MPa)', dataIndex: 'hydrogenPressureMpa', sortField: 'hydrogenPressureMpa' as const, width: 140, render: (value: number) => <strong className="v2-monitor-live-value" title="车辆上报的最高氢气压力">{energyValue(value, 2)}</strong> },
{ title: '剩余氢量(Kg)', dataIndex: 'hydrogenMassKg', width: 116, render: (_value: number, row: VehicleRealtimeRow) => <HydrogenRemainingValue vehicle={row} /> },
{ title: '百公里氢耗(kg/100km)', dataIndex: 'hydrogenConsumptionKgPer100Km', width: 156, render: (value: number) => <strong className="v2-monitor-live-value" title="车辆上报的百公里氢耗">{energyValue(value, 2)}</strong> },
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 110, render: (_value: string, row: VehicleRealtimeRow) => {
const sourceProtocols = vehicleProtocols(row);
return <div className="v2-monitor-protocol"><ProtocolTag protocol={vehiclePrimaryProtocol(row, sourceProtocols)} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>+{sourceProtocols.length - 1} 路</small> : null}</div>;
} },
{ title: '上报时间', dataIndex: 'lastSeen', sortField: 'lastSeen' as const, width: 100, render: (value: string) => <ReportTime value={value} now={now} /> },
{ title: '经纬度', dataIndex: 'longitude', width: 112, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable">—</span> },
{ title: '地理位置', dataIndex: 'vin', width: 130, render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> },
{ title: '操作', dataIndex: 'vin', width: 90, className: 'v2-monitor-table-actions', render: (_value: string, row: VehicleRealtimeRow) => <span className="fleet-row-actions"><Link aria-label={`查看 ${row.plate || row.vin} 档案`} to={withMonitorReturn(`/vehicles/${encodeURIComponent(row.vin)}`, monitorReturn)}>档案</Link><Link aria-label={`回放 ${row.plate || row.vin} 轨迹`} to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(row.vin)}`, monitorReturn)}>轨迹</Link></span> }
], [monitorReturn, now, onSelect, selectedVin]);
useLayoutEffect(() => {
if (!mobile || !mobileCardsRef.current) return;
mobileCardsRef.current.scrollTop = mobileScrollTop;
}, [mobile, mobileScrollTop, selectedVin, sort, sortOrder]);
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-monitor-list-header"
title="车辆实时列表"
description={mobile ? undefined : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
meta={`${total.toLocaleString('zh-CN')} 辆`}
/>
{!mobile ? <div className={`v2-monitor-table-scroll${error ? ' is-error' : ''}`}><Table className="business-data-table" style={{ minWidth: 1586 }} aria-label="车辆实时数据">
<TableHeader><TableRow>{columns.map((column) => <TableHead key={column.title} className={column.className} style={{ width: column.width }} aria-sort={column.sortField ? sort === column.sortField ? sortOrder === 'asc' ? 'ascending' : 'descending' : 'none' : undefined}>{column.sortField ? <MonitorSortButton field={column.sortField} label={column.title} sort={sort} sortOrder={sortOrder} onSort={onSort} /> : column.title}</TableHead>)}</TableRow></TableHeader>
<TableBody>{rows.map((row) => <TableRow key={row.vin} data-state={row.vin === selectedVin ? 'selected' : undefined}>{columns.map((column) => <TableCell key={column.title} className={column.className}>{column.render(row[column.dataIndex as keyof VehicleRealtimeRow] as never, row)}</TableCell>)}</TableRow>)}</TableBody>
</Table>{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '保留当前列表,完成后平滑替换。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-sort" role="group" aria-label="车辆列表排序"><span>排序</span><MonitorSortButton field="lastSeen" label="上报时间" sort={sort} sortOrder={sortOrder} onSort={onSort} /><MonitorSortButton field="speedKmh" label="速度" sort={sort} sortOrder={sortOrder} onSort={onSort} /><MonitorSortButton field="todayMileageKm" label="今日里程" sort={sort} sortOrder={sortOrder} onSort={onSort} /><MonitorSortButton field="totalMileageKm" label="总里程" sort={sort} sortOrder={sortOrder} onSort={onSort} /><MonitorSortButton field="socPercent" label="电 SOC" sort={sort} sortOrder={sortOrder} onSort={onSort} /><MonitorSortButton field="hydrogenPressureMpa" label="氢气压力(MPa)" sort={sort} sortOrder={sortOrder} onSort={onSort} /></div> : null}
{mobile ? <div ref={mobileCardsRef} className="v2-monitor-mobile-cards" tabIndex={0} aria-label="车辆实时列表" onScroll={(event) => onMobileScroll(event.currentTarget.scrollTop)}>{rows.map((row) => <MonitorMobileVehicleCard now={now} row={row} selected={row.vin === selectedVin} onSelect={onSelect} key={row.vin} />)}{loading && !rows.length ? <PanelLoading className="v2-monitor-table-loading" compact /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>;
}
@@ -0,0 +1,88 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { api } from '../../api/client';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import OneOSVehicleCatalogPage from './OneOSVehicleCatalogPage';
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
function renderCatalog(entry = '/vehicles') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[entry]}><Routes><Route path="/vehicles/:vin?" element={<OneOSVehicleCatalogPage />} /></Routes></MemoryRouter></QueryClientProvider>);
}
test('uses OneOS asset filters and pagination while marking local telemetry unavailable', async () => {
const catalog = vi.spyOn(api, 'oneOSAssetVehicles').mockImplementation(async (params) => ({
records: [{ vin: 'LTESTVIN001', plateNumber: '粤A12345', brand: '测试品牌', brandName: '', model: '测试型号', modelName: '', businessDepartment: '甲部门', operationStatus: '运营中', vehicleSource: 'OneOS' }],
total: 21, current: Number(params.get('pageNum') || 1), size: Number(params.get('pageSize') || 10),
telemetryAvailable: false, historyAvailable: false, source: 'oneos-asset-catalog'
}));
const lastParams = () => catalog.mock.calls[catalog.mock.calls.length - 1]?.[0];
renderCatalog();
expect(await screen.findByRole('list', { name: 'OneOS 车辆结果' })).toHaveTextContent('粤A12345');
expect(screen.getByText('VIN:LTESTVIN001')).toBeInTheDocument();
expect(screen.getAllByText('暂无本地遥测').length).toBeGreaterThan(0);
expect(screen.queryByText('当前在线')).not.toBeInTheDocument();
expect(catalog.mock.calls[0][0].get('assetCategory')).toBe('allOperating');
fireEvent.change(screen.getByPlaceholderText('输入车牌号'), { target: { value: '粤A' } });
fireEvent.click(screen.getByText('查询车辆').closest('button')!);
await waitFor(() => expect(lastParams()?.get('plateNumber')).toBe('粤A'));
expect(screen.queryByRole('tab', { name: '租赁车辆' })).not.toBeInTheDocument();
expect(lastParams()?.get('assetCategory')).toBe('allOperating');
expect(lastParams()?.get('pageNum')).toBe('1');
});
test('does not open platform vehicle detail or telemetry for a catalog VIN route', () => {
const catalog = vi.spyOn(api, 'oneOSAssetVehicles');
const detail = vi.spyOn(api, 'vehicleDetail');
const realtime = vi.spyOn(api, 'vehicleRealtime');
const telemetry = vi.spyOn(api, 'latestTelemetry');
renderCatalog('/vehicles/LTESTVIN001');
expect(screen.getByText('目录中的 VIN 不是中台历史或实时数据授权;请返回车辆目录查看 OneOS 资产资料。')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '返回车辆目录' })).toHaveAttribute('href', '/vehicles');
expect(catalog).not.toHaveBeenCalled();
expect(detail).not.toHaveBeenCalled();
expect(realtime).not.toHaveBeenCalled();
expect(telemetry).not.toHaveBeenCalled();
});
test('keeps the fixed operating category despite URL overrides while restoring other filters', async () => {
const catalog = vi.spyOn(api, 'oneOSAssetVehicles').mockImplementation(async (params) => ({
records: [], total: 31, current: Number(params.get('pageNum')), size: Number(params.get('pageSize')),
telemetryAvailable: false, historyAvailable: false, source: 'oneos-asset-catalog'
}));
renderCatalog('/vehicles?assetCategory=stock&pageNum=2&pageSize=20&customerName=%E7%94%B2%E5%AE%A2%E6%88%B7&projectType=2');
await waitFor(() => expect(catalog).toHaveBeenCalled());
const params = catalog.mock.calls[0][0];
expect(params.get('assetCategory')).toBe('allOperating');
expect(params.get('pageNum')).toBe('2');
expect(params.get('pageSize')).toBe('20');
expect(params.get('customerName')).toBe('甲客户');
expect(params.get('operationStatus')).toBe('2');
expect(params.has('projectType')).toBe(false);
expect(screen.queryByRole('tab', { name: '库存车辆' })).not.toBeInTheDocument();
});
test('converts the OneOS region selector into repeated province IDs without sending region', async () => {
const catalog = vi.spyOn(api, 'oneOSAssetVehicles').mockImplementation(async (params) => ({
records: [], total: 0, current: Number(params.get('pageNum')), size: Number(params.get('pageSize')),
telemetryAvailable: false, historyAvailable: false, source: 'oneos-asset-catalog'
}));
renderCatalog('/vehicles?region=3');
await waitFor(() => expect(catalog).toHaveBeenCalled());
expect(catalog.mock.calls[0][0].getAll('provinces')).toEqual(['410000', '420000', '430000']);
expect(catalog.mock.calls[0][0].has('region')).toBe(false);
fireEvent.click(screen.getByText('更多筛选条件'));
expect(screen.getByRole('combobox', { name: '区域' })).toHaveValue('3');
expect(screen.getByPlaceholderText('省份 ID,如 330000')).toBeInTheDocument();
expect(screen.getByPlaceholderText('品牌字典值,如 feichi')).toBeInTheDocument();
expect(screen.getByPlaceholderText('业务部门名称')).toBeInTheDocument();
expect(screen.getByPlaceholderText('登记所有权名称')).toBeInTheDocument();
});
@@ -0,0 +1,160 @@
import './oneos-vehicle-catalog.css';
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Input, Tag } from '@douyinfe/semi-ui';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { OneOSAssetVehicle } from '../../api/types';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
const category = 'allOperating';
// Matches OneOS's provinceIdToRegionMap; region is a UI value, never an API filter.
const regionProvinces: Record<string, readonly string[]> = {
'1': ['310000', '320000', '330000', '340000', '350000', '360000', '370000', '886001'],
'2': ['440000', '450000', '460000', '00853', '00852'],
'3': ['410000', '420000', '430000'],
'4': ['110000', '120000', '130000', '140000', '150000'],
'5': ['210000', '220000', '230000'],
'6': ['510000', '530000', '520000', '500000', '540000'],
'7': ['610000', '620000', '630000', '640000', '650000']
};
const regionOptions = [
['1', '华东'], ['2', '华南'], ['3', '华中'], ['4', '华北'],
['5', '东北'], ['6', '西南'], ['7', '西北']
] as const;
const codePlaceholders: Partial<Record<SearchField, string>> = {
brand: '品牌字典值,如 feichi', model: 'OneOS 型号选项值', province: '省份 ID,如 330000', city: '城市 ID,如 330100',
businessDept: '业务部门名称', registeredOwnership: '登记所有权名称', operationOrgId: '运营组织 ID',
operationStatus: 'OneOS 运营状态选项值', licenseStatus: 'OneOS 证照状态选项值', insuranceStatus: 'OneOS 保险状态选项值',
vehicleSource: 'OneOS 车辆来源选项值', packingLotId: '停车场 ID'
};
const searchFields = [
{ key: 'plateNumber', label: '车牌号' },
{ key: 'brand', label: '品牌' },
{ key: 'model', label: '车型' },
{ key: 'customerName', label: '客户名称' },
{ key: 'province', label: '省份' },
{ key: 'city', label: '城市' },
{ key: 'businessDept', label: '业务部门' },
{ key: 'projectName', label: '项目名称' },
{ key: 'contractCode', label: '合同编号' },
{ key: 'registeredOwnership', label: '登记权属' },
{ key: 'operationOrgId', label: '运营组织 ID' },
{ key: 'operationStatus', label: '运营状态' },
{ key: 'licenseStatus', label: '牌照状态' },
{ key: 'insuranceStatus', label: '保险状态' },
{ key: 'vehicleSource', label: '车辆来源' },
{ key: 'rentalCompany', label: '租赁公司' },
{ key: 'packingLotId', label: '停车场 ID' }
] as const;
type SearchField = typeof searchFields[number]['key'];
type Filters = Record<SearchField, string> & { projectType: string; region: string };
const emptyFilters = Object.fromEntries([...searchFields.map(({ key }) => [key, '']), ['projectType', ''], ['region', '']]) as Filters;
function filtersFromParams(params: URLSearchParams): Filters {
const filters = { ...emptyFilters };
for (const { key } of searchFields) filters[key] = params.get(key) ?? '';
const projectType = params.get('projectType');
filters.projectType = projectType === '1' || projectType === '2' ? projectType : '';
const region = params.get('region');
filters.region = region && regionProvinces[region] ? region : '';
return filters;
}
function cleanPage(raw: string | null, fallback: number, max: number) {
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 1 && value <= max ? value : fallback;
}
function assetLabel(row: OneOSAssetVehicle) {
return row.plateNumber || row.vin || '未标识车辆';
}
export default function OneOSVehicleCatalogPage() {
const { vin } = useParams();
const [routeParams, setRouteParams] = useSearchParams();
const page = cleanPage(routeParams.get('pageNum'), 1, 1_000_000);
const pageSize = cleanPage(routeParams.get('pageSize'), 10, 50);
const [draft, setDraft] = useState<Filters>(() => filtersFromParams(routeParams));
useEffect(() => setDraft(filtersFromParams(routeParams)), [routeParams]);
const queryParams = useMemo(() => {
const result = new URLSearchParams({ assetCategory: category, pageNum: String(page), pageSize: String(pageSize) });
for (const { key } of searchFields) {
const value = routeParams.get(key)?.trim();
if (value) result.set(key, value);
}
for (const provinceId of regionProvinces[routeParams.get('region') ?? ''] ?? []) result.append('provinces', provinceId);
if (!result.has('operationStatus')) {
const projectType = routeParams.get('projectType');
if (projectType === '1' || projectType === '2') result.set('operationStatus', projectType);
}
return result;
}, [category, page, pageSize, routeParams]);
const catalog = useQuery({
queryKey: ['oneos-asset-catalog', queryParams.toString()],
queryFn: ({ signal }) => api.oneOSAssetVehicles(queryParams, signal),
enabled: !vin,
retry: false,
staleTime: 15_000
});
const result = catalog.data?.source === 'oneos-asset-catalog' && Array.isArray(catalog.data.records) ? catalog.data : undefined;
const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const updateRoute = (update: (next: URLSearchParams) => void) => {
const next = new URLSearchParams(routeParams);
update(next);
setRouteParams(next);
};
const search = (event: FormEvent) => {
event.preventDefault();
updateRoute((next) => {
next.set('pageNum', '1');
for (const { key } of searchFields) {
const value = draft[key].trim();
if (value) next.set(key, value);
else next.delete(key);
}
if (draft.projectType) next.set('projectType', draft.projectType);
else next.delete('projectType');
if (draft.region) next.set('region', draft.region);
else next.delete('region');
});
};
if (vin) return <Card className="v2-oneos-catalog" bodyStyle={{ padding: 24 }}>
<WorkspacePanelHeader title="OneOS 车辆资产目录" description="当前目录没有中台车辆详情或本地遥测记录。" />
<PanelEmpty title="暂无本地遥测" description="目录中的 VIN 不是中台历史或实时数据授权;请返回车辆目录查看 OneOS 资产资料。" action={<Link to="/vehicles"><Button theme="solid" type="primary">返回车辆目录</Button></Link>} />
</Card>;
return <div className="fleet-workflow v2-oneos-catalog">
<Card className="v2-oneos-catalog-intro" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader title="OneOS 车辆资产目录" description="以下车辆来自当前 OneOS 账号可查询的资产目录。目录记录不代表中台实时、轨迹或历史数据已接通。" meta={<Tag color="blue">真实 OneOS 目录</Tag>} />
<p>暂无本地遥测。在线状态、轨迹与统计尚未接通,页面不会用模拟数据补齐。</p>
</Card>
<Card className="v2-oneos-catalog-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader title="车辆查询" description="按 OneOS 车辆页条件筛选,结果与总数由 OneOS 返回。" />
<Tag color="blue">所有营运车辆</Tag>
<form className="v2-oneos-catalog-filters" onSubmit={search} aria-label="OneOS 车辆筛选">
{searchFields.slice(0, 4).map(({ key, label }) => <label key={key}><span>{label}</span><Input value={draft[key]} onChange={(value) => setDraft((current) => ({ ...current, [key]: value }))} placeholder={codePlaceholders[key] || `输入${label}`} /></label>)}
<details><summary>更多筛选条件</summary><div className="v2-oneos-catalog-more">
<label><span>区域</span><select aria-label="区域" value={draft.region} onChange={(event) => setDraft((current) => ({ ...current, region: event.target.value }))}><option value="">全部区域</option>{regionOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
{searchFields.slice(4).map(({ key, label }) => <label key={key}><span>{label}</span><Input value={draft[key]} onChange={(value) => setDraft((current) => ({ ...current, [key]: value, ...(key === 'operationStatus' && value ? { projectType: '' } : {}) }))} placeholder={codePlaceholders[key] || `输入${label}`} /></label>)}
<label><span>项目类型</span><select value={draft.projectType} onChange={(event) => setDraft((current) => ({ ...current, projectType: event.target.value, ...(event.target.value ? { operationStatus: '' } : {}) }))}><option value="">全部项目</option><option value="1">租赁项目</option><option value="2">自营项目</option></select></label>
</div></details>
<div className="v2-oneos-catalog-filter-actions"><Button htmlType="submit" theme="solid" type="primary" icon={<IconSearch />}>查询车辆</Button><Button htmlType="button" onClick={() => { setDraft({ ...emptyFilters }); updateRoute((next) => { for (const { key } of searchFields) next.delete(key); next.delete('projectType'); next.delete('region'); next.set('pageNum', '1'); }); }}>清空条件</Button><Button htmlType="button" icon={<IconRefresh />} onClick={() => void catalog.refetch()}>刷新</Button></div>
</form>
{catalog.isPending ? <PanelLoading title="正在读取 OneOS 车辆目录" /> : null}
{catalog.isError || (catalog.data && !result) ? <InlineError message={catalog.isError && catalog.error instanceof Error ? catalog.error.message : 'OneOS 车辆目录响应不完整'} onRetry={() => void catalog.refetch()} /> : null}
{result && !result.records.length ? <PanelEmpty title="暂无匹配车辆" description="调整分类或筛选条件后重试。" /> : null}
{result?.records.length ? <div className="v2-oneos-catalog-list" role="list" aria-label="OneOS 车辆结果">{result.records.map((row, index) => <article key={`${row.vin}-${index}`} role="listitem" className="v2-oneos-catalog-row">
<div><strong>{assetLabel(row)}</strong><small>VIN:{row.vin || '未提供'}</small></div>
<div><span>品牌 / 车型</span><strong>{[row.brandName || row.brand, row.modelName || row.model].filter(Boolean).join(' / ') || '—'}</strong></div>
<div><span>业务部门</span><strong>{row.businessDepartment || '—'}</strong></div>
<div><span>运营状态</span><strong>{row.operationStatus || '—'}</strong></div>
<Tag color="grey">暂无本地遥测</Tag>
</article>)}</div> : null}
{result ? <footer className="v2-oneos-catalog-pagination"><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆 · 第 ${page} 页`} onPageChange={(value) => updateRoute((next) => next.set('pageNum', String(value)))} pageSize={pageSize} pageSizeLabel="每页车辆数" pageSizeOptions={[{ value: 10, label: '10 辆/页' }, { value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }]} onPageSizeChange={(value) => updateRoute((next) => { next.set('pageSize', String(value)); next.set('pageNum', '1'); })} /></footer> : null}
</Card>
</div>;
}
@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { api } from '../../api/client';
import type { OperationComplianceRow, OperationMileageSource } from '../../api/operationCompliance';
import { WorkspaceDialog } from '../shared/WorkspaceDialog';
export const operationSourceLabels = { gps: 'GPS', odometer: '仪表', hydrogen: '纯氢里程' };
export const operationSourceDescription = 'GPS:使用所有 GPS 平台择优后的 JT808 最终日里程。仪表:整合 GB32960 和宇通 MQTT,同车同日只选一个有效值,不叠加;优先非零有效值,同等级优先 GB32960,宇通 MQTT 补充。纯氢里程:与客户运营里程页保持同一口径,使用广州平台(GZEV)的氢能源日里程(hyRunMileage),按 VIN 和自然日匹配,缺失不补为零。';
const number = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 3 });
const statusLabels = { passed: '达标', failed: '未达标', insufficient: '数据不足' };
const dayStatusLabels = { valid: '有效', missing: '缺失', invalid: '异常', restricted: '未授权' };
const protocolLabels: Record<string, string> = { JT808: 'GPS · JT808', GB32960: '仪表 · GB32960', YUTONG_MQTT: '仪表 · 宇通 MQTT', GZEV: '纯氢 · 广州平台' };
type Props = {
vehicle: OperationComplianceRow;
dateFrom: string; dateTo: string; targetKm: string; mileageSource: OperationMileageSource;
onClose: () => void;
};
export function OperationComplianceDaily({ vehicle, dateFrom, dateTo, targetKm, mileageSource, onClose }: Props) {
const query = useQuery({
queryKey: ['operation-compliance-daily', vehicle.vin, dateFrom, dateTo, targetKm, mileageSource],
queryFn: ({ signal }) => api.operationComplianceDetail(new URLSearchParams({ vin: vehicle.vin, dateFrom, dateTo, targetKm, mileageSource }), signal),
staleTime: 0, retry: 1
});
const data = query.data;
const source = operationSourceLabels[mileageSource];
return <WorkspaceDialog visible ariaLabel="车辆每日里程详情" closeLabel="关闭每日里程详情" title={`${vehicle.plate || vehicle.vin} · 每日里程`} description={`${dateFrom} 至 ${dateTo} · ${source}`} icon={<CalendarDays />} width={920} className="oc-daily-dialog" onCancel={onClose}>
<div className="oc-daily-toolbar"><span>{vehicle.vin}</span><Button size="sm" variant="outline" disabled={query.isFetching} onClick={() => void query.refetch()}><RefreshCw aria-hidden="true" />刷新明细</Button></div>
{query.isError ? <div className="oc-daily-error" role="alert"><p>{query.error instanceof Error ? query.error.message : '每日里程加载失败'}</p><Button variant="outline" onClick={() => void query.refetch()}>重试明细</Button></div> : !data ? <p role="status">正在加载每日里程…</p> : <>
<div className="oc-daily-owner">客户:{data.vehicle.customer || '未填写'} · 部门:{data.vehicle.department || '未填写'} · 业务员:{data.vehicle.responsiblePerson || '未填写'}</div>
<div className="oc-daily-summary" aria-label="单车区间汇总"><span>区间里程<strong>{data.vehicle.mileageKm == null ? '—' : `${number.format(Math.trunc(data.vehicle.mileageKm))} km`}</strong></span><span>区间达标标准<strong>{number.format(data.targetKm)} km</strong></span><span>区间达标情况<Badge variant={data.vehicle.status === 'passed' ? 'success' : data.vehicle.status === 'failed' ? 'danger' : 'warning'}>{statusLabels[data.vehicle.status]}</Badge></span><span>有效 / 应统计天数<strong>{data.vehicle.validDays} / {data.vehicle.expectedDays} 天</strong></span></div>
<p className="oc-daily-note">以下为每日里程,里程源:{source};达标标准用于整个区间。缺失、异常和未授权日期不补为零,真实零里程显示 0。</p>
<div className="oc-daily-table" tabIndex={0} aria-label="每日里程明细,可滚动"><Table aria-label="车辆每日里程明细"><TableHeader><TableRow><TableHead>日期</TableHead><TableHead className="oc-number">当日里程 / km</TableHead><TableHead>采用来源</TableHead><TableHead>数据状态</TableHead><TableHead>说明</TableHead></TableRow></TableHeader><TableBody>{data.days.map(day => <TableRow key={day.date}><TableCell>{day.date}</TableCell><TableCell className="oc-number">{day.mileageKm == null ? '—' : number.format(day.mileageKm)}</TableCell><TableCell>{protocolLabels[day.protocol] || '—'}</TableCell><TableCell><Badge variant={day.status === 'valid' ? 'success' : day.status === 'invalid' ? 'danger' : 'warning'}>{dayStatusLabels[day.status]}</Badge></TableCell><TableCell>{day.reason}</TableCell></TableRow>)}</TableBody></Table></div>
<p className="oc-daily-note">共 {data.days.length} 天 · 有效 {data.vehicle.validDays} 天 · 缺失 {data.vehicle.missingDays} 天 · 异常 {data.vehicle.invalidDays} 天 · 未授权 {data.vehicle.expectedDays - data.vehicle.authorizedDays} 天</p>
</>}
</WorkspaceDialog>;
}
@@ -0,0 +1,216 @@
import { afterEach, expect, test, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { OperationComplianceResponse, OperationMileageSource } from '../../api/operationCompliance';
import OperationCompliancePage from './OperationCompliancePage';
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
function fixture(): OperationComplianceResponse {
return {
dateFrom: '2026-09-17', dateTo: '2026-10-16', targetKm: 3000, mileageSource: 'gps', limit: 30, offset: 0, total: 3,
summary: { total: 3, passed: 1, failed: 1, insufficient: 1, passRate: 50 },
assignmentSource: 'oneos-synced', assignmentObservedAt: '', assignmentStale: false, assignmentMissingCount: 1,
items: [
{ vin: 'LNX00000000000001', plate: '沪A达标', customer: '客户甲', department: '业务一部', responsiblePerson: '张业务', mileageKm: 3200, status: 'passed', validDays: 29, expectedDays: 30, authorizedDays: 30, invalidDays: 0, missingDays: 1, incomplete: true, reasons: ['部分授权日期缺少JT808日里程'] },
{ vin: 'LNX00000000000002', plate: '沪A零值', customer: '', department: '', responsiblePerson: '', mileageKm: 0, status: 'failed', validDays: 30, expectedDays: 30, authorizedDays: 30, invalidDays: 0, missingDays: 0, incomplete: false, reasons: [] },
{ vin: 'LNX00000000000003', plate: '沪A缺失', customer: '客户乙', department: '业务二部', responsiblePerson: '李业务', mileageKm: null, status: 'insufficient', validDays: 0, expectedDays: 30, authorizedDays: 20, invalidDays: 1, missingDays: 19, incomplete: true, reasons: ['部分日期不在完整自然日历史授权范围内'] }
]
};
}
function setup() {
vi.spyOn(api, 'operationComplianceOptions').mockResolvedValue({ customers: ['客户甲', '客户乙', '其他客户'], departments: ['业务一部', '业务二部'], responsiblePeople: ['张业务', '李业务'] });
return vi.spyOn(api, 'operationCompliance').mockImplementation(async params => ({ ...fixture(), dateFrom: params.get('dateFrom')!, dateTo: params.get('dateTo')!, targetKm: Number(params.get('targetKm')), mileageSource: params.get('mileageSource') as OperationMileageSource }));
}
function mount() { return render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}><OperationCompliancePage /></QueryClientProvider>); }
test('shows interval result, zero, missing, ownership and data coverage distinctly', async () => {
setup(); mount();
const table = await screen.findByRole('table', { name: '车辆运营达标明细' });
const zero = within(table).getByText('沪A零值').closest('tr')!;
expect(within(zero).getByText('0')).toBeInTheDocument();
expect(within(zero).getAllByText('未填写')).toHaveLength(3);
expect(within(zero).getByText('未达标')).toBeInTheDocument();
const missing = within(table).getByText('沪A缺失').closest('tr')!;
expect(within(missing).getByText('—')).toBeInTheDocument();
expect(within(missing).getByText('数据不足')).toBeInTheDocument();
expect(within(missing).getByText(/授权 20 天/)).toBeInTheDocument();
expect(within(table).getByText('数据不完整')).toBeInTheDocument();
expect(screen.getByText('50.0%')).toBeInTheDocument();
expect(screen.getByText(/1 辆未同步归属/)).toBeInTheDocument();
});
test('fuzzy searches and selects multiple objects, sends repeated exact filter values, removes and clears choices', async () => {
const request = setup(); mount(); await screen.findByText('沪A达标');
fireEvent.click(screen.getByRole('button', { name: '选择客户' }));
const search = screen.getByRole('textbox', { name: '搜索客户选项' });
fireEvent.change(search, { target: { value: '甲' } });
expect(screen.getByRole('checkbox', { name: '客户甲' })).toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: '客户乙' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: '客户甲' }));
fireEvent.change(search, { target: { value: '' } });
fireEvent.click(screen.getByRole('checkbox', { name: '客户乙' }));
fireEvent.click(screen.getByRole('button', { name: '完成' }));
for (const [label, value] of [['业务部门', '业务一部'], ['业务负责人', '张业务']]) {
fireEvent.click(screen.getByRole('button', { name: `选择${label}` }));
fireEvent.click(screen.getByRole('checkbox', { name: value }));
fireEvent.click(screen.getByRole('button', { name: '完成' }));
}
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
const params = request.mock.calls[1][0];
expect(params.getAll('customer')).toEqual(['客户甲', '客户乙']);
expect(params.getAll('department')).toEqual(['业务一部']);
expect(params.getAll('responsiblePerson')).toEqual(['张业务']);
fireEvent.click(screen.getByRole('button', { name: '移除客户:客户甲' }));
expect(screen.queryByRole('button', { name: '移除客户:客户甲' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '移除客户:客户乙' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '选择客户' }));
fireEvent.click(screen.getByRole('button', { name: '清空客户选择' }));
expect(screen.getByText('已选 0 项')).toBeInTheDocument();
});
test('keeps draft thresholds separate from applied results, accepts 4000 and resets every filter', async () => {
const request = setup(); mount(); await screen.findByText('沪A达标');
const initial = request.mock.calls[0][0];
fireEvent.change(screen.getByRole('spinbutton', { name: '设定达标里程(km)' }), { target: { value: '4000' } });
expect(request).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('3,000 km');
expect(screen.getByText('达标里程已修改,点击查询后重新计算达标状态和达标率')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('4,000 km'));
expect(request.mock.calls[1][0].get('targetKm')).toBe('4000');
fireEvent.change(screen.getByRole('combobox', { name: '达标状态' }), { target: { value: 'passed' } });
await waitFor(() => expect(request).toHaveBeenCalledTimes(3));
fireEvent.change(await screen.findByRole('combobox', { name: '区间里程排序' }), { target: { value: 'mileage-asc' } });
await waitFor(() => expect(request).toHaveBeenCalledTimes(4));
await screen.findByRole('table', { name: '车辆运营达标明细' });
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(screen.getByRole('spinbutton', { name: '设定达标里程(km)' })).toHaveValue(3000);
expect(screen.getByLabelText('开始日期')).toHaveValue(initial.get('dateFrom'));
expect(screen.getByRole('combobox', { name: '达标状态' })).toHaveValue('');
expect(screen.getByRole('combobox', { name: '区间里程排序' })).toHaveValue('mileage-desc');
});
test('paginates on server without reducing the displayed global summary', async () => {
const request = setup(); request.mockImplementation(async params => ({ ...fixture(), total: 61, summary: { ...fixture().summary, total: 61 }, offset: Number(params.get('offset')) }));
mount(); await screen.findByText('沪A达标');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
expect(request.mock.calls[1][0].get('offset')).toBe('30');
expect(within(await screen.findByLabelText('运营达标概况')).getByText('61')).toBeInTheDocument();
expect(screen.getByLabelText('第2页,共3页')).toBeInTheDocument();
});
test('applies GPS or odometer to results and loads daily details with the same applied source and range', async () => {
const request = setup();
const detailRequest = vi.spyOn(api, 'operationComplianceDetail').mockImplementation(async params => ({
vehicle: { ...fixture().items[0], validDays: 2, expectedDays: 5, authorizedDays: 4, invalidDays: 1, missingDays: 1 },
dateFrom: params.get('dateFrom')!, dateTo: params.get('dateTo')!, targetKm: Number(params.get('targetKm')), mileageSource: params.get('mileageSource') as OperationMileageSource,
days: [
{ date: '2026-09-17', mileageKm: 0, protocol: 'GB32960', status: 'valid', reason: '已计入区间里程' },
{ date: '2026-09-18', mileageKm: 3200, protocol: 'YUTONG_MQTT', status: 'valid', reason: '已计入区间里程' },
{ date: '2026-09-19', mileageKm: null, protocol: '', status: 'missing', reason: '暂无仪表日里程,不补为零' },
{ date: '2026-09-20', mileageKm: null, protocol: 'GB32960', status: 'invalid', reason: '日里程异常或冲突,未计入区间里程' },
{ date: '2026-09-21', mileageKm: null, protocol: '', status: 'restricted', reason: '不在完整自然日历史授权范围内' }
]
}));
mount(); await screen.findByText('沪A达标');
expect(screen.getByRole('combobox', { name: '里程数据源' })).toHaveValue('gps');
fireEvent.change(screen.getByRole('combobox', { name: '里程数据源' }), { target: { value: 'odometer' } });
expect(request).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('GPS');
expect(screen.getByText('数据源已修改,点击查询后按所选来源重新计算里程和达标情况')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('仪表'));
expect(request.mock.calls[1][0].get('mileageSource')).toBe('odometer');
fireEvent.click(screen.getByRole('button', { name: '查看沪A达标每日明细' }));
const dialog = await screen.findByRole('dialog', { name: '车辆每日里程详情' });
const table = await within(dialog).findByRole('table', { name: '车辆每日里程明细' });
expect(detailRequest.mock.calls[0][0].get('mileageSource')).toBe('odometer');
expect(detailRequest.mock.calls[0][0].get('vin')).toBe('LNX00000000000001');
expect(detailRequest.mock.calls[0][0].get('dateFrom')).toBe(request.mock.calls[1][0].get('dateFrom'));
expect(within(table).getByText('0')).toBeInTheDocument();
expect(within(table).getByText('仪表 · 宇通 MQTT')).toBeInTheDocument();
for (const label of ['缺失', '异常', '未授权']) expect(within(table).getByText(label)).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '关闭每日里程详情' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '车辆每日里程详情' })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(screen.getByRole('combobox', { name: '里程数据源' })).toHaveValue('gps');
});
test('applies hydrogen mileage to totals, status and daily detail, then resets to GPS', async () => {
const request = setup();
const hydrogenVehicle = { ...fixture().items[0], mileageKm: 1800, status: 'failed' as const, validDays: 3, expectedDays: 3, authorizedDays: 3, missingDays: 0, incomplete: false, reasons: [] };
request.mockImplementation(async params => ({
...fixture(), dateFrom: params.get('dateFrom')!, dateTo: params.get('dateTo')!, targetKm: Number(params.get('targetKm')), mileageSource: params.get('mileageSource') as OperationMileageSource,
...(params.get('mileageSource') === 'hydrogen' ? { items: [hydrogenVehicle, fixture().items[1], fixture().items[2]], summary: { total: 3, passed: 0, failed: 2, insufficient: 1, passRate: 0 } } : {})
}));
const detailRequest = vi.spyOn(api, 'operationComplianceDetail').mockImplementation(async params => ({
vehicle: hydrogenVehicle, dateFrom: params.get('dateFrom')!, dateTo: params.get('dateTo')!, targetKm: Number(params.get('targetKm')), mileageSource: params.get('mileageSource') as OperationMileageSource,
days: [0, 1800, 0].map((mileageKm, i) => ({ date: `2026-09-${17 + i}`, mileageKm, protocol: 'GZEV', status: 'valid' as const, reason: '已计入区间里程' }))
}));
mount(); await screen.findByText('沪A达标');
const source = screen.getByRole('combobox', { name: '里程数据源' });
expect(within(source).getByRole('option', { name: '纯氢里程' })).toHaveValue('hydrogen');
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-09-17' } });
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-09-19' } });
fireEvent.change(source, { target: { value: 'hydrogen' } });
expect(request).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('GPS');
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('纯氢里程'));
const row = screen.getByText('沪A达标').closest('tr')!;
expect(within(row).getByText('1,800')).toBeInTheDocument();
expect(within(row).getByText('未达标')).toBeInTheDocument();
expect(screen.getByText('0.0%')).toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '区间里程排序' }), { target: { value: 'mileage-asc' } });
await waitFor(() => expect(request).toHaveBeenCalledTimes(3));
expect(request.mock.calls[2][0].get('mileageSource')).toBe('hydrogen');
await screen.findByRole('table', { name: '车辆运营达标明细' });
fireEvent.change(source, { target: { value: 'gps' } });
fireEvent.click(screen.getByRole('button', { name: '查看沪A达标每日明细' }));
const dialog = await screen.findByRole('dialog', { name: '车辆每日里程详情' });
const table = await within(dialog).findByRole('table', { name: '车辆每日里程明细' });
expect(within(table).getAllByText('纯氢 · 广州平台')).toHaveLength(3);
expect(within(table).getAllByText('0')).toHaveLength(2);
expect(within(dialog).getByText('1,800 km')).toBeInTheDocument();
expect(detailRequest.mock.calls[0][0].get('mileageSource')).toBe('hydrogen');
expect(detailRequest.mock.calls[0][0].get('dateFrom')).toBe('2026-09-17');
fireEvent.click(within(dialog).getByRole('button', { name: '关闭每日里程详情' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() => expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('GPS'));
expect(source).toHaveValue('gps');
});
test('rejects invalid dates and zero target without querying', async () => {
const request = setup(); mount(); await screen.findByText('沪A达标');
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-10-10' } });
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-10-01' } });
fireEvent.submit(screen.getByRole('form', { name: '运营达标筛选' }));
expect(screen.getByRole('alert')).toHaveTextContent('有效日期范围');
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-10-01' } });
fireEvent.change(screen.getByRole('spinbutton', { name: '设定达标里程(km)' }), { target: { value: '0' } });
fireEvent.submit(screen.getByRole('form', { name: '运营达标筛选' }));
expect(screen.getByRole('alert')).toHaveTextContent('必须大于零');
expect(request).toHaveBeenCalledTimes(1);
});
test('shows empty and unknown denominator without implying zero mileage or zero pass rate', async () => {
const request = setup(); request.mockResolvedValue({ ...fixture(), items: [], total: 0, summary: { total: 0, passed: 0, failed: 0, insufficient: 0, passRate: null } });
mount(); await screen.findByText('没有符合条件的车辆');
expect(within(screen.getByLabelText('运营达标概况')).getByText('—')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '重置全部筛选' })).toBeInTheDocument();
});
test('reports loading failure and retries while preserving input', async () => {
const request = setup(); request.mockRejectedValue(new Error('里程服务暂不可用')); mount();
expect(await screen.findByRole('heading', { name: '暂时无法加载运营达标统计' }, { timeout: 4000 })).toBeInTheDocument();
fireEvent.change(screen.getByRole('spinbutton', { name: '设定达标里程(km)' }), { target: { value: '5000' } });
request.mockResolvedValue(fixture());
fireEvent.click(screen.getByRole('button', { name: '重新加载' }));
await screen.findByText('沪A达标');
expect(screen.getByRole('spinbutton', { name: '设定达标里程(km)' })).toHaveValue(5000);
expect(screen.getByLabelText('当前统计口径')).toHaveTextContent('3,000 km');
});
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, ArrowDownWideNarrow, ChevronLeft, ChevronRight, Info, RefreshCw, RotateCcw, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { api } from '../../api/client';
import type { OperationComplianceRow, OperationComplianceStatus, OperationMileageSource } from '../../api/operationCompliance';
import { OperationComplianceSelect } from './OperationComplianceSelect';
import { OperationComplianceDaily, operationSourceDescription, operationSourceLabels } from './OperationComplianceDaily';
import './operation-compliance.css';
import { MileageFilterPanel, MileageHint, MileageMetric } from './MileageWorkspace';
function dateOffset(days: number) {
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date());
const part = (type: string) => parts.find(value => value.type === type)?.value;
const date = new Date(`${part('year')}-${part('month')}-${part('day')}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
type Filters = { dateFrom: string; dateTo: string; targetKm: string; mileageSource: OperationMileageSource; customers: string[]; departments: string[]; people: string[] };
type Criteria = Filters & { status: string; sort: string; offset: number; limit: number };
const defaults = (): Filters => ({ dateFrom: dateOffset(-30), dateTo: dateOffset(-1), targetKm: '3000', mileageSource: 'gps', customers: [], departments: [], people: [] });
const initialCriteria = (): Criteria => ({ ...defaults(), status: '', sort: 'mileage-desc', offset: 0, limit: 30 });
const emptyOptions = { customers: [], departments: [], responsiblePeople: [] };
const labels: Record<OperationComplianceStatus, string> = { passed: '达标', failed: '未达标', insufficient: '数据不足' };
const number = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 3 });
const targetExplanation = '区间累计里程达到此值即为达标。修改后点击“查询”,将重新计算达标状态和达标率。';
function parameters(value: Criteria) {
const result = new URLSearchParams({ dateFrom: value.dateFrom, dateTo: value.dateTo, targetKm: value.targetKm, mileageSource: value.mileageSource, status: value.status, sort: value.sort, limit: String(value.limit), offset: String(value.offset) });
value.customers.forEach(item => result.append('customer', item));
value.departments.forEach(item => result.append('department', item));
value.people.forEach(item => result.append('responsiblePerson', item));
return result;
}
export default function OperationCompliancePage() {
const [draft, setDraft] = useState<Filters>(defaults);
const [criteria, setCriteria] = useState<Criteria>(initialCriteria);
const [formError, setFormError] = useState('');
const [detailVehicle, setDetailVehicle] = useState<OperationComplianceRow | null>(null);
const resultScroll = useRef<HTMLDivElement>(null);
useEffect(() => { if (resultScroll.current) resultScroll.current.scrollTop = 0; }, [criteria]);
const query = useQuery({ queryKey: ['operation-compliance', criteria], queryFn: ({ signal }) => api.operationCompliance(parameters(criteria), signal), staleTime: 60_000, retry: 1 });
const optionsQuery = useQuery({ queryKey: ['operation-compliance-options'], queryFn: ({ signal }) => api.operationComplianceOptions(signal), staleTime: 60_000, retry: 1 });
const options = optionsQuery.data ?? emptyOptions;
const data = query.data;
const appliedFilters: Filters = { dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, targetKm: criteria.targetKm, mileageSource: criteria.mileageSource, customers: criteria.customers, departments: criteria.departments, people: criteria.people };
const dirty = JSON.stringify(draft) !== JSON.stringify(appliedFilters);
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => setDraft(previous => ({ ...previous, [key]: value }));
function apply() {
const { dateFrom, dateTo, targetKm } = draft;
if (!dateFrom || !dateTo || dateFrom > dateTo || dateTo > dateOffset(0) || (Date.parse(dateTo) - Date.parse(dateFrom)) / 86_400_000 > 365) {
setFormError('请选择不晚于今天、最多366天的有效日期范围'); return false;
}
if (!/^[0-9]{1,15}(\.[0-9]{1,3})?$/.test(targetKm) || Number(targetKm) <= 0) {
setFormError('达标里程必须大于零,最多保留三位小数'); return false;
}
setFormError(''); setCriteria(previous => ({ ...previous, ...draft, offset: 0 })); return true;
}
function reset() { setDraft(defaults()); setCriteria(initialCriteria()); setFormError(''); }
const changeResult = (patch: Partial<Criteria>) => setCriteria(previous => ({ ...previous, ...patch, offset: 0 }));
const page = Math.floor(criteria.offset / criteria.limit) + 1;
const pages = Math.max(1, Math.ceil((data?.total ?? 0) / criteria.limit));
return <main className="oc-page" aria-label="车辆运营达标统计">
<MileageFilterPanel className="oc-filter-panel" label="运营达标筛选" summary={`${criteria.dateFrom} 至 ${criteria.dateTo} · ${operationSourceLabels[criteria.mileageSource]} · ${number.format(Number(criteria.targetKm))} km${criteria.customers.length + criteria.departments.length + criteria.people.length ? ' · 已筛选' : ''}`} onApply={apply}>
<div className="oc-filter-grid">
<label className="oc-field"><span>开始日期</span><Input aria-label="开始日期" type="date" max={dateOffset(0)} required value={draft.dateFrom} onChange={event => update('dateFrom', event.target.value)} /></label>
<label className="oc-field"><span>结束日期</span><Input aria-label="结束日期" type="date" max={dateOffset(0)} required value={draft.dateTo} onChange={event => update('dateTo', event.target.value)} /></label>
<div className="oc-field oc-target-field"><span className="oc-target-label"><label htmlFor="oc-target-km">设定达标里程 <small>km</small></label><MileageHint label="设定达标里程说明">{targetExplanation}</MileageHint></span><Input id="oc-target-km" aria-label="设定达标里程(km)" aria-describedby="oc-target-help" title={targetExplanation} type="number" min="0.001" step="0.001" required value={draft.targetKm} onChange={event => update('targetKm', event.target.value)} /><span id="oc-target-help" className="sr-only">{targetExplanation}</span></div>
<div className="oc-field"><span className="oc-target-label"><label htmlFor="oc-mileage-source">数据源</label><MileageHint label="里程数据源说明">{operationSourceDescription}</MileageHint></span><NativeSelect id="oc-mileage-source" aria-label="里程数据源" value={draft.mileageSource} onChange={event => update('mileageSource', event.target.value as OperationMileageSource)}><NativeSelectOption value="gps">GPS</NativeSelectOption><NativeSelectOption value="odometer">仪表</NativeSelectOption><NativeSelectOption value="hydrogen">纯氢里程</NativeSelectOption></NativeSelect></div>
<OperationComplianceSelect label="客户" options={options.customers} value={draft.customers} onChange={value => update('customers', value)} />
<OperationComplianceSelect label="业务部门" options={options.departments} value={draft.departments} onChange={value => update('departments', value)} />
<OperationComplianceSelect label="业务负责人" options={options.responsiblePeople} value={draft.people} onChange={value => update('people', value)} />
<div className="oc-filter-actions"><Button type="submit" disabled={query.isFetching}><Search aria-hidden="true" />查询</Button><Button type="button" variant="ghost" size="icon-sm" aria-label="重置" title="重置" onClick={reset}><RotateCcw aria-hidden="true" /></Button><Button type="button" variant="outline" size="icon-sm" aria-label="刷新" title="刷新" disabled={query.isFetching} onClick={() => { void query.refetch(); void optionsQuery.refetch(); }}><RefreshCw className={query.isFetching ? 'oc-spinning' : ''} aria-hidden="true" /></Button></div>
</div>
{dirty ? <p className="mileage-query-note" role="status">{draft.mileageSource !== criteria.mileageSource ? '数据源已修改,点击查询后按所选来源重新计算里程和达标情况' : draft.targetKm !== criteria.targetKm ? '达标里程已修改,点击查询后重新计算达标状态和达标率' : '筛选条件已修改,点击查询应用'}</p> : null}
{formError ? <p className="oc-error" role="alert"><AlertCircle aria-hidden="true" />{formError}</p> : null}
{optionsQuery.isError ? <p className="oc-error" role="alert">归属选项加载失败,已选条件仍保留。<Button type="button" size="sm" variant="ghost" onClick={() => void optionsQuery.refetch()}>重试选项</Button></p> : null}
</MileageFilterPanel>
{query.isError ? <section className="oc-empty oc-panel" role="alert"><AlertCircle aria-hidden="true" /><h2>暂时无法加载运营达标统计</h2><p>{query.error instanceof Error ? query.error.message : '请稍后重试'}</p><Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button></section> : !data ? <section className="oc-loading" role="status" aria-label="正在统计车辆运营里程"><p>正在汇总授权车辆的区间里程({operationSourceLabels[criteria.mileageSource]})…</p><div>{[0,1,2,3,4].map(value => <Skeleton className="h-24 w-full" key={value} />)}</div><Skeleton className="h-64 w-full" /></section> : <>
<section className="oc-summary" aria-label="运营达标概况">
<MileageMetric label="统计车辆" value={number.format(data.summary.total)} unit="辆" description="当前全部筛选结果" />
<MileageMetric label="达标车辆" value={number.format(data.summary.passed)} unit="辆" className="oc-metric-passed" description={`有效累计 ≥ ${number.format(data.targetKm)} km`} />
<MileageMetric label="未达标车辆" value={number.format(data.summary.failed)} unit="辆" description="数据完整且累计低于阈值" />
<MileageMetric label="数据不足" value={number.format(data.summary.insufficient)} unit="辆" description="无数据或尚不足以判断" />
<MileageMetric label="达标率" value={data.summary.passRate == null ? '—' : `${data.summary.passRate.toFixed(1)}%`} className="oc-metric-rate" description={`达标 / 可判定 ${data.summary.passed + data.summary.failed} 辆`} />
</section>
{data.assignmentStale || data.assignmentMissingCount ? <div className="oc-data-notice" role="status"><Info aria-hidden="true" /><p>{data.assignmentStale ? '归属可能过期。' : ''}{data.assignmentMissingCount ? ` ${data.assignmentMissingCount} 辆未同步归属,已保留。` : ''}可由有权限的 OneOS 用户更新。</p></div> : null}
<section className="oc-panel oc-results" aria-label="车辆达标结果">
<div className="oc-results-heading"><div className="oc-results-copy"><h2>车辆区间里程</h2><p aria-label="当前统计口径">{data.dateFrom} 至 {data.dateTo} · {operationSourceLabels[data.mileageSource]} · 当前达标标准 {number.format(data.targetKm)} km{query.isFetching ? ' · 正在更新…' : ''}</p></div><div className="oc-result-controls"><label><span>达标状态</span><NativeSelect aria-label="达标状态" value={criteria.status} onChange={event => changeResult({ status: event.target.value })}><NativeSelectOption value="">全部状态</NativeSelectOption><NativeSelectOption value="passed">达标</NativeSelectOption><NativeSelectOption value="failed">未达标</NativeSelectOption><NativeSelectOption value="insufficient">数据不足</NativeSelectOption></NativeSelect></label><label><span><ArrowDownWideNarrow aria-hidden="true" />排序</span><NativeSelect aria-label="区间里程排序" value={criteria.sort} onChange={event => changeResult({ sort: event.target.value })}><NativeSelectOption value="mileage-desc">里程从高到低</NativeSelectOption><NativeSelectOption value="mileage-asc">里程从低到高</NativeSelectOption></NativeSelect></label></div></div>
{data.items.length ? <div className="oc-table-scroll" ref={resultScroll} tabIndex={0} aria-label="车辆达标明细,可横向滚动"><Table aria-label="车辆运营达标明细"><TableHeader><TableRow><TableHead scope="col">车辆车牌</TableHead><TableHead scope="col" className="oc-number">区间里程 / km</TableHead><TableHead scope="col">客户名称</TableHead><TableHead scope="col">业务部门</TableHead><TableHead scope="col">业务员</TableHead><TableHead scope="col">达标状态</TableHead><TableHead scope="col">数据覆盖</TableHead></TableRow></TableHeader><TableBody>{data.items.map(row => <TableRow key={row.vin}>
<TableCell><button type="button" className="oc-vehicle-link" aria-label={`查看${row.plate || row.vin}每日明细`} onClick={() => setDetailVehicle(row)}>{row.plate || '未登记车牌'}<ChevronRight aria-hidden="true" /></button><small className="oc-vin">{row.vin}</small></TableCell><TableCell className="oc-number oc-mileage">{row.mileageKm == null ? '—' : number.format(Math.trunc(row.mileageKm))}</TableCell><TableCell>{row.customer || '未填写'}</TableCell><TableCell>{row.department || '未填写'}</TableCell><TableCell>{row.responsiblePerson || '未填写'}</TableCell>
<TableCell><Badge variant={row.status === 'passed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'}>{labels[row.status]}</Badge>{row.status === 'passed' && row.incomplete ? <small className="oc-incomplete">数据不完整</small> : null}</TableCell><TableCell><span className="oc-coverage-value">{row.validDays} / {row.expectedDays} 天{row.reasons.length ? <MileageHint label={`${row.plate || row.vin}数据说明`}>{row.reasons.join(';')}</MileageHint> : null}</span><small>有效 / 应统计 · 授权 {row.authorizedDays} 天</small></TableCell>
</TableRow>)}</TableBody></Table></div> : <div className="oc-empty"><Search aria-hidden="true" /><h3>{data.total ? '当前页暂无车辆' : '没有符合条件的车辆'}</h3><p>可调整客户、部门、负责人或达标状态后重新查询。</p><Button variant="outline" onClick={reset}>重置全部筛选</Button></div>}
<div className="oc-results-footer"><div className="oc-pagination" aria-label="车辆分页"><span>共 {number.format(data.total)} 辆 · 每页 {criteria.limit} 辆</span><div><Button size="sm" variant="outline" disabled={criteria.offset === 0 || query.isFetching} onClick={() => setCriteria(previous => ({ ...previous, offset: Math.max(0, previous.offset - previous.limit) }))}><ChevronLeft aria-hidden="true" />上一页</Button><span aria-label={`第${page}页,共${pages}页`}>{page} / {pages}</span><Button size="sm" variant="outline" disabled={criteria.offset + criteria.limit >= data.total || query.isFetching} onClick={() => setCriteria(previous => ({ ...previous, offset: previous.offset + previous.limit }))}>下一页<ChevronRight aria-hidden="true" /></Button></div></div><details className="oc-method oc-panel" onKeyDown={event => { if (event.key === 'Escape') event.currentTarget.open = false; }}><summary><Info aria-hidden="true" />数据口径</summary><div className="mileage-method-body"><p>按 OneOS 当前归属统计历史里程。区间里程为授权完整自然日内所选来源的有效日里程之和,包含开始日和结束日。{operationSourceDescription} 缺失、异常及未授权日期均不补为零。点击车牌查看每天的里程、采用来源和数据状态。</p><p>概况统计覆盖当前归属及达标状态筛选的全部车辆,不受分页影响。累计达到阈值为达标;覆盖完整且低于阈值为未达标;覆盖不足且累计低于阈值为数据不足。达标但数据不完整会单独标识。达标率仅以可判定车辆为分母,数据不足车辆另列。</p><p>归属来源:{data.assignmentSource === 'oneos-live' ? '当前 OneOS 授权车辆快照' : '已同步的 OneOS 归属'}。{data.assignmentObservedAt ? `归属同步时间:${new Date(data.assignmentObservedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}。` : '尚无归属同步记录。'}达标里程只影响本次查询。</p></div></details></div>
</section>
</>}
{detailVehicle ? <OperationComplianceDaily vehicle={detailVehicle} dateFrom={criteria.dateFrom} dateTo={criteria.dateTo} targetKm={criteria.targetKm} mileageSource={criteria.mileageSource} onClose={() => setDetailVehicle(null)} /> : null}
</main>;
}
@@ -0,0 +1,36 @@
import { useId, useMemo, useRef, useState } from 'react';
import { Popover } from 'radix-ui';
import { ChevronDown, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
export function OperationComplianceSelect({ label, options, value, onChange }: {
label: string; options: string[]; value: string[]; onChange: (value: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const search = useRef<HTMLDivElement>(null);
const id = useId();
const selected = useMemo(() => new Set(value), [value]);
const matches = useMemo(() => options.filter(option => option.toLocaleLowerCase().includes(keyword.trim().toLocaleLowerCase())), [options, keyword]);
const toggle = (option: string) => onChange(selected.has(option) ? value.filter(item => item !== option) : [...value, option]);
return <div className="oc-multiselect">
<span className="oc-field-label" id={`${id}-label`}>{label}</span>
<Popover.Root open={open} onOpenChange={next => { setOpen(next); if (next) setKeyword(''); }}>
<Popover.Trigger asChild><Button type="button" variant="outline" className="oc-select-trigger" aria-label={`选择${label}`}>
<span id={`${id}-value`}>{value.length ? `已选择 ${value.length} 项` : `不限${label}`}</span><ChevronDown aria-hidden="true" />
</Button></Popover.Trigger>
<Popover.Portal><Popover.Content align="start" sideOffset={6} className="oc-select-popover" aria-label={`${label}多选`} onOpenAutoFocus={event => { event.preventDefault(); search.current?.querySelector('input')?.focus(); }}>
<div className="oc-select-search" ref={search}><Search aria-hidden="true" /><Input aria-label={`搜索${label}选项`} placeholder="输入关键词搜索" value={keyword} onChange={event => setKeyword(event.target.value)} /></div>
<div className="oc-select-options" role="group" aria-label={`${label}候选项`}>
{matches.slice(0, 100).map((option, index) => <label key={option} htmlFor={`${id}-option-${index}`} className="oc-select-option"><Checkbox id={`${id}-option-${index}`} checked={selected.has(option)} onCheckedChange={() => toggle(option)} /><span>{option}</span></label>)}
{!matches.length ? <p role="status">没有匹配的选项</p> : null}
{matches.length > 100 ? <p>已显示前100项,请输入关键词缩小范围</p> : null}
</div>
<div className="oc-select-footer"><span>已选 {value.length} 项</span><Button type="button" size="sm" variant="ghost" disabled={!value.length} onClick={() => onChange([])}>清空{label}选择</Button><Button type="button" size="sm" onClick={() => setOpen(false)}>完成</Button></div>
</Popover.Content></Popover.Portal>
</Popover.Root>
{value.length ? <div className="oc-selection-chips" aria-label={`已选${label}`}>{value.map(option => <span key={option} title={option}><span>{option}</span><button type="button" aria-label={`移除${label}:${option}`} onClick={() => toggle(option)}><X aria-hidden="true" /></button></span>)}</div> : null}
</div>;
}
@@ -85,19 +85,19 @@ test('renders reconciliation queue, loads evidence on demand and records review
expect(navigation).toHaveClass('v2-ops-navigation');
expect(within(navigation).getByText('每日自动对账')).toBeInTheDocument();
expect(await within(navigation).findByText('1 项待复核')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '差异处置 1', selected: true })).toHaveClass('semi-button');
expect(screen.getByRole('tab', { name: '差异处置 1', selected: true })).toHaveAttribute('data-slot', 'tabs-trigger');
expect(screen.getByRole('tabpanel', { name: '差异处置' })).toHaveAttribute('tabindex', '0');
expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation');
expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();
expect(screen.getByText('质量问题队列').closest('.semi-card')).toHaveClass('v2-reconcile-center');
expect(screen.getByText('质量问题队列').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading');
const reconciliationMetrics = screen.getByRole('list', { name: '差异处置队列概览' });
expect(reconciliationMetrics.closest('.semi-card')).toHaveClass('v2-workspace-metric-rail', 'v2-reconcile-metric-rail');
expect(reconciliationMetrics.closest('.governance-status-strip')).toHaveClass('v2-reconcile-metric-rail');
expect(within(reconciliationMetrics).getAllByRole('listitem')).toHaveLength(4);
expect(within(reconciliationMetrics).getByText('待复核').closest('[role="listitem"]')).toHaveClass('is-primary');
expect(within(reconciliationMetrics).getByText('SLA 超时').closest('[role="listitem"]')).toHaveClass('is-danger');
expect(within(reconciliationMetrics).getByText('已自动恢复').closest('[role="listitem"]')).toHaveClass('is-secondary', 'is-success');
expect(screen.getByText('当前筛选').closest('.v2-workspace-metric-context')).toHaveTextContent('1');
expect(within(reconciliationMetrics).getByText('已自动恢复').closest('[role="listitem"]')).toHaveClass('is-success');
expect(screen.getByText('当前筛选').closest('.governance-status-context')).toHaveTextContent('1');
expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(0);
expect(screen.queryByText('活跃问题', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
@@ -107,7 +107,7 @@ test('renders reconciliation queue, loads evidence on demand and records review
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trendButton);
const trendDialog = await screen.findByRole('dialog', { name: '30 天差异趋势' });
expect(trendDialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-trend-sidesheet');
expect(trendDialog.closest('.business-sheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-trend-sidesheet');
expect(within(trendDialog).getByText('趋势明细').closest('.semi-card')).toHaveClass('v2-reconcile-trend');
expect(within(trendDialog).getByText('趋势明细').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(within(trendDialog).getByRole('list', { name: '30 天差异趋势摘要' })).toBeInTheDocument();
@@ -128,7 +128,7 @@ test('renders reconciliation queue, loads evidence on demand and records review
expect(await screen.findByText('规则证据')).toBeInTheDocument();
expect(reconcileRow).toHaveAttribute('aria-expanded', 'true');
const detailDialog = screen.getByRole('dialog', { name: '差异证据与处置' });
expect(detailDialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-detail-sidesheet');
expect(detailDialog.closest('.business-sheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-detail-sidesheet');
expect(within(detailDialog).getByRole('list', { name: '差异证据与处置摘要' })).toBeInTheDocument();
expect(screen.getByText('规则证据').closest('.semi-card')).toHaveClass('v2-reconcile-evidence');
expect(screen.getByText('复核结论').closest('.semi-card')).toHaveClass('v2-reconcile-review');
@@ -278,9 +278,9 @@ test('renders only the compact mobile reconciliation surface and defers secondar
fireEvent.click(mobileIssueAction);
expect(await screen.findByRole('dialog', { name: '差异证据与处置' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(await screen.findByRole('button', { name: '关闭差异证据与处置' })).toBeInTheDocument();
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭差异证据与处置' }));
@@ -299,8 +299,8 @@ test('renders only the compact mobile reconciliation surface and defers secondar
expect(screen.getByRole('menuitem', { name: /刷新结果/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('menuitem', { name: /30 天趋势/ }));
expect(await screen.findByRole('dialog', { name: '30 天差异趋势' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-trend-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-trend-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(72dvh, 620px)' });
expect(document.querySelector('.v2-reconcile-trend-sidesheet')).toHaveClass('business-sheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-trend-sidesheet')).toHaveStyle({ height: 'min(72dvh, 620px)' });
const trend = screen.getByText('趋势明细').closest('.v2-reconcile-trend');
expect(trend).toBeInTheDocument();
expect(screen.getByText(/最近运行/)).toBeInTheDocument();
@@ -341,9 +341,9 @@ test('reconciles service identities with bound and identity-required vehicles',
expect(screen.getByText('全部协议达到当前口径')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行概览', level: 5 })).toBeInTheDocument();
expect(screen.getByText('车辆总数 / 在线').closest('[role="listitem"]')).toHaveClass('is-secondary');
expect(screen.getByRole('list', { name: '运行健康指标' })).toHaveClass('v2-workspace-metric-list');
expect(screen.getByText('Kafka Lag').closest('.v2-workspace-metric-context')).toHaveClass('v2-workspace-metric-context');
expect(screen.getByText('车辆总数 / 在线').closest('[role="listitem"]')).toHaveClass('governance-status-item');
expect(screen.getByRole('list', { name: '运行健康指标' })).toHaveClass('governance-status-items');
expect(screen.getByText('Kafka Lag').closest('.governance-status-context')).toHaveClass('governance-status-context');
expect(screen.getByText('全部正常')).toBeInTheDocument();
expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime');
expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources');
@@ -353,7 +353,7 @@ test('reconciles service identities with bound and identity-required vehicles',
expect(document.querySelector('.v2-ops-link-card')).toHaveTextContent('MySQL 实时连接MySQL realtime主库连接正常');
expect(document.querySelector('.v2-ops-link-card.semi-card')).not.toBeInTheDocument();
expect(document.querySelector('.v2-ops-runtime-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('容量检查通过').closest('.semi-empty')).toHaveClass('v2-ops-capacity-empty');
expect(screen.getByText('容量检查通过').closest('[role="status"]')).toHaveClass('governance-capacity-ok');
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).toBeInTheDocument();
expect(screen.getByText('GB32960').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.getByLabelText('GB32960 在线覆盖率')).toBeInTheDocument();
@@ -584,13 +584,13 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
expect(screen.getByText('1 个来源')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '车辆来源诊断概览' })).toHaveClass('v2-source-summary-region');
const sourceSummary = screen.getByRole('list', { name: '车辆来源诊断概览' });
expect(sourceSummary.closest('.v2-source-summary-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
expect(sourceSummary.closest('.v2-source-summary-rail')).toHaveClass('governance-status-strip');
expect(sourceSummary.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
expect(sourceSummary).toHaveTextContent('来源状态1 / 1位置冲突 233m');
expect(sourceSummary).toHaveTextContent('当前推荐G7JT808');
expect(sourceSummary).toHaveTextContent('车辆粤A00001VIN001');
expect(sourceSummary).toHaveTextContent('策略版本v1尚无人工调整');
expect(sourceSummary.querySelector(':scope > [role="listitem"]')).toHaveClass('is-warning', 'is-primary');
expect(sourceSummary.querySelector(':scope > [role="listitem"]')).toHaveClass('is-warning');
expect(screen.getByText('推荐说明').closest('.semi-card')).toHaveClass('v2-source-recommendation');
expect(screen.getByText('最近策略审计').closest('.semi-card')).toHaveClass('v2-source-audit');
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
@@ -603,7 +603,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
expect(screen.getByText('优先级 20')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '维护策略' }));
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
expect(document.querySelector('.v2-source-policy-sidesheet .semi-sidesheet-inner')).toBeInTheDocument();
expect(document.querySelector('.v2-source-policy-sidesheet')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('G7 提供方'), { target: { value: 'G7s' } });
const save = screen.getByRole('button', { name: '保存策略' });
expect(save).toBeDisabled();
@@ -659,8 +659,8 @@ test('allows provider maintenance but keeps canonical source policy read only',
expect(filterToggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(filterToggle);
const filterDialog = await screen.findByRole('dialog', { name: '诊断车辆筛选' });
expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-source-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(78dvh, 660px)' });
expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveStyle({ height: 'min(78dvh, 660px)' });
expect(within(filterDialog).getByText('输入车牌或 VIN 开始查找')).toBeInTheDocument();
fireEvent.change(within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
const candidateButton = await waitFor(() => {
@@ -673,20 +673,20 @@ test('allows provider maintenance but keeps canonical source policy read only',
expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument();
const selectedToggle = screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' });
expect(selectedToggle).toHaveAttribute('aria-expanded', 'false');
expect(filterDialog).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(filterDialog).not.toBeInTheDocument();
fireEvent.click(selectedToggle);
const selectedSearch = within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆');
expect(selectedSearch).toHaveValue('沪A00001');
const selectedSearch = within(screen.getByRole('dialog', { name: '诊断车辆筛选' })).getByLabelText('按车牌或 VIN 搜索诊断车辆');
expect(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆')).toHaveValue('沪A00001');
fireEvent.change(selectedSearch, { target: { value: '其他车辆' } });
fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' }));
fireEvent.click(screen.getByRole('button', { name: '关闭诊断车辆筛选' }));
fireEvent.click(selectedToggle);
expect(selectedSearch).toHaveValue('沪A00001');
fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' }));
expect(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆')).toHaveValue('沪A00001');
fireEvent.click(screen.getByRole('button', { name: '关闭诊断车辆筛选' }));
expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '车辆来源诊断概览' })).toHaveClass('v2-source-summary-region');
expect(screen.getByRole('list', { name: '车辆来源诊断概览' }).closest('.v2-source-summary-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
expect(screen.getByRole('list', { name: '车辆来源诊断概览' }).closest('.v2-source-summary-rail')).toHaveClass('governance-status-strip');
expect(screen.getByRole('list', { name: '车辆来源诊断概览' }).querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
expect(screen.getByText('优先级 100')).toBeInTheDocument();
expect(screen.queryByLabelText('JT808 优先级')).not.toBeInTheDocument();
@@ -709,3 +709,34 @@ test('allows provider maintenance but keeps canonical source policy read only',
remark: ''
}));
});
test('filters the reconciliation queue directly from actionable status counts', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000 } });
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
fireEvent.click(await screen.findByRole('button', { name: '筛选超时差异' }));
await waitFor(() => expect(mocks.reconciliationIssues).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'active', sla: 'overdue', offset: 0 }), expect.any(AbortSignal)));
expect(screen.getByRole('button', { name: '筛选超时差异' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '筛选已自动恢复差异' }));
await waitFor(() => expect(mocks.reconciliationIssues).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'recovered', sla: 'all', offset: 0 }), expect.any(AbortSignal)));
expect(screen.getByTestId('operations-location')).toHaveTextContent('reconcileStatus=recovered');
expect(screen.getByTestId('operations-location')).not.toHaveTextContent('reconcileSla=overdue');
});
test('renders one actionable reconciliation empty state without a blank table', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000 } });
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
mocks.reconciliationIssues.mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
expect(await screen.findByText('当前筛选条件没有差异')).toBeInTheDocument();
const queue = screen.getByRole('region', { name: '差异队列,可上下滚动' });
expect(queue).toHaveClass('is-empty');
expect(within(queue).queryByRole('table')).not.toBeInTheDocument();
expect(within(queue).queryByText('暂无数据')).not.toBeInTheDocument();
expect(within(queue).getByRole('button', { name: /重置筛选/ })).toBeEnabled();
});
@@ -1,4 +1,5 @@
import { IconAlertTriangle, IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import './governance-workflows.css';
import { IconAlertTriangle, IconPulse, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Collapse, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
@@ -13,7 +14,7 @@ import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from './GovernanceStatusStrip';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
@@ -593,7 +594,7 @@ export default function OperationsPage() {
status: healthEvidencePending ? '读取中' : readinessUnavailable ? '协议证据缺失' : healthIssueCount > 0 ? `${healthIssueCount} 项关注` : '运行正常',
color: healthEvidencePending ? 'grey' as const : readinessUnavailable || sourceErrorCount > 0 ? 'red' as const : healthIssueCount > 0 ? 'orange' as const : 'green' as const
};
return <div className={`v2-ops-page is-${workspace}`}>
return <div className={`v2-ops-page governance-workspace is-${workspace}`}>
<MonitorReturnBar />
<WorkspaceCommandBar
className="v2-ops-command-bar"
@@ -601,7 +602,7 @@ export default function OperationsPage() {
icon={<IconPulse />}
tone="health"
title="运行与数据质量"
description="集中处理自动对账问题,按需诊断单车来源,并核对服务与协议健康;所有结论均可追溯。"
description="问题复核 / 单车来源 / 服务健康"
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
meta={<Typography.Text type="tertiary">上海时间 · {data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
@@ -670,7 +671,7 @@ export default function OperationsPage() {
{ key: 'TDengine 写探针', value: <HealthTag status={data?.tdengineWritable ? 'ok' : 'error'}>{data?.tdengineWritable ? '正常' : '异常'}</HealthTag> },
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
{ key: '高德安全代理', value: <HealthTag status={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'ok' : 'warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</HealthTag> }
]} />{capacityFindings.length ? <div className="v2-ops-capacity-findings">{capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning">待处理</HealthTag><p>{item}</p></Card>)}</div> : <PanelEmpty className="v2-ops-capacity-empty" compact tone="success" icon={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
]} />{capacityFindings.length ? <div className="v2-ops-capacity-findings">{capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning">待处理</HealthTag><p>{item}</p></Card>)}</div> : <div className="governance-capacity-ok" role="status"><HealthTag status="ok">容量检查通过</HealthTag><span>当前没有需要处理的容量风险。</span></div>}</Card></div>
</> : null}
</section>
</div>;
@@ -1,3 +1,4 @@
import './governance-workflows.css';
import { IconAlertTriangle, IconBox, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconDownload, IconHistory, IconMore, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, Checkbox, Dropdown, Input, RadioGroup, Select, Table, Tag, TextArea, Toast } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -13,7 +14,7 @@ import { PlatformTime } from '../shared/PlatformTime';
import { TablePagination } from '../shared/TablePagination';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { WorkspaceDialog } from '../shared/WorkspaceDialog';
import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail';
import { WorkspaceMetricRail } from './GovernanceStatusStrip';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { detailTriggerRow } from '../shared/detailTriggerRow';
@@ -841,12 +842,12 @@ export default function ReconciliationCenter() {
/></span>
}, ...baseColumns] : baseColumns, [allCurrentPageSelected, baseColumns, batchMode, eligibleIDs, eligibleRows.length, selectedCurrentPageCount, selectedIDs, selectedIssues.length, toggleBatchIssue, toggleCurrentPage]);
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
return <><Card className="v2-reconcile-center governance-queue" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-reconcile-heading"
tone="health"
title="质量问题队列"
description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
description="按严重程度与发现时间排序 · 点击记录复核证据"
meta="每日自动对账"
actions={mobileLayout ? <Dropdown
trigger="click"
@@ -925,12 +926,18 @@ export default function ReconciliationCenter() {
] : [
{
label: '待复核',
active: status === 'pending',
ariaLabel: '筛选待复核差异',
onClick: () => { setStatus('pending'); setSLA('all'); setOffset(0); },
value: data?.pending.toLocaleString('zh-CN') ?? '—',
note: `活跃 ${data?.active.toLocaleString('zh-CN') ?? '—'} 项`,
tone: 'primary'
},
{
label: 'SLA 超时',
active: sla === 'overdue',
ariaLabel: '筛选超时差异',
onClick: () => { setStatus('active'); setSLA('overdue'); setOffset(0); },
value: data?.overSla.toLocaleString('zh-CN') ?? '—',
note: data?.overSla ? '超过处理期限' : '当前无超时',
tone: data?.overSla ? 'danger' : 'success'
@@ -943,6 +950,9 @@ export default function ReconciliationCenter() {
},
{
label: '已自动恢复',
active: status === 'recovered',
ariaLabel: '筛选已自动恢复差异',
onClick: () => { setStatus('recovered'); setSLA('all'); setOffset(0); },
value: data?.recovered.toLocaleString('zh-CN') ?? '—',
note: '规则不再命中',
tone: 'success',
@@ -998,12 +1008,12 @@ export default function ReconciliationCenter() {
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div
className="v2-reconcile-table-wrap is-scroll-region"
className={`v2-reconcile-table-wrap is-scroll-region${!issueRows.length ? ' is-empty' : ''}`}
role="region"
aria-label="差异队列,可上下滚动"
tabIndex={0}
>
{!mobileLayout ? <Table className="v2-reconcile-table" columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: batchMode ? 1194 : 1140 }} onRow={(item) => {
{!mobileLayout && issueRows.length ? <Table className="v2-reconcile-table" empty={<></>} columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: batchMode ? 1194 : 1140 }} onRow={(item) => {
if (!item) return {} as never;
if (batchMode) return {
className: selectedIDs.has(item.id) ? 'is-batch-selected' : '',
@@ -7,7 +7,7 @@ import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
const mocks = vi.hoisted(() => ({ historyLocations: vi.fn(), mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const mocks = vi.hoisted(() => ({ historyLocations: vi.fn(), mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), mileageVehicles: vi.fn(), mileageVehicleOptions: vi.fn(), mileageVehicleResolve: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
const layout = vi.hoisted(() => ({ mobile: false }));
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
@@ -50,9 +50,9 @@ function prepareData() {
parameters: { batteryCapacityKWh: 21.04, hydrogenEnergyKWhPerKg: 16, powerOnDelaySeconds: 60, powerOffLeadSeconds: 60, refuelRiseMpa: 3, refuelSustainSeconds: 300, pureElectricDropMpa: 5, pureElectricWindowSeconds: 1800, algorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2' },
intervals: [{ index: 1, type: 'MIXED', startTime: '2026-07-13T08:01:00+08:00', endTime: '2026-07-13T10:30:00+08:00', startEventId: 'event-start', endEventId: 'event-end', source: 'factory-a', startPressureMpa: 30, endPressureMpa: 22, startTemperatureC: 30, endTemperatureC: 35, startMassKg: 11.3, endMassKg: 8.2, rawHydrogenConsumptionKg: 3.1, startSocPercent: 80, endSocPercent: 78, batteryDischargeKWh: 0.421, batteryEquivalentKg: 0.026, socBalancedConsumptionKg: 3.126, startMileageKm: 1000, endMileageKm: 1056.2, mileageKm: 56.2, consumptionKgPer100Km: 5.562, sampleCount: 120, qualityStatus: 'OK', qualityReason: '' }]
});
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }], total: 1, limit: 20, offset: 0 });
mocks.vehicleServiceOverviews.mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 });
mocks.mileageVehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.mileageVehicleOptions.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', brandName: '现代', modelName: 'XCIENT' }], total: 1, limit: 20, offset: 0 });
mocks.mileageVehicleResolve.mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 });
}
test('parses Excel cells and common separators while removing headers and duplicates', () => {
@@ -376,7 +376,7 @@ test('routes vertical wheel input from the mileage workspace to the table withou
test('supports searching and selecting license plates before querying exact VINs', async () => {
prepareData();
mocks.vehicles.mockResolvedValue({
mocks.mileageVehicles.mockResolvedValue({
items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' }
@@ -403,7 +403,7 @@ test('supports searching and selecting license plates before querying exact VINs
test('resolves Excel-pasted plates in batches and queries the resulting VIN array', async () => {
prepareData();
mocks.vehicleServiceOverviews.mockResolvedValue({
mocks.mileageVehicleResolve.mockResolvedValue({
items: [
{ vin: 'LTEST000000000001', plate: '粤A12345' },
{ vin: 'LTEST000000000002', plate: '粤B67890' }
@@ -420,7 +420,7 @@ test('resolves Excel-pasted plates in batches and queries the resulting VIN arra
expect(screen.getByRole('button', { name: '解析并添加(2)' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '解析并添加(2)' }));
await waitFor(() => expect(mocks.vehicleServiceOverviews).toHaveBeenCalledWith({
await waitFor(() => expect(mocks.mileageVehicleResolve).toHaveBeenCalledWith({
keywords: ['粤A12345', '粤B67890'], limit: 200, offset: 0
}));
expect(await screen.findByText(/已新增 2 辆/)).toBeInTheDocument();
@@ -433,7 +433,7 @@ test('resolves Excel-pasted plates in batches and queries the resulting VIN arra
test('shows and recovers from a failed license plate candidate query', async () => {
prepareData();
mocks.vehicles.mockRejectedValueOnce(new Error('车辆目录查询超时')).mockResolvedValueOnce({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 12, offset: 0 });
mocks.mileageVehicles.mockRejectedValueOnce(new Error('车辆目录查询超时')).mockResolvedValueOnce({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 12, offset: 0 });
renderPage();
fireEvent.focus(screen.getByRole('textbox', { name: '搜索车牌' }));
@@ -443,7 +443,7 @@ test('shows and recovers from a failed license plate candidate query', async ()
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(mocks.vehicles).toHaveBeenCalledTimes(2);
expect(mocks.mileageVehicles).toHaveBeenCalledTimes(2);
});
test('keeps mileage source strategy anchored to its desktop trigger and persists priority changes', async () => {
@@ -485,8 +485,8 @@ test('uses a compact bottom sheet for mileage source strategy on mobile', async
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-source-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-mileage-source-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(58dvh, 500px)' });
expect(document.querySelector('.v2-mileage-source-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-mileage-source-sidesheet')).toHaveStyle({ height: 'min(58dvh, 500px)' });
});
test('opens mobile mileage filters in a bottom task sheet without displacing the data canvas', async () => {
@@ -503,15 +503,15 @@ test('opens mobile mileage filters in a bottom task sheet without displacing the
fireEvent.click(screen.getByRole('button', { name: /修改里程范围/ }));
expect(await screen.findByRole('dialog', { name: '里程范围筛选' })).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-mileage-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(document.querySelector('.v2-mileage-filter-sidesheet')).toHaveClass('business-sheet-bottom');
expect(document.querySelector('.v2-mileage-filter-sidesheet')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(screen.getByRole('textbox', { name: '搜索车牌' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '应用并查询' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭里程范围筛选' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭里程范围筛选' }));
expect(screen.getByRole('dialog', { name: '里程范围筛选' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(screen.queryByRole('dialog', { name: '里程范围筛选' })).not.toBeInTheDocument();
expect(screen.getByText('车辆每日里程')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /修改里程范围/ })).toHaveAttribute('aria-expanded', 'false');
});
@@ -520,7 +520,7 @@ test('paginates all unique vehicles when no license plate is selected', async ()
prepareData();
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
const secondPage = Array.from({ length: 12 }, (_, index) => ({ vin: `VIN${String(index + 21).padStart(14, '0')}`, plate: `粤A${String(index + 21).padStart(5, '0')}` }));
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve({
mocks.mileageVehicleOptions.mockImplementation((params: URLSearchParams) => Promise.resolve({
items: params.get('offset') === '20' ? secondPage : firstPage,
total: 32,
limit: 20,
@@ -534,14 +534,14 @@ test('paginates all unique vehicles when no license plate is selected', async ()
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('1/2');
expect(screen.getByText('3% 覆盖 · 未上报不计为 0')).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('有里程车辆1 / 32 辆3% 覆盖 · 未上报不计为 0');
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
expect(mocks.mileageVehicleOptions.mock.calls[0][0].get('bindingStatus')).toBe('bound');
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].vins).toContain('VIN00000000000001'));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('2/2');
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
await waitFor(() => expect(mocks.mileageVehicleOptions.mock.calls[mocks.mileageVehicleOptions.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
});
test('exports the full fleet mileage with fleet-scoped time windows instead of VIN batches', async () => {
@@ -568,7 +568,7 @@ test('exports the full fleet mileage with fleet-scoped time windows instead of V
expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]);
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1);
expect(exportMocks.finish.mock.calls[0][0][0]).toMatchObject({ brandName: '现代', modelName: 'XCIENT' });
expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(mocks.mileageVehicleOptions.mock.calls[mocks.mileageVehicleOptions.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
@@ -582,7 +582,7 @@ test('loads archive brand and model before exporting selected vehicles', async (
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
const archiveCall = mocks.vehicleCoverage.mock.calls.find(([params]) => params.get('keywords') === 'LTEST000000000001');
const archiveCall = mocks.mileageVehicleOptions.mock.calls.find(([params]) => params.get('keywords') === 'LTEST000000000001');
expect(archiveCall?.[0].get('bindingStatus')).toBe('bound');
expect(archiveCall?.[1]).toBeInstanceOf(AbortSignal);
expect(exportMocks.finish.mock.calls[0][0]).toEqual([
@@ -593,7 +593,7 @@ test('loads archive brand and model before exporting selected vehicles', async (
test('splits a 1024-vehicle full-fleet export into bounded time windows', async () => {
prepareData();
const fleet = Array.from({ length: 1024 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve(params.get('limit') === String(2_000)
mocks.mileageVehicleOptions.mockImplementation((params: URLSearchParams) => Promise.resolve(params.get('limit') === String(2_000)
? { items: fleet, total: fleet.length, limit: fleet.length, offset: 0 }
: { items: fleet.slice(0, 20), total: fleet.length, limit: 20, offset: 0 }));
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
@@ -614,7 +614,7 @@ test('splits a 1024-vehicle full-fleet export into bounded time windows', async
test('shows export progress and lets the user cancel the active request', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
mocks.mileageVehicleOptions.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
if (params.get('limit') !== String(2_000)) {
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
@@ -639,7 +639,7 @@ test('shows export progress and lets the user cancel the active request', async
test('aborts an active export when the mileage route unmounts', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
mocks.mileageVehicleOptions.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
if (params.get('limit') !== String(2_000)) {
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
@@ -669,3 +669,19 @@ test('exports stored daily geography without querying history or geocoding', asy
expect(exportMocks.appendRows).toHaveBeenCalledWith([row]);
expect(mocks.historyLocations).not.toHaveBeenCalled();
});
test('keeps applied results stable while resetting draft criteria and restores the applied scope', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText('104.6 km')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(screen.getByText('查询条件已修改,下方仍为已应用范围的结果。')).toBeInTheDocument();
expect(mocks.dailyMileage).toHaveBeenCalledTimes(1);
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: '恢复已应用条件' }));
expect(screen.queryByText('查询条件已修改,下方仍为已应用范围的结果。')).not.toBeInTheDocument();
expect(mocks.dailyMileage).toHaveBeenCalledTimes(1);
});
@@ -1,3 +1,4 @@
import '../styles/data-workflows.css';
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
@@ -369,7 +370,7 @@ async function resolveMileageVehicles(identifiers: string[]) {
}
for (let start = 0; start < plates.length; start += BATCH_RESOLVE_SIZE * 4) {
const group = Array.from({ length: 4 }, (_, index) => plates.slice(start + index * BATCH_RESOLVE_SIZE, start + (index + 1) * BATCH_RESOLVE_SIZE)).filter((batch) => batch.length);
const pages = await Promise.all(group.map((keywords) => api.vehicleServiceOverviews({ keywords, limit: BATCH_RESOLVE_SIZE, offset: 0 })));
const pages = await Promise.all(group.map((keywords) => api.mileageVehicleResolve({ keywords, limit: BATCH_RESOLVE_SIZE, offset: 0 })));
pages.forEach((page, pageIndex) => {
const keywords = group[pageIndex];
keywords.forEach((keyword, index) => {
@@ -473,7 +474,7 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
}, [debounced]);
const candidates = useQuery({
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
queryFn: ({ signal }) => api.mileageVehicles(candidateParams, signal),
enabled: open,
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
@@ -481,7 +482,7 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const add = (vehicle: VehicleRow) => {
const add = (vehicle: VehicleOption) => {
if (selected.has(vehicle.vin) || value.length >= MAX_MILEAGE_VEHICLES) return;
onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]);
setSearch('');
@@ -993,7 +994,7 @@ export default function StatisticsPage() {
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
const fleetVehicles = useQuery({
queryKey: ['mileage-fleet-page', fleetParams.toString()],
queryFn: ({ signal }) => api.vehicleCoverage(fleetParams, signal),
queryFn: ({ signal }) => api.mileageVehicleOptions(fleetParams, signal),
enabled: !hasVehicles,
staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
@@ -1176,7 +1177,7 @@ export default function StatisticsPage() {
reportProgress({ label: '正在读取车辆档案', completed: 0, total: criteria.vehicles.length });
for (let start = 0; start < criteria.vehicles.length; start += BATCH_RESOLVE_SIZE) {
const batch = criteria.vehicles.slice(start, start + BATCH_RESOLVE_SIZE);
const result = await api.vehicleCoverage(new URLSearchParams({
const result = await api.mileageVehicleOptions(new URLSearchParams({
keywords: batch.map((vehicle) => vehicle.vin).join(','),
limit: String(batch.length),
offset: '0',
@@ -1196,7 +1197,7 @@ export default function StatisticsPage() {
let offset = 0;
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
while (offset < totalVehicles) {
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
const result = await api.mileageVehicleOptions(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
vehicles.push(...result.items.map((vehicle) => ({
vin: vehicle.vin,
plate: vehicle.plate,
@@ -1270,6 +1271,8 @@ export default function StatisticsPage() {
exportControllerRef.current.abort();
};
const draftDirty = JSON.stringify(mileageQuery(draft)) !== JSON.stringify(mileageQuery(criteria))
|| draft.sources.map((source) => `${source.protocol}:${source.enabled}`).join(',') !== criteria.sources.map((source) => `${source.protocol}:${source.enabled}`).join(',');
const rangeSummary = criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天`;
const filterForm = <form className="v2-mileage-filter" onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
@@ -1291,13 +1294,13 @@ export default function StatisticsPage() {
}}
/>
</label>
<Button className="v2-primary-button v2-mileage-inline-submit" theme="solid" htmlType="submit">查询</Button>
<div className="v2-data-query-actions"><Button className="v2-primary-button v2-mileage-inline-submit" theme="solid" htmlType="submit">查询</Button><Button theme="light" htmlType="button" onClick={resetDraft}>重置</Button></div>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<MileageRangeShortcuts value={draft} ranges={quickRanges} onChange={setRange} />
</form>;
const filterValidation = validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null;
return <div className="v2-mileage-page" ref={pageRef}>
return <div className="v2-mileage-page v2-data-workflow" ref={pageRef}>
<MonitorReturnBar />
<div className="v2-mileage-discovery-shell">
{mobileLayout ? <div className="v2-mileage-mobile-discovery">
@@ -1310,8 +1313,6 @@ export default function StatisticsPage() {
collapsedLabel="修改"
onToggle={toggleFilters}
/>
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
<MobileFilterSheet
className="v2-mileage-filter-sidesheet"
visible={mobileFiltersOpen}
@@ -1343,10 +1344,9 @@ export default function StatisticsPage() {
>
{filterForm}
{filterValidation}
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
</WorkspaceFilterPanel>}
</div>
{draftDirty ? <section className="v2-data-draft-notice" role="status"><span>查询条件已修改,下方仍为已应用范围的结果。</span><Button size="small" theme="borderless" htmlType="button" onClick={() => { setDraft(criteria); setValidationError(''); }}>恢复已应用条件</Button></section> : null}
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title={metricView === 'mileage' ? '车辆每日里程' : '车辆每日氢耗'}
@@ -1355,6 +1355,10 @@ export default function StatisticsPage() {
actionsClassName="v2-mileage-result-actions"
actions={<><Button className="is-refresh" theme="borderless" aria-label="刷新里程数据" icon={<IconRefresh />} onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}>{refreshing ? '更新中' : '刷新'}</Button><Button theme="light" aria-label={isExporting ? '取消导出' : '导出 Excel'} icon={isExporting ? <IconClose /> : <IconDownload />} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? '取消导出' : '导出 Excel'}</Button></>}
/>
<div className="v2-data-result-overview">
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
</div>
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
{exportPercent == null
@@ -268,6 +268,12 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2));
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '跳转到下一个事件' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '2');
expect(screen.getByRole('button', { name: '跳转到下一个事件' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '跳转到上一个事件' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1');
fireEvent.click(screen.getByRole('tab', { name: '概览' }));
expect(view.container.querySelectorAll('.v2-track-overview-section')).toHaveLength(3);
expect(view.container.querySelectorAll('.v2-track-overview-section.semi-card')).toHaveLength(3);
@@ -386,13 +392,13 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden
fireEvent.click(screen.getByRole('button', { name: '打开轨迹详情' }));
const sheet = await waitFor(() => {
const element = document.querySelector('.v2-track-detail-sidesheet .semi-sidesheet-inner');
const element = document.querySelector('.v2-track-detail-sidesheet');
expect(element).toHaveAttribute('aria-label', '轨迹查询与明细');
return element;
});
expect(sheet).toHaveClass('semi-sidesheet-inner');
expect(sheet).toHaveClass('business-sheet');
expect(sheet).toHaveAttribute('id', 'v2-track-mobile-workbench');
expect(document.querySelector('.v2-track-detail-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet', 'semi-sidesheet-bottom');
expect(document.querySelector('.v2-track-detail-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet', 'business-sheet-bottom');
expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-rail')).toBeInTheDocument();
expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-query-summary')).not.toBeInTheDocument();
expect(screen.queryByLabelText('轨迹查询任务栏')).not.toBeInTheDocument();
@@ -417,7 +423,7 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden
fireEvent.click(screen.getByRole('button', { name: '修改轨迹查询' }));
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
expect(screen.getByRole('button', { name: '收起轨迹查询' })).toHaveTextContent('收起查询');
expect(sheet).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(screen.getByRole('dialog', { name: '轨迹查询与明细' })).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(screen.getByRole('button', { name: '关闭轨迹查询与明细' })).toBeInTheDocument();
});
@@ -446,7 +452,7 @@ test('keeps the mobile track canvas focused when the viewport crosses the respon
});
expect(view.container.querySelector('.v2-track-page')).toHaveClass('is-mobile-layout', 'is-rail-collapsed');
expect(document.querySelector('.v2-track-detail-sidesheet .semi-sidesheet-animation-content_hide_bottom')).toBeInTheDocument();
expect(document.querySelector('.v2-track-detail-sidesheet')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择车辆/ })).toBeInTheDocument();
});
@@ -485,3 +491,31 @@ test('coalesces rapid track scrubbing into one address lookup for the final poin
expect(mocks.reverseGeocode.mock.calls[1][0].get('longitude')).toBe('113.3000');
expect(mocks.reverseGeocode.mock.calls[1][0].get('latitude')).toBe('23.3000');
});
test('resets playback when switching cached queries with the same snapshot timestamp', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
const first = 'LTEST000000000001';
const second = 'LTEST000000000002';
const dateFrom = '2026-07-15T00:00';
const dateTo = '2026-07-15T23:59';
for (const keyword of [first, second]) {
const params = new URLSearchParams({ keyword, maxPoints: '1600', dateFrom, dateTo });
client.setQueryData(['track-playback', params.toString()], { ...track, vin: keyword, plate: keyword === first ? '粤A12345' : '粤B12345' });
}
function CachedNavigation() {
const navigate = useNavigate();
return <><button onClick={() => navigate(`/tracks?${new URLSearchParams({ keyword: second, dateFrom, dateTo })}`)}>切换缓存轨迹</button><TrackPage /></>;
}
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[`/tracks?${new URLSearchParams({ keyword: first, dateFrom, dateTo })}`]}><CachedNavigation /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByTestId('track-map')).toHaveAttribute('data-active-index', '0');
fireEvent.click(screen.getByRole('button', { name: '跳转到下一个事件' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1');
fireEvent.click(screen.getByRole('button', { name: '开始轨迹播放' }));
expect(screen.getByRole('button', { name: '暂停轨迹播放' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '切换缓存轨迹' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
expect(screen.getByRole('button', { name: '开始轨迹播放' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '跳转到上一个事件' })).toBeDisabled();
expect(mocks.trackPlayback).not.toHaveBeenCalled();
});
@@ -1,3 +1,4 @@
import './fleet-workflows.css';
import { useQuery } from '@tanstack/react-query';
import {
IconCalendar, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
@@ -403,7 +404,8 @@ export default function TrackPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria]);
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: ({ signal }) => api.trackPlayback(params, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const playbackScope = params.toString();
const query = useQuery({ queryKey: ['track-playback', playbackScope], enabled: Boolean(criteria.keyword), queryFn: ({ signal }) => api.trackPlayback(params, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const track = query.data;
const points = track?.points ?? [];
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
@@ -437,7 +439,7 @@ export default function TrackPage() {
if (previousMobileLayout === mobileLayout || track?.points.length) return;
setRailCollapsed(mobileLayout);
}, [mobileLayout, track?.points.length]);
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 960px)').matches); }, [track?.asOf]);
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 960px)').matches); }, [playbackScope, track?.asOf]);
useEffect(() => {
if (!playing || points.length < 2) return;
lastFrameRef.current = 0;
@@ -521,7 +523,7 @@ export default function TrackPage() {
: '地图会呈现完整路径、停留点、事件点与逐帧播放位置。';
const activeVehicleLabel = track?.plate || criteria.keyword.trim() || '选择车辆';
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn || vehicleReturn ? ' has-monitor-return' : ''}`}>
return <div className={`fleet-workflow v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn || vehicleReturn ? ' has-monitor-return' : ''}`}>
<MonitorReturnBar />
{mobileLayout ? <WorkspaceSideSheet
className="v2-track-detail-sidesheet"
@@ -574,6 +576,14 @@ export default function TrackPage() {
{track?.points.length ? <div className="v2-track-stage-tools" role="toolbar" aria-label="地图轨迹工具">
<Button theme="borderless" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} icon={<IconMapPin />} disabled={!points.length} onClick={() => setFollow((value) => !value)}>{follow ? '跟随车辆' : '自由浏览'}</Button>
<Button theme="borderless" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} icon={showStops ? <IconEyeOpened /> : <IconEyeClosed />} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>停留点</Button>
<Button theme="borderless" aria-label="跳转到上一个事件" icon={<IconChevronLeft />} disabled={!track.events.some((event) => sampledEventIndex(event, points.length, track.summary.pointCount) < boundedIndex)} onClick={() => {
const previous = track.events.map((event) => sampledEventIndex(event, points.length, track.summary.pointCount)).filter((index) => index < boundedIndex);
if (previous.length) { selectIndex(Math.max(...previous)); setPanelTab('events'); }
}}>上一事件</Button>
<Button theme="borderless" aria-label="跳转到下一个事件" icon={<IconChevronRight />} disabled={!track.events.some((event) => sampledEventIndex(event, points.length, track.summary.pointCount) > boundedIndex)} onClick={() => {
const next = track.events.map((event) => sampledEventIndex(event, points.length, track.summary.pointCount)).filter((index) => index > boundedIndex);
if (next.length) { selectIndex(Math.min(...next)); setPanelTab('events'); }
}}>下一事件</Button>
<Button theme="borderless" aria-label="导出当前回放点 CSV" icon={<IconDownload />} disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}>导出</Button>
</div> : null}
@@ -163,11 +163,9 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
expect(screen.getByLabelText('账号管理操作')).toHaveTextContent('变更最多 30 秒生效');
expect(view.container.querySelector('.v2-page-heading')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-user-discovery-shell')).toContainElement(screen.getByLabelText('账号管理操作'));
expect(screen.getByRole('heading', { name: '账号范围', level: 5 })).toBeInTheDocument();
expect(screen.getByText('按客户名称、登录账号、权限状态与身份来源查找')).toBeInTheDocument();
expect(screen.getByText('1 / 1 个账号')).toBeInTheDocument();
expect(screen.getByRole('search', { name: '客户账号筛选' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-list.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-filter-panel.v2-workspace-filter-panel.semi-card')).toBeInTheDocument();
expect(screen.getByRole('search', { name: '客户账号筛选' })).toContainElement(screen.getByRole('textbox', { name: '搜索客户账号' }));
expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument();
const directoryMetrics = screen.getByRole('list', { name: '客户权限目录统计' });
expect(directoryMetrics.closest('.v2-user-directory-metric-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
@@ -188,23 +186,23 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
expect(accountSummary).toHaveTextContent('登录账号@customer-east平台本地账号 · 凭据可维护');
expect(accountSummary).toHaveTextContent('权限范围1 菜单 · 1 辆最小必要权限');
expect(accountSummary).toHaveTextContent('账号状态启用保存后最多 30 秒生效');
const editorSheet = document.querySelector<HTMLElement>('.v2-user-editor-sidesheet .semi-sidesheet-inner');
const editorSheet = document.querySelector<HTMLElement>('.v2-user-editor-sidesheet');
expect(editorSheet).toHaveAttribute('aria-label', '客户账号详情');
expect(editorSheet).toHaveStyle({ width: 'min(820px, 100vw)' });
expect(editorSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet');
expect(editorSheet?.closest('.business-sheet')).toHaveClass('v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-user-editor-sidesheet .v2-user-editor-tabs.semi-tabs')).toBeInTheDocument();
expect(document.querySelector('.v2-user-editor-form')).toHaveAttribute('id', 'v2-user-editor-form');
expect(screen.getAllByRole('tab')).toHaveLength(3);
expect(screen.getByRole('tab', { name: '登录身份' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByLabelText('登录身份摘要')).toHaveTextContent('登录账号customer-east不可修改');
expect(screen.getByLabelText('登录身份摘要')).toHaveTextContent('客户显示名称华东客户已设置');
expect(screen.getByRole('textbox', { name: '用户名' })).toBeDisabled();
expect(screen.getByRole('textbox', { name: '客户名称' })).toHaveValue('华东客户');
expect(screen.getByRole('group', { name: '基础身份' })).toHaveTextContent('用于客户登录和平台内展示');
expect(screen.getByRole('group', { name: '安全与系统映射' })).toHaveTextContent('密码留空则保持不变');
fireEvent.click(screen.getByRole('tab', { name: /菜单权限/ }));
expect(screen.getByText('客户开放菜单').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getByLabelText('已开放 1 个菜单,共 4 个')).toHaveTextContent('客户工作台按最小权限开放');
expect(screen.getByLabelText('已开放 1 个菜单,共 6 个')).toHaveTextContent('客户工作台按最小权限开放');
expect(screen.getAllByText('已开放')).toHaveLength(1);
expect(screen.getAllByText('未开放')).toHaveLength(3);
expect(screen.getAllByText('未开放')).toHaveLength(5);
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
expect(document.querySelector('.v2-user-vehicle-section .v2-user-section-title .semi-tag')).toHaveTextContent('1 辆');
expect(screen.getByText('添加授权车辆')).toBeInTheDocument();
@@ -223,9 +221,9 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
expect(await screen.findByRole('textbox', { name: '粤A11111 停用时间' })).toBeInTheDocument();
expect(document.querySelectorAll('.v2-workspace-date-time-field.semi-datepicker')).toHaveLength(2);
expect(screen.getByRole('button', { name: '完成' })).toBeDisabled();
const grantSheet = document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner');
const grantSheet = document.querySelector('.v2-user-grant-sidesheet');
expect(grantSheet).toHaveAttribute('aria-label', '车辆授权有效期');
expect(grantSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-config-sidesheet');
expect(grantSheet?.closest('.business-sheet')).toHaveClass('v2-workspace-config-sidesheet');
const grantSummary = screen.getByRole('list', { name: '车辆授权有效期摘要' });
expect(grantSummary).toHaveTextContent('车牌粤A11111');
expect(grantSummary).toHaveTextContent('启用2026-06-05 00:00授权开始');
@@ -441,9 +439,9 @@ test('keeps the customer directory visible on mobile and opens details on demand
const filterToggle = screen.getByRole('button', { name: '修改账号范围:全部状态' });
expect(filterToggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(filterToggle);
expect(screen.getByRole('button', { name: '关闭账号范围:全部状态' })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: '关闭账号范围:全部状态', hidden: true })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toBeInTheDocument();
expect(document.querySelector('.v2-user-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveAttribute('aria-label', '客户账号筛选');
expect(document.querySelector('.v2-user-mobile-filter-sidesheet')).toHaveAttribute('aria-label', '客户账号筛选');
expect(document.querySelector('.v2-user-filter-panel')).not.toBeInTheDocument();
expect(document.querySelector('.v2-user-editor-sidesheet')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭客户账号筛选' }));
@@ -486,7 +484,7 @@ test('guides new customers through an explicit least-privilege creation flow', a
fireEvent.click(identityNext);
expect(screen.getByRole('tab', { name: /菜单权限 0/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
expect(screen.getAllByRole('checkbox')).toHaveLength(6);
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
fireEvent.click(screen.getByRole('checkbox', { name: /全局监控/ }));
fireEvent.click(screen.getByRole('button', { name: '下一步:车辆权限' }));
@@ -522,33 +520,33 @@ test('applies mobile customer filters explicitly and discards unconfirmed draft
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderPage(client);
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /选择客户 华东客户/, hidden: true })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 西部客户/, hidden: true })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改账号范围:全部状态' }));
fireEvent.change(screen.getByRole('textbox', { name: '搜索客户账号' }), { target: { value: '西部' } });
expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 华东客户/, hidden: true })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 西部客户/, hidden: true })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭客户账号筛选' }));
await waitFor(() => expect(screen.getByRole('button', { name: '修改账号范围:全部状态' })).toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选择客户 华东客户/, hidden: true })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改账号范围:全部状态' }));
expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toHaveValue('');
fireEvent.change(screen.getByRole('textbox', { name: '搜索客户账号' }), { target: { value: '西部' } });
fireEvent.click(screen.getByRole('button', { name: '应用筛选' }));
await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 华东客户/ })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 华东客户/, hidden: true })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 西部客户/, hidden: true })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '修改账号范围:全部状态 · 西部' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改账号范围:全部状态 · 西部' }));
fireEvent.click(screen.getByRole('button', { name: '重置条件' }));
expect(screen.getByRole('dialog', { name: '客户账号筛选' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭账号范围:全部状态 · 西部' })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: '关闭账号范围:全部状态 · 西部', hidden: true })).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '应用筛选' }));
await waitFor(() => expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: /选择客户 华东客户/, hidden: true })).toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 西部客户/, hidden: true })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '修改账号范围:全部状态' })).toBeInTheDocument();
});
@@ -581,7 +579,7 @@ test('keeps granted vehicles primary and reveals mobile assignment tools on dema
expect(screen.getByText('第 1–4 条,共 5 辆')).toBeInTheDocument();
expect(document.querySelectorAll('.v2-assigned-vehicle-card.is-mobile-compact')).toHaveLength(4);
expect(document.querySelector('.v2-assigned-vehicle-interval')).toHaveAttribute('aria-label', '授权有效期:启用 2026-06-05 00:00;停用 持续有效');
expect(document.querySelector('.v2-user-editor-sidesheet .v2-workspace-config-title small')).toHaveTextContent('@customer-east · 最近登录');
expect(document.querySelector('.v2-user-editor-sidesheet .business-dialog-heading [data-slot="sheet-description"]')).toHaveTextContent('@customer-east · 最近登录');
fireEvent.click(toggle);
@@ -620,7 +618,6 @@ test('filters the customer directory by status and exposes the active result sco
await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 华东客户/ })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
expect(screen.getByText('1 / 2 个账号')).toBeInTheDocument();
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('当前账号1 / 2筛选结果 / 全部客户');
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('权限就绪0启用且权限完整');
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('外部身份0当前范围已连接');
@@ -12,7 +12,6 @@ import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
@@ -22,8 +21,11 @@ import { WorkspaceDateTimeField } from '../shared/WorkspaceDateTimeField';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
import '../styles/users.css';
import '../styles/user-workflow.css';
const customerMenus = [
{ key: 'operation-compliance', label: '车辆运营达标统计', description: '按客户、部门和负责人筛选授权车辆,切换 GPS/仪表/纯氢里程统计达标情况并查看每日里程' },
{ key: 'customer-mileage', label: '客户运营里程', description: '查看客户车辆每日 GPS、纯氢里程及偏差预警' },
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
@@ -275,7 +277,6 @@ export default function UsersPage() {
const disabledCustomerCount = useMemo(() => customers.filter((user) => user.status === 'disabled').length, [customers]);
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).key === 'ready').length, [visibleCustomers]);
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).attention).length, [visibleCustomers]);
const disabledCustomers = useMemo(() => visibleCustomers.filter((user) => user.status === 'disabled').length, [visibleCustomers]);
const externalCustomers = useMemo(() => visibleCustomers.filter((user) => authProviderMeta(user.authProvider, user.externalSubject).external), [visibleCustomers]);
const externalIdentityPending = useMemo(() => externalCustomers.filter(identityNeedsAttention).length, [externalCustomers]);
const localCustomerCount = customers.length - customers.filter((user) => authProviderMeta(user.authProvider, user.externalSubject).external).length;
@@ -291,6 +292,9 @@ export default function UsersPage() {
label: '待完善',
value: attentionCustomers,
note: '身份、菜单或车辆待完善',
ariaLabel: '筛选待完善权限账号',
active: customerStatus === 'attention',
onClick: () => { setCustomerStatus('attention'); setUserRoute({ status: 'attention', page: 1 }, true); },
tone: attentionCustomers ? 'warning' : 'success',
emphasis: 'primary'
},
@@ -749,7 +753,7 @@ export default function UsersPage() {
setUserRoute({ page: directoryTotalPages }, true);
}, [directoryTotalPages, routeView.page, users.isSuccess]);
return <div className="v2-user-admin">
return <div className="v2-user-admin user-workflow">
<div className="v2-user-discovery-shell">
<WorkspaceCommandBar
className="v2-user-command-bar"
@@ -805,17 +809,7 @@ export default function UsersPage() {
</MobileFilterSheetSection>
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-user-filter-panel"
title="账号范围"
description="按客户名称、登录账号、权限状态与身份来源查找"
mobileSummary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
expanded
status={users.isPending ? '正在读取账号' : `${visibleCustomers.length} / ${customers.length} 个账号`}
statusColor={customerKeyword.trim() || customerStatus !== 'all' ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
>
</div> : <div className="v2-user-filter-panel" role="search" aria-label="客户账号筛选">
<div className="v2-user-filter-form">
<label className="v2-user-filter-keyword">
<span>客户账号</span>
@@ -853,19 +847,15 @@ export default function UsersPage() {
清空
</Button>
</div>
</WorkspaceFilterPanel>}
</div>}
</div>
<div className={`v2-user-admin-grid${creating || selected ? ' is-editor-open' : ''}`}>
<Card className="v2-user-list" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-user-directory-header"
title="客户权限目录"
description="当前范围内的账号、菜单与车辆授权摘要"
meta={<span className="v2-user-directory-glance" aria-label={`共 ${visibleCustomers.length} 个账号,${readyCustomers} 个权限就绪,${disabledCustomers} 个已停用`}>
<b>{visibleCustomers.length}</b> 个账号
<i aria-hidden="true" />
<span className={attentionCustomers ? 'has-attention' : ''}>{attentionCustomers ? `${attentionCustomers} 个待完善` : disabledCustomers ? `${readyCustomers} 就绪 · ${disabledCustomers} 停用` : '全部权限就绪'}</span>
</span>}
description=""
/>
<WorkspaceMetricRail
variant="queue"
@@ -1108,20 +1098,6 @@ export default function UsersPage() {
}}>
<Tabs.TabPane tab={<span className="v2-user-tab-label"><i aria-hidden="true">1</i>登录身份</span>} itemKey="identity">
<Card className="v2-user-editor-section v2-user-identity-section" title="登录身份" headerLine>
<div className="v2-user-identity-overview" aria-label="登录身份摘要">
<div>
<span><small>登录账号</small><strong>{draft.username || '待设置'}</strong></span>
<Tag color={creating ? 'blue' : 'grey'} type="light" size="small">{creating ? '新账号' : '不可修改'}</Tag>
</div>
<div>
<span><small>客户显示名称</small><strong>{draft.displayName || '待设置'}</strong></span>
<Tag color={draft.displayName ? 'green' : 'amber'} type="light" size="small">{draft.displayName ? '已设置' : '待完善'}</Tag>
</div>
<div>
<span><small>身份来源</small><strong>{creating ? '平台本地账号' : selectedIdentityProvider.sourceLabel}</strong></span>
<Tag color={selectedIdentityProvider.mappingComplete ? (selectedIdentityProvider.external ? 'cyan' : 'grey') : 'amber'} type="light" size="small">{creating ? '创建本地凭据' : selectedIdentityProvider.stateLabel}</Tag>
</div>
</div>
<section
className={`v2-user-identity-source${selectedIdentityProvider.external ? ' is-external' : ' is-local'}${!selectedIdentityProvider.mappingComplete ? ' has-warning' : ''}`}
role={!selectedIdentityProvider.mappingComplete ? 'alert' : 'status'}
@@ -1131,11 +1107,11 @@ export default function UsersPage() {
<span><strong>{creating ? '平台本地身份' : selectedIdentityProvider.sourceLabel}</strong><small>{selectedIdentityProvider.external ? '外部身份只负责登录,平台权限仍在此维护' : '登录凭据和账号状态由本平台维护'}</small></span>
<Tag color={!selectedIdentityProvider.mappingComplete ? 'amber' : selectedIdentityProvider.external ? 'cyan' : 'green'} type="light" size="small">{creating ? '待创建' : selectedIdentityProvider.stateLabel}</Tag>
</header>
<div>
{selectedIdentityProvider.external ? <div>
<span><small>身份源</small><strong>{creating ? '本平台' : selectedIdentityProvider.sourceLabel}</strong></span>
<span><small>身份映射</small><strong>{creating ? '创建后生成' : selectedIdentityProvider.external ? maskExternalSubject(selected?.externalSubject) : `@${draft.username || '待设置'}`}</strong></span>
<span><small>凭据管理</small><strong>{creating ? '本平台' : selectedIdentityProvider.credentialOwner}</strong></span>
</div>
</div> : null}
<p>{!selectedIdentityProvider.mappingComplete
? `该账号缺少 ${selectedIdentityProvider.sourceLabel} 身份映射,当前无法通过外部身份源登录。请在上游目录或受信任身份适配器中补齐映射;菜单与车辆权限可继续维护。`
: selectedIdentityProvider.external
@@ -95,6 +95,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(realtimeSpy).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.vehicleDetail).mock.calls[0][0]?.get('includeHistory')).toBe('0');
const realtimeQuery = client.getQueryCache().find({ queryKey: ['vehicle-detail-realtime', initialRealtime.vin] });
expect((realtimeQuery?.options as { refetchInterval?: number }).refetchInterval).toBe(10_000);
const params = realtimeSpy.mock.calls[0][0];
@@ -150,6 +151,10 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(view.container.querySelector('.v2-record-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('待维护').closest('.semi-tag')).toBeInTheDocument();
expect(sourceEvidence).not.toHaveBeenCalled();
expect(view.container.querySelector('#vehicle-location-panel')).toHaveAttribute('hidden');
expect(businessPanel).not.toHaveAttribute('hidden');
fireEvent.click(screen.getByRole('button', { name: '全部信息' }));
expect(view.container.querySelector('#vehicle-location-panel')).not.toHaveAttribute('hidden');
const locationSource = screen.getByRole('button', { name: '查看全部位置来源' });
expect(locationSource).toHaveClass('semi-button', 'v2-map-source-link');
expect(locationSource.closest('.semi-card')).toHaveClass('v2-single-map-card');
@@ -300,13 +305,8 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
expect(initialQuery).toBeDefined();
expect(initialQuery!.get('limit')).toBe('10');
expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument();
const directoryMetrics = screen.getByRole('list', { name: '车辆目录摘要' });
expect(directoryMetrics.closest('.v2-vehicle-directory-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
expect(Array.from(directoryMetrics.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['授权车辆', '本页在线', '本页多源']);
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(1)')).toHaveTextContent('授权车辆2辆账号范围');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(2)')).toHaveTextContent('本页在线1辆本页 1 辆');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(3)')).toHaveTextContent('本页多源0辆2+ 协议来源');
expect(screen.getByText('第 1 / 1 页').closest('.semi-tag')).toBeInTheDocument();
const directoryMetrics = screen.getByLabelText('车辆目录摘要');
expect(directoryMetrics).toHaveTextContent('共 2 辆 · 本页在线 1 · 多源 0');
expect(screen.getByRole('button', { name: '刷新车辆目录' })).toHaveClass('semi-button');
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-search-page')).toHaveClass('v2-vehicle-directory-v3');
@@ -370,10 +370,10 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes><RouteState /></MemoryRouter></QueryClientProvider>);
const mobileDirectoryMetrics = await screen.findByRole('list', { name: '车辆目录摘要' });
await waitFor(() => expect(mobileDirectoryMetrics).toHaveTextContent('本页在线6辆本页 8 辆'));
const mobileDirectoryMetrics = await screen.findByLabelText('车辆目录摘要');
await waitFor(() => expect(mobileDirectoryMetrics).toHaveTextContent('共 10 辆 · 本页在线 6 · 多源 0'));
expect(view.container.querySelectorAll('.v2-vehicle-recent-grid [role="listitem"]')).toHaveLength(8);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/, hidden: true })).toHaveLength(8);
expect(screen.getAllByText(/GB\/T 32960|JT\/T 808/).length).toBeGreaterThan(0);
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument();
@@ -392,11 +392,11 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
for (const label of ['部门', '业务负责人', '客户', '状态']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/, hidden: true })).toHaveLength(8);
fireEvent.change(input, { target: { value: items[0].plate } });
expect(await screen.findByRole('listbox')).toHaveAttribute('id', 'v2-vehicle-mobile-search-options');
expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent(`当前条件${items[0].plate}车牌 / VIN / 终端`);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/, hidden: true })).toHaveLength(8);
fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `打开车辆搜索:${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'false'));
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
@@ -409,7 +409,7 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
fireEvent.click(screen.getByRole('button', { name: `打开车辆搜索:${items[0].plate} · 10 辆匹配` }));
fireEvent.click(screen.getByRole('button', { name: '重置范围' }));
expect(screen.getByRole('dialog', { name: '车辆搜索' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: `关闭车辆搜索:${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: `关闭车辆搜索:${items[0].plate} · 10 辆匹配`, hidden: true })).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: '打开车辆搜索:10 辆授权车辆' })).toBeInTheDocument());
expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument();
@@ -779,12 +779,13 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveTextContent('36km/h正常');
fireEvent.click(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' }));
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'semi-sidesheet-bottom');
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'business-sheet-bottom');
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型数值');
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-cyan-light');
expect(screen.getByRole('button', { name: '关闭遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-vehicle-record-page')).toHaveClass('v2-vehicle-record-v3', 'is-mobile-layout');
expect(document.querySelector('.v2-identity-model strong')).toHaveAttribute('title', '测试品牌 / 移动端完整车型名称');
fireEvent.click(screen.getByRole('button', { name: '关闭遥测字段详情' }));
const mobileActions = screen.getByRole('group', { name: '车辆快捷操作' });
expect(mobileActions.querySelectorAll('.semi-button')).toHaveLength(3);
expect(mobileActions).toHaveTextContent('切换车辆轨迹回放更多');
@@ -795,7 +796,7 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(moreActions).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(moreActions);
expect(await screen.findByRole('dialog', { name: '更多车辆操作' })).toBeVisible();
expect(document.querySelector('.v2-vehicle-mobile-actions-sidesheet')).toHaveClass('v2-workspace-action-sidesheet', 'semi-sidesheet-bottom');
expect(document.querySelector('.v2-vehicle-mobile-actions-sidesheet')).toHaveClass('v2-workspace-action-sidesheet', 'business-sheet-bottom');
expect(screen.getByRole('button', { name: '历史数据' })).toBeVisible();
expect(screen.getByRole('button', { name: '里程查询' })).toBeVisible();
expect(screen.getByRole('button', { name: '告警事件' })).toBeVisible();
@@ -1,3 +1,4 @@
import './fleet-workflows.css';
import { useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconBox, IconClock, IconCopy,
@@ -37,6 +38,7 @@ function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ?
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
const LIVE_UNIT_CLASS = 'v2-vehicle-live-unit';
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
const OneOSVehicleCatalog = lazy(() => import('./OneOSVehicleCatalogPage'));
const VehicleActions = lazy(() => import('./VehicleActions'));
const VehicleArchiveCard = lazy(() => import('./VehicleArchiveCard'));
const VehicleBusinessRelationCard = lazy(() => import('./VehicleBusinessRelationCard'));
@@ -74,7 +76,7 @@ function eventTimeLabel(value?: string) {
return normalized.length >= 16 ? normalized.slice(5, 16) : normalized;
}
function Events({ detail }: { detail: VehicleDetail }) {
function Events({ detail, canViewAlerts }: { detail: VehicleDetail; canViewAlerts: boolean }) {
const events = [
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${protocolDisplayLabel(item.protocol)} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
@@ -84,7 +86,7 @@ function Events({ detail }: { detail: VehicleDetail }) {
title="最近事件"
description="质量提醒与来源状态"
meta={`${events.length} 条`}
actions={<Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small">查看全部</Button></Link>}
actions={canViewAlerts ? <Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small">查看全部</Button></Link> : null}
/>
<List
className="v2-event-list"
@@ -342,12 +344,10 @@ const vehicleSectionIDs = {
type VehicleSection = keyof typeof vehicleSectionIDs;
function VehicleRecordNavigation({ onOpenSources }: { onOpenSources: () => void }) {
function VehicleRecordNavigation({ onOpenSources, section, onSection }: { onOpenSources: () => void; section: VehicleSection | 'all'; onSection: (section: VehicleSection | 'all') => void }) {
const jumpToSection = (section: VehicleSection) => {
const target = document.getElementById(vehicleSectionIDs[section]);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.focus({ preventScroll: true });
onSection(section);
};
const navigationItems = [
['location', '实时位置', <IconMapPin />],
@@ -358,8 +358,9 @@ function VehicleRecordNavigation({ onOpenSources }: { onOpenSources: () => void
] as const;
return <Card className="v2-record-card v2-vehicle-record-nav" bodyStyle={{ padding: 0 }}>
<nav className="v2-vehicle-section-nav" aria-label="单车详情导航">
<Button size="small" theme={section === 'all' ? 'light' : 'borderless'} aria-pressed={section === 'all'} onClick={() => onSection('all')}>全部信息</Button>
<VehicleSourceEvidenceButton onClick={onOpenSources} />
{navigationItems.map(([key, label, icon]) => <Button key={key} size="small" theme="borderless" type="tertiary" icon={icon} aria-label={`跳转到${label}`} onClick={() => jumpToSection(key)}>{label}</Button>)}
{navigationItems.map(([key, label, icon]) => <Button key={key} size="small" theme={section === key ? 'light' : 'borderless'} aria-pressed={section === key} type="tertiary" icon={icon} aria-label={`跳转到${label}`} onClick={() => jumpToSection(key)}>{label}</Button>)}
</nav>
</Card>;
}
@@ -370,6 +371,13 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
const mobileLayout = useMobileLayout();
const recordRootRef = useRef<HTMLDivElement>(null);
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const [recordSection, setRecordSection] = useState<VehicleSection | 'all'>('all');
useLayoutEffect(() => {
if (recordSection === 'all') return;
const target = document.getElementById(vehicleSectionIDs[recordSection]);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
target?.focus({ preventScroll: true });
}, [recordSection]);
const realtime = liveRealtime ?? detail.realtimeSummary;
const identity = detail.identity;
const hasLocation = !!(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
@@ -455,7 +463,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
stopEntryGuard();
};
}, [detail.vin]);
return <div ref={recordRootRef} className={`v2-vehicle-record-page v2-vehicle-record-v3${mobileLayout ? ' is-mobile-layout' : ''}`} tabIndex={-1} aria-label={`${fmt(identity?.plate || realtime?.plate)} 车辆档案`}>
return <div ref={recordRootRef} className={`fleet-workflow v2-vehicle-record-page v2-vehicle-record-v3${mobileLayout ? ' is-mobile-layout' : ''}`} tabIndex={-1} aria-label={`${fmt(identity?.plate || realtime?.plate)} 车辆档案`}>
<MonitorReturnBar />
<Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card" bodyStyle={{ padding: 0 }}>
<WorkspaceCommandBar
@@ -494,10 +502,10 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
</Card>
<VehicleSourceEvidencePanel vin={detail.vin} hideTrigger open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<VehicleRecordNavigation onOpenSources={() => setSourceEvidenceOpen(true)} />
<VehicleRecordNavigation section={recordSection} onSection={setRecordSection} onOpenSources={() => setSourceEvidenceOpen(true)} />
<div className="v2-record-grid">
<div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">
<div className={`v2-record-grid${recordSection !== 'all' ? ' is-focused-section' : ''}`}>
<div hidden={recordSection !== 'all' && recordSection !== 'location'} id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时位置"
@@ -508,20 +516,20 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
<footer><Button theme="borderless" type="tertiary" className="v2-map-source-link" title="展开全部位置来源" aria-label="查看全部位置来源" icon={<IconMapPin />} onClick={() => setSourceEvidenceOpen(true)}>{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</Button><span className="v2-map-update-time">更新时间:<PlatformTime value={realtime?.lastSeen} /></span></footer>
</Card>
</div>
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
<div id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
<div id={vehicleSectionIDs.business} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-business-relation-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取业务关联" description="客户、合同与运营责任就绪后会自动显示。" /></Card>}><VehicleBusinessRelationCard relation={detail.businessRelation} /></Suspense></div>
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取车辆主档" description="主档与维护权限就绪后会自动显示。" /></Card>}><VehicleArchiveCard detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></Suspense></div>
<div hidden={recordSection !== 'all' && recordSection !== 'events'} id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} canViewAlerts={hasMenu(session, 'alerts')} /></div>
<div hidden={recordSection !== 'all' && recordSection !== 'telemetry'} id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
<div hidden={recordSection !== 'all' && recordSection !== 'business'} id={vehicleSectionIDs.business} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-business-relation-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取业务关联" description="客户、合同与运营责任就绪后会自动显示。" /></Card>}><VehicleBusinessRelationCard relation={detail.businessRelation} /></Suspense></div>
<div hidden={recordSection !== 'all' && recordSection !== 'archive'} id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取车辆主档" description="主档与维护权限就绪后会自动显示。" /></Card>}><VehicleArchiveCard detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></Suspense></div>
</div>
</div>;
}
export default function VehiclePage() {
function PlatformVehiclePage() {
const { vin } = useParams();
const [searchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const directoryReturn = vehicleDirectoryReturnFromParams(searchParams);
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: !!vin, queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: !!vin, queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, includeHistory: '0' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
const realtime = useQuery({
queryKey: ['vehicle-detail-realtime', resolvedVin],
@@ -562,3 +570,9 @@ export default function VehiclePage() {
/></Card>;
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} directoryReturn={directoryReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
}
export default function VehiclePage() {
const { session } = usePlatformSession();
if (session.authorizationStatus === 'asset-catalog') return <Suspense fallback={<PageLoading label="正在加载 OneOS 车辆目录" />}><OneOSVehicleCatalog /></Suspense>;
return <PlatformVehiclePage />;
}
@@ -1,5 +1,6 @@
import { Progress } from '@/components/ui/progress';
import { IconAlertTriangle, IconBox, IconDownload, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Input, Progress, Select, Tag, Upload } from '@douyinfe/semi-ui';
import { Button, Input, Select, Tag, Upload } from '@douyinfe/semi-ui';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../api/client';
import type { VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
@@ -256,7 +257,7 @@ export default function VehicleProfileSyncPanel({ onClose, onApplied }: VehicleP
{recovered || running || interrupted ? <section className={`v2-profile-sync-progress${interrupted ? ' is-interrupted' : ''}`} role="status" aria-live="polite">
<header><span><strong>{recovered && !running ? '已恢复上次任务' : interrupted ? '任务已暂停,可安全续办' : phase === 'previewing' ? '正在预演全部批次' : '正在分批写入主档'}</strong><small>{items.length.toLocaleString('zh-CN')} 辆 · {completedChunks} / {totalChunks} 批完成</small></span><b>{progress}%</b></header>
<Progress aria-label={`主档同步进度 ${progress}%`} percent={progress} showInfo={false} size="small" strokeLinecap="round" />
<Progress aria-label={`主档同步进度 ${progress}%`} value={progress} />
<p>{interrupted ? '点击“继续写入”从下一批开始;相同来源版本重复提交会安全识别为未变化。' : recovered && !running ? '来源、文件内容和已完成批次已从当前浏览器恢复。' : '每完成一批都会保存进度,刷新或离开页面不会丢失已完成位置。'}</p>
</section> : null}
@@ -1,3 +1,4 @@
import './fleet-workflows.css';
import { useQuery } from '@tanstack/react-query';
import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Input, Select, Table, Tag } from '@douyinfe/semi-ui';
@@ -7,7 +8,6 @@ import { api } from '../../api/client';
import type { Page, VehicleBusinessFilterOption, VehicleCoverageRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
@@ -21,7 +21,6 @@ import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { monitorReturnFromParams } from '../routing/monitorContext';
import { buildVehicleDirectoryPath, parseVehicleDirectoryContext, type VehicleDirectoryView, withVehicleDirectoryReturn } from '../routing/vehicleContext';
@@ -277,40 +276,12 @@ export default function VehicleSearchWorkspace() {
: candidates.data
? directoryView === 'all' ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆${businessFilterSuffix}` : `${directoryViewLabel}${businessFilterSuffix} · ${candidates.data.total.toLocaleString('zh-CN')} 辆`
: '正在读取授权范围';
const directoryMetrics: WorkspaceQueueMetricRailItem[] = [
{
label: deferredKeyword ? '匹配车辆' : directoryView === 'all' ? '授权车辆' : directoryViewLabel,
value: <>{candidates.data ? formatZhNumber(total) : '—'}<i className="v2-vehicle-directory-unit">辆</i></>,
note: deferredKeyword || directoryView !== 'all' ? '当前视图' : '账号范围',
tone: 'primary',
emphasis: 'primary'
},
{
label: '本页在线',
value: <>{candidates.data ? formatZhNumber(onlineOnPage) : '—'}<i className="v2-vehicle-directory-unit">辆</i></>,
note: candidates.data ? `本页 ${formatZhNumber(pageVehicles.length)} 辆` : '正在读取',
tone: 'success',
emphasis: 'primary'
},
{
label: '本页多源',
value: <>{candidates.data ? formatZhNumber(multiSourceOnPage) : '—'}<i className="v2-vehicle-directory-unit">辆</i></>,
note: '2+ 协议来源',
tone: 'primary',
emphasis: 'secondary'
}
];
const directoryTitle = deferredKeyword
? '匹配车辆'
: directoryView === 'all'
? mobileLayout ? '授权车辆' : '授权车辆目录'
: directoryViewLabel;
const directoryDescription = deferredKeyword
? '按当前搜索条件展示,可直接进入车辆数字档案'
: directoryView === 'all'
? '按照最新上报时间排列,分页浏览全部授权车辆'
: `仅展示${directoryViewLabel},按最新上报时间排列`;
return <section className={`v2-vehicle-search-page v2-vehicle-directory-v3${desktopCandidatesVisible ? ' has-candidates' : ''}`}>
return <section className={`fleet-workflow v2-vehicle-search-page v2-vehicle-directory-v3${desktopCandidatesVisible ? ' has-candidates' : ''}`}>
<MonitorReturnBar />
<div className="v2-vehicle-discovery-shell">
{!mobileLayout ? <WorkspaceCommandBar
@@ -499,6 +470,7 @@ export default function VehicleSearchWorkspace() {
loading={Boolean(keyword.trim()) && candidates.isFetching}
disabled={!keyword.trim()}
>{desktopSearchAction}</Button>
<Button theme="borderless" type="tertiary" disabled={!keyword && !departmentIds.length && !responsibleUserIds.length && !customerIds.length && !operationStatuses.length} onClick={() => { setDirectoryRoute({ search: '', departmentIds: [], responsibleUserIds: [], customerIds: [], operationStatuses: [], page: 1 }); setCandidatesOpen(false); }}>清空条件</Button>
<div className="v2-vehicle-business-filter-grid">
<label><span>部门</span><Select multiple showClear value={departmentIds} onChange={(value) => setDirectoryRoute({ departmentIds: Array.isArray(value) ? value.map(String) : [], page: 1 })} optionList={selectOptions(businessFilters.data?.departments)} placeholder="全部部门" /></label>
<label><span>业务负责人</span><Select multiple showClear value={responsibleUserIds} onChange={(value) => setDirectoryRoute({ responsibleUserIds: Array.isArray(value) ? value.map(String) : [], page: 1 })} optionList={selectOptions(businessFilters.data?.responsibleUsers)} placeholder="全部负责人" /></label>
@@ -517,19 +489,11 @@ export default function VehicleSearchWorkspace() {
/> : null}
</div>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspaceMetricRail
variant="queue"
ariaLabel="车辆目录摘要"
className="v2-vehicle-directory-metric-rail"
items={directoryMetrics}
context={<div className="v2-vehicle-directory-context">
<span><h5 ref={directoryHeadingRef} tabIndex={-1}>{directoryTitle}</h5><small>{directoryDescription}</small></span>
<span className="v2-vehicle-directory-context-actions">
<Tag color="blue" type="light" size="small">第 {page} / {totalPages} 页</Tag>
<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} aria-label="刷新车辆目录" onClick={() => candidates.refetch()}>刷新</Button>
</span>
</div>}
/>
<div className="fleet-results-toolbar">
<h5 ref={directoryHeadingRef} tabIndex={-1}>{directoryTitle}</h5>
<span aria-label="车辆目录摘要">共 {total.toLocaleString('zh-CN')} 辆 · 本页在线 {onlineOnPage} · 多源 {multiSourceOnPage}</span>
<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} aria-label="刷新车辆目录" onClick={() => candidates.refetch()}>刷新</Button>
</div>
{candidates.isFetching && !candidates.data
? <PanelLoading className="v2-vehicle-directory-loading" compact title="正在读取授权车辆" description="车辆目录返回后会自动显示。" />
: candidates.isError
@@ -0,0 +1,170 @@
/* Compact ERP-style workspace. Theme tokens are scoped to this page and its portals. */
.cm-page,.cm-dialog{color:var(--foreground);font-size:14px;font-variant-numeric:tabular-nums}
.cm-page{display:flex;flex-direction:column;gap:14px;min-width:0;max-width:1800px;margin:auto;padding:0 0 20px}
.cm-panel,.cm-filters,.cm-summary,.cm-overview,.cm-method{background:var(--background);border:1px solid var(--border);border-radius:8px}
.cm-filters{padding:16px 18px 12px}
.cm-filter-fields[data-slot=field-group]{display:grid;grid-template-columns:repeat(2,minmax(135px,.95fr)) repeat(3,minmax(130px,1fr)) auto;gap:14px;align-items:end}
.cm-filter-fields [data-slot=field]{gap:7px;min-width:0}
.cm-query-button{min-width:84px}
.cm-filter-footer,.cm-actions,.cm-overview-legend,.cm-overview-tools,.cm-sort{display:flex;align-items:center;gap:10px}
.cm-filter-footer{flex-wrap:wrap;margin-top:14px}
.cm-timezone{font-size:12px;color:var(--muted-foreground)}
.cm-actions{margin-left:auto;gap:6px}
.cm-page [data-slot=button],.cm-dialog [data-slot=button],.cm-page [data-slot=toggle-group-item]{cursor:pointer}
.cm-page [data-slot=button]:disabled,.cm-dialog [data-slot=button]:disabled{cursor:not-allowed}
.cm-notice{margin:0;box-shadow:none}
.cm-summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));overflow:hidden}
.cm-summary article{position:relative;padding:16px 22px;min-width:0}
.cm-summary article+article:before{content:'';position:absolute;left:0;top:18px;bottom:18px;width:1px;background:var(--border)}
.cm-summary article>span{color:var(--muted-foreground);font-size:13px}
.cm-summary strong{display:block;font-size:28px;line-height:1.25;letter-spacing:-.6px;font-weight:600;margin:8px 0}
.cm-summary small{font-size:12px;font-weight:400;letter-spacing:0;color:var(--muted-foreground)}
.cm-summary p{font-size:12px;line-height:1.5;color:var(--muted-foreground);margin:0}
.cm-overview{overflow:hidden;min-width:0}
.cm-overview-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px 20px 12px}
.cm-overview-heading h2{font-size:16px;line-height:1.4;font-weight:600;margin:0 0 5px}
.cm-overview-heading p{font-size:12px;line-height:1.5;color:var(--muted-foreground);margin:0}
.cm-overview-legend{color:var(--muted-foreground);font-size:12px;gap:14px;white-space:nowrap}
.cm-overview-legend span{display:flex;align-items:center;gap:6px}
.cm-overview-legend i{height:7px;width:7px;border-radius:50%}
.cm-overview-legend .gps{background:var(--cm-gps)}
.cm-overview-legend .hydrogen{background:var(--cm-hydrogen)}
.cm-overview-tools{justify-content:space-between;padding:0 20px 14px;flex-wrap:wrap}
.cm-sort{font-size:12px;color:var(--muted-foreground)}
.cm-matrix-scroll{max-height:600px;overflow:auto;scrollbar-width:thin;border-top:1px solid var(--border);border-bottom:1px solid var(--border);--cm-customer-width:230px}
.cm-matrix-scroll [data-slot=table-container]{overflow:visible}
.cm-matrix{border-collapse:separate;border-spacing:0;table-layout:fixed;width:max-content;min-width:100%}
.cm-matrix th,.cm-matrix td{border-bottom:1px solid var(--border);border-right:1px solid var(--border)}
.cm-matrix th:last-child,.cm-matrix td:last-child{border-right:0}
.cm-matrix thead th{position:sticky;top:0;z-index:2;background:var(--muted);height:54px;padding:10px 14px;font-size:13px;font-weight:500}
.cm-matrix th small{display:block;margin-top:4px;font-size:11px;line-height:1.45;color:var(--muted-foreground);font-weight:400}
.cm-matrix .cm-customer-column{position:sticky;left:0;z-index:1;min-width:var(--cm-customer-width);width:var(--cm-customer-width);max-width:var(--cm-customer-width);padding:12px 18px;background:var(--background);white-space:normal}
.cm-matrix .cm-total-column{position:sticky;left:var(--cm-customer-width);z-index:1;width:140px;min-width:140px;padding:12px 16px;text-align:right;background:var(--background)}
.cm-matrix thead .cm-customer-column,.cm-matrix thead .cm-total-column{z-index:3;background:var(--muted)}
.cm-matrix tbody .cm-customer-column strong{display:block;line-height:1.55;font-size:13px;font-weight:550;overflow-wrap:anywhere}
.cm-matrix tbody .cm-customer-column small{margin-top:3px}
.cm-total-column>small{display:block;font-size:10px;margin-top:7px;color:var(--muted-foreground);font-weight:400}
.cm-date-column{min-width:126px;width:126px;text-align:right!important}
.cm-matrix .cm-date-cell{min-width:126px;width:126px;padding:5px}
.cm-mileage-pair{display:flex;flex-direction:column;gap:3px;line-height:1.35;align-items:flex-end;font-size:14px;font-weight:550;color:var(--cm-gps)}
.cm-mileage-pair>span:nth-child(2){color:var(--cm-hydrogen);font-weight:500}
.cm-day-cell{display:flex;flex-direction:column;align-items:flex-end;justify-content:flex-start;gap:5px;border:1px solid transparent;border-radius:5px;width:100%;min-height:89px;padding:8px 9px;background:transparent;color:inherit;font:inherit;cursor:pointer;text-align:right}
.cm-day-cell.has-alert{background:var(--cm-danger-soft)}
.cm-day-cell:hover{border-color:var(--input);background:var(--accent)}
.cm-day-cell:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}
.cm-day-status{max-width:100%;white-space:nowrap}
.cm-coverage{font-size:10px;line-height:1.4;color:var(--muted-foreground);white-space:nowrap}
.cm-cell-unavailable{display:block;text-align:right;color:var(--muted-foreground);padding:12px}
.cm-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 18px;color:var(--muted-foreground);font-size:12px}
.cm-pagination>div{display:flex;align-items:center;gap:10px}
.cm-page-number{min-width:42px;text-align:center}
.cm-matrix-note{display:flex;align-items:flex-start;gap:7px;font-size:11px;line-height:1.65;color:var(--muted-foreground);margin:0;padding:0 18px 14px}
.cm-matrix-note svg{width:14px;height:14px;flex-shrink:0;margin-top:2px}
.cm-method{color:var(--muted-foreground);font-size:12px}
.cm-method summary{display:flex;align-items:center;gap:8px;cursor:pointer;list-style:none;padding:13px 16px;color:var(--foreground)}
.cm-method summary::-webkit-details-marker{display:none}
.cm-method summary>svg{width:15px;height:15px;flex-shrink:0}
.cm-method summary>.cm-method-chevron{margin-left:auto}
.cm-method[open] .cm-method-chevron{transform:rotate(90deg)}
.cm-method summary>span{margin-left:8px;color:var(--muted-foreground);font-size:11px}
.cm-method p{margin:14px 16px;line-height:1.8}
.cm-loading{display:flex;flex-direction:column;gap:16px;padding:22px}
.cm-loading>p{display:flex;align-items:center;gap:8px;color:var(--muted-foreground);margin:0;font-size:13px}
.cm-loading>p svg{width:16px;height:16px}
.cm-loading-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.cm-dialog{display:flex;flex-direction:column;width:calc(100vw - 32px);max-height:88dvh;padding:24px;gap:20px}
.cm-dialog-close{position:absolute;right:12px;top:12px}
.cm-dialog [data-slot=dialog-header]{padding-right:24px;text-align:left}
.cm-dialog-body{min-height:0;overflow:auto;scrollbar-width:thin;overscroll-behavior:contain}
.cm-detail-dialog{max-width:1120px}
.cm-rules-dialog{max-width:640px}
.cm-detail-summary{display:grid;grid-template-columns:1.1fr 1fr 1fr 1fr;align-items:end;gap:20px;padding:0 0 20px;border-bottom:1px solid var(--border)}
.cm-detail-summary [data-slot=field]{gap:7px}
.cm-detail-summary [data-slot=native-select-wrapper]{width:100%}
.cm-detail-summary>div>span{font-size:12px;color:var(--muted-foreground)}
.cm-detail-summary strong{display:block;font-size:22px;font-weight:600;line-height:1.4;margin-top:5px}
.cm-detail-summary small{font-size:11px;color:var(--muted-foreground);font-weight:400}
.cm-detail-summary p{font-size:13px;margin:8px 0 4px;white-space:nowrap}
.cm-detail-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px 0 12px;flex-wrap:wrap}
.cm-detail-toolbar h3{font-size:14px;font-weight:600;margin:0}
.cm-detail-toolbar p{font-size:12px;color:var(--muted-foreground);margin:5px 0 0}
.cm-detail-controls{display:flex;align-items:center;gap:8px;min-width:0}
.cm-detail-controls [data-slot=input-group]{max-width:230px}
.cm-legend{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11px;color:var(--muted-foreground);margin-bottom:14px}
.cm-vehicle-table{border:1px solid var(--border);border-radius:6px;overflow:hidden}
.cm-vehicle-table table{min-width:800px}
.cm-vehicle-table th{background:var(--muted);padding:11px 14px;font-size:12px}
.cm-vehicle-table td{padding:14px;vertical-align:top;font-size:13px;white-space:normal}
.cm-vehicle-table td strong{display:block;font-weight:550}
.cm-vehicle-table td small{display:block;margin-top:5px;font-size:11px;color:var(--muted-foreground);line-height:1.5;max-width:260px;overflow-wrap:anywhere}
.cm-vin{font-variant-numeric:tabular-nums}
.cm-dialog .cm-pagination{padding:16px 0 0}
.cm-rule-groups{gap:24px}
.cm-rule-groups [data-slot=field-set]{gap:12px}
.cm-rule-groups [data-slot=field-legend]{margin-bottom:0}
.cm-rule-fields[data-slot=field-group]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.cm-rule-groups [data-slot=field]{gap:8px}
.cm-rules-dialog .cm-notice{margin-top:18px}
@media(max-width:1350px){.cm-filter-fields[data-slot=field-group]{grid-template-columns:repeat(3,minmax(0,1fr))}.cm-query-button{width:100%}.cm-summary article{padding:16px}.cm-matrix-scroll{--cm-customer-width:210px}}
@media(max-width:900px){.cm-timezone{display:none}.cm-summary strong{font-size:25px}.cm-detail-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.cm-overview-heading{align-items:flex-start}.cm-overview-legend{gap:8px}.cm-detail-controls{width:100%}.cm-detail-controls [data-slot=input-group]{max-width:none;flex:1}}
@media(max-width:700px){.cm-page{gap:12px}.cm-filters{padding:14px}.cm-filter-fields[data-slot=field-group]{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.cm-filter-footer{gap:12px}.cm-actions{width:100%;justify-content:flex-end}.cm-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.cm-summary article:nth-child(n+3){border-top:1px solid var(--border)}.cm-summary article:nth-child(3):before{display:none}.cm-summary strong{font-size:24px}.cm-overview-heading{padding:16px 14px 12px;flex-wrap:wrap;gap:8px}.cm-overview-tools{padding:0 14px 12px;gap:12px}.cm-sort{margin-left:auto}.cm-matrix-scroll{--cm-customer-width:150px;max-height:520px}.cm-matrix .cm-customer-column{padding:12px}.cm-matrix .cm-total-column{width:110px;min-width:110px;padding:10px}.cm-pagination{padding:12px 14px}.cm-pagination>div{margin-left:auto}.cm-matrix-note{padding:0 14px 14px}.cm-method summary{flex-wrap:wrap}.cm-method summary>span{display:none}.cm-dialog{padding:20px 16px;max-height:90dvh;gap:16px}.cm-detail-summary{gap:16px}.cm-rule-fields[data-slot=field-group]{grid-template-columns:1fr}.cm-loading-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(prefers-reduced-motion:reduce){.cm-page [data-slot=skeleton]{animation:none}}
/* The matrix has its own row anatomy inside both platform density modes. */
.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix [data-slot=table-head]{padding:10px 14px;font-size:13px;color:var(--foreground)}
.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix tbody [data-slot=table-head]{background:var(--background);padding:12px 18px}
.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix [data-slot=table-cell]{height:auto;padding:5px;font-size:13px}
.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix [data-slot=table-cell].cm-total-column{padding:12px 16px}
.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix thead [data-slot=table-head]{height:54px;background:var(--muted)}
@media(max-width:700px){.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix tbody [data-slot=table-head]{padding:12px}.business-shell[data-density] .v2-content .cm-page .cm-matrix.cm-matrix [data-slot=table-cell].cm-total-column{padding:10px}}
.cm-mileage-pair>span{display:flex;align-items:center;justify-content:flex-end;gap:6px}
.cm-source-tag{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid;font-size:10px;font-weight:650;line-height:16px;padding:0 5px;border-radius:4px;white-space:nowrap}
.cm-source-gps{color:#1d4ed8;background:#eff6ff;border-color:#bfdbfe}
.cm-source-hydrogen{color:#15803d;background:#f0fdf4;border-color:#bbf7d0}
.cm-vehicle-count{font-size:11px;font-weight:500;color:var(--muted-foreground);background:var(--muted);border:1px solid var(--border)}
.cm-matrix .cm-date-column,.cm-matrix .cm-date-cell{min-width:188px;width:188px}
.cm-daily-lines{display:flex;flex-direction:column;gap:6px;width:100%}
.cm-daily-line{display:flex;align-items:center;justify-content:space-between;gap:8px}
.cm-daily-value{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;font-weight:550;color:var(--cm-gps)}
.cm-daily-line:nth-child(2) .cm-daily-value{color:var(--cm-hydrogen)}
.cm-daily-line .cm-day-status{margin:0;white-space:nowrap}
.cm-matrix .cm-customer-owner{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cm-source-tag{box-sizing:border-box;width:36px;min-width:36px;height:18px;padding:0;justify-content:center}
.cm-daily-value{font-variant-numeric:tabular-nums}
.cm-page{max-width:none;width:100%}
.cm-overview-fullscreen{position:fixed;inset:12px;z-index:100;display:flex;flex-direction:column;box-shadow:0 0 0 12px var(--background)}
.cm-overview-fullscreen .cm-overview-heading,.cm-overview-fullscreen .cm-overview-tools,.cm-overview-fullscreen .cm-pagination,.cm-overview-fullscreen .cm-matrix-note{flex-shrink:0}
.cm-overview-fullscreen .cm-matrix-scroll{flex:1;min-height:0;max-height:none}
@media(max-width:700px){.cm-overview-fullscreen{inset:4px;box-shadow:0 0 0 4px var(--background)}}
/* A transformed entrance-animation ancestor otherwise becomes the fixed panel's containing block. */
.cm-page:has(.cm-overview-fullscreen){animation:none!important;transform:none!important;will-change:auto}
.cm-overview-fullscreen{height:calc(100dvh - 24px);max-height:calc(100dvh - 24px)}
@media(max-width:700px){.cm-overview-fullscreen{height:calc(100dvh - 8px);max-height:calc(100dvh - 8px)}}
.cm-total-drill{width:100%;padding:6px 0;border:0;background:transparent;text-align:right;cursor:pointer;color:inherit;border-radius:4px}
.cm-total-drill:hover{background:var(--accent)}
.cm-total-drill:focus-visible{outline:2px solid var(--ring)}
.cm-total-drill>small{display:block;font-size:10px;color:var(--muted-foreground);margin-top:6px}
.cm-gps-filter{display:flex;align-items:center;gap:6px;font-size:12px;flex-wrap:wrap}
.cm-gps-filter input{width:105px}.cm-gps-filter select{width:75px}
.cm-interval-dates{display:flex;gap:8px;flex-wrap:wrap}
.cm-dialog{z-index:150!important}
.cm-search{position:relative;min-width:0}
.cm-search [data-slot=input-group-addon]{gap:0;padding-right:3px}
.cm-search [data-slot=input-group-addon] button{width:26px;height:26px}
.cm-search-menu{position:absolute;top:calc(100% + 5px);left:0;z-index:40;width:max(100%,240px);max-width:min(420px,calc(100vw - 48px));background:var(--popover);color:var(--popover-foreground);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px #0002;padding:4px}
.cm-search-menu [role=listbox]{max-height:240px;overflow:auto}
.cm-search-menu [role=option]{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-radius:4px;cursor:pointer;overflow-wrap:anywhere}
.cm-search-menu [role=option]:hover,.cm-search-menu [role=option][data-active=true]{background:var(--accent);color:var(--accent-foreground)}
.cm-search-menu [role=option] svg{width:15px;height:15px;flex-shrink:0}
.cm-search-menu p{margin:6px 10px;font-size:12px;color:var(--muted-foreground)}
.cm-search-hint{border-top:1px solid var(--border);padding-top:8px}
@media(max-width:760px){.cm-search-menu{width:100%;max-width:100%}}
@@ -0,0 +1,273 @@
/* Dense operational workspaces: identity, query, results, and contextual actions. */
.fleet-workflow { font-size: 12px; line-height: 1.45; gap: 8px; }
.fleet-workflow :is(h1,h2,h3,h4,h5,p) { margin-block: 0; }
.fleet-workflow :is(.semi-button,.semi-input,.semi-select,.semi-tag) { font-size: 12px; }
.fleet-workflow :is(.semi-button,.semi-select,.semi-input-wrapper) { min-height: 30px; }
.fleet-workflow :is(.semi-card,.v2-filterbar) { border-radius: 6px; box-shadow: none; }
.fleet-workflow :is(.v2-workspace-command-bar,.v2-workspace-panel-header) { padding: 8px 12px; min-height: 44px; }
.fleet-workflow :is(.v2-workspace-command-title,h1) { font-size: 18px; line-height: 1.3; }
.fleet-workflow :is(h3,h4,h5,.v2-plate) { font-size: 14px; line-height: 1.4; }
.fleet-workflow .v2-workspace-metric-list { gap: 0; }
.fleet-workflow .v2-workspace-metric-list > span { min-height: 52px; padding: 6px 12px; }
.fleet-workflow .v2-workspace-metric-list strong { font-size: 19px; line-height: 1.25; }
.fleet-workflow .v2-workspace-metric-list :is(small,em) { font-size: 11px; }
.fleet-workflow .v2-workspace-metric-action { padding: 0; min-height: 38px; }
.fleet-workflow .v2-workspace-metric-context { padding: 6px 12px; }
.fleet-workflow .v2-monitor-summary-support :is(strong,small) { font-size: 12px; }
.fleet-workflow .v2-monitor-summary-support em { display: none; }
.fleet-workflow .v2-filterbar { padding: 8px 10px; gap: 8px; }
.fleet-workflow .v2-monitor-filter-fields { gap: 8px; }
.fleet-workflow .v2-vehicle-row { min-height: 48px; padding: 6px 10px; }
.fleet-workflow .v2-vehicle-row :is(strong,small) { font-size: 12px; line-height: 1.4; }
.fleet-workflow :is(.v2-vehicle-rail > header,.v2-vehicle-rail > footer) { padding: 8px 10px; min-height: 32px; font-size: 12px; }
.fleet-workflow .v2-rail-search { padding: 4px 10px; min-height: 26px; }
.fleet-workflow :is(.business-data-table,.semi-table) { font-size: 12px; }
.fleet-workflow :is(.business-data-table,.semi-table) :is(th,td) { padding: 6px 10px; font-size: 12px; }
.fleet-workflow :is(.v2-monitor-live-value,.v2-monitor-vehicle-action strong,.v2-vehicle-directory-identity strong,.v2-vehicle-directory-profile strong) { font-size: 12px; }
.fleet-workflow :is(.v2-monitor-vehicle-action,.v2-vehicle-directory-identity,.v2-vehicle-directory-profile) { min-height: 32px; gap: 0; padding: 0; }
.fleet-workflow :is(.v2-monitor-vehicle-action span,.v2-vehicle-directory-identity small,.v2-vehicle-directory-profile small,.v2-monitor-coordinate) { font-size: 11px; line-height: 1.4; }
.fleet-row-actions { display: flex; gap: 10px; white-space: nowrap; }
.fleet-row-actions a { color: var(--semi-color-primary); text-decoration: none; }
.fleet-row-actions a:hover { text-decoration: underline; }
.fleet-status { white-space: nowrap; }
.fleet-status.is-driving,.fleet-status.is-online { color: var(--semi-color-success); }
.fleet-results-toolbar { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 4px 12px; border-bottom: 1px solid var(--semi-color-border); }
.fleet-results-toolbar h5 { font-size: 13px; }
.fleet-results-toolbar > span { flex: 1; color: var(--semi-color-text-2); font-size: 12px; }
.fleet-workflow .v2-vehicle-discovery-shell { gap: 8px; padding: 0; }
.fleet-workflow .v2-vehicle-query-panel { padding: 8px 12px; }
.fleet-workflow .v2-vehicle-business-filter-grid { gap: 8px; }
.fleet-workflow .v2-vehicle-business-filter-grid label { gap: 3px; font-size: 11px; }
.fleet-workflow .v2-vehicle-directory-context { padding: 6px 12px; }
.fleet-workflow .v2-vehicle-section-nav { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 10px; }
.fleet-workflow .v2-record-grid { gap: 8px; }
.fleet-workflow .v2-record-grid.is-focused-section { display: block; }
.fleet-workflow .v2-record-section-anchor[hidden] { display: none !important; }
.fleet-workflow .v2-record-grid.is-focused-section > :not([hidden]) { width: 100%; }
.fleet-workflow .v2-telemetry-metric-grid { grid-template-columns: repeat(auto-fill,minmax(190px,1fr)); gap: 6px; padding: 8px 12px; }
.fleet-workflow .v2-telemetry-metric { padding: 8px; min-height: 68px; }
.fleet-workflow .v2-telemetry-metric-value strong { font-size: 18px; }
.fleet-workflow .v2-track-query { padding: 10px 12px; gap: 8px; }
.fleet-workflow .v2-track-list-action { padding: 8px 10px; min-height: 48px; }
.fleet-workflow .v2-track-list-action :is(strong,small,em) { font-size: 12px; }
.fleet-workflow .v2-track-current-metrics strong { font-size: 18px; }
.fleet-workflow .v2-track-current-metrics { padding: 8px 12px; }
.fleet-workflow .v2-track-current-card > .semi-card-body > p { margin: 0; padding: 6px 12px; font-size: 11px; }
.fleet-workflow .v2-track-overview-card-body { padding: 10px 12px; }
@media (min-width: 761px) {
.fleet-workflow.v2-monitor-page .v2-monitor-workspace { grid-template-columns: 248px minmax(0,1fr); }
.fleet-workflow.v2-monitor-page .v2-monitor-workspace.is-detail-open { grid-template-columns: 248px minmax(0,1fr) 300px; }
.fleet-workflow .v2-vehicle-search-form { gap: 8px; }
.fleet-workflow .v2-vehicle-command-header { padding: 10px 12px; }
.fleet-workflow .v2-vehicle-live-section { padding: 0 12px 8px; }
}
@media (max-width: 760px) {
.fleet-workflow :is(.semi-button,.semi-select,.semi-input-wrapper) { min-height: 40px; }
.fleet-workflow .v2-workspace-metric-action { min-height: 40px; }
.fleet-workflow .v2-workspace-metric-list > span { padding: 6px 8px; }
.fleet-workflow .v2-workspace-metric-list strong { font-size: 17px; }
.fleet-results-toolbar { flex-wrap: wrap; gap: 6px 10px; }
.fleet-results-toolbar > span { flex-basis: 50%; font-size: 11px; }
.fleet-workflow .v2-vehicle-section-nav { flex-wrap: nowrap; overflow-x: auto; }
.fleet-workflow .v2-vehicle-section-nav > * { flex-shrink: 0; }
}
/* Compound fleet controls own their content height; global compact buttons are single-line. */
.business-shell[data-density="compact"] .v2-content .fleet-workflow :is(.v2-vehicle-row,.v2-track-list-action).semi-button {
height: auto; min-height: 48px; padding: 6px 10px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-vehicle-action.semi-button {
height: auto; min-height: 32px; padding: 0; white-space: normal;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail.is-queue .v2-workspace-metric-list {
padding: 0; gap: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"] {
padding: 6px 12px; min-height: 52px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"] strong {
font-size: 19px; line-height: 1.25;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail .v2-workspace-metric-action.semi-button {
height: auto; min-height: 38px; padding: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-vehicle-query-panel { padding: 0; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-vehicle-search-form { padding: 8px 12px; gap: 8px; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-vehicle-business-filter-grid { gap: 8px; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-track-query { padding: 10px 12px; gap: 8px; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-track-query > .v2-track-query-button { height: 32px; min-height: 32px; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-track-list-action .semi-button-content :is(strong,small,em) { font-size: 12px; line-height: 17px; }
/* The mobile track inspector is portaled outside the workspace root. */
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-track-list-action.semi-button {
height: auto; min-height: 48px; padding: 6px 10px;
}
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-track-list-action .semi-button-content :is(strong,small,em) {
font-size: 12px; line-height: 17px;
}
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-workspace-metric-action.semi-button { height: auto; min-height: 40px; padding: 0; }
@media (max-width: 760px) {
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-vehicle-action.semi-button,
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail .v2-workspace-metric-action.semi-button,
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-track-query > .v2-track-query-button { min-height: 40px; height: auto; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"] { padding: 6px 8px; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-workspace-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"] strong { font-size: 17px; }
}
/* Monitor's summary is one compact row; legacy support cells must not dictate its height. */
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-rail.is-queue.has-context > .semi-card-body {
min-height: 0; height: auto; padding: 0; align-items: stretch;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-rail .v2-workspace-metric-context {
min-height: 0; height: auto; width: 220px; min-width: 220px; padding: 0 8px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-support {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: 100%; align-items: center;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-support > span,
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-support > .v2-monitor-summary-support-action.semi-button {
display: flex; flex-direction: column; justify-content: center; align-items: flex-start;
min-height: 48px; height: auto; min-width: 0; padding: 4px; overflow: visible; gap: 2px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-support-action .semi-button-content {
display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; white-space: nowrap; overflow: visible;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-support :is(small,strong) {
margin: 0; font-size: 11px; line-height: 16px; white-space: nowrap; overflow: visible;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-map-control-button.semi-button {
min-height: 48px; height: auto; padding: 6px 10px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .fleet-row-actions {
display: inline-flex; align-items: center; gap: 12px; white-space: nowrap;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .fleet-row-actions > a {
display: inline-flex; align-items: center; min-height: 30px; text-decoration: none;
}
@media (min-width: 761px) {
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-monitor-page > .v2-filterbar {
padding: 8px 10px; min-height: 50px; height: auto;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-monitor-page > .v2-filterbar > .semi-card-body {
display: flex; align-items: center; padding: 0; min-height: 32px; gap: 10px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-filter-fields {
display: grid; grid-template-columns: minmax(180px,1fr) 120px 116px auto;
gap: 8px; min-height: 32px; padding: 0; margin: 0; flex: 1 1 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-filter-fields > .v2-search-field,
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-filter-fields [data-slot="input-group"] {
height: 32px; min-height: 32px; padding-block: 0; margin: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-filter-actions { flex: 0 0 auto; min-height: 32px; gap: 6px; }
}
@media (max-width: 760px) {
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-rail .v2-workspace-metric-context {
width: 100%; min-width: 0; padding: 0 8px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-summary-support { grid-template-columns: 1fr 1fr; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow .fleet-row-actions > a { min-height: 40px; padding-inline: 4px; }
}
/* Single-vehicle identity stays above the fold with two deliberate compact rows. */
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-plate {
font-size: 18px; line-height: 24px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-live-section {
padding: 0 12px 8px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-live-section > .v2-workspace-panel-header {
min-height: 32px; padding: 4px 0; gap: 8px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-live-section .v2-workspace-panel-description { display: none; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-live-metric-rail > .semi-card-body,
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-live-metric-rail .v2-workspace-metric-context { min-height: 0; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-current-location {
min-height: 32px; padding: 4px 0; gap: 12px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-current-location > span { padding: 0; min-height: 0; }
@media (min-width: 961px) {
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command {
display: grid; grid-template-columns: minmax(210px,.8fr) minmax(0,2fr); align-items: center;
min-height: 76px; height: auto; padding: 8px 12px; gap: 8px 16px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command > .v2-workspace-command-copy {
display: flex; flex-direction: column; align-items: flex-start; gap: 0; padding: 0; min-width: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command > .v2-workspace-command-actions {
display: flex; flex-direction: column; justify-content: center; align-items: flex-end; gap: 4px; min-width: 0; padding: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 0; margin: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-meta > span {
display: flex; flex-direction: row; align-items: center; gap: 6px; padding: 0; border: 0; min-height: 20px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-meta :is(small,strong) {
font-size: 11px; line-height: 16px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-actions {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0; margin: 0;
}
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-track-result-metric-rail.is-queue .v2-workspace-metric-list,
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-track-result-metric-rail .v2-workspace-metric-list {
display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0; padding: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-track-result-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"],
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-track-result-metric-rail .v2-workspace-metric-list > [role="listitem"] {
grid-area: auto; min-height: 48px; padding: 4px 10px;
}
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-workspace-config-summary {
display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 0;
}
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-workspace-config-summary > span > small,
html[data-workspace-density="compact"] .business-sheet.v2-track-detail-sidesheet .v2-track-result-metric-rail .v2-workspace-metric-list small {
white-space: normal; overflow: visible; text-overflow: clip; max-width: 100%; font-size: 11px; line-height: 16px;
}
@media (max-width: 760px) {
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-directory-v3 .v2-vehicle-recent-item-shell { height: auto; min-height: 0; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-directory-v3 .v2-vehicle-recent-item.semi-button {
height: auto; min-height: 76px; padding: 8px 26px 8px 12px; white-space: normal;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-directory-v3 .v2-vehicle-recent-item .semi-button-content {
height: auto; min-height: 60px; row-gap: 4px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-directory-v3 .v2-vehicle-recent-identity strong { font-size: 13px; line-height: 18px; }
.business-shell[data-density="compact"] .v2-content .fleet-workflow.v2-vehicle-directory-v3 :is(.v2-vehicle-recent-identity,.v2-vehicle-recent-status) small { font-size: 11px; line-height: 16px; }
}
/* The 290px vehicle inspector has its own vertical metric layout. */
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail.v2-workspace-metric-rail.is-queue.has-context > .semi-card-body {
display: grid; grid-template-columns: minmax(0,1fr); grid-template-rows: auto auto;
min-height: 0; min-width: 0; padding: 0; gap: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail.is-queue .v2-workspace-metric-list {
display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); grid-template-rows: auto auto;
width: 100%; min-width: 0; padding: 0; gap: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"] {
display: flex; flex-direction: column; align-items: stretch; justify-content: center;
grid-area: auto; min-width: 0; min-height: 56px; padding: 6px 8px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail .v2-workspace-metric-action.semi-button,
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail .v2-workspace-metric-action .semi-button-content {
display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
min-width: 0; min-height: 42px; height: auto; padding: 0; gap: 2px; white-space: normal;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail.is-queue .v2-workspace-metric-list > [role="listitem"] strong {
width: auto; max-width: 100%; font-size: 17px; line-height: 22px; white-space: nowrap; overflow: visible; text-overflow: clip;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail .v2-workspace-metric-list .v2-monitor-detail-unit {
display: inline; font-size: 10px; margin-left: 3px;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail .v2-workspace-metric-context {
grid-area: auto; display: block; width: 100%; min-width: 0; min-height: 0; padding: 0; border-left: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail .v2-monitor-detail-state {
display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); width: 100%; min-width: 0; gap: 0;
}
.business-shell[data-density="compact"] .v2-content .fleet-workflow .v2-monitor-detail-metric-rail .v2-monitor-detail-state > span {
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; min-height: 40px; padding: 6px 8px; gap: 4px;
}
@@ -0,0 +1,171 @@
/* Queue-first governance: compact command rows, direct filters, evidence on demand. */
.governance-workspace { font-size:12px; gap:8px; min-width:0; }
.governance-workspace :is(.semi-typography,.semi-button,.semi-input,.semi-select,.semi-table,.semi-descriptions) { font-size:12px; }
.governance-workspace :is(.semi-card,.v2-workspace-command-bar) { box-shadow:none; border-radius:6px; }
.governance-workspace .v2-workspace-command-bar { min-height:48px; padding:8px 12px; gap:12px; }
.governance-workspace .v2-workspace-command-title { font-size:18px; line-height:24px; margin:0; }
.governance-workspace .v2-workspace-command-description { font-size:11px; line-height:16px; }
.governance-workspace .v2-workspace-command-icon { width:28px; height:28px; flex:0 0 28px; }
.governance-workspace .v2-workspace-panel-header { min-height:42px; padding:8px 12px; gap:8px; }
.governance-workspace .v2-workspace-panel-header :is(h3,h4,h5) { font-size:13px; line-height:18px; }
.governance-workspace .v2-workspace-panel-header :is(p,.semi-typography-secondary,.semi-typography-tertiary) { font-size:11px; line-height:16px; }
.governance-workspace :is(.v2-access-discovery-shell,.v2-access-workspace-v3,.v2-ops-workspace,.v2-alert-rules) { gap:8px; }
.governance-workspace .governance-query-bar { background:var(--semi-color-bg-1,#fff); border:1px solid var(--semi-color-border); border-radius:6px; padding:8px 12px; }
.governance-workspace :is(.v2-access-filter-v3,.v2-alert-filter-primary) { display:flex; align-items:flex-end; flex-wrap:wrap; padding:0; gap:8px; grid-template-columns:none; }
.governance-workspace :is(.v2-access-filter-v3,.v2-alert-filter-primary) > label { display:flex; flex-direction:column; flex:1 1 132px; gap:3px; min-width:100px; max-width:240px; }
.governance-workspace :is(.v2-access-filter-v3,.v2-alert-filter-primary) > label:first-child { flex:2 1 220px; max-width:340px; }
.governance-workspace :is(.v2-access-filter-v3,.v2-alert-filter-primary) > label > span { font-size:11px; line-height:15px; }
.governance-workspace .v2-access-filter-actions { display:flex; align-items:center; gap:6px; }
.governance-workspace :is(.v2-ops-navigation,.v2-alert-navigation) { display:flex; align-items:center; justify-content:space-between; min-height:38px; gap:12px; padding:0 8px; }
.governance-workspace :is(.v2-ops-tabs,.v2-alert-tabs) { display:flex; width:auto; min-width:0; flex:0 1 auto; grid-template-columns:none; overflow:auto; }
.governance-workspace :is(.v2-ops-tabs,.v2-alert-tabs) > .semi-button { width:auto; min-width:max-content; flex:0 0 auto; padding:0 12px; }
.governance-workspace .v2-ops-navigation-meta { min-width:0; font-size:11px; }
.governance-workspace .governance-status-strip { display:flex; flex-wrap:wrap; align-items:center; gap:4px 12px; padding:5px 12px; border:0; border-bottom:1px solid var(--semi-color-border); background:var(--semi-color-bg-1,#fff); border-radius:0; }
.governance-workspace .governance-status-items { display:flex; flex-wrap:wrap; gap:4px 16px; min-width:0; }
.governance-workspace .governance-status-item { display:inline-flex; align-items:center; gap:6px; min-height:28px; white-space:nowrap; }
.governance-workspace .governance-status-item > .semi-button { height:28px; min-height:28px; padding:0 8px; border-radius:4px; }
.governance-workspace .governance-status-item .semi-button-content { display:flex; align-items:center; gap:6px; }
.governance-workspace .governance-status-item strong { font-size:14px; line-height:20px; font-variant-numeric:tabular-nums; }
.governance-workspace .governance-status-item small { color:var(--semi-color-text-2); font-size:11px; }
.governance-workspace .governance-status-item.is-active > .semi-button { color:var(--semi-color-primary); background:var(--semi-color-primary-light-default); }
.governance-workspace .governance-status-context { display:flex; margin-left:auto; font-size:11px; color:var(--semi-color-text-2); }
.governance-workspace .governance-status-context > span { display:flex; align-items:center; gap:6px; }
.governance-workspace .governance-status-context :is(strong,em,small) { font-size:11px; font-style:normal; }
.governance-workspace .v2-access-protocol-coverage { display:flex; gap:10px; padding:0; }
.governance-workspace :is(.v2-access-status-tabs,.v2-reconcile-scope-nav,.v2-source-summary-region) { margin:0; padding:0; }
.governance-workspace .v2-event-status-tabs { display:flex; gap:4px; padding:4px 8px; min-height:36px; border-bottom:1px solid var(--semi-color-border); }
.governance-workspace .v2-event-status-tabs > .semi-button { min-height:28px; height:28px; padding:0 10px; }
.governance-workspace .v2-event-status-tabs strong { font-size:12px; }
.governance-workspace :is(.v2-access-table-scroll-v3,.v2-alert-table-scroll,.v2-reconcile-table-wrap.is-scroll-region,.v2-alert-delivery-table-wrap) { min-height:220px; max-height:calc(100dvh - 310px); overflow:auto; }
.governance-workspace .semi-table-thead > .semi-table-row > .semi-table-row-head { height:32px; padding:6px 10px; font-size:11px; background:#f6f8fb; }
.governance-workspace .semi-table-tbody > .semi-table-row > .semi-table-row-cell { height:44px; padding:6px 10px; font-size:12px; line-height:16px; }
.governance-workspace .semi-table-row-cell :is(strong,b) { font-size:12px; line-height:16px; }
.governance-workspace .semi-table-row-cell small { font-size:11px; line-height:15px; }
.governance-workspace :is(.v2-access-table-v3 > .semi-card-body > footer,.v2-reconcile-pagination,.v2-alert-pagination,.v2-alert-notification-pagination,.v2-alert-rule-pagination) { padding:6px 12px; min-height:38px; }
.governance-workspace .v2-reconcile-scope-nav { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:4px 12px; }
.governance-workspace .v2-reconcile-scope-nav > span { font-size:11px; }
.governance-workspace .v2-reconcile-toolbar { display:flex; flex-wrap:wrap; gap:6px; padding:8px 12px; }
.governance-workspace .v2-reconcile-toolbar > .semi-select { flex:1 1 118px; min-width:100px; }
.governance-workspace .v2-reconcile-toolbar .v2-reconcile-search { flex:2 1 210px; }
.governance-workspace .v2-reconcile-main { min-width:0; }
.governance-workspace .v2-reconcile-layout { display:block; padding:0; }
.governance-workspace .v2-alert-automation-intro { display:flex; flex-wrap:wrap; padding:8px 10px; gap:8px; }
.governance-workspace .v2-alert-automation-intro > .semi-input-wrapper { flex:1 1 210px; }
.governance-workspace .v2-event-automation-workspace { display:grid; grid-template-columns:minmax(300px, .8fr) minmax(0,1.4fr); gap:8px; align-items:start; }
.governance-workspace .v2-alert-rule-items { padding:0; max-height:calc(100dvh - 330px); overflow:auto; }
.governance-workspace .v2-alert-automation-row { min-height:54px; padding:5px 10px; gap:6px; border-radius:0; border-bottom:1px solid var(--semi-color-border); }
.governance-workspace .v2-alert-automation-icon { width:26px; height:26px; border-radius:4px; }
.governance-workspace .v2-alert-rule-item { min-height:42px; padding:0; }
.governance-workspace .v2-alert-rule-copy strong { font-size:12px; line-height:17px; }
.governance-workspace .v2-alert-rule-copy small { font-size:11px; line-height:16px; }
.governance-workspace .v2-alert-automation-flow { padding:10px; gap:8px; }
.governance-workspace .v2-event-template-shortcut { padding:8px 10px; }
.governance-workspace .v2-alert-delivery-health { display:flex; flex-wrap:wrap; align-items:center; gap:6px 16px; padding:8px 12px; border-radius:6px; }
.governance-workspace .v2-alert-delivery-health > header { padding:0; gap:8px; }
.governance-workspace .v2-alert-delivery-health > header strong { font-size:12px; }
.governance-workspace .v2-alert-delivery-health-metrics { display:flex; flex-wrap:wrap; gap:16px; padding:0; }
.governance-workspace .v2-alert-delivery-health-metrics > span { display:flex; align-items:center; gap:6px; padding:0; border:0; }
.governance-workspace .v2-alert-delivery-health-metrics strong { font-size:14px; }
.governance-workspace .v2-alert-delivery-health > footer { padding:0; border:0; margin-left:auto; }
.governance-workspace .v2-alert-delivery-toolbar { padding:8px 10px; gap:6px; }
.governance-workspace .v2-ops-source-list { display:flex; flex-direction:column; gap:0; padding:0; }
.governance-workspace .v2-ops-source-card { width:100%; margin:0; border:0; border-bottom:1px solid var(--semi-color-border); border-radius:0; }
.governance-workspace .v2-ops-source-card .semi-card-header { padding:8px 12px; border:0; }
.governance-workspace .v2-ops-source-card .semi-card-body { display:grid; grid-template-columns:180px 1fr 1fr; align-items:start; gap:12px; padding:0 12px 10px; }
.governance-workspace .v2-ops-source-card .semi-card-body > footer { grid-column:2 / -1; display:flex; gap:8px; padding:0; border:0; }
.governance-workspace .v2-ops-source-card :is(p,strong,small) { font-size:12px; line-height:18px; margin:0; }
.governance-workspace :is(.v2-ops-source-evidence,.v2-ops-source-action,.v2-ops-source-coverage) { padding:0; margin:0; }
.governance-workspace .v2-ops-grid { gap:8px; }
.governance-workspace .v2-ops-link-list { padding:0 10px; gap:0; }
.governance-workspace .v2-ops-link-card { padding:8px 0; border:0; border-bottom:1px solid var(--semi-color-border); border-radius:0; }
.governance-workspace .v2-ops-link-card :is(strong,p) { font-size:12px; line-height:18px; }
@media (min-width:761px) {
.governance-workspace :is(.semi-button,.semi-input-wrapper,.semi-select) { height:30px; min-height:30px; }
.governance-workspace .semi-input { height:28px; line-height:28px; }
.governance-workspace .v2-alert-delivery-workspace.is-inspector-open { grid-template-columns:minmax(0,1fr) 340px; }
}
@media (max-width:1100px) and (min-width:761px) {
.governance-workspace .v2-event-automation-workspace { grid-template-columns:300px minmax(0,1fr); }
.governance-workspace .governance-status-context { margin-left:0; }
.governance-workspace .v2-ops-navigation-meta > .semi-typography { display:none; }
}
@media (max-width:760px) {
.governance-workspace :is(.semi-button,.semi-input-wrapper,.semi-select) { min-height:40px; }
.governance-workspace .v2-workspace-command-bar { padding:8px; }
.governance-workspace .v2-workspace-command-title { font-size:18px; }
.governance-workspace .governance-status-items { flex-wrap:nowrap; overflow:auto; width:100%; gap:6px; }
.governance-workspace .governance-status-item > .semi-button { min-height:40px; }
.governance-workspace .governance-status-context { display:none; }
.governance-workspace :is(.v2-ops-navigation,.v2-alert-navigation) { padding:0; gap:4px; }
.governance-workspace .v2-ops-navigation-meta { display:none; }
.governance-workspace .v2-event-status-tabs { overflow:auto; }
.governance-workspace .v2-event-status-tabs > .semi-button { min-height:40px; flex:0 0 auto; }
.governance-workspace :is(.v2-access-table-scroll-v3,.v2-alert-table-scroll,.v2-reconcile-table-wrap.is-scroll-region,.v2-alert-delivery-table-wrap) { max-height:none; }
.governance-workspace .v2-event-automation-workspace { grid-template-columns:minmax(0,1fr); }
.governance-workspace .v2-alert-rule-item { min-height:44px; }
.governance-workspace .v2-reconcile-scope-nav > span { display:none; }
.governance-workspace .v2-alert-delivery-health-metrics { gap:10px; }
.governance-workspace .v2-alert-delivery-health > footer { margin:0; }
}
@media (min-width:761px) {
.governance-workspace .v2-alert-rule-item { height:auto; min-height:42px; }
.governance-workspace .governance-status-item.is-warning strong { color:var(--semi-color-warning); }
.governance-workspace .governance-status-item.is-danger strong { color:var(--semi-color-danger); }
.governance-workspace .governance-status-item.is-success strong { color:var(--semi-color-success); }
}
.governance-workspace .v2-alert-delivery-health-metrics > .semi-button { padding:0 6px; }
.governance-workspace .v2-alert-delivery-health-metrics > .semi-button .semi-button-content { display:flex; align-items:center; gap:6px; }
.governance-workspace .v2-alert-delivery-health-metrics > .semi-button[aria-pressed="true"] { background:var(--semi-color-primary-light-default); color:var(--semi-color-primary); }
/* Keep real queue evidence to two lines, even under legacy table-cell rules. */
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-semi-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell { height:50px; padding:6px 10px; line-height:16px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace :is(.v2-access-protocol-cell,.v2-access-connection,.v2-access-primary-cell) { display:flex; flex-direction:column; justify-content:center; gap:2px; min-height:0; padding:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace :is(.v2-access-protocol-cell,.v2-access-connection,.v2-access-primary-cell) > :is(strong,span,small) { font-size:11px; line-height:16px; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-primary-cell > strong { font-size:12px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-connection-line { display:flex; align-items:center; gap:6px; min-height:20px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-connection-line small { font-size:11px; line-height:16px; color:var(--semi-color-text-2); white-space:nowrap; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-connection-tag { min-height:18px; height:18px; line-height:18px; font-size:11px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-protocol-cell .v2-access-protocol-status-line { display:flex; align-items:center; justify-content:space-between; gap:6px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-protocol-status-line > span { display:inline-flex; align-items:center; gap:4px; white-space:nowrap; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-protocol-status-line small { font-size:10px; line-height:16px; color:var(--semi-color-text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-access-protocol-cell .v2-access-protocol-absent { color:var(--semi-color-text-2); }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-reconcile-table-wrap.is-empty { height:auto; min-height:190px; max-height:none; overflow:visible; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-reconcile-table-wrap.is-empty .v2-reconcile-empty { height:auto; min-height:190px; max-height:none; overflow:visible; padding:20px 16px; }
/* Final compact contract outranks the inherited experience layout. */
.business-shell[data-density="compact"] .v2-content .governance-workspace { gap:8px; padding:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-navigation { height:40px; min-height:40px; padding:0 8px; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-tabs { height:40px; min-height:40px; gap:4px; padding:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-tabs > .semi-button { min-height:36px; height:36px; padding:0 12px; font-size:12px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .governance-query-bar { padding:6px 10px; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-filter-primary { display:flex; flex-wrap:wrap; gap:8px; min-height:0; padding:0; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-filter-primary > label { gap:2px; min-height:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-filter-primary > label > span { font-size:11px; line-height:15px; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-filter-primary :is(.semi-input-wrapper,.semi-select,.semi-button) { height:30px; min-height:30px; font-size:12px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-event-status-tabs { height:38px; min-height:38px; padding:3px 8px; margin:0; gap:4px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-event-status-tabs > .semi-button { height:30px; min-height:30px; padding:0 10px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-event-status-tabs :is(strong,span) { font-size:12px; line-height:18px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-card { display:grid; grid-template-columns:minmax(130px,.7fr) minmax(0,1fr) auto; align-items:center; column-gap:12px; min-height:46px; padding:8px 0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-status { display:flex; width:auto; min-width:0; max-width:none; gap:7px; overflow:visible; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-status > span { display:flex; flex-direction:column; flex:1; width:auto; min-width:0; max-width:none; gap:1px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-status :is(strong,small) { display:block; width:auto; max-width:none; font-size:12px; line-height:17px; visibility:visible; opacity:1; overflow:visible; white-space:normal; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-status small { color:var(--semi-color-text-2); font-size:10px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-card > p { grid-column:auto; margin:0; font-size:11px; line-height:17px; white-space:normal; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-source-empty.v2-state-surface { min-height:110px; height:auto; padding:10px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-source-empty.semi-empty { min-height:0; height:auto; padding:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-source-empty .semi-empty-image { width:24px; height:24px; margin:0 auto 4px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-source-empty .semi-empty-title { margin:0; font-size:12px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-source-empty .semi-empty-description { margin-top:4px; font-size:11px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-reconcile-table-wrap.is-empty .v2-reconcile-empty.v2-state-surface { min-height:160px; padding:16px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-reconcile-table-wrap.is-empty .v2-reconcile-empty.semi-empty { min-height:0; padding:0; }
.governance-workspace .governance-capacity-ok { display:flex; align-items:center; flex-wrap:wrap; gap:8px; padding:8px 12px; border-top:1px solid var(--semi-color-border); font-size:11px; color:var(--semi-color-text-2); }
@media (max-width:760px) {
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-tabs > .semi-button,
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-event-status-tabs > .semi-button { min-height:40px; height:40px; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-event-status-tabs { min-height:46px; height:auto; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-card { grid-template-columns:minmax(0,1fr) auto; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-ops-link-card > p { grid-row:2; grid-column:1 / -1; }
}
/* Event tabs have different children: fixed legacy grid rows create phantom headers. */
.business-shell[data-density="compact"] .v2-content .governance-workspace.v2-event-center { display:flex; flex-direction:column; align-items:stretch; grid-template-rows:none; grid-auto-rows:auto; gap:8px; min-height:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace.v2-event-center > :is(.v2-alert-navigation,.governance-query-bar,.v2-event-status-tabs) { flex:0 0 auto; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace.v2-event-center > :is(.v2-alert-workspace,.v2-alert-rules,.v2-alert-delivery-workspace) { flex:1 1 auto; min-height:0; height:auto; margin:0; }
.business-shell[data-density="compact"] .v2-content .governance-workspace .v2-alert-delivery-workspace { padding:0; margin:0; gap:8px; }
@@ -0,0 +1,304 @@
/* These two data workspaces keep one scroll owner: the results table. */
.business-shell .v2-content:is(.is-section-customer-mileage,.is-section-operation-compliance) {
display:flex;
flex-direction:column;
overflow:hidden;
padding:8px 12px;
scrollbar-gutter:auto;
}
.business-shell .v2-content :is(.cm-page,.oc-page) {
display:flex;
flex-direction:column;
flex:1;
width:100%;
height:100%;
min-height:0;
min-width:0;
margin:0;
padding:0;
gap:6px;
overflow:hidden;
}
.business-shell .v2-content :is(.cm-filters,.oc-filter-panel) {
flex-shrink:0;
position:relative;
padding:4px 10px;
border-radius:8px;
}
.mileage-filter-body[hidden] { display:none!important; }
.mileage-filter-body { min-width:0; }
.business-shell .v2-main:has(>.v2-content:is(.is-section-customer-mileage,.is-section-operation-compliance))>.semi-banner { padding:3px 12px; min-height:26px; }
.business-shell .v2-content :is(.cm-filters,.oc-filter-panel) :is([data-slot=input],[data-slot=input-group],.oc-select-trigger) { height:32px; min-height:32px; }
.business-shell .v2-content .cm-filter-fields[data-slot=field-group] {
display:grid;
grid-template-columns:repeat(2,minmax(124px,.9fr)) minmax(140px,1.2fr) repeat(2,minmax(110px,1fr)) auto;
align-items:end;
gap:8px;
}
.business-shell .v2-content .oc-filter-grid {
grid-template-columns:repeat(2,minmax(124px,.95fr)) 132px 112px repeat(3,minmax(100px,1fr)) auto;
align-items:end;
gap:8px;
}
.business-shell .v2-content :is(.cm-filter-fields [data-slot=field],.oc-field,.oc-multiselect) {
min-width:0;
gap:3px;
}
.business-shell[data-density] .v2-content :is(.cm-page,.oc-page) :is([data-slot=field-label],.oc-field>span,.oc-field-label) {
font-size:12px;
line-height:16px;
font-weight:500;
}
.business-shell .v2-content :is(.cm-actions,.oc-filter-actions) {
display:flex;
align-items:center;
align-self:end;
gap:4px;
margin:0;
padding:0;
border:0;
}
.business-shell .v2-content :is(.cm-query-button,.oc-filter-actions>[type=submit]) { min-width:64px; }
.business-shell .v2-content :is(.cm-actions,.oc-filter-actions)>button { flex-shrink:0; }
.business-shell .v2-content .oc-selection-chips {
max-height:24px;
flex-wrap:nowrap;
overflow:auto;
}
.oc-selection-chips>span { flex-shrink:0; }
.business-shell .v2-content .oc-target-label { display:flex; align-items:center; gap:3px; white-space:nowrap; }
.business-shell .v2-content .oc-target-field>.sr-only { position:absolute; }
.mileage-query-note { margin:4px 0 0; font-size:12px; line-height:16px; color:#6e7480; }
.mileage-toolbar-popover {
z-index:90;
max-width:calc(100vw - 24px);
padding:12px;
border:1px solid var(--border);
border-radius:8px;
background:var(--background);
color:var(--foreground);
box-shadow:0 8px 24px #0002;
}
.mileage-toolbar-popover p { margin:0 0 8px; font-size:12px; color:var(--muted-foreground); }
.mileage-mobile-filter-bar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.mileage-mobile-filter-bar>span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
.mileage-mobile-filter-bar>button { flex-shrink:0; }
.business-shell .v2-content :is(.cm-summary,.oc-summary) {
display:grid;
grid-template-columns:repeat(5,minmax(0,1fr));
grid-template-rows:minmax(0,1fr);
height:60px;
flex-shrink:0;
gap:0;
border:1px solid #e5e7eb;
border-radius:8px;
background:#fff;
overflow:visible;
}
.business-shell .v2-content :is(.cm-summary,.oc-summary) article {
display:flex;
flex-direction:column;
align-items:flex-start;
justify-content:center;
gap:1px;
min-width:0;
min-height:0;
padding:5px 14px;
border:0;
border-radius:0;
background:transparent;
}
.business-shell .v2-content :is(.cm-summary,.oc-summary) article+article {
border-left:1px solid #edf0f3;
}
.business-shell .v2-content .cm-summary article:before { display:none; }
.business-shell .v2-content :is(.cm-summary,.oc-summary) article>span {
display:flex;
align-items:center;
gap:5px;
font-size:12px;
line-height:16px;
white-space:nowrap;
}
.business-shell .v2-content :is(.cm-summary,.oc-summary) strong {
margin:0;
font-size:23px;
line-height:27px;
letter-spacing:-.5px;
font-weight:600;
white-space:nowrap;
}
.business-shell .v2-content :is(.cm-summary,.oc-summary) strong small { font-size:12px; letter-spacing:0; }
.mileage-hint {
display:inline-flex;
justify-content:center;
align-items:center;
flex-shrink:0;
width:18px;
height:18px;
padding:0;
border:0;
border-radius:4px;
background:transparent;
color:#7b8490;
cursor:help;
}
.mileage-hint svg { width:13px!important; height:13px!important; }
.mileage-hint:focus-visible { outline:2px solid #0071e3; outline-offset:1px; }
.mileage-hint-content { max-width:min(360px,calc(100vw - 24px)); font-size:12px; line-height:1.6; }
.mileage-hint-popover { z-index:100; max-width:min(360px,calc(100vw - 24px)); padding:10px 12px; border:1px solid #dfe3e8; border-radius:8px; background:#fff; color:#333; box-shadow:0 8px 24px #0002; font-size:12px; line-height:1.6; }
.business-shell .v2-content :is(.cm-page .cm-notice,.oc-data-notice,.oc-error) {
flex-shrink:0;
padding:4px 10px;
margin:0;
gap:6px;
font-size:12px;
line-height:18px;
border-radius:6px;
}
.business-shell .v2-content :is(.cm-page .cm-notice,.oc-data-notice) svg { width:14px; height:14px; margin-top:2px; }
.business-shell .v2-content .oc-data-notice p { margin:0; }
.business-shell .v2-content :is(.cm-overview,.oc-results) {
display:flex;
flex-direction:column;
flex:1;
min-height:0;
min-width:0;
overflow:hidden;
}
.business-shell .v2-content :is(.cm-overview-heading,.oc-results-heading) {
flex-shrink:0;
flex-direction:row;
align-items:center;
gap:12px;
padding:6px 10px;
}
.business-shell .v2-content :is(.cm-overview-copy,.oc-results-copy) { display:flex; align-items:center; gap:12px; min-width:0; }
.business-shell .v2-content :is(.cm-overview-copy,.oc-results-copy) h2 { flex-shrink:0; margin:0; font-size:14px; line-height:20px; }
.business-shell .v2-content :is(.cm-overview-copy,.oc-results-copy) p { margin:0; font-size:12px; line-height:18px; }
.business-shell .v2-content .cm-overview-legend { gap:8px; }
.business-shell .v2-content .cm-overview-tools { flex-shrink:0; padding:0 10px 6px; gap:8px; flex-wrap:nowrap; }
.business-shell .v2-content .cm-overview-command { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:5px 10px; flex-shrink:0; }
.business-shell .v2-content .cm-overview-command .cm-overview-copy { display:block; flex-shrink:0; }
.business-shell .v2-content .cm-overview-command .cm-overview-copy p { font-size:12px; }
.business-shell .v2-content .cm-overview-command .cm-overview-tools { padding:0; justify-content:flex-end; min-width:0; }
.business-shell .v2-content .cm-gps-filter { flex-wrap:nowrap; gap:4px; white-space:nowrap; }
.business-shell .v2-content .cm-gps-filter input { width:86px; }
.business-shell .v2-content .cm-sort { gap:5px; white-space:nowrap; }
.business-shell .v2-content .cm-sort select { width:152px; }
.business-shell .v2-content .oc-result-controls { gap:8px; flex-shrink:0; }
.business-shell .v2-content .oc-result-controls label { display:flex; align-items:center; gap:5px; min-width:0; white-space:nowrap; }
.business-shell .v2-content .oc-result-controls label>span { font-size:12px; }
.business-shell .v2-content :is(.cm-matrix-scroll,.oc-table-scroll) {
flex:1;
min-height:0;
max-height:none;
min-width:0;
overflow:auto;
overscroll-behavior:contain;
scrollbar-width:thin;
overflow-anchor:none;
}
.business-shell .v2-content .cm-matrix-scroll { --cm-customer-width:240px; }
.business-shell[data-density] .v2-content .cm-page .cm-overview .cm-matrix.cm-matrix :is([data-slot=table-cell],[data-slot=table-head]) {
height:auto;
padding:2px 8px;
font-size:13px;
line-height:18px;
}
.business-shell[data-density] .v2-content .cm-page .cm-overview .cm-matrix.cm-matrix thead [data-slot=table-head] { height:36px; padding:3px 8px; }
.business-shell[data-density] .v2-content .cm-page .cm-overview .cm-matrix.cm-matrix [data-slot=table-cell].cm-total-column { padding:2px 8px; }
.business-shell .v2-content .cm-matrix thead th small { display:inline; margin:0 0 0 6px; font-size:12px; }
.business-shell .v2-content .cm-matrix .cm-total-column { width:150px; min-width:150px; }
.business-shell .v2-content .cm-matrix :is(.cm-date-cell,.cm-date-column) { width:204px; min-width:204px; }
.business-shell .v2-content .cm-customer-identity { display:flex; align-items:center; gap:6px; }
.business-shell .v2-content .cm-customer-identity strong { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:13px; line-height:18px; }
.business-shell .v2-content .cm-customer-identity .cm-vehicle-count { flex-shrink:0; font-size:11px; line-height:16px; }
.business-shell .v2-content .cm-matrix .cm-customer-owner { font-size:12px; line-height:16px; margin-top:1px; }
.business-shell .v2-content .cm-day-cell { min-height:0; padding:1px 2px; gap:0; }
.business-shell .v2-content .cm-daily-lines { gap:1px; }
.business-shell .v2-content .cm-daily-line { gap:5px; line-height:18px; }
.business-shell .v2-content .cm-day-status { font-size:11px; line-height:15px; padding:0 4px; }
.business-shell .v2-content .cm-total-drill { padding:0; }
.business-shell .v2-content .cm-mileage-pair { gap:1px; font-size:13px; line-height:18px; }
.business-shell[data-density] .v2-content .oc-page .oc-results .oc-table-scroll th[data-slot=table-head] {
position:sticky;
top:0;
z-index:2;
height:34px;
padding:5px 10px;
font-size:12px;
background:#f7f8fa;
}
.business-shell[data-density] .v2-content .oc-page .oc-results .oc-table-scroll th[data-slot=table-head]:first-child { z-index:4; }
.business-shell[data-density] .v2-content .oc-page .oc-results .oc-table-scroll td[data-slot=table-cell] {
height:42px;
padding:3px 10px;
font-size:13px;
line-height:18px;
vertical-align:middle;
}
.business-shell .v2-content .oc-table-scroll td>small { margin-top:0; font-size:11px; line-height:15px; }
.business-shell .v2-content .oc-table-scroll .oc-vin { font-size:11px; letter-spacing:0; }
.oc-coverage-value { display:flex; align-items:center; gap:4px; }
.business-shell .v2-content :is(.cm-pagination,.oc-pagination) {
flex-shrink:0;
flex-direction:row;
align-items:center;
flex-wrap:nowrap;
min-height:38px;
padding:3px 10px;
gap:8px;
font-size:12px;
}
.business-shell .v2-content :is(.cm-method,.oc-method) { flex-shrink:0; padding:0; max-height:25vh; overflow:auto; font-size:12px; }
.business-shell .v2-content :is(.cm-method,.oc-method) summary { padding:3px 10px; font-size:12px; line-height:18px; }
.business-shell .v2-content .oc-method p { margin:8px 10px; }
.business-shell .v2-content :is(.cm-overview-footer,.oc-results-footer) { display:flex; align-items:center; flex-shrink:0; border-top:1px solid #edf0f3; }
.business-shell .v2-content :is(.cm-overview-footer,.oc-results-footer)>:is(.cm-pagination,.oc-pagination) { flex:1; min-width:0; border-top:0; }
.business-shell .v2-content :is(.cm-overview-footer,.oc-results-footer)>:is(.cm-method,.oc-method) { position:relative; overflow:visible; max-height:none; border:0; margin-right:6px; background:transparent; }
.business-shell .v2-content .mileage-method-body { position:absolute; bottom:calc(100% + 6px); right:0; z-index:45; width:min(680px,calc(100vw - 40px)); max-height:45dvh; overflow:auto; padding:8px; border:1px solid #dfe3e8; border-radius:8px; background:#fff; box-shadow:0 8px 24px #0002; }
.business-shell .v2-content :is(.oc-loading,.cm-loading) { flex:1; min-height:0; overflow:hidden; }
@media(max-width:1100px) and (min-width:761px) {
.business-shell .v2-content .cm-filter-fields[data-slot=field-group],
.business-shell .v2-content .oc-filter-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
.business-shell .v2-content :is(.cm-overview-tools,.oc-results-heading) { flex-wrap:wrap; }
.business-shell .v2-content .cm-overview-command { flex-wrap:wrap; }
}
@media(max-width:760px) {
.business-shell .v2-content:is(.is-section-customer-mileage,.is-section-operation-compliance) { padding:6px; }
.business-shell .v2-content :is(.cm-filters,.oc-filter-panel) { padding:6px 8px; max-height:55dvh; overflow:auto; }
.mileage-mobile-filter-bar:has(+.mileage-filter-body:not([hidden])) { margin-bottom:8px; }
.business-shell .v2-content .cm-filter-fields[data-slot=field-group],
.business-shell .v2-content .oc-filter-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.business-shell .v2-content :is(.cm-actions,.oc-filter-actions) { grid-column:1/-1; justify-content:flex-end; flex-direction:row; }
.business-shell .v2-content :is(.cm-page,.oc-page) [data-slot=input] { min-width:0; }
.business-shell .v2-content :is(.cm-summary,.oc-summary) { grid-template-columns:repeat(5,minmax(128px,1fr)); height:60px; overflow:auto; }
.business-shell .v2-content :is(.cm-summary,.oc-summary) article { grid-column:auto; padding:5px 9px; }
.business-shell .v2-content :is(.cm-summary,.oc-summary) strong { font-size:22px; }
.business-shell .v2-content :is(.cm-overview-heading,.oc-results-heading) { align-items:flex-start; flex-direction:column; gap:4px; padding:5px 8px; }
.business-shell .v2-content :is(.cm-overview-copy,.oc-results-copy) { width:100%; justify-content:space-between; gap:8px; flex-wrap:wrap; }
.business-shell .v2-content .cm-overview-legend { width:100%; justify-content:flex-end; }
.business-shell .v2-content .cm-overview-command { align-items:flex-start; flex-direction:column; gap:4px; padding:5px 8px; }
.business-shell .v2-content .cm-overview-command .cm-overview-tools { justify-content:flex-start; }
.business-shell .v2-content .cm-overview-tools { flex-wrap:wrap; padding:0 8px 5px; gap:4px; }
.business-shell .v2-content .cm-overview-tools>[data-slot=toggle-group] { flex-basis:100%; }
.business-shell .v2-content .cm-overview-tools .cm-sort { margin-left:0; }
.business-shell .v2-content .cm-overview-tools .cm-sort select { width:140px; }
.business-shell .v2-content .oc-result-controls { width:100%; justify-content:space-between; gap:6px; }
.business-shell .v2-content .oc-result-controls label { flex:initial; }
.business-shell .v2-content .oc-result-controls label>span { display:none; }
.business-shell .v2-content .cm-matrix-scroll { --cm-customer-width:150px; }
.business-shell .v2-content .cm-matrix .cm-total-column { position:static; width:126px; min-width:126px; }
.business-shell .v2-content .cm-customer-identity { flex-wrap:wrap; gap:0; }
.business-shell .v2-content .cm-customer-identity strong { flex-basis:100%; }
.business-shell .v2-content .cm-customer-owner { white-space:normal; }
.business-shell .v2-content :is(.cm-pagination,.oc-pagination) { min-height:38px; padding:3px 6px; gap:4px; font-size:11px; }
.business-shell .v2-content :is(.cm-pagination,.oc-pagination)>div { width:auto; margin-left:auto; gap:4px; }
.business-shell .v2-content :is(.cm-pagination,.oc-pagination)>div>button { padding-inline:5px; font-size:12px; min-width:0; }
.business-shell .v2-content .cm-method summary>span { display:none; }
.business-shell .v2-content :is(.cm-overview-footer,.oc-results-footer) { flex-wrap:wrap; }
.business-shell .v2-content :is(.cm-overview-footer,.oc-results-footer)>:is(.cm-pagination,.oc-pagination) { flex-basis:100%; }
.business-shell .v2-content :is(.cm-overview-footer,.oc-results-footer)>:is(.cm-method,.oc-method) { margin-left:auto; }
}
@@ -0,0 +1,32 @@
.v2-oneos-catalog { display: grid; gap: 18px; }
.v2-oneos-catalog-intro > .v2-workspace-panel-header,
.v2-oneos-catalog-panel > .v2-workspace-panel-header { padding: 18px 20px; }
.v2-oneos-catalog-intro > p { margin: 0; padding: 0 20px 18px; color: #73511d; }
.v2-oneos-catalog-panel .business-tabs { margin: 0 20px; overflow-x: auto; }
.v2-oneos-catalog-filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; padding: 18px 20px; }
.v2-oneos-catalog-filters label { display: grid; gap: 6px; min-width: 0; color: #52647a; font-size: 12px; }
.v2-oneos-catalog-filters label > .semi-input-wrapper,
.v2-oneos-catalog-filters select { width: 100%; min-width: 0; }
.v2-oneos-catalog-filters select { height: 32px; padding: 0 8px; border: 1px solid #dbe4ef; border-radius: 6px; background: #fff; color: #243950; }
.v2-oneos-catalog-filters details { grid-column: 1 / -1; }
.v2-oneos-catalog-filters summary { width: max-content; cursor: pointer; color: #2a69a2; }
.v2-oneos-catalog-more { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; padding-top: 14px; }
.v2-oneos-catalog-filter-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; }
.v2-oneos-catalog-list { border-top: 1px solid #e8edf3; }
.v2-oneos-catalog-row { display: grid; grid-template-columns: 1.5fr 1.25fr 1fr 1fr auto; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #e8edf3; }
.v2-oneos-catalog-row > div { display: grid; gap: 4px; min-width: 0; }
.v2-oneos-catalog-row strong { overflow-wrap: anywhere; color: #243950; }
.v2-oneos-catalog-row small,
.v2-oneos-catalog-row span { color: #718199; font-size: 12px; }
.v2-oneos-catalog-pagination { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px 20px; }
@media (max-width: 900px) {
.v2-oneos-catalog-filters,
.v2-oneos-catalog-more { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-oneos-catalog-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.v2-oneos-catalog-filters,
.v2-oneos-catalog-more,
.v2-oneos-catalog-row { grid-template-columns: 1fr; }
.v2-oneos-catalog-pagination { flex-wrap: wrap; }
}
@@ -0,0 +1,528 @@
.oc-page {
display:grid;
grid-template-columns:minmax(0,1fr);
gap:18px;
padding:20px;
min-width:0;
color:#1d1d1f;
font-size:13px;
}
.oc-page svg {
width:16px;
height:16px;
flex-shrink:0;
}
.oc-panel,.oc-filter-panel {
min-width:0;
background:#fff;
border:1px solid #e5e7eb;
border-radius:12px;
}
.oc-filter-panel {
padding:20px;
}
.oc-filter-grid {
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:18px 22px;
align-items:start;
}
.oc-field,.oc-multiselect {
display:grid;
gap:7px;
min-width:0;
}
.oc-field>span,.oc-field-label {
font-size:12px;
font-weight:600;
color:#42474e;
}
.oc-field small {
font-weight:400;
color:#757b84;
font-size:11px;
}
.oc-field>span small {
margin-left:4px;
}
.oc-select-trigger {
width:100%;
justify-content:space-between;
font-weight:400;
}
.oc-select-trigger>span {
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.oc-selection-chips {
display:flex;
flex-wrap:wrap;
gap:5px;
max-height:90px;
overflow:auto;
}
.oc-selection-chips>span {
display:flex;
align-items:center;
gap:3px;
max-width:100%;
padding:3px 5px 3px 8px;
background:#f0f5fc;
border-radius:5px;
font-size:11px;
color:#28548a;
}
.oc-selection-chips>span>span {
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.oc-selection-chips button {
border:0;
background:transparent;
cursor:pointer;
padding:1px;
border-radius:3px;
display:flex;
}
.oc-selection-chips svg {
width:12px;
height:12px;
}
.oc-select-popover {
z-index:90;
width:var(--radix-popover-trigger-width);
min-width:260px;
max-width:calc(100vw - 24px);
border:1px solid #dfe3e8;
background:#fff;
border-radius:10px;
box-shadow:0 12px 40px #152b4826;
padding:10px;
color:#1d1d1f;
}
.oc-select-search {
display:flex;
align-items:center;
gap:8px;
}
.oc-select-search svg {
width:16px;
height:16px;
color:#7c8490;
}
.oc-select-options {
max-height:250px;
overflow:auto;
margin:8px -2px;
}
.oc-select-option {
display:flex;
align-items:flex-start;
gap:10px;
border-radius:5px;
padding:9px 7px;
font-size:13px;
cursor:pointer;
line-height:1.4;
}
.oc-select-option:hover {
background:#f3f6fa;
}
.oc-select-option>button {
margin-top:1px;
flex-shrink:0;
}
.oc-select-options>p {
padding:14px 8px;
font-size:12px;
color:#737b87;
}
.oc-select-footer {
display:flex;
align-items:center;
gap:4px;
border-top:1px solid #edf0f3;
padding-top:8px;
font-size:11px;
}
.oc-select-footer>span {
margin-right:auto;
color:#68717f;
}
.oc-filter-actions {
display:flex;
justify-content:space-between;
align-items:center;
gap:16px;
border-top:1px solid #eef0f3;
margin-top:18px;
padding-top:14px;
}
.oc-filter-actions>span {
font-size:11px;
color:#777f8a;
}
.oc-filter-actions>div {
display:flex;
gap:8px;
}
.oc-error {
display:flex;
align-items:center;
gap:8px;
color:#b42318;
background:#fff5f4;
border-radius:6px;
padding:10px;
margin-top:12px;
}
.oc-summary {
display:grid;
grid-template-columns:repeat(5,minmax(0,1fr));
gap:12px;
}
.oc-summary article {
border:1px solid #e5e7eb;
background:#fff;
border-radius:10px;
padding:18px;
display:grid;
gap:8px;
min-width:0;
}
.oc-summary article>span {
display:flex;
align-items:center;
gap:5px;
font-size:12px;
color:#69717d;
}
.oc-summary strong {
font-size:27px;
font-weight:650;
letter-spacing:-.8px;
font-variant-numeric:tabular-nums;
white-space:nowrap;
}
.oc-summary strong small {
font-size:11px;
color:#8a9098;
font-weight:400;
letter-spacing:0;
}
.oc-summary p {
font-size:10px;
color:#858c96;
line-height:1.5;
}
.oc-summary .oc-metric-passed strong {
color:#208150;
}
.oc-summary .oc-metric-rate {
background:#f5f8fd;
border-color:#dfe8f5;
}
.oc-summary .oc-metric-rate strong {
color:#2464ad;
}
.oc-data-notice {
display:flex;
gap:8px;
background:#fff9eb;
border:1px solid #f2e2b7;
padding:10px 14px;
border-radius:8px;
color:#876322;
font-size:12px;
line-height:1.6;
}
.oc-data-notice>svg {
margin-top:2px;
}
.oc-results-heading {
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
padding:20px 20px 12px;
}
.oc-results-heading h2 {
font-size:15px;
font-weight:650;
margin:0 0 7px;
}
.oc-results-heading p {
font-size:11px;
color:#737c89;
line-height:1.6;
}
.oc-result-controls {
display:flex;
gap:12px;
}
.oc-result-controls label {
display:grid;
gap:5px;
font-size:11px;
color:#6f7884;
min-width:120px;
}
.oc-result-controls label>span {
display:flex;
align-items:center;
gap:4px;
}
.oc-summary-scope {
padding:0 20px 14px;
color:#8a919c;
font-size:10px;
}
.oc-table-scroll {
overflow:auto;
max-width:100%;
border-top:1px solid #edf0f3;
}
.oc-table-scroll table {
min-width:1020px;
}
.oc-table-scroll>[data-slot=table-container] {
overflow:visible;
}
.oc-table-scroll th {
font-size:11px;
color:#727b88;
background:#fafbfc;
font-weight:500;
padding:12px 16px;
}
.oc-table-scroll td {
padding:14px 16px;
vertical-align:top;
font-size:12px;
max-width:240px;
white-space:normal;
overflow-wrap:anywhere;
}
.oc-table-scroll td>strong {
font-weight:600;
white-space:nowrap;
}
.oc-table-scroll td>small {
display:block;
font-size:10px;
line-height:1.6;
color:#8b929b;
margin-top:4px;
}
.oc-table-scroll .oc-vin {
font-size:9px;
white-space:nowrap;
letter-spacing:.2px;
}
.oc-table-scroll .oc-number {
text-align:right;
white-space:nowrap;
font-variant-numeric:tabular-nums;
}
.oc-table-scroll .oc-mileage {
font-weight:600;
font-size:14px;
}
.oc-table-scroll .oc-reason,.oc-table-scroll .oc-incomplete {
color:#9d7323;
}
.oc-pagination {
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding:14px 20px;
border-top:1px solid #edf0f3;
font-size:11px;
color:#7c8591;
}
.oc-pagination>div {
display:flex;
gap:12px;
align-items:center;
}
.oc-empty {
display:grid;
justify-items:center;
gap:12px;
text-align:center;
padding:50px 20px;
}
.oc-empty>svg {
width:28px;
height:28px;
color:#8794a5;
}
.oc-empty h2,.oc-empty h3 {
font-size:14px;
font-weight:600;
}
.oc-empty p {
font-size:12px;
color:#7b8492;
max-width:500px;
line-height:1.7;
}
.oc-method {
padding:14px 18px;
color:#747e8b;
font-size:11px;
line-height:1.8;
}
.oc-method summary {
display:flex;
align-items:center;
gap:7px;
cursor:pointer;
font-weight:500;
color:#566274;
}
.oc-method p {
margin-top:10px;
}
.oc-loading {
display:grid;
gap:18px;
color:#7b8591;
font-size:12px;
}
.oc-loading>div {
display:grid;
grid-template-columns:repeat(5,1fr);
gap:12px;
}
.oc-spinning {
animation:oc-spin 1.2s linear infinite;
}
.oc-vehicle-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:0; background:transparent; color:#1967c2; cursor:pointer; font:inherit; font-weight:600; white-space:nowrap; }
.oc-vehicle-link:hover { text-decoration:underline; }
.oc-vehicle-link:focus-visible { outline:2px solid #1967c2; outline-offset:3px; border-radius:2px; }
.oc-vehicle-link svg { width:13px; height:13px; }
.oc-table-scroll :is(th:first-child,td:first-child) { position:sticky; left:0; z-index:1; background:#fff; }
.oc-table-scroll th:first-child { z-index:4; }
.oc-daily-dialog { display:flex; flex-direction:column; height:min(700px,calc(100dvh - 32px)); gap:0; padding:0; }
.oc-daily-dialog .business-dialog-header { flex-shrink:0; }
.oc-daily-dialog .business-dialog-body { display:flex; flex:1; flex-direction:column; min-height:0; overflow:hidden; padding:12px 16px; gap:10px; }
.oc-daily-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; color:#737c89; }
.oc-daily-toolbar>span { overflow-wrap:anywhere; }
.oc-daily-owner { font-size:12px; color:#57616e; overflow-wrap:anywhere; }
.oc-daily-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border:1px solid #e5e7eb; border-radius:6px; flex-shrink:0; }
.oc-daily-summary>span { display:flex; flex-direction:column; align-items:flex-start; gap:5px; padding:8px 10px; font-size:12px; color:#737c89; }
.oc-daily-summary strong { font-size:15px; color:#1d1d1f; font-weight:600; font-variant-numeric:tabular-nums; }
.oc-daily-note { margin:0; font-size:12px; line-height:1.6; color:#737c89; flex-shrink:0; }
.oc-daily-table { flex:1; min-height:0; overflow:auto; border:1px solid #e5e7eb; border-radius:6px; }
.oc-daily-table [data-slot=table-container] { overflow:visible; }
.oc-daily-table table { min-width:680px; font-size:12px; }
.oc-daily-table th { position:sticky; top:0; z-index:2; background:#f7f8fa; }
.oc-daily-table :is(td,th):first-child { position:sticky; left:0; background:#fff; z-index:1; }
.oc-daily-table th:first-child { z-index:3; background:#f7f8fa; }
.oc-daily-table :is(td,th) { padding:9px 10px; }
.oc-daily-table .oc-number { text-align:right; font-variant-numeric:tabular-nums; }
.oc-daily-error { display:grid; gap:12px; justify-items:start; font-size:13px; }
@media(max-width:600px) {
.oc-daily-dialog .business-dialog-body { padding:10px; gap:8px; }
.oc-daily-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
.oc-daily-summary>span { padding:6px 8px; }
}
@keyframes oc-spin {
to {
transform:rotate(360deg);
}
}
@media(min-width:1600px) {
.oc-filter-grid {
grid-template-columns:repeat(6,minmax(0,1fr));
}
}
@media(max-width:1000px) {
.oc-summary {
grid-template-columns:repeat(3,minmax(0,1fr));
}
.oc-results-heading {
align-items:flex-start;
flex-direction:column;
}
.oc-summary article {
padding:14px;
}
.oc-filter-grid {
gap:15px;
}
}
@media(max-width:600px) {
.oc-page {
padding:12px;
gap:12px;
}
.oc-filter-panel {
padding:14px;
}
.oc-filter-grid {
grid-template-columns:repeat(2,minmax(0,1fr));
gap:14px 12px;
}
.oc-filter-grid>.oc-field:nth-child(3) {
grid-column:1/-1;
}
.oc-filter-grid>.oc-multiselect:last-child {
grid-column:1/-1;
}
.oc-summary {
grid-template-columns:repeat(2,minmax(0,1fr));
gap:8px;
}
.oc-summary article:last-child {
grid-column:1/-1;
}
.oc-summary strong {
font-size:25px;
}
.oc-filter-actions {
flex-direction:column;
align-items:stretch;
gap:10px;
}
.oc-filter-actions>div {
justify-content:flex-end;
}
.oc-results-heading {
padding:16px 14px 10px;
}
.oc-result-controls {
width:100%;
gap:10px;
}
.oc-result-controls label {
flex:1;
min-width:0;
}
.oc-summary-scope {
padding:0 14px 12px;
line-height:1.6;
}
.oc-pagination {
padding:12px;
font-size:10px;
align-items:flex-start;
flex-direction:column;
}
.oc-pagination>div {
width:100%;
justify-content:space-between;
}
.oc-loading>div {
grid-template-columns:repeat(2,1fr);
}
}
@media(prefers-reduced-motion:reduce) {
.oc-spinning {
animation:none;
}
}
@@ -14,6 +14,7 @@ const corePageSources = Object.fromEntries(['MonitorPage', 'VehiclePage', 'Track
name,
readFileSync(resolve(process.cwd(), `src/v2/pages/${name}.tsx`), 'utf8')
]));
const monitorTableSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/MonitorVehicleTable.tsx'), 'utf8');
const vehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleSearchWorkspace.tsx'), 'utf8');
const monitorVehicleDetailSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/MonitorVehicleDetailCard.tsx'), 'utf8');
const batchVehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/BatchVehicleSearchDialog.tsx'), 'utf8');
@@ -77,7 +78,7 @@ describe('V2 production entry', () => {
expect(appV2Source).toContain('window.dispatchEvent(new Event(PLATFORM_READY_EVENT))');
});
test('keeps core business pages on shared Semi UI controls and page hierarchy', () => {
test('keeps business page hierarchy while migrating shared controls to shadcn', () => {
for (const source of Object.values(corePageSources)) {
expect(source).toContain("from '@douyinfe/semi-ui'");
expect(source).toContain('<Button');
@@ -110,9 +111,7 @@ describe('V2 production entry', () => {
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleSearchWorkspace'))");
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceCommandBar'");
expect(vehicleSearchSource).toContain('<WorkspaceCommandBar');
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceMetricRail'");
expect(vehicleSearchSource).toContain('<WorkspaceMetricRail');
expect(vehicleSearchSource).toContain('className="v2-vehicle-directory-metric-rail"');
expect(vehicleSearchSource).toContain('aria-label="车辆目录摘要"');
expect(vehicleSearchSource).not.toContain('className="v2-vehicle-directory-summary"');
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleArchiveCard'))");
expect(vehicleArchiveSource).toContain('<Select');
@@ -179,9 +178,9 @@ describe('V2 production entry', () => {
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-vehicle-detail"');
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-detail-peek"');
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-event-strip"');
expect(corePageSources.MonitorPage).toContain('<Table className="v2-monitor-table"');
expect(corePageSources.MonitorPage).toContain('<PanelEmpty className="v2-monitor-table-empty"');
expect(corePageSources.MonitorPage).toContain('<PanelLoading className="v2-monitor-table-loading"');
expect(monitorTableSource).toContain('<Table className="business-data-table"');
expect(monitorTableSource).toContain('<PanelEmpty className="v2-monitor-table-empty"');
expect(monitorTableSource).toContain('<PanelLoading className="v2-monitor-table-loading"');
expect(corePageSources.MonitorPage).toContain('title="正在加载车辆详情"');
expect(corePageSources.MonitorPage).not.toContain('<table><thead><tr><th>车辆</th>');
expect(sourceEvidenceSource).toContain("import { Button, Card, Input, Tag } from '@douyinfe/semi-ui'");
@@ -219,7 +218,8 @@ describe('V2 production entry', () => {
expect(corePageSources.HistoryPage).toContain('className="v2-history-summary-rail"');
expect(corePageSources.HistoryPage).not.toContain('v2-history-context-card');
expect(corePageSources.HistoryPage).not.toContain('v2-history-metric-scroll');
expect(corePageSources.HistoryPage).toContain('<WorkspaceFilterPanel');
expect(corePageSources.HistoryPage).toContain('<Card className="v2-history-filter-card"');
expect(corePageSources.HistoryPage).not.toContain('<WorkspaceFilterPanel');
expect(corePageSources.HistoryPage).toContain('<WorkspaceCommandBar');
expect(corePageSources.HistoryPage).toContain('className="v2-history-discovery-shell"');
expect(corePageSources.HistoryPage).toContain('className="v2-history-filter-card"');
@@ -268,7 +268,7 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-event-table');
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');
expect(corePageSources.AlertsPage).toContain('className="v2-alert-filter-card"');
expect(corePageSources.AlertsPage).toContain('aria-label="筛选事件"');
expect(corePageSources.AlertsPage).not.toContain('<section className="v2-alert-table-card"');
expect(corePageSources.AlertsPage).not.toContain('<aside className="v2-alert-inspector"');
expect(corePageSources.AlertsPage).not.toContain('<select');
@@ -315,7 +315,7 @@ describe('V2 production entry', () => {
expect(corePageSources.AccessPage).toContain('className="v2-access-governance-sidesheet"');
expect(corePageSources.AccessPage).toContain('<WorkspaceSideSheet');
expect(corePageSources.AccessPage).toContain('variant="config"');
expect(corePageSources.AccessPage).toContain('className="v2-access-filter-card-v3"');
expect(corePageSources.AccessPage).toContain('aria-label="接入筛选"');
expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-table-v3"');
expect(corePageSources.AccessPage).not.toContain('<aside className="v2-access-inspector-v3"');
expect(corePageSources.AccessPage).toContain('<Card key={row.vin} className={`v2-access-mobile-card');
@@ -412,7 +412,7 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('<Input');
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-diagnostic"');
expect(corePageSources.OperationsPage).toContain('<Card className="v2-ops-overview"');
expect(corePageSources.OperationsPage).toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.OperationsPage).toContain("from './GovernanceStatusStrip'");
expect(corePageSources.OperationsPage).toContain('<WorkspaceMetricRail');
expect(workspaceMetricRailSource).toContain("variant: 'queue'");
expect(workspaceMetricRailSource).not.toContain('CardGroup');
@@ -423,7 +423,7 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('<article className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).not.toContain('<Card className={`v2-ops-link-card');
expect(corePageSources.OperationsPage).toContain('<Descriptions className="v2-ops-runtime-descriptions"');
expect(corePageSources.OperationsPage).toContain('<PanelEmpty className="v2-ops-capacity-empty"');
expect(corePageSources.OperationsPage).toContain('<div className="governance-capacity-ok" role="status"');
expect(corePageSources.OperationsPage).toContain('<CardGroup className="v2-ops-source-list"');
expect(corePageSources.OperationsPage).toContain('<SourceReadinessCard source={source}');
expect(corePageSources.OperationsPage).toContain('<Collapse className="v2-ops-source-collapse"');
@@ -548,15 +548,15 @@ describe('V2 production entry', () => {
expect(corePageSources[name]).toContain("from '../shared/WorkspaceSideSheet'");
expect(corePageSources[name]).toContain('<WorkspaceSideSheet');
}
expect(workspaceSideSheetSource).toContain("from '@douyinfe/semi-ui'");
expect(workspaceSideSheetSource).toContain('<SideSheet');
expect(workspaceSideSheetSource).toContain('className={sheetClassName}');
expect(workspaceSideSheetSource).toContain("from '@/components/ui/sheet'");
expect(workspaceSideSheetSource).toContain('<SheetContent');
expect(workspaceSideSheetSource).toContain('business-sheet');
expect(workspaceStyles).toContain(':is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-inner {');
expect(workspaceStyles).toContain('.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon {');
expect(workspaceStyles).toContain('.v2-workspace-config-summary {');
expect(workspaceStyles).toContain('.v2-workspace-config-footer {');
expect(workspaceDialogSource).toContain("from '@douyinfe/semi-ui'");
expect(workspaceDialogSource).toContain('<Modal');
expect(workspaceDialogSource).toContain("from '@/components/ui/dialog'");
expect(workspaceDialogSource).toContain('<DialogContent');
expect(workspaceDialogSource).toContain('export function WorkspaceConfirmDialog');
expect(appShellSource).toContain('<WorkspaceDialog');
expect(appShellSource).not.toContain('<Modal');
@@ -585,7 +585,7 @@ describe('V2 production entry', () => {
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-sidesheet');
expect(corePageSources.MonitorPage).not.toContain('<Modal');
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
expect(batchVehicleSearchSource).toContain('<TextArea');
expect(batchVehicleSearchSource).toContain('<Textarea');
expect(corePageSources.MonitorPage).toContain('<SegmentedTabs');
expect(corePageSources.MonitorPage).toContain('ariaLabel="监控视图"');
expect(corePageSources.MonitorPage).toContain('variant="filled"');
@@ -615,15 +615,15 @@ describe('V2 production entry', () => {
expect(corePageSources.MonitorPage).toContain('<WorkspaceMetricRail');
expect(corePageSources.MonitorPage).toContain('className="v2-monitor-summary-rail"');
expect(corePageSources.MonitorPage).not.toContain('className="v2-kpis"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-monitor-table-panel"');
expect(monitorTableSource).toContain('<Card className="v2-monitor-table-panel"');
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-filterbar"');
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-kpis"');
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-monitor-table-panel"');
expect(corePageSources.MonitorPage).toContain('<Card className={`v2-monitor-mobile-card');
expect(monitorTableSource).toContain('<Card className={`v2-monitor-mobile-card');
expect(reconciliationSource).toContain("from '@douyinfe/semi-ui'");
expect(reconciliationSource).toContain('<RadioGroup');
expect(reconciliationSource).toContain('<Select');
expect(reconciliationSource).toContain('<Card className="v2-reconcile-center"');
expect(reconciliationSource).toContain('<Card className="v2-reconcile-center governance-queue"');
expect(reconciliationSource).toContain('<WorkspaceMetricRail');
expect(reconciliationSource).toContain('variant="queue"');
expect(reconciliationSource).not.toContain('v2-reconcile-kpi-card');
@@ -647,9 +647,9 @@ describe('V2 production entry', () => {
expect(sourceEvidenceSource).toContain('<Button');
expect(mobileFilterToggleSource).toContain("from '@douyinfe/semi-ui'");
expect(mobileFilterToggleSource).toContain('<Button');
expect(segmentedTabsSource).toContain("from '@douyinfe/semi-ui'");
expect(segmentedTabsSource).toContain('role="tablist"');
expect(segmentedTabsSource).toContain('role="tab"');
expect(segmentedTabsSource).toContain("from '@/components/ui/tabs'");
expect(segmentedTabsSource).toContain('<TabsList');
expect(segmentedTabsSource).toContain('<TabsTrigger');
for (const name of ['VehiclePage', 'AlertsPage', 'TrackPage', 'OperationsPage']) {
expect(corePageSources[name]).toContain("from '../shared/SegmentedTabs'");
expect(corePageSources[name]).toContain('<SegmentedTabs');
@@ -663,8 +663,8 @@ describe('V2 production entry', () => {
expect(tablePaginationSource).toContain('currentPage={safePage}');
expect(tablePaginationSource).toContain('onPageChange={onPageChange}');
for (const name of ['MonitorPage', 'AlertsPage', 'HistoryPage', 'AccessPage', 'StatisticsPage']) {
expect(corePageSources[name]).toContain("from '../shared/TablePagination'");
expect(corePageSources[name]).toContain('<TablePagination');
expect(name === 'MonitorPage' ? monitorTableSource : corePageSources[name]).toContain("from '../shared/TablePagination'");
expect(name === 'MonitorPage' ? monitorTableSource : corePageSources[name]).toContain('<TablePagination');
}
expect(reconciliationSource).toContain("from '../shared/TablePagination'");
expect(reconciliationSource).toContain('<TablePagination');
@@ -685,11 +685,11 @@ describe('V2 production entry', () => {
expect(source).toContain('<MapRetryAction');
expect(source).not.toContain('><IconRefresh />重新加载地图</button>');
}
expect(corePageSources.AccessPage).toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.AccessPage).toContain("from './GovernanceStatusStrip'");
expect(corePageSources.AccessPage).toContain('<WorkspaceMetricRail');
expect(corePageSources.AlertsPage).not.toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.AlertsPage).toContain('className="v2-event-status-tabs"');
expect(corePageSources.OperationsPage).toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.OperationsPage).toContain("from './GovernanceStatusStrip'");
expect(corePageSources.OperationsPage).toContain('<WorkspaceMetricRail');
expect(corePageSources.HistoryPage).toContain('className="v2-history-result-meta"');
expect(corePageSources.AlertsPage).toContain('function EventExecutionTag');
@@ -718,18 +718,24 @@ describe('V2 production entry', () => {
expect(workspaceStyles).toContain('bottom: 110px;');
expect(workspaceFilterPanelSource).toContain("from './MobileFilterToggle'");
expect(workspaceFilterPanelSource).toContain('<MobileFilterToggle');
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage', 'StatisticsPage', 'UsersPage', 'OperationsPage']) {
for (const name of ['StatisticsPage', 'OperationsPage']) {
expect(corePageSources[name]).toContain("from '../shared/WorkspaceFilterPanel'");
expect(corePageSources[name]).toContain('<WorkspaceFilterPanel');
}
// Compact query surfaces keep named controls and responsive filter entry points
// without requiring an extra card heading above the result workspace.
for (const [name, label] of [['HistoryPage', '查询范围'], ['AlertsPage', '筛选事件'], ['AccessPage', '接入筛选'], ['UsersPage', '客户账号筛选']]) {
expect(corePageSources[name]).toContain(`aria-label="${label}"`);
expect(corePageSources[name]).toMatch(/<(?:MobileFilter|WorkspaceSideSheet)/);
}
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceFilterPanel'");
expect(vehicleSearchSource).toContain('<WorkspaceFilterPanel');
expect(tablePaginationSource).toContain('<Pagination');
});
test('keeps the shared desktop and mobile application shell on Semi UI navigation primitives', () => {
expect(appShellSource).toContain('<Layout className="v2-shell">');
expect(appShellSource).toContain('<Nav className="v2-navigation"');
test('keeps desktop navigation and mobile entry points in the shared shell', () => {
expect(appShellSource).toContain('<Layout className="v2-shell business-shell" data-density={density}>');
expect(appShellSource).toContain('<nav className="business-navigation"');
expect(appShellSource).toContain('className="v2-mobile-more-sidesheet"');
expect(appShellSource.match(/<WorkspaceSideSheet/g)).toHaveLength(2);
expect(appShellSource).not.toContain('<SideSheet');
@@ -743,8 +749,8 @@ describe('V2 production entry', () => {
expect(authGateSource.match(/<PasswordInput/g)).toHaveLength(1);
expect(authGateSource).not.toContain('<Tabs');
expect(authGateSource).not.toContain('运维令牌');
expect(authGateSource).toContain('aria-label="账号密码登录"');
expect(authGateSource).toContain('>账号密码登录</Tag>');
expect(authGateSource).toContain("'中台账号登录'");
expect(authGateSource).toContain("'OneOS 统一身份' : '中台账号'");
expect(passwordInputSource).toContain('<Input');
expect(passwordInputSource).toContain('<Button');
expect(passwordInputSource).toContain("`${visible ? '隐藏' : '显示'}${visibilityLabel}`");
@@ -87,7 +87,7 @@ describe('route recovery boundary', () => {
const { container } = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><RoutePage page={PendingPage} label="接入管理" /></MemoryRouter>);
expect(screen.getByRole('status')).toHaveTextContent('正在加载接入管理');
expect(screen.getByText(/导航与筛选状态已保留/)).toBeInTheDocument();
expect(container.querySelectorAll('.v2-page-skeleton > i')).toHaveLength(4);
expect(container.querySelectorAll('.v2-page-skeleton > [data-slot="skeleton"]')).toHaveLength(4);
});
it('keeps page state mounted when only URL search parameters change', () => {
@@ -55,3 +55,29 @@ test('rejects malformed viewport input instead of restoring unsafe map state', (
expect(context.listOffset).toBe(0);
expect(context.listLimit).toBe(50);
});
test('preserves energy sorting across reloads and monitor return links', () => {
const path = '/monitor?mode=list&listSort=hydrogenPressureMpa&listSortOrder=desc&listOffset=50';
const canonical = validatedMonitorReturn(path);
expect(parseMonitorRouteContext(canonical.split('?')[1])).toMatchObject({ listSort: 'hydrogenPressureMpa', listSortOrder: 'desc', listOffset: 50 });
expect(withMonitorReturn('/tracks', canonical)).toContain('listSort%3DhydrogenPressureMpa');
expect(parseMonitorRouteContext('?listSort=anything&listSortOrder=anything')).toMatchObject({ listSort: 'hydrogenPressureMpa', listSortOrder: 'asc' });
});
test('defaults fresh monitor URLs to pressure ascending and keeps explicit choices', () => {
expect(validatedMonitorReturn('/monitor?mode=list&zoom=5')).toBe('/monitor?mode=list&zoom=5');
expect(validatedMonitorReturn('/monitor?mode=list&zoom=5&listSort=hydrogenPressureMpa&listSortOrder=asc')).toBe('/monitor?mode=list&zoom=5&listSort=hydrogenPressureMpa&listSortOrder=asc');
expect(parseMonitorRouteContext('?mode=list')).toMatchObject({ listSort: 'hydrogenPressureMpa', listSortOrder: 'asc' });
for (const field of ['todayMileageKm', 'totalMileageKm', 'socPercent', 'identity']) {
const path = validatedMonitorReturn(`/monitor?mode=list&listSort=${field}&listSortOrder=desc`);
expect(parseMonitorRouteContext(path.split('?')[1])).toMatchObject({ listSort: field, listSortOrder: 'desc' });
}
expect(parseMonitorRouteContext('?listSort=hydrogenMassKg')).toMatchObject({ listSort: 'hydrogenPressureMpa', listSortOrder: 'asc' });
});
test.each(['speedKmh', 'lastSeen'] as const)('preserves %s sort in navigation and return links', (listSort) => {
const path = buildMonitorPath(parseMonitorRouteContext(`?mode=list&listSort=${listSort}&listSortOrder=desc`));
expect(path).toContain(`listSort=${listSort}&listSortOrder=desc`);
expect(validatedMonitorReturn(path)).toBe(path);
});
@@ -2,6 +2,8 @@ import type { MonitorViewport } from '../hooks/useMonitorData';
import { normalizeMonitorBounds } from '../domain/monitor';
export const MONITOR_RETURN_PARAM = 'monitorReturn';
export type MonitorListSort = 'identity' | 'todayMileageKm' | 'totalMileageKm' | 'socPercent' | 'hydrogenPressureMpa' | 'speedKmh' | 'lastSeen';
export type MonitorSortOrder = 'asc' | 'desc';
export type MonitorRouteContext = {
mode: 'map' | 'list';
@@ -13,6 +15,8 @@ export type MonitorRouteContext = {
viewport: MonitorViewport;
listOffset: number;
listLimit: number;
listSort?: MonitorListSort;
listSortOrder?: MonitorSortOrder;
hasViewport: boolean;
};
@@ -57,6 +61,8 @@ export function parseMonitorRouteContext(search: string | URLSearchParams): Moni
},
listOffset: integer(params.get('listOffset'), 0, 0, 1_000_000),
listLimit: [20, 50, 100].includes(Number(params.get('listLimit'))) ? Number(params.get('listLimit')) : 50,
listSort: ['identity', 'todayMileageKm', 'totalMileageKm', 'socPercent', 'hydrogenPressureMpa', 'speedKmh', 'lastSeen'].includes(params.get('listSort') ?? '') ? params.get('listSort') as MonitorListSort : 'hydrogenPressureMpa',
listSortOrder: params.get('listSortOrder') === 'desc' ? 'desc' : 'asc',
hasViewport
};
}
@@ -76,6 +82,10 @@ export function buildMonitorPath(context: Omit<MonitorRouteContext, 'hasViewport
if (bounds) params.set('bounds', bounds);
if (context.listOffset) params.set('listOffset', String(context.listOffset));
if (context.listLimit !== 50) params.set('listLimit', String(context.listLimit));
if (context.listSort) {
params.set('listSort', context.listSort);
params.set('listSortOrder', context.listSortOrder === 'desc' ? 'desc' : 'asc');
}
const query = params.toString();
return query ? `/monitor?${query}` : '/monitor';
}
@@ -85,7 +95,13 @@ export function validatedMonitorReturn(value?: string | null) {
try {
const parsed = new URL(value, 'https://vehicle-platform.invalid');
if (parsed.origin !== 'https://vehicle-platform.invalid' || parsed.pathname !== '/monitor') return '';
return buildMonitorPath(parseMonitorRouteContext(parsed.searchParams));
const context = parseMonitorRouteContext(parsed.searchParams);
// Legacy return links inherit the default on arrival without rewriting their URL.
if (!parsed.searchParams.has('listSort') && !parsed.searchParams.has('listSortOrder')) {
context.listSort = undefined;
context.listSortOrder = undefined;
}
return buildMonitorPath(context);
} catch {
return '';
}
@@ -1,6 +1,6 @@
import { lazy, type ComponentType } from 'react';
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations' | 'users';
export type RouteSection = 'operation-compliance' | 'customer-mileage' | 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations' | 'users';
type RouteModule = { default: ComponentType };
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number };
@@ -11,6 +11,8 @@ const ROUTE_LOAD_TIMEOUT_MS = 10_000;
const ROUTE_RETRY_DELAY_MS = 180;
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
'operation-compliance': () => import('../pages/OperationCompliancePage'),
'customer-mileage': () => import('../pages/CustomerMileagePage'),
monitor: () => import('../pages/MonitorPage'),
vehicles: () => import('../pages/VehiclePage'),
tracks: () => import('../pages/TrackPage'),
@@ -24,6 +26,8 @@ const importers: Record<RouteSection, () => Promise<RouteModule>> = {
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
const likelyNextRoutes: Record<RouteSection, RouteSection[]> = {
'operation-compliance': ['statistics', 'customer-mileage'],
'customer-mileage': ['statistics', 'vehicles'],
monitor: ['vehicles', 'tracks'],
vehicles: ['monitor', 'tracks'],
tracks: ['monitor', 'history'],
@@ -190,6 +194,8 @@ export function scheduleIdleRoutePreloads({
}
export const RoutePages = {
OperationCompliance: createRouteComponent('operation-compliance'),
CustomerMileage: createRouteComponent('customer-mileage'),
Monitor: createRouteComponent('monitor'),
Vehicles: createRouteComponent('vehicles'),
Tracks: createRouteComponent('tracks'),
@@ -202,6 +208,8 @@ export const RoutePages = {
} as const;
export const RoutePageFactories = {
OperationCompliance: () => createRouteComponent('operation-compliance'),
CustomerMileage: () => createRouteComponent('customer-mileage'),
Monitor: () => createRouteComponent('monitor'),
Vehicles: () => createRouteComponent('vehicles'),
Tracks: () => createRouteComponent('tracks'),
@@ -1,3 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton';
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Empty, Spin, Typography } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
@@ -6,8 +7,8 @@ type StateScope = 'page' | 'panel' | 'inline';
type StateKind = 'loading' | 'empty' | 'error';
export type WorkspaceStateTone = 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
const PAGE_LOADING_SKELETON = <div className="v2-state-skeleton v2-page-skeleton" aria-hidden="true"><i /><i /><i /><i /></div>;
const PANEL_LOADING_SKELETON = <div className="v2-state-skeleton" aria-hidden="true"><i /><i /><i /></div>;
const PAGE_LOADING_SKELETON = <div className="v2-state-skeleton v2-page-skeleton" aria-hidden="true"><Skeleton /><Skeleton /><Skeleton /><Skeleton /></div>;
const PANEL_LOADING_SKELETON = <div className="v2-state-skeleton" aria-hidden="true"><Skeleton /><Skeleton /><Skeleton /></div>;
function stateRole(kind: StateKind) {
return kind === 'error' ? 'alert' : 'status';

Some files were not shown because too many files have changed in this diff Show More