feat(history): align fields quality and exports

This commit is contained in:
lingniu
2026-07-16 16:46:10 +08:00
parent 17c4591040
commit a2722e4afd
13 changed files with 273 additions and 66 deletions

View File

@@ -846,7 +846,8 @@ func (m *MockStore) mockHistoryExportRows(ctx context.Context, query HistoryExpo
for key, value := range item.ParsedFields {
values[key] = value
}
rows = append(rows, HistoryDataRow{ID: item.ID, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: "normal", EvidenceID: item.ID, Values: values})
quality, reason := historyRawQuality(item)
rows = append(rows, HistoryDataRow{ID: item.ID, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, QualityReason: reason, EvidenceID: item.ID, Values: values})
}
return rows, nil
case "mileage":
@@ -856,7 +857,8 @@ func (m *MockStore) mockHistoryExportRows(ctx context.Context, query HistoryExpo
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
rows = append(rows, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: "normal", Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
quality, reason := historyMileageQuality(item)
rows = append(rows, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: quality, QualityReason: reason, Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
}
return rows, nil
default:
@@ -866,7 +868,8 @@ func (m *MockStore) mockHistoryExportRows(ctx context.Context, query HistoryExpo
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
rows = append(rows, HistoryDataRow{ID: "location-" + item.VIN + "-" + strconv.Itoa(index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: "normal", Values: map[string]any{"speedKmh": item.SpeedKmh, "totalMileageKm": item.TotalMileageKm, "longitude": item.Longitude, "latitude": item.Latitude}})
quality, reason := historyLocationQuality(item)
rows = append(rows, HistoryDataRow{ID: "location-" + item.VIN + "-" + strconv.Itoa(index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, QualityReason: reason, Values: map[string]any{"speedKmh": item.SpeedKmh, "totalMileageKm": item.TotalMileageKm, "longitude": item.Longitude, "latitude": item.Latitude}})
}
return rows, nil
}

View File

@@ -248,6 +248,7 @@ type HistoryDataRow struct {
DeviceTime string `json:"deviceTime"`
ServerTime string `json:"serverTime"`
Quality string `json:"quality"`
QualityReason string `json:"qualityReason"`
EvidenceID string `json:"evidenceId,omitempty"`
Values map[string]any `json:"values"`
}

View File

@@ -757,7 +757,8 @@ func (s *ProductionStore) HistoryExportBatch(ctx context.Context, query HistoryE
}
result := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
result = append(result, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(cursor.Offset+index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: "normal", Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
quality, reason := historyMileageQuality(item)
result = append(result, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(cursor.Offset+index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: quality, QualityReason: reason, Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
}
cursor.Offset += len(result)
return result, cursor, nil
@@ -792,12 +793,9 @@ func scanLocationHistoryExportRows(rows *sql.Rows, cursor HistoryExportCursor, p
if err := rows.Scan(&ts, &vin, &protocol, &longitude, &latitude, &speed, &mileage, &receivedAt); err != nil {
return nil, cursor, err
}
quality := "normal"
location := HistoryLocationRow{Longitude: nullFloat64(longitude), Latitude: nullFloat64(latitude)}
if !validTrackCoordinate(location) {
quality = "warning"
}
items = append(items, HistoryDataRow{ID: "location-" + vin + "-" + protocol + "-" + ts, VIN: vin, Plate: plate, Protocol: protocol, DeviceTime: ts, ServerTime: firstNonEmpty(receivedAt, ts), Quality: quality, Values: map[string]any{"speedKmh": nullFloat64(speed), "totalMileageKm": nullFloat64(mileage), "longitude": nullFloat64(longitude), "latitude": nullFloat64(latitude)}})
location := HistoryLocationRow{Longitude: nullFloat64(longitude), Latitude: nullFloat64(latitude), SpeedKmh: nullFloat64(speed), TotalMileageKm: nullFloat64(mileage), DeviceTime: ts, ServerTime: firstNonEmpty(receivedAt, ts)}
quality, reason := historyLocationQuality(location)
items = append(items, HistoryDataRow{ID: "location-" + vin + "-" + protocol + "-" + ts, VIN: vin, Plate: plate, Protocol: protocol, DeviceTime: ts, ServerTime: firstNonEmpty(receivedAt, ts), Quality: quality, QualityReason: reason, Values: map[string]any{"speedKmh": nullFloat64(speed), "totalMileageKm": nullFloat64(mileage), "longitude": nullFloat64(longitude), "latitude": nullFloat64(latitude)}})
cursor = HistoryExportCursor{Time: ts, Protocol: protocol}
}
return items, cursor, rows.Err()
@@ -819,11 +817,9 @@ func scanRawHistoryExportRows(rows *sql.Rows, query HistoryExportStoreQuery, cur
for key, value := range fields {
values[key] = value
}
quality := "normal"
if parseStatus != "" && !strings.EqualFold(parseStatus, "ok") {
quality = "warning"
}
items = append(items, HistoryDataRow{ID: frameID, VIN: vin, Plate: plate, Protocol: protocol, DeviceTime: firstNonEmpty(eventTime, ts), ServerTime: firstNonEmpty(receivedAt, ts), Quality: quality, EvidenceID: frameID, Values: values})
frame := RawFrameRow{ParseStatus: parseStatus, ParseError: parseError, DeviceTime: firstNonEmpty(eventTime, ts), ServerTime: firstNonEmpty(receivedAt, ts)}
quality, reason := historyRawQuality(frame)
items = append(items, HistoryDataRow{ID: frameID, VIN: vin, Plate: plate, Protocol: protocol, DeviceTime: frame.DeviceTime, ServerTime: frame.ServerTime, Quality: quality, QualityReason: reason, EvidenceID: frameID, Values: values})
cursor = HistoryExportCursor{Time: ts, Protocol: protocol, ID: frameID}
}
return items, cursor, rows.Err()

View File

@@ -2127,7 +2127,7 @@ func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDat
pageRows := append([]HistoryDataRow(nil), rows[offset:end]...)
columns := historyMetricsForCategory(category)
if category == "raw" {
columns = mergeDiscoveredRawMetrics(columns, pageRows)
columns = mergeDiscoveredRawMetrics(columns, rows)
}
vehicles := map[string]struct{}{}
sources := map[string]struct{}{}
@@ -2396,11 +2396,8 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
quality := "normal"
if !validTrackCoordinate(item) {
quality = "warning"
}
rows = append(rows, HistoryDataRow{ID: "location-" + item.VIN + "-" + strconv.Itoa(index) + "-" + item.DeviceTime, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, Values: map[string]any{"speedKmh": item.SpeedKmh, "totalMileageKm": item.TotalMileageKm, "longitude": item.Longitude, "latitude": item.Latitude}})
quality, reason := historyLocationQuality(item)
rows = append(rows, HistoryDataRow{ID: "location-" + item.VIN + "-" + strconv.Itoa(index) + "-" + item.DeviceTime, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, QualityReason: reason, Values: map[string]any{"speedKmh": item.SpeedKmh, "totalMileageKm": item.TotalMileageKm, "longitude": item.Longitude, "latitude": item.Latitude}})
}
return rows, page.Total, nil
case "raw":
@@ -2418,7 +2415,8 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s
for key, value := range item.ParsedFields {
values[key] = value
}
rows = append(rows, HistoryDataRow{ID: item.ID, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: "normal", EvidenceID: item.ID, Values: values})
quality, reason := historyRawQuality(item)
rows = append(rows, HistoryDataRow{ID: item.ID, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, QualityReason: reason, EvidenceID: item.ID, Values: values})
}
return rows, page.Total, nil
case "mileage":
@@ -2437,17 +2435,83 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
quality := "normal"
if item.AnomalySeverity != "" {
quality = item.AnomalySeverity
}
rows = append(rows, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(index) + "-" + item.Date, VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: quality, Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
quality, reason := historyMileageQuality(item)
rows = append(rows, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(index) + "-" + item.Date, VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: quality, QualityReason: reason, Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
}
return rows, page.Total, nil
}
return nil, 0, nil
}
func historyLocationQuality(item HistoryLocationRow) (string, string) {
if !validTrackCoordinate(item) {
return "warning", "坐标超出有效范围或为无效零点"
}
if item.SpeedKmh < 0 || item.SpeedKmh > 200 {
return "warning", fmt.Sprintf("速度 %.2f km/h 超出 0200 km/h 基础校验范围", item.SpeedKmh)
}
if item.TotalMileageKm < 0 {
return "warning", "总里程为负值"
}
deviceTime, deviceOK := parseVehicleServiceTime(item.DeviceTime)
serverTime, serverOK := parseVehicleServiceTime(item.ServerTime)
if !deviceOK || !serverOK {
return "warning", "设备时间或服务接收时间无法解析"
}
delaySeconds := int64(math.Abs(serverTime.Sub(deviceTime).Seconds()))
if delaySeconds > int64(latestTelemetryStaleAfter/time.Second) {
return "warning", fmt.Sprintf("设备时间与服务接收时间相差 %d 秒,超过 5 分钟", delaySeconds)
}
return "normal", "坐标、速度、里程及设备/服务时间通过基础校验"
}
func historyRawQuality(item RawFrameRow) (string, string) {
status := strings.TrimSpace(item.ParseStatus)
if status != "" && !strings.EqualFold(status, "ok") && !strings.EqualFold(status, "success") && !strings.EqualFold(status, "parsed") {
reason := "原始报文解析状态为 " + status
if detail := strings.TrimSpace(item.ParseError); detail != "" {
reason += "" + detail
}
return "warning", reason
}
deviceTime, deviceOK := parseVehicleServiceTime(item.DeviceTime)
serverTime, serverOK := parseVehicleServiceTime(item.ServerTime)
if !deviceOK || !serverOK {
return "warning", "设备时间或服务接收时间无法解析"
}
delaySeconds := int64(math.Abs(serverTime.Sub(deviceTime).Seconds()))
if delaySeconds > int64(latestTelemetryStaleAfter/time.Second) {
return "warning", fmt.Sprintf("设备时间与服务接收时间相差 %d 秒,超过 5 分钟", delaySeconds)
}
return "normal", "报文解析成功,设备时间与服务接收时间关系正常"
}
func historyMileageQuality(item DailyMileageRow) (string, string) {
severity := strings.ToLower(strings.TrimSpace(item.AnomalySeverity))
if severity != "" && severity != "normal" && severity != "good" && severity != "ok" {
quality := "warning"
if strings.Contains(severity, "error") || strings.Contains(severity, "critical") || strings.Contains(severity, "严重") {
quality = "error"
}
return quality, "里程聚合记录标记异常:" + item.AnomalySeverity
}
if item.StartMileageKm < 0 || item.EndMileageKm < 0 || item.DailyMileageKm < 0 {
return "warning", "起始、结束或日里程存在负值"
}
if item.EndMileageKm < item.StartMileageKm {
return "warning", "结束里程小于起始里程"
}
calculated := item.EndMileageKm - item.StartMileageKm
tolerance := math.Max(1, calculated*0.05)
if math.Abs(calculated-item.DailyMileageKm) > tolerance {
return "warning", fmt.Sprintf("日里程 %.2f km 与起止里程差 %.2f km 不一致", item.DailyMileageKm, calculated)
}
if item.DailyMileageKm > 1500 {
return "warning", fmt.Sprintf("单日里程 %.2f km 超过 1500 km 基础校验范围", item.DailyMileageKm)
}
return "normal", "起止里程与日里程关系通过基础校验"
}
func copyHistoryFilters(target, source url.Values, protocol string) {
if protocol != "" {
target.Set("protocol", protocol)
@@ -2931,7 +2995,7 @@ func writeHistoryExportMetadata(writer *csv.Writer, request HistoryExportRequest
rows := [][]string{
{"导出元数据", "查询开始", request.DateFrom, "查询结束", request.DateTo, "车辆", strings.Join(request.Keywords, "、"), "数据类型", request.Category, "协议", firstNonEmpty(request.Protocol, "全部")},
{"指标与单位", strings.Join(exportMetricLabels(columns), "")},
append([]string{"设备时间", "服务时间", "车牌", "VIN", "协议", "数据质量", "证据ID"}, exportMetricLabels(columns)...),
append([]string{"设备时间", "服务时间", "车牌", "VIN", "协议", "数据质量", "质量原因", "证据ID"}, exportMetricLabels(columns)...),
}
for _, row := range rows {
if err := writer.Write(row); err != nil {
@@ -2942,7 +3006,7 @@ func writeHistoryExportMetadata(writer *csv.Writer, request HistoryExportRequest
}
func historyExportRecord(row HistoryDataRow, columns []HistoryMetricDefinition) []string {
record := []string{row.DeviceTime, row.ServerTime, row.Plate, row.VIN, row.Protocol, row.Quality, row.EvidenceID}
record := []string{row.DeviceTime, row.ServerTime, row.Plate, row.VIN, row.Protocol, row.Quality, row.QualityReason, row.EvidenceID}
for _, column := range columns {
value, ok := row.Values[column.Key]
if !ok || value == nil {

View File

@@ -580,6 +580,9 @@ func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) {
if len(body) < 3 || string(body[:3]) != "\xEF\xBB\xBF" {
t.Fatalf("CSV should include UTF-8 BOM")
}
if !strings.Contains(string(body), "数据质量,质量原因,证据ID") {
t.Fatalf("CSV should explain platform quality: %q", body[:min(len(body), 512)])
}
return
}
time.Sleep(10 * time.Millisecond)
@@ -671,6 +674,39 @@ func TestRawMetricMetadataKeepsProtocolAndUnit(t *testing.T) {
}
}
func TestHistoryQualityReasonsExplainLocationRawAndMileageWarnings(t *testing.T) {
quality, reason := historyLocationQuality(HistoryLocationRow{Longitude: 0, Latitude: 0, SpeedKmh: 30, DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59"})
if quality != "warning" || !strings.Contains(reason, "坐标") {
t.Fatalf("invalid coordinate should be explained: %s %s", quality, reason)
}
quality, reason = historyRawQuality(RawFrameRow{DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "failed", ParseError: "checksum"})
if quality != "warning" || !strings.Contains(reason, "checksum") {
t.Fatalf("parse failure should retain detail: %s %s", quality, reason)
}
quality, reason = historyMileageQuality(DailyMileageRow{StartMileageKm: 100, EndMileageKm: 130, DailyMileageKm: 12})
if quality != "warning" || !strings.Contains(reason, "不一致") {
t.Fatalf("mileage mismatch should be explained: %s %s", quality, reason)
}
}
func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) {
rows := []HistoryDataRow{
{Values: map[string]any{"frameType": "realtime"}},
{Values: map[string]any{"gb32960.vehicle.soc_percent": 88}},
}
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows)
found := false
for _, column := range columns {
if column.Key == "gb32960.vehicle.soc_percent" {
found = true
break
}
}
if !found {
t.Fatalf("discovered field outside the visible page should remain selectable: %+v", columns)
}
}
func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) {
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
definitions := []MetricDefinition{

View File

@@ -242,7 +242,7 @@ export interface MetricDefinition {
export interface HistoryDataCategory { key: 'location' | 'raw' | 'mileage' | string; label: string; }
export interface HistoryMetricDefinition { key: string; label: string; unit: string; category: string; valueType: string; defaultVisible: boolean; }
export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; evidenceId?: string; values: Record<string, unknown>; }
export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; qualityReason: string; evidenceId?: string; values: Record<string, unknown>; }
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; sources: string[]; queryDurationMs: number; }
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
export interface HistorySeriesPoint { time: string; value: number | null; min: number | null; max: number | null; count: number; }

View File

@@ -31,7 +31,7 @@ function historyData(vin: string, plate: string, asOf: string): HistoryDataRespo
return {
category: 'location', columns: [metric], total: 1, limit: 50, offset: 0, asOf,
summary: { resultRows: 1, vehicleCount: 1, sources: ['JT808'], queryDurationMs: 12 },
rows: [{ id: `${vin}-row`, vin, plate, protocol: 'JT808', deviceTime: '2026-07-16 04:00:00', serverTime: '2026-07-16 04:00:01', quality: 'normal', evidenceId: `${vin}-evidence`, values: { speedKmh: 32 } }]
rows: [{ id: `${vin}-row`, vin, plate, protocol: 'JT808', deviceTime: '2026-07-16 04:00:00', serverTime: '2026-07-16 04:00:01', quality: 'normal', qualityReason: '坐标、速度、里程及设备/服务时间通过基础校验', evidenceId: `${vin}-evidence`, values: { speedKmh: 32 } }]
};
}
@@ -111,6 +111,10 @@ test('opens a working column visibility panel and preserves non-hideable identit
fireEvent.click(screen.getByRole('button', { name: '列设置' }));
expect(screen.getByRole('dialog', { name: '列显示设置' })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('搜索字段名 / 字段 key'), { target: { value: 'soc' } });
expect(screen.getByRole('checkbox', { name: /SOC/ })).toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: /速度/ })).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('搜索字段名 / 字段 key'), { target: { value: '' } });
fireEvent.click(screen.getByRole('checkbox', { name: /速度/ }));
expect(screen.queryByRole('columnheader', { name: '速度 (km/h)' })).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'VIN' })).toBeInTheDocument();
@@ -139,3 +143,26 @@ test('keeps history readable without mounting operator-only export requests for
expect(mocks.historyExports).not.toHaveBeenCalled();
expect(mocks.createHistoryExport).not.toHaveBeenCalled();
});
test('uses selected chartable fields for trends and omits empty RAW evidence UI', async () => {
const totalMileageMetric = { key: 'totalMileageKm', label: '总里程', unit: 'km', category: 'location', valueType: 'number', defaultVisible: true };
const data = historyData('OLDVIN', '旧车牌', 'old-as-of');
data.columns = [metric, totalMileageMetric];
data.rows[0].evidenceId = undefined;
data.rows[0].values.totalMileageKm = 1234;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric, totalMileageMetric] });
mocks.historyData.mockResolvedValue(data);
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([]);
renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
await waitFor(() => expect(mocks.historySeries).toHaveBeenCalled());
expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh,totalMileageKm');
expect(screen.queryByText('RAW 证据')).not.toBeInTheDocument();
const mileageToggle = screen.getAllByTitle('点击取消显示').find((button) => button.textContent?.includes('总里程'));
expect(mileageToggle).toBeDefined();
fireEvent.click(mileageToggle!);
await waitFor(() => expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh'));
});

View File

@@ -12,9 +12,17 @@ import { canOperate } from '../auth/session';
const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
function historyQualityLabel(quality: string) {
if (quality === 'normal' || quality === 'good') return '正常';
if (quality === 'warning') return '需关注';
if (quality === 'error' || quality === 'critical') return '异常';
return quality || '未知';
}
function HistoryTrend({ response, category, loading, error, hasMetrics }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string; hasMetrics: boolean }) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
if (category !== 'location') return <section className="v2-history-trend"><header><strong></strong></header><div className="v2-history-chart-empty">{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}</div></section>;
if (!hasMetrics) return <section className="v2-history-trend"><header><strong></strong></header><div className="v2-history-chart-empty"></div></section>;
const summary = response?.summary;
const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0;
return <section className="v2-history-trend"><header><strong></strong><div>{summary ? <><span>{formatSeriesGrain(summary.grainSeconds)}</span><span> {coverage.toFixed(1)}%</span><span>{summary.rawPointCount.toLocaleString('zh-CN')} </span></> : null}</div></header>
@@ -58,8 +66,8 @@ function ExportJobsPanel() {
}
function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) {
return <section className="v2-history-evidence"><header><strong></strong>{row ? <button onClick={onClose} type="button" aria-label="关闭行证据"><IconClose /></button> : null}</header>
{row ? <><dl><div><dt></dt><dd>{row.deviceTime}</dd></div><div><dt></dt><dd>{row.serverTime}</dd></div><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt></dt><dd>{row.protocol}</dd></div><div><dt></dt><dd><i className={`is-${row.quality}`} />{row.quality === 'normal' ? '正常' : row.quality}</dd></div></dl><div className="v2-evidence-values"><strong></strong>{metrics.slice(0, 12).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>)}</div><footer><span>RAW </span><b>{row.evidenceId || '该数据类型没有独立 RAW 帧 ID'}</b></footer></> : <div className="v2-history-side-empty"></div>}
return <section className="v2-history-evidence"><header><strong></strong>{row ? <button onClick={onClose} type="button" aria-label="关闭数据详情"><IconClose /></button> : null}</header>
{row ? <><dl><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt></dt><dd>{row.deviceTime}</dd></div><div><dt></dt><dd>{row.serverTime}</dd></div><div><dt></dt><dd>{row.protocol}</dd></div><div><dt></dt><dd><i className={`is-${row.quality}`} />{historyQualityLabel(row.quality)}</dd></div><div className="v2-history-quality-detail"><dt></dt><dd>{row.qualityReason || '平台未返回质量说明'}</dd></div></dl><div className="v2-evidence-values"><strong></strong>{metrics.length ? metrics.slice(0, 20).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>) : <p></p>}</div>{row.evidenceId ? <footer><span>RAW </span><b>{row.evidenceId}</b></footer> : null}</> : <div className="v2-history-side-empty"></div>}
</section>;
}
@@ -71,6 +79,13 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
onReset: () => void;
onClose: () => void;
}) {
const [search, setSearch] = useState('');
const filteredMetrics = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return metrics;
return metrics.filter((metric) => `${metric.label} ${metric.key} ${metric.unit}`.toLowerCase().includes(keyword));
}, [metrics, search]);
const displayedMetrics = filteredMetrics.slice(0, 200);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
@@ -78,8 +93,9 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
}, [onClose]);
return <aside className="v2-history-column-panel" role="dialog" aria-modal="false" aria-label="列显示设置">
<header><div><strong></strong><span></span></div><button type="button" aria-label="关闭列显示设置" onClick={onClose}><IconClose /></button></header>
<div>{metrics.map((metric) => <label key={metric.key}><input type="checkbox" checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</span><small>{metric.key}</small></label>)}{!metrics.length ? <p></p> : null}</div>
<footer><button type="button" onClick={onShowAll} disabled={!metrics.length}></button><button type="button" onClick={onReset} disabled={!metrics.length}></button></footer>
<label className="v2-history-column-search"><IconSearch /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索字段名 / 字段 key" /></label>
<div>{displayedMetrics.map((metric) => <label key={metric.key}><input type="checkbox" checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</span><small>{metric.key}</small></label>)}{!metrics.length ? <p></p> : search && !filteredMetrics.length ? <p></p> : filteredMetrics.length > displayedMetrics.length ? <p> 200 key </p> : null}</div>
<footer><span> {visibleKeys.length} / {metrics.length}</span><button type="button" onClick={onShowAll} disabled={!metrics.length}></button><button type="button" onClick={onReset} disabled={!metrics.length}></button></footer>
</aside>;
}
@@ -106,13 +122,6 @@ export default function HistoryPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords, limit, offset]);
const seriesParams = useMemo(() => {
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, metrics: 'speedKmh,totalMileageKm', targetPoints: '240' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords]);
const dataScope = useMemo(() => {
const scope = new URLSearchParams(params);
scope.delete('limit');
@@ -127,11 +136,19 @@ export default function HistoryPage() {
placeholderData: retainPreviousPageWithinScope<HistoryDataResponse>(dataScope),
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const result = dataQuery.data;
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
const visibleMetrics = allMetrics.filter((metric) => visibleKeys.includes(metric.key));
const seriesMetricKey = visibleKeys.filter((key) => key === 'speedKmh' || key === 'totalMileageKm').join(',');
const seriesParams = useMemo(() => {
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, metrics: seriesMetricKey, targetPoints: '240' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords, seriesMetricKey]);
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location' && Boolean(seriesMetricKey), queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const selectedRow = selectedRowRef?.scope === dataScope ? result?.rows.find((row) => row.id === selectedRowRef.id) : undefined;
useEffect(() => {
@@ -166,21 +183,20 @@ export default function HistoryPage() {
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: event.target.value }))} placeholder="车牌 / VIN多台用逗号分隔" /></div></label>
<label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
<fieldset className="v2-history-range"><legend></legend><div><input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /><span></span><input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></div></fieldset>
<label><span></span><select value={draft.category} onChange={(event) => setDraft((value) => ({ ...value, category: event.target.value }))}>{(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></button><button className="v2-secondary-button" type="button" onClick={reset}></button>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <span className="v2-role-badge"> · </span>}
</form>
<div className="v2-history-metrics"><strong></strong>{allMetrics.map((metric) => <button type="button" className={visibleKeys.includes(metric.key) ? 'is-active' : ''} onClick={() => toggleMetric(metric.key)} key={metric.key}><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{!allMetrics.length ? <span></span> : null}</div>
<div className="v2-history-metrics"><strong></strong>{visibleMetrics.map((metric) => <button type="button" className="is-active" onClick={() => toggleMetric(metric.key)} key={metric.key} title="点击取消显示"><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{allMetrics.length > visibleMetrics.length ? <span> {allMetrics.length - visibleMetrics.length} </span> : !allMetrics.length ? <span></span> : null}<button type="button" className="v2-history-metrics-manage" onClick={() => setColumnSettingsOpen(true)} disabled={!allMetrics.length}><IconSetting /></button></div>
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className="v2-history-workspace">
<div className="v2-history-main">
<div className="v2-history-summary"><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small></small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
<section className={`v2-history-table-card is-${density}`}><header><strong></strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting /></button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact"></option><option value="comfortable"></option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th></th><th></th><th></th><th>VIN</th><th></th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th></th><th></th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}></button></td></tr>)}</tbody></table><div className="v2-history-mobile-list">{result?.rows.map((row) => <button type="button" key={row.id} className={selectedRow?.id === row.id ? 'is-selected' : ''} onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><em></em></button>)}</div>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i /></div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span> {page} / {totalPages} {result?.total ?? 0} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} />
<section className={`v2-history-table-card is-${density}`}><header><strong></strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting /></button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact"></option><option value="comfortable"></option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th></th><th></th><th></th><th>VIN</th><th></th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th></th><th></th><th></th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></td><td className="v2-history-quality-reason" title={row.qualityReason}>{row.qualityReason || '—'}</td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}></button></td></tr>)}</tbody></table><div className="v2-history-mobile-list">{result?.rows.map((row) => <button type="button" key={row.id} className={selectedRow?.id === row.id ? 'is-selected' : ''} onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><em></em></button>)}</div>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i /></div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span> {page} / {totalPages} {result?.total ?? 0} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
</div>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
</div>
</div>;
}

View File

@@ -604,12 +604,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-track-inspector.is-empty p { max-width: 220px; margin: 7px 0 0; font-size: 9px; line-height: 1.6; }
.v2-history-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 10px; overflow: hidden; padding: 12px 16px 16px; }
.v2-history-toolbar { display: grid; flex: 0 0 auto; grid-template-columns: minmax(230px, 1.2fr) minmax(160px, .8fr) minmax(160px, .8fr) 120px 130px auto auto auto; align-items: end; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-history-toolbar { display: grid; flex: 0 0 auto; grid-template-columns: minmax(230px, 1.1fr) minmax(350px, 1.5fr) 120px 130px auto auto auto; align-items: end; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-history-toolbar label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: var(--v2-muted); font-size: 8px; }
.v2-history-toolbar label > div { display: flex; height: 34px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 9px; color: #8996a8; }
.v2-history-toolbar input, .v2-history-toolbar select { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 8px; color: #435168; outline: 0; font-size: 9px; }
.v2-history-toolbar label > div input { height: auto; flex: 1; border: 0; padding: 0; }
.v2-history-toolbar input:focus, .v2-history-toolbar select:focus, .v2-history-toolbar label > div:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-history-range { min-width: 0; margin: 0; border: 0; padding: 0; }
.v2-history-range legend { margin-bottom: 5px; padding: 0; color: var(--v2-muted); font-size: 8px; }
.v2-history-range > div { display: grid; height: 34px; grid-template-columns: minmax(0,1fr) auto minmax(0,1fr); align-items: center; overflow: hidden; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; }
.v2-history-range > div:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-history-range > div input { width: 100%; height: 32px; border: 0; border-radius: 0; padding: 0 8px; box-shadow: none; }
.v2-history-range > div span { color: #8794a5; font-size: 8px; }
.v2-history-toolbar button { height: 34px; padding: 0 11px; white-space: nowrap; }
.v2-history-toolbar button:disabled { opacity: .45; cursor: not-allowed; }
.v2-history-metrics { display: flex; min-height: 42px; flex: 0 0 auto; align-items: center; gap: 7px; overflow-x: auto; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 6px 11px; box-shadow: var(--v2-shadow); }
@@ -619,6 +625,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-metrics button.is-active { border-color: #b8d1fa; background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-history-metrics button.is-active i { border-color: var(--v2-blue); background: var(--v2-blue); }
.v2-history-metrics > span { color: var(--v2-muted); font-size: 8px; }
.v2-history-metrics > button.v2-history-metrics-manage { margin-left: auto; border-color: #cddcf0; color: #526987; font-weight: 700; }
.v2-history-metrics > button.v2-history-metrics-manage:disabled { opacity: .4; cursor: not-allowed; }
.v2-history-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(660px, 1fr) 310px; gap: 10px; }
.v2-history-main { display: grid; min-width: 0; min-height: 0; grid-template-rows: 58px 205px minmax(260px, 1fr); gap: 9px; }
.v2-history-summary { display: grid; grid-template-columns: repeat(4, 1fr); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
@@ -653,8 +661,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-table-card > header button, .v2-history-table-card > header select { display: inline-flex; height: 27px; align-items: center; gap: 5px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 8px; color: #657286; cursor: pointer; font-size: 8px; }
.v2-history-column-panel { position: absolute; z-index: 15; top: 37px; right: 9px; display: flex; width: min(340px, calc(100% - 18px)); max-height: min(390px, calc(100% - 48px)); flex-direction: column; overflow: hidden; border: 1px solid #d8e2ee; border-radius: 8px; background: #fff; box-shadow: 0 14px 34px rgba(30, 47, 70, .18); }
.v2-history-column-panel > header { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; border-bottom: 1px solid #e8edf4; padding: 10px 11px; }.v2-history-column-panel > header div { display: grid; gap: 3px; }.v2-history-column-panel > header strong { color: #35445a; font-size: 10px; }.v2-history-column-panel > header span { color: #8591a2; font-size: 8px; }.v2-history-column-panel > header button { display: grid; width: 25px; height: 25px; place-items: center; border: 0; border-radius: 5px; background: #f2f5f9; color: #68768a; cursor: pointer; }
.v2-history-column-panel > label.v2-history-column-search { display: flex; min-height: 38px; flex: 0 0 auto; flex-direction: row; align-items: center; gap: 7px; border-bottom: 1px solid #e8edf4; padding: 5px 10px; color: #8a97a9; }
.v2-history-column-panel > label.v2-history-column-search input { width: 100%; height: 28px; border: 1px solid #dce4ee; border-radius: 6px; padding: 0 8px; color: #435168; outline: 0; font-size: 9px; }
.v2-history-column-panel > label.v2-history-column-search input:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-history-column-panel > div { display: grid; min-height: 0; overflow: auto; padding: 5px 8px; }.v2-history-column-panel label { display: grid; min-height: 36px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #f0f3f7; padding: 0 4px; color: #4d5b6f; font-size: 9px; cursor: pointer; }.v2-history-column-panel label input { margin: 0; accent-color: var(--v2-blue); }.v2-history-column-panel label small { max-width: 130px; overflow: hidden; color: #929dac; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }.v2-history-column-panel > div > p { margin: 18px 8px; color: var(--v2-muted); text-align: center; font-size: 9px; }
.v2-history-column-panel > footer { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 6px; border-top: 1px solid #e8edf4; padding: 8px 10px; }.v2-history-column-panel > footer button { height: 27px; border: 1px solid #dce4ee; border-radius: 5px; background: #fff; padding: 0 9px; color: #5f6e83; cursor: pointer; font-size: 8px; }.v2-history-column-panel > footer button:disabled { opacity: .4; cursor: not-allowed; }
.v2-history-column-panel > footer { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 6px; border-top: 1px solid #e8edf4; padding: 8px 10px; }.v2-history-column-panel > footer span { margin-right: auto; color: #8491a3; font-size: 8px; }.v2-history-column-panel > footer button { height: 27px; border: 1px solid #dce4ee; border-radius: 5px; background: #fff; padding: 0 9px; color: #5f6e83; cursor: pointer; font-size: 8px; }.v2-history-column-panel > footer button:disabled { opacity: .4; cursor: not-allowed; }
.v2-history-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-history-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 8px; }
.v2-history-table-scroll th { position: sticky; z-index: 3; top: 0; height: 31px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; white-space: nowrap; font-weight: 700; }
@@ -663,6 +674,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-table-card.is-comfortable .v2-history-table-scroll td { height: 40px; }
.v2-history-table-scroll tr:hover td, .v2-history-table-scroll tr.is-selected td { background: #f2f7ff; }
.v2-history-table-scroll td button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 8px; }
.v2-history-table-scroll td.v2-history-quality-reason { max-width: 260px; color: #66758a; }
.v2-history-table-scroll input { width: 13px; height: 13px; accent-color: var(--v2-blue); }
.v2-quality { display: inline-flex; align-items: center; gap: 5px; }
.v2-quality i { width: 6px; height: 6px; border-radius: 50%; background: var(--v2-green); }
@@ -686,8 +698,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-evidence dt { color: #7f8c9f; }
.v2-history-evidence dd { margin: 0; overflow-wrap: anywhere; text-align: right; }
.v2-history-evidence dd i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: var(--v2-green); }
.v2-history-evidence dd i.is-warning { background: var(--v2-orange); }
.v2-history-evidence dd i.is-error, .v2-history-evidence dd i.is-critical { background: var(--v2-red); }
.v2-history-evidence .v2-history-quality-detail { align-items: start; }
.v2-history-evidence .v2-history-quality-detail dd { color: #5c6b80; line-height: 1.5; white-space: normal; }
.v2-evidence-values { padding: 10px 12px; }
.v2-evidence-values > strong { display: block; margin-bottom: 5px; font-size: 9px; }
.v2-evidence-values > p { margin: 10px 0; color: var(--v2-muted); font-size: 8px; text-align: center; }
.v2-evidence-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; padding: 6px 0; }
.v2-evidence-values span { min-width: 0; color: #617087; font-size: 8px; }
.v2-evidence-values span small { display: block; margin-top: 2px; overflow: hidden; color: #a0aaba; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
@@ -1000,7 +1017,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-track-workspace { grid-template-columns: minmax(500px, 1fr) 290px; }
.v2-track-playback { grid-template-columns: 180px minmax(210px, 1fr); }
.v2-current-metrics { grid-column: 1 / -1; border-top: 1px solid var(--v2-border); border-left: 0; padding-top: 6px; }
.v2-history-toolbar { grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(120px, .7fr)); }
.v2-history-toolbar { grid-template-columns: minmax(220px, 1fr) minmax(320px, 1.5fr) repeat(3, minmax(100px, .7fr)); }
.v2-history-toolbar button { min-width: 90px; }
.v2-history-workspace { grid-template-columns: minmax(600px, 1fr) 280px; }
.v2-access-filter { grid-template-columns: minmax(210px, 1fr) repeat(4, minmax(110px, .7fr)); }
@@ -1046,6 +1063,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-page { height: auto; min-height: 100%; overflow: auto; }
.v2-history-toolbar { grid-template-columns: 1fr 1fr; }
.v2-history-vehicles { grid-column: 1 / -1; }
.v2-history-range { grid-column: 1 / -1; }
.v2-history-workspace { display: flex; flex-direction: column; }
.v2-history-main { height: 760px; min-height: 0; flex: none; grid-template-rows: 58px 220px minmax(0, 1fr); }
.v2-history-side { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; overflow: visible; }
@@ -1337,6 +1355,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-page { padding: 8px; }
.v2-history-toolbar { grid-template-columns: 1fr; }
.v2-history-vehicles { grid-column: auto; }
.v2-history-range { grid-column: auto; }
.v2-history-toolbar button { width: 100%; }
.v2-history-summary { grid-template-columns: 1fr 1fr; min-height: 106px; }
.v2-history-summary > div:nth-child(3)::before { display: none; }
@@ -1683,6 +1702,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-mobile-list header > div > span { margin-top: 3px; color: #8794a5; font-size: 10px; }
.v2-history-mobile-list p { margin: 9px 0; color: #718096; font-size: 10px; }
.v2-history-mobile-list p b { color: var(--v2-blue); }
.v2-history-mobile-quality { display: block; margin: -2px 0 9px; color: #6d7b8f; font-size: 10px; line-height: 1.45; }
.v2-history-mobile-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 0; border-top: 1px solid #edf1f5; padding-top: 9px; }
.v2-history-mobile-list dt { color: #8a97a9; font-size: 9px; }
.v2-history-mobile-list dd { margin: 3px 0 0; font-size: 11px; font-weight: 700; }
@@ -1911,8 +1931,11 @@ button, a { -webkit-tap-highlight-color: transparent; }
/* History data table. */
.v2-history-page { gap: 12px; padding: 16px 20px 20px; }
.v2-history-toolbar { padding: 14px 16px; }
.v2-history-toolbar label { font-size: 11px; }
.v2-history-toolbar label, .v2-history-range legend { font-size: 11px; }
.v2-history-toolbar input, .v2-history-toolbar select { height: 38px; font-size: 12px; }
.v2-history-range > div { height: 38px; }
.v2-history-range > div input { height: 36px; font-size: 12px; }
.v2-history-range > div span { font-size: 11px; }
.v2-history-summary { min-height: 76px; }
.v2-history-summary small { font-size: 11px; }
.v2-history-summary strong { font-size: 20px; }

View File

@@ -4,6 +4,7 @@ set -euo pipefail
RELEASE_ID=${1:?release id is required}
ARCHIVE=${2:?web archive is required}
API_BINARY=${3:-}
ROOT=${PLATFORM_ROOT:-/opt/lingniu-vehicle-platform}
BASE_URL=${PLATFORM_BASE_URL:-http://127.0.0.1:20300}
SERVICE=${PLATFORM_SERVICE:-lingniu-vehicle-platform}
@@ -17,6 +18,9 @@ case "$RELEASE_ID" in
''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$RELEASE_ID" >&2; exit 1 ;;
esac
test -f "$ARCHIVE" || { printf 'web archive is missing: %s\n' "$ARCHIVE" >&2; exit 1; }
if test -n "$API_BINARY"; then
test -f "$API_BINARY" || { printf 'platform API binary is missing: %s\n' "$API_BINARY" >&2; exit 1; }
fi
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
@@ -67,7 +71,15 @@ trap rollback ERR
test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; }
mkdir -p "$next/web"
cp "$old/platform-api" "$next/platform-api"
for runtime_file in platform-api alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync alert-benchmark; do
if test -f "$old/$runtime_file"; then
cp "$old/$runtime_file" "$next/$runtime_file"
fi
done
test -f "$next/platform-api" || { printf 'current release is missing platform-api\n' >&2; exit 1; }
if test -n "$API_BINARY"; then
cp "$API_BINARY" "$next/platform-api"
fi
if test -f "$old/lingniu-vehicle-platform.service"; then
cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service"
fi
@@ -76,6 +88,9 @@ cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-t
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
cp "$0" "$next/deploy/install-web-release.sh"
chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py"
for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync alert-benchmark; do
test ! -f "$next/$runtime_file" || chmod +x "$next/$runtime_file"
done
while IFS= read -r member; do
case "$member" in

View File

@@ -9,6 +9,7 @@ root="$fixture/platform"
old="$root/releases/old-release"
mkdir -p "$old/web/assets" "$old/deploy" "$root/env" "$fixture/bin"
printf '#!/usr/bin/env bash\nexit 0\n' > "$old/platform-api"
printf 'old evaluator\n' > "$old/alert-evaluator"
chmod +x "$old/platform-api"
cp "$SCRIPT_DIR/verify-web-release.sh" "$old/deploy/verify-web-release.sh"
printf 'old.js\n' > "$old/web/.release-assets"
@@ -25,15 +26,18 @@ printf 'new\n' > "$new_web/assets/new.js"
printf '<main>new</main>\n' > "$new_web/index.html"
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$new_web/app-config.js"
tar -C "$new_web" -czf "$fixture/new.tar.gz" .
printf 'new platform API\n' > "$fixture/new-platform-api"
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/systemctl"
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/curl"
printf '#!/usr/bin/env bash\nprintf "mock_verify=ok\\n"\n' > "$fixture/bin/verify"
chmod +x "$fixture/bin/"*
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" > "$fixture/install.out"
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" "$fixture/new-platform-api" > "$fixture/install.out"
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
test "$(cat "$root/current/platform-api")" = 'new platform API'
test "$(cat "$root/current/alert-evaluator")" = 'old evaluator'
test -f "$root/current/web/assets/new.js"
test -f "$root/current/web/assets/old.js"
test -f "$root/current/web/.compatibility-manifests/1.assets"

View File

@@ -66,6 +66,15 @@ PREVIOUS_WEB=/opt/lingniu-vehicle-platform/releases/$PREVIOUS_RELEASE/web
"$PREVIOUS_WEB/.release-assets"
```
`deploy/install-web-release.sh` also accepts an optional third argument containing a newly built `platform-api`. When provided, it atomically publishes the new API and Web together. The installer inherits every runtime binary that exists in the previous release (`alert-evaluator`, `alert-stream-evaluator`, `platform-migrate`, `oneos-scope-sync` and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:
```bash
deploy/install-web-release.sh \
"$PLATFORM_RELEASE" \
"/tmp/$PLATFORM_RELEASE-web.tar.gz" \
"/tmp/$PLATFORM_RELEASE-platform-api"
```
## Environment
```text
@@ -235,6 +244,8 @@ When `alertStream.lastInvalidCode` reports a recent `missing_vin_jt808`, query `
The history-series gate must use an active production VIN and a current local-time window. Verify that `rawPointCount > 0`, every returned series remains within the requested point budget, `dateFrom`/`dateTo` and point timestamps describe the same absolute window, and the browser renders `Asia/Shanghai` labels. TDengine timestamp literals for this endpoint include an explicit RFC3339 offset; bare UTC-looking strings are unsafe because the server session may interpret them again in its local timezone.
The history-query gate must also request both `location` and `raw` through `/api/v2/history/query`. Every returned row must contain a non-empty `qualityReason`; RAW columns must include fields discovered from the bounded fetched scope rather than only the visible page. In the browser, verify field-name/key search, multi-select, the single visual time-range control, selected-field consistency across table/trend/export, and absence of a RAW-evidence placeholder when `evidenceId` is unavailable. Keep the existing maximum of five vehicles, 200 rows per page and 1,000 fetched rows for interactive field discovery.
Track smoke must also select the final playback event and verify synchronized SOC/direction/alarm availability plus a resolved current address. For high-frequency sources, confirm positive subsecond samples do not become alternating zero-second `数据间隔` segments; only non-increasing timestamps or intervals over ten minutes are gaps. Access smoke should exercise `model`/`provider` and one paired first/latest receive-time range, then confirm the filter survives a shareable URL and every returned row remains in scope.
Global-monitor smoke must verify `zoom=5` returns clusters, a city `bounds` at `zoom=13` returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`.

View File

@@ -159,7 +159,18 @@
### P0-04 历史数据可配置字段与质量原因
状态:`进行中`
状态:`已完成`
已上线结果release `history-fields-quality-20260716164356`
- 位置、RAW 解析字段和日里程统一返回平台计算的质量等级与中文“质量原因”;坐标、速度、里程关系、解析状态、解析错误、设备/接收时间延迟和里程异常均有明确说明。
- RAW 字段目录从本次受控查询范围内已读取的数据发现,不再只扫描当前分页;生产 GB32960 实车查询可选择 123 个实际字段。
- 字段设置支持按中文名称、字段 key 和单位搜索、多选、全选及恢复默认;面板同时最多挂载 200 个选项,字段很多时通过搜索缩小范围,避免一次渲染造成卡顿。
- 当前字段选择统一驱动表格、详情、导出以及可绘图指标;隐藏速度或总里程后,趋势接口不再继续请求已隐藏指标。
- 起止时间合并为一个连续时间范围组件车牌主显、VIN 辅显;无独立 RAW 帧的数据不再显示空“证据”占位。
- 查询仍限制最多 5 台车辆、单页 200 行、服务端本次字段发现最多 1,000 行;异步导出继续限制 31 天、32 指标和 100 万行。
- ECS 实车验证:位置历史 5 行均返回质量原因GB32960 RAW 5 行发现 123 个字段并返回解析质量原因。各 20 次、每次 50 行请求中,位置历史中位约 6ms/P95 约 7msRAW 中位约 25ms/P95 约 30ms。
- 安装器同步支持在原子 Web 切换时替换新 API并完整继承告警评估器、迁移和 OneOS 同步等现有运行文件,避免未来服务器重启后 release 缺少二进制。
目标: