feat(history): align fields quality and exports
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -241,15 +241,16 @@ type HistoryDataSummary struct {
|
||||
}
|
||||
|
||||
type HistoryDataRow struct {
|
||||
ID string `json:"id"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Protocol string `json:"protocol"`
|
||||
DeviceTime string `json:"deviceTime"`
|
||||
ServerTime string `json:"serverTime"`
|
||||
Quality string `json:"quality"`
|
||||
EvidenceID string `json:"evidenceId,omitempty"`
|
||||
Values map[string]any `json:"values"`
|
||||
ID string `json:"id"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Protocol string `json:"protocol"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type HistorySeriesResponse struct {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 超出 0–200 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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 约 7ms,RAW 中位约 25ms/P95 约 30ms。
|
||||
- 安装器同步支持在原子 Web 切换时替换新 API,并完整继承告警评估器、迁移和 OneOS 同步等现有运行文件,避免未来服务器重启后 release 缺少二进制。
|
||||
|
||||
目标:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user