diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 1ee55273..0b6ecf29 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -1,33 +1,115 @@ -import { Button, Card, Form, SideSheet, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui'; -import { useEffect, useState } from 'react'; +import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Toast, Typography } from '@douyinfe/semi-ui'; +import { IconChevronLeft, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { HistoryLocationRow, RawFrameRow } from '../api/types'; +import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; -export function History() { - const [locations, setLocations] = useState([]); - const [rawFrames, setRawFrames] = useState([]); - const [selectedRaw, setSelectedRaw] = useState(null); +type HistoryFilters = { + vin?: string; + protocol?: string; + dateFrom?: string; + dateTo?: string; + fields?: string; + includeFields?: boolean; +}; - const load = (values?: Record) => { - const params = new URLSearchParams({ limit: '20', includeFields: 'true' }); - if (values?.vin) params.set('vin', values.vin); - if (values?.protocol) params.set('protocol', values.protocol); - api.historyLocations(params).then((page) => setLocations(page.items)).catch((error: Error) => Toast.error(error.message)); - api.rawFrames(params).then((page) => setRawFrames(page.items)).catch((error: Error) => Toast.error(error.message)); +const defaultFilters: HistoryFilters = { + protocol: 'GB32960', + vin: 'LB9A32A24R0LS1426', + includeFields: true +}; + +const defaultPage = { items: [], total: 0, limit: 10, offset: 0 }; + +export function History() { + const [filters, setFilters] = useState(defaultFilters); + const [locations, setLocations] = useState>(defaultPage); + const [rawFrames, setRawFrames] = useState>(defaultPage); + const [selectedRaw, setSelectedRaw] = useState(null); + const [loadingLocations, setLoadingLocations] = useState(false); + const [loadingRaw, setLoadingRaw] = useState(false); + + const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (nextFilters.vin?.trim()) params.set('vin', nextFilters.vin.trim()); + if (nextFilters.protocol?.trim()) params.set('protocol', nextFilters.protocol.trim()); + if (nextFilters.dateFrom?.trim()) params.set('dateFrom', nextFilters.dateFrom.trim()); + if (nextFilters.dateTo?.trim()) params.set('dateTo', nextFilters.dateTo.trim()); + if (raw && nextFilters.includeFields) params.set('includeFields', 'true'); + if (raw && nextFilters.fields?.trim()) params.set('fields', nextFilters.fields.trim()); + return params; }; - useEffect(() => load(), []); + const loadLocations = (nextFilters = filters, offset = locations.offset) => { + setLoadingLocations(true); + api.historyLocations(buildParams(nextFilters, locations.limit || 10, offset, false)) + .then(setLocations) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setLoadingLocations(false)); + }; + + const loadRawFrames = (nextFilters = filters, offset = rawFrames.offset) => { + setLoadingRaw(true); + api.rawFrames(buildParams(nextFilters, rawFrames.limit || 10, offset, true)) + .then(setRawFrames) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setLoadingRaw(false)); + }; + + const submit = (values: Record) => { + const nextFilters: HistoryFilters = { + vin: String(values.vin ?? ''), + protocol: String(values.protocol ?? ''), + dateFrom: String(values.dateFrom ?? ''), + dateTo: String(values.dateTo ?? ''), + fields: String(values.fields ?? ''), + includeFields: Boolean(values.includeFields) + }; + setFilters(nextFilters); + loadLocations(nextFilters, 0); + loadRawFrames(nextFilters, 0); + }; + + const reset = () => { + setFilters(defaultFilters); + loadLocations(defaultFilters, 0); + loadRawFrames(defaultFilters, 0); + }; + + useEffect(() => { + loadLocations(defaultFilters, 0); + loadRawFrames(defaultFilters, 0); + }, []); + + const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]); return (
-
load(values as Record)}> + submit(values)}> - - - + + GB32960 + JT808 + YUTONG_MQTT + + + + + 返回解析字段 + + + + +
@@ -36,38 +118,92 @@ export function History() { + loadLocations(filters, Math.max(0, locations.offset - locations.limit))} + onNext={() => loadLocations(filters, locations.offset + locations.limit)} + />
} + { title: 'ID', dataIndex: 'id', width: 260 }, + { title: 'VIN', dataIndex: 'vin', width: 190 }, + { title: '协议', dataIndex: 'protocol', width: 120 }, + { title: '帧类型', dataIndex: 'frameType', width: 190 }, + { title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 }, + { title: '设备时间', dataIndex: 'deviceTime', width: 190 }, + { title: '入库时间', dataIndex: 'serverTime', width: 190 }, + { title: '操作', width: 100, render: (_: unknown, row: RawFrameRow) => } ]} /> + loadRawFrames(filters, Math.max(0, rawFrames.offset - rawFrames.limit))} + onNext={() => loadRawFrames(filters, rawFrames.offset + rawFrames.limit)} + /> setSelectedRaw(null)} width={720}> + + + {selectedRaw?.protocol ?? '-'} / {selectedRaw?.vin ?? '-'} / {rawFieldCount} 个字段 + + {rawFieldCount === 0 ? ( + 当前查询未返回解析字段,请勾选“返回解析字段”或配置字段裁剪后重新查询。 + ) : null} +
{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}
); } + +function HistoryPager({ + page, + loading, + onPrev, + onNext +}: { + page: Page; + loading: boolean; + onPrev: () => void; + onNext: () => void; +}) { + const start = page.offset + 1; + const end = page.offset + page.items.length; + const hasPrevious = page.offset > 0; + const hasNext = page.items.length >= page.limit; + return ( +
+ + 当前 {page.items.length === 0 ? 0 : start}-{end} 条,每页 {page.limit} 条 + + + + + +
+ ); +} diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 3fe735bf..93247464 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -1,52 +1,195 @@ -import { Card, Descriptions, Table, Tabs, Toast } from '@douyinfe/semi-ui'; -import { useEffect, useState } from 'react'; +import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui'; +import { IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { RawFrameRow, RealtimeLocationRow } from '../api/types'; +import type { DailyMileageRow, HistoryLocationRow, RawFrameRow, RealtimeLocationRow, VehicleRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; +import { StatusTag } from '../components/StatusTag'; + +type VehicleServiceState = { + vehicles: VehicleRow[]; + realtime: RealtimeLocationRow[]; + history: HistoryLocationRow[]; + raw: RawFrameRow[]; + mileage: DailyMileageRow[]; +}; + +type VehicleQuery = { + vin: string; + protocol?: string; +}; + +const defaultQuery: VehicleQuery = { + vin: 'LB9A32A24R0LS1426' +}; + +const emptyState: VehicleServiceState = { + vehicles: [], + realtime: [], + history: [], + raw: [], + mileage: [] +}; export function VehicleDetail() { - const [latest, setLatest] = useState(null); - const [raw, setRaw] = useState(null); + const [query, setQuery] = useState(defaultQuery); + const [state, setState] = useState(emptyState); + const [loading, setLoading] = useState(false); + + const load = (nextQuery = query) => { + const vin = nextQuery.vin.trim(); + if (!vin) { + Toast.warning('请输入 VIN'); + return; + } + setLoading(true); + const scoped = new URLSearchParams({ vin, limit: '20' }); + const vehicleParams = new URLSearchParams({ keyword: vin, limit: '5' }); + const rawParams = new URLSearchParams({ vin, limit: '10', includeFields: 'true' }); + const mileageParams = new URLSearchParams({ vin, limit: '20' }); + if (nextQuery.protocol?.trim()) { + scoped.set('protocol', nextQuery.protocol.trim()); + rawParams.set('protocol', nextQuery.protocol.trim()); + } + Promise.all([ + api.vehicles(vehicleParams), + api.realtimeLocations(scoped), + api.historyLocations(scoped), + api.rawFrames(rawParams), + api.dailyMileage(mileageParams) + ]) + .then(([vehicles, realtime, history, raw, mileage]) => { + setState({ + vehicles: vehicles.items, + realtime: realtime.items, + history: history.items, + raw: raw.items, + mileage: mileage.items + }); + }) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setLoading(false)); + }; useEffect(() => { - const params = new URLSearchParams({ vin: 'LB9A32A24R0LS1426', limit: '1' }); - api.realtimeLocations(params) - .then((page) => setLatest(page.items[0] ?? null)) - .catch((error: Error) => Toast.error(error.message)); - params.set('includeFields', 'true'); - api.rawFrames(params) - .then((page) => setRaw(page.items[0] ?? null)) - .catch((error: Error) => Toast.error(error.message)); + load(defaultQuery); }, []); + const identity = state.vehicles[0]; + const latest = state.realtime[0]; + const protocols = useMemo( + () => Array.from(new Set([...state.vehicles.map((row) => row.protocol), ...state.realtime.map((row) => row.protocol)].filter(Boolean))), + [state.realtime, state.vehicles] + ); + const latestRaw = state.raw[0]; + return (
- + - +
{ + const nextQuery = { vin: String(values.vin ?? ''), protocol: String(values.protocol ?? '') }; + setQuery(nextQuery); + load(nextQuery); + }}> + + + GB32960 + JT808 + YUTONG_MQTT + + + + + +
+ + +
+ }, + { key: '来源协议', value: protocols.length > 0 ? {protocols.map((item) => {item})} : '-' }, + { key: '最后位置时间', value: latest?.lastSeen ?? '-' }, + { key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' } + ]} + /> +
+
+ - +
+ + +
-
{JSON.stringify(raw?.parsedFields ?? {}, null, 2)}
+
+ 最新 RAW 解析字段 +
{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}
+ + +
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 442007df..8f2faa77 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -140,6 +140,21 @@ body { line-height: 1.65; } +.vp-table-footer { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 12px; +} + +.vp-vehicle-summary { + min-height: 72px; + display: flex; + align-items: center; +} + .semi-navigation-item-text { font-size: 14px; } diff --git a/vehicle-data-platform/docs/product-spec.md b/vehicle-data-platform/docs/product-spec.md index 318106bf..8aaab9b9 100644 --- a/vehicle-data-platform/docs/product-spec.md +++ b/vehicle-data-platform/docs/product-spec.md @@ -14,6 +14,10 @@ The UI is a professional vehicle data operations console. It uses Semi UI as the 6. Mileage: daily and range mileage analysis. 7. Quality: data quality and link health workspace. +## Domain Principle + +The product model is vehicle-first. GB32960, JT808, and Yutong MQTT are ingestion sources for one vehicle service, not separate business products. The UI should lead with VIN, plate, online state, location, mileage, and data quality; protocol appears only as source attribution, diagnosis, and raw-frame traceability. + ## Interaction Rules - Tables are the default data surface. @@ -21,4 +25,3 @@ The UI is a professional vehicle data operations console. It uses Semi UI as the - Details open in a right drawer unless a full route is needed. - Error and empty states must be explicit. - No decorative hero, no oversized marketing card layout, no dark large-screen style. -