feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -253,6 +253,8 @@ func requiredRole(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
if r.Method == http.MethodPost {
|
||||
switch path {
|
||||
case "/api/v2/auth/logout":
|
||||
return "viewer"
|
||||
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
|
||||
return "viewer"
|
||||
case "/api/v2/alerts/notifications/read":
|
||||
|
||||
@@ -147,6 +147,15 @@ func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
|
||||
if role := requiredRole(httptest.NewRequest(http.MethodPost, "/api/v2/auth/logout", nil)); role != "viewer" {
|
||||
t.Fatalf("logout should allow authenticated customers, required role=%s", role)
|
||||
}
|
||||
if role := requiredRole(httptest.NewRequest(http.MethodPut, "/api/v2/auth/password", nil)); role != "viewer" {
|
||||
t.Fatalf("password change should allow authenticated customers, required role=%s", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
|
||||
cases := []config.Config{
|
||||
{AuthMode: "enforce"},
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceR
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
v.vin,
|
||||
COALESCE(NULLIF(s.plate, ''), NULLIF(b.plate, ''), '') AS plate,
|
||||
COALESCE(NULLIF(b.oem, ''), '') AS oem,
|
||||
COALESCE(NULLIF(p.brand_name, ''), NULLIF(b.oem, ''), '') AS oem,
|
||||
COALESCE(p.model_name, '') AS model_name,
|
||||
COALESCE(p.company_name, '') AS company_name,
|
||||
COALESCE(s.protocol, '') AS protocol,
|
||||
@@ -145,7 +145,7 @@ LIMIT ?`, accessEvidenceLimit+1)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
s.accessSchemaOnce.Do(func() {
|
||||
return s.accessSchema.ensure(func() error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config (
|
||||
id TINYINT NOT NULL PRIMARY KEY,
|
||||
@@ -170,17 +170,16 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := s.db.ExecContext(ctx, statement); err != nil {
|
||||
s.accessSchemaErr = err
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
defaults := defaultAccessThresholds(time.Now())
|
||||
protocols, _ := json.Marshal(defaults.Protocols)
|
||||
_, s.accessSchemaErr = s.db.ExecContext(ctx, `INSERT IGNORE INTO vehicle_access_threshold_config
|
||||
_, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO vehicle_access_threshold_config
|
||||
(id, version, default_threshold_sec, delay_threshold_sec, long_offline_sec, protocol_overrides_json, updated_by)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?)`, defaults.Version, defaults.DefaultThresholdSec, defaults.DelayThresholdSec, defaults.LongOfflineSec, string(protocols), defaults.UpdatedBy)
|
||||
return err
|
||||
})
|
||||
return s.accessSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AccessThresholds(ctx context.Context) (AccessThresholdConfig, error) {
|
||||
|
||||
@@ -439,7 +439,7 @@ func loadActiveAlertEvents(ctx context.Context, tx *sql.Tx) (map[string][]alertA
|
||||
}
|
||||
|
||||
func (s *ProductionStore) alertEvaluationEvidence(ctx context.Context) ([]alertEvaluationEvidence, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT l.vin,COALESCE(NULLIF(l.plate,''),NULLIF(b.plate,''),''),l.protocol,COALESCE(b.oem,''),COALESCE(p.model_name,''),COALESCE(p.company_name,''),COALESCE(l.speed_kmh,0),COALESCE(l.soc_percent,0),COALESCE(l.alarm_flag,0),COALESCE(l.longitude,0),COALESCE(l.latitude,0),COALESCE(l.event_id,''),COALESCE(DATE_FORMAT(l.event_time,'%Y-%m-%d %H:%i:%s.%f'),''),COALESCE(DATE_FORMAT(l.received_at,'%Y-%m-%d %H:%i:%s.%f'),''),GREATEST(0,TIMESTAMPDIFF(SECOND,l.updated_at,NOW())),TIMESTAMPDIFF(SECOND,l.event_time,l.received_at),COALESCE(CONCAT(l.longitude,',',l.latitude),'') FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin=l.vin LEFT JOIN vehicle_profile p ON p.vin=l.vin WHERE l.vin<>'' ORDER BY l.updated_at DESC LIMIT ?`, alertEvaluationVehicleLimit+1)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT l.vin,COALESCE(NULLIF(l.plate,''),NULLIF(b.plate,''),''),l.protocol,COALESCE(NULLIF(p.brand_name,''),NULLIF(b.oem,''),''),COALESCE(p.model_name,''),COALESCE(p.company_name,''),COALESCE(l.speed_kmh,0),COALESCE(l.soc_percent,0),COALESCE(l.alarm_flag,0),COALESCE(l.longitude,0),COALESCE(l.latitude,0),COALESCE(l.event_id,''),COALESCE(DATE_FORMAT(l.event_time,'%Y-%m-%d %H:%i:%s.%f'),''),COALESCE(DATE_FORMAT(l.received_at,'%Y-%m-%d %H:%i:%s.%f'),''),GREATEST(0,TIMESTAMPDIFF(SECOND,l.updated_at,NOW())),TIMESTAMPDIFF(SECOND,l.event_time,l.received_at),COALESCE(CONCAT(l.longitude,',',l.latitude),'') FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin=l.vin LEFT JOIN vehicle_profile p ON p.vin=l.vin WHERE l.vin<>'' ORDER BY l.updated_at DESC LIMIT ?`, alertEvaluationVehicleLimit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
)
|
||||
|
||||
func (s *ProductionStore) ensureAlertSchema(ctx context.Context) error {
|
||||
s.alertSchemaOnce.Do(func() {
|
||||
return s.alertSchema.ensure(func() error {
|
||||
var count int
|
||||
s.alertSchemaErr = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule`).Scan(&count)
|
||||
if s.alertSchemaErr != nil {
|
||||
s.alertSchemaErr = fmt.Errorf("alert center schema unavailable; apply deploy/migrations/002_alert_center.sql: %w", s.alertSchemaErr)
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("alert center schema unavailable; apply deploy/migrations/002_alert_center.sql: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s.alertSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
|
||||
@@ -367,7 +367,7 @@ func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []A
|
||||
selects[i] = "SELECT ? AS vin"
|
||||
args[i] = vin
|
||||
}
|
||||
query := `SELECT v.vin,COALESCE(MAX(NULLIF(b.plate,'')),''),COALESCE(MAX(b.oem),''),COALESCE(MAX(p.model_name),''),COALESCE(MAX(p.company_name),'') FROM (` + strings.Join(selects, " UNION ALL ") + `) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_profile p ON p.vin=v.vin GROUP BY v.vin`
|
||||
query := `SELECT v.vin,COALESCE(MAX(NULLIF(b.plate,'')),''),COALESCE(MAX(NULLIF(p.brand_name,'')),MAX(NULLIF(b.oem,'')),''),COALESCE(MAX(p.model_name),''),COALESCE(MAX(p.company_name),'') FROM (` + strings.Join(selects, " UNION ALL ") + `) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_profile p ON p.vin=v.vin GROUP BY v.vin`
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
@@ -15,6 +16,33 @@ import (
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := store.ensureAlertSchema(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected canceled schema probe, got %v", err)
|
||||
}
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
if err := store.ensureAlertSchema(t.Context()); err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if err := store.ensureAlertSchema(t.Context()); err != nil {
|
||||
t.Fatalf("expected successful probe to stay cached, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type configuredMetricStore struct {
|
||||
*MockStore
|
||||
definitions []MetricDefinition
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gb32960FieldReference is the platform copy of the authoritative Chinese
|
||||
// terminology supplied for GB/T 32960 and the Guangdong fuel-cell extension.
|
||||
// It enriches protocol values at read time without changing the stored RAW
|
||||
// evidence or replacing the parser's stable source-field keys.
|
||||
type gb32960FieldReference struct {
|
||||
Label string
|
||||
Unit string
|
||||
Description string
|
||||
ValueMappings []MetricValueMapping
|
||||
}
|
||||
|
||||
func enumMappings(values ...string) []MetricValueMapping {
|
||||
result := make([]MetricValueMapping, 0, len(values)/3)
|
||||
for index := 0; index+2 < len(values); index += 3 {
|
||||
result = append(result, MetricValueMapping{Value: values[index], Label: values[index+1], Description: values[index+2]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var gb32960PositionMappings = func() []MetricValueMapping {
|
||||
result := make([]MetricValueMapping, 0, 8)
|
||||
for value := 0; value < 8; value++ {
|
||||
parts := []string{"有效定位", "北纬", "东经"}
|
||||
if value&1 != 0 {
|
||||
parts[0] = "无效定位"
|
||||
}
|
||||
if value&2 != 0 {
|
||||
parts[1] = "南纬"
|
||||
}
|
||||
if value&4 != 0 {
|
||||
parts[2] = "西经"
|
||||
}
|
||||
result = append(result, MetricValueMapping{
|
||||
Value: strconv.Itoa(value),
|
||||
Label: strings.Join(parts, " · "),
|
||||
Description: "bit0 表示定位有效性,bit1 表示纬度方向,bit2 表示经度方向。",
|
||||
})
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
var gb32960FieldReferences = map[string]gb32960FieldReference{
|
||||
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
|
||||
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
|
||||
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
|
||||
"gb32960.vehicle.total_current_a": {Label: "总电流", Unit: "A", Description: "动力系统总电流。"},
|
||||
"gb32960.vehicle.soc_percent": {Label: "SOC", Unit: "%", Description: "动力电池荷电状态。"},
|
||||
"gb32960.vehicle.dc_dc_status": {Label: "DC/DC 状态", Description: "DC/DC 工作状态编码。", ValueMappings: enumMappings("1", "工作", "DC/DC 处于工作状态。", "2", "断开", "DC/DC 处于断开状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.gear": {Label: "挡位原始值", Description: "挡位、驱动力和制动力组合原始编码。"},
|
||||
"gb32960.vehicle.insulation_kohm": {Label: "绝缘电阻", Unit: "kΩ", Description: "高压系统绝缘电阻。"},
|
||||
"gb32960.vehicle.accelerator_pct": {Label: "加速踏板行程", Unit: "%", Description: "2016 版字段,加速踏板开度。"},
|
||||
"gb32960.vehicle.brake_pct": {Label: "制动踏板状态", Unit: "%", Description: "2016 版字段,制动踏板状态或开度。"},
|
||||
|
||||
"gb32960.drive_motor.serial_no": {Label: "驱动电机序号", Description: "电机编号。"},
|
||||
"gb32960.drive_motor.state": {Label: "驱动电机状态", Description: "电机工作状态编码。", ValueMappings: enumMappings("1", "耗电", "驱动电机耗电。", "2", "发电", "驱动电机发电。", "3", "关闭", "驱动电机关闭。", "4", "准备", "驱动电机准备。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.drive_motor.controller_temperature_c": {Label: "控制器温度", Unit: "℃", Description: "驱动电机控制器温度。"},
|
||||
"gb32960.drive_motor.speed_rpm": {Label: "转速", Unit: "rpm", Description: "驱动电机转速。"},
|
||||
"gb32960.drive_motor.torque_nm": {Label: "转矩", Unit: "N·m", Description: "驱动电机输出转矩。"},
|
||||
"gb32960.drive_motor.motor_temperature_c": {Label: "电机温度", Unit: "℃", Description: "驱动电机温度。"},
|
||||
"gb32960.drive_motor.controller_voltage_v": {Label: "控制器输入电压", Unit: "V", Description: "电机控制器直流母线输入电压。"},
|
||||
"gb32960.drive_motor.controller_current_a": {Label: "控制器直流母线电流", Unit: "A", Description: "电机控制器直流母线电流。"},
|
||||
|
||||
"gb32960.fuel_cell.fuel_cell_voltage_v": {Label: "燃料电池电压", Unit: "V", Description: "燃料电池系统输出电压。"},
|
||||
"gb32960.fuel_cell.fuel_cell_current_a": {Label: "燃料电池电流", Unit: "A", Description: "燃料电池系统输出电流。"},
|
||||
"gb32960.fuel_cell.hydrogen_consumption_kg_per_100km": {Label: "氢耗", Unit: "kg/100km", Description: "燃料电池氢气消耗率。"},
|
||||
"gb32960.fuel_cell.temperature_probe_values_c": {Label: "探针温度", Unit: "℃", Description: "燃料电池温度探针列表。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_c": {Label: "氢系统最高温度", Unit: "℃", Description: "氢系统最高温度。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_percent": {Label: "最高氢浓度", Unit: "%", Description: "氢气浓度最高值。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_mpa": {Label: "最高氢压力", Unit: "MPa", Description: "氢系统最高压力。"},
|
||||
"gb32960.fuel_cell.dc_dc_status": {Label: "高压 DC/DC 状态", Description: "燃料电池高压 DC/DC 状态。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_probe_id": {Label: "最高氢温探针编号", Description: "检测到氢系统最高温度的探针编号。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_probe_id": {Label: "最高氢浓度探针编号", Description: "检测到最高氢浓度的探针编号。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_probe_id": {Label: "最高氢压探针编号", Description: "检测到最高氢压力的探针编号。"},
|
||||
|
||||
"gb32960.position.position_status": {Label: "定位状态标志", Description: "定位有效性及经纬度方向标志。", ValueMappings: gb32960PositionMappings},
|
||||
"gb32960.position.coordinate_system": {Label: "坐标系", Description: "2025 版新增坐标系编码。", ValueMappings: enumMappings("1", "WGS84", "WGS84 坐标系。", "2", "GCJ-02", "GCJ-02 坐标系。", "3", "其他", "其他坐标系。")},
|
||||
"gb32960.position.longitude": {Label: "经度", Unit: "°", Description: "车辆经度。"},
|
||||
"gb32960.position.latitude": {Label: "纬度", Unit: "°", Description: "车辆纬度。"},
|
||||
|
||||
"gb32960.alarm.max_alarm_level": {Label: "最高报警等级", Description: "当前最高报警等级。", ValueMappings: enumMappings("0", "无故障", "当前无报警故障。", "1", "一级故障", "一级报警故障。", "2", "二级故障", "二级报警故障。", "3", "三级故障", "三级报警故障。")},
|
||||
"gb32960.alarm.general_alarm_flag": {Label: "通用报警标志", Description: "通用报警位图。"},
|
||||
"gb32960.alarm.battery_faults": {Label: "可充电储能装置故障码", Description: "动力电池相关故障码列表。"},
|
||||
"gb32960.alarm.motor_faults": {Label: "驱动电机故障码", Description: "驱动电机相关故障码列表。"},
|
||||
"gb32960.alarm.engine_faults": {Label: "发动机故障码", Description: "发动机相关故障码列表。"},
|
||||
"gb32960.alarm.other_faults": {Label: "其他故障码", Description: "其他故障码列表。"},
|
||||
|
||||
"gb32960.gd_fc_stack.stack_count": {Label: "电堆数量", Description: "本包包含的电堆数量。"},
|
||||
"gb32960.gd_fc_stack.engine_work_state": {Label: "发动机工作状态", Description: "燃料电池发动机工作状态。"},
|
||||
"gb32960.gd_fc_stack.stack_water_outlet_temp_c": {Label: "电堆出水温度", Unit: "℃", Description: "电堆冷却水出口温度。"},
|
||||
"gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa": {Label: "氢气入口压力", Unit: "kPa", Description: "电堆氢气入口压力。"},
|
||||
"gb32960.gd_fc_stack.air_inlet_pressure_kpa": {Label: "空气入口压力", Unit: "kPa", Description: "电堆空气入口压力。"},
|
||||
"gb32960.gd_fc_stack.air_inlet_temp_c": {Label: "空气入口温度", Unit: "℃", Description: "电堆空气入口温度。"},
|
||||
"gb32960.gd_fc_stack.max_cell_voltage_v": {Label: "单体最高电压", Unit: "V", Description: "本电堆单体最高电压。"},
|
||||
"gb32960.gd_fc_stack.min_cell_voltage_v": {Label: "单体最低电压", Unit: "V", Description: "本电堆单体最低电压。"},
|
||||
"gb32960.gd_fc_stack.avg_cell_voltage_v": {Label: "单体平均电压", Unit: "V", Description: "本电堆单体平均电压。"},
|
||||
"gb32960.gd_fc_stack.cell_count": {Label: "单体总数", Description: "电堆单体总数量。"},
|
||||
"gb32960.gd_fc_stack.frame_cell_start": {Label: "本帧单体起始序号", Description: "当前分段电压起始单体序号。"},
|
||||
"gb32960.gd_fc_stack.frame_cell_count": {Label: "本帧单体数量", Description: "当前分段包含的单体数量。"},
|
||||
"gb32960.gd_fc_stack.frame_cell_voltages_v": {Label: "本帧单体电压列表", Unit: "V", Description: "当前分段的单体电压数组;未跨帧合并时不代表完整电堆快照。"},
|
||||
|
||||
"gb32960.gd_fc_dcdc.input_voltage_v": {Label: "输入电压", Unit: "V", Description: "DC/DC 输入电压。"},
|
||||
"gb32960.gd_fc_dcdc.input_current_a": {Label: "输入电流", Unit: "A", Description: "DC/DC 输入电流。"},
|
||||
"gb32960.gd_fc_dcdc.output_voltage_v": {Label: "输出电压", Unit: "V", Description: "DC/DC 输出电压。"},
|
||||
"gb32960.gd_fc_dcdc.output_current_a": {Label: "输出电流", Unit: "A", Description: "DC/DC 输出电流。"},
|
||||
"gb32960.gd_fc_dcdc.controller_temp_c": {Label: "控制器温度", Unit: "℃", Description: "DC/DC 控制器温度。"},
|
||||
|
||||
"gb32960.gd_fc_air_conditioner.status": {Label: "空调状态", Description: "空调工作状态。", ValueMappings: enumMappings("0", "关闭", "空调关闭。", "1", "启动", "空调启动。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.gd_fc_air_conditioner.power_kw": {Label: "空调功率", Unit: "kW", Description: "空调消耗功率。"},
|
||||
"gb32960.gd_fc_air_conditioner.compressor_input_voltage_v": {Label: "压缩机输入电压", Unit: "V", Description: "空调压缩机输入电压。"},
|
||||
|
||||
"gb32960.gd_fc_vehicle_info.collision_alarm": {Label: "碰撞报警", Description: "碰撞报警状态。", ValueMappings: enumMappings("0", "无碰撞报警", "当前无碰撞报警。", "1", "有碰撞报警", "当前有碰撞报警。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.gd_fc_vehicle_info.ambient_temp_c": {Label: "环境温度", Unit: "℃", Description: "车辆环境温度。"},
|
||||
"gb32960.gd_fc_vehicle_info.ambient_pressure_kpa": {Label: "环境压力", Unit: "kPa", Description: "车辆环境压力。"},
|
||||
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg": {Label: "车载氢量", Unit: "kg", Description: "当前车载氢气质量。"},
|
||||
}
|
||||
|
||||
func canonicalGB32960ReferenceKey(sourceField string) string {
|
||||
sourceField = strings.ToLower(strings.TrimSpace(sourceField))
|
||||
if !strings.HasPrefix(sourceField, "gb32960.") {
|
||||
return sourceField
|
||||
}
|
||||
if strings.HasPrefix(sourceField, "gb32960.drive_motor.") {
|
||||
return "gb32960.drive_motor." + lastSourcePart(sourceField)
|
||||
}
|
||||
if strings.HasPrefix(sourceField, "gb32960.gd_fc_stack.") {
|
||||
return "gb32960.gd_fc_stack." + lastSourcePart(sourceField)
|
||||
}
|
||||
return sourceField
|
||||
}
|
||||
|
||||
func lastSourcePart(sourceField string) string {
|
||||
if index := strings.LastIndexByte(sourceField, '.'); index >= 0 && index+1 < len(sourceField) {
|
||||
return sourceField[index+1:]
|
||||
}
|
||||
return sourceField
|
||||
}
|
||||
|
||||
func gb32960ReferenceForSource(sourceField string) (gb32960FieldReference, bool) {
|
||||
reference, ok := gb32960FieldReferences[canonicalGB32960ReferenceKey(sourceField)]
|
||||
if !ok {
|
||||
return gb32960FieldReference{}, false
|
||||
}
|
||||
reference.ValueMappings = append([]MetricValueMapping(nil), reference.ValueMappings...)
|
||||
return reference, true
|
||||
}
|
||||
|
||||
func protocolDisplayValue(sourceField string, value any) string {
|
||||
reference, ok := gb32960ReferenceForSource(sourceField)
|
||||
if !ok || len(reference.ValueMappings) == 0 || value == nil {
|
||||
return ""
|
||||
}
|
||||
raw := metricMappingValue(value)
|
||||
for _, mapping := range reference.ValueMappings {
|
||||
if mapping.Value == raw {
|
||||
return fmt.Sprintf("%s(%s)", mapping.Label, raw)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("未知状态(%s)", raw)
|
||||
}
|
||||
|
||||
func metricMappingValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int8:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int16:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint8:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint16:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint32:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(typed, 10)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
|
||||
func enrichMetricDefinitions(definitions []MetricDefinition) []MetricDefinition {
|
||||
result := make([]MetricDefinition, len(definitions))
|
||||
copy(result, definitions)
|
||||
for index := range result {
|
||||
definition := &result[index]
|
||||
sourceField := definition.SourceFields["GB32960"]
|
||||
reference, ok := gb32960ReferenceForSource(sourceField)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(definition.Protocols) == 1 && strings.EqualFold(definition.Protocols[0], "GB32960") {
|
||||
definition.Label = reference.Label
|
||||
definition.Description = reference.Description
|
||||
definition.Unit = reference.Unit
|
||||
}
|
||||
definition.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -84,13 +84,13 @@ func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsInvalidMonitorBounds(t *testing.T) {
|
||||
func TestHandlerIgnoresInvalidOptionalMonitorBounds(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v2/monitor/map?zoom=12&bounds=105,31,103,29", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") {
|
||||
t.Fatalf("unexpected invalid bounds response: status=%d body=%s", response.Code, response.Body.String())
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"mode"`) || strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") {
|
||||
t.Fatalf("optional invalid bounds must degrade to an unbounded map response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func NewMockStore() *MockStore {
|
||||
sourceProviders: map[string]string{},
|
||||
sourcePolicyRemarks: map[string]string{},
|
||||
profiles: map[string]VehicleProfile{
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", BrandName: "飞驰", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
},
|
||||
locations: []RealtimeLocationRow{
|
||||
{VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen},
|
||||
@@ -350,7 +350,7 @@ func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input Vehi
|
||||
if exists {
|
||||
version = current.Version + 1
|
||||
}
|
||||
profile := VehicleProfile{VIN: vin, ModelName: input.ModelName, VehicleType: input.VehicleType, CompanyName: input.CompanyName, OperationStatus: input.OperationStatus, AccessProvider: input.AccessProvider, FirstAccessAt: input.FirstAccessAt, RuntimeSeconds: input.RuntimeSeconds, SourceSystem: "manual", Version: version, UpdatedBy: input.Actor, UpdatedAt: time.Now().Format(time.RFC3339)}
|
||||
profile := VehicleProfile{VIN: vin, BrandName: input.BrandName, ModelName: input.ModelName, VehicleType: input.VehicleType, CompanyName: input.CompanyName, OperationStatus: input.OperationStatus, AccessProvider: input.AccessProvider, FirstAccessAt: input.FirstAccessAt, RuntimeSeconds: input.RuntimeSeconds, SourceSystem: "manual", Version: version, UpdatedBy: input.Actor, UpdatedAt: time.Now().Format(time.RFC3339)}
|
||||
m.profiles[vin] = profile
|
||||
return profile, nil
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func (m *MockStore) SyncVehicleProfiles(_ context.Context, request VehicleProfil
|
||||
continue
|
||||
}
|
||||
m.profiles[item.VIN] = VehicleProfile{
|
||||
VIN: item.VIN, ModelName: item.ModelName, VehicleType: item.VehicleType,
|
||||
VIN: item.VIN, BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType,
|
||||
CompanyName: item.CompanyName, OperationStatus: item.OperationStatus,
|
||||
AccessProvider: item.AccessProvider, FirstAccessAt: item.FirstAccessAt,
|
||||
RuntimeSeconds: item.RuntimeSeconds, SourceSystem: request.SourceSystem,
|
||||
|
||||
@@ -206,17 +206,24 @@ type MetricCatalog struct {
|
||||
}
|
||||
|
||||
type MetricDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
Protocols []string `json:"protocols"`
|
||||
SourceFields map[string]string `json:"sourceFields"`
|
||||
Searchable bool `json:"searchable"`
|
||||
Chartable bool `json:"chartable"`
|
||||
Alertable bool `json:"alertable"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
Protocols []string `json:"protocols"`
|
||||
SourceFields map[string]string `json:"sourceFields"`
|
||||
ValueMappings []MetricValueMapping `json:"valueMappings,omitempty"`
|
||||
Searchable bool `json:"searchable"`
|
||||
Chartable bool `json:"chartable"`
|
||||
Alertable bool `json:"alertable"`
|
||||
}
|
||||
|
||||
type MetricValueMapping struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type HistoryDataCategory struct {
|
||||
@@ -225,12 +232,14 @@ type HistoryDataCategory struct {
|
||||
}
|
||||
|
||||
type HistoryMetricDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
DefaultVisible bool `json:"defaultVisible"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
ValueMappings []MetricValueMapping `json:"valueMappings,omitempty"`
|
||||
DefaultVisible bool `json:"defaultVisible"`
|
||||
}
|
||||
|
||||
type HistoryDataResponse struct {
|
||||
@@ -940,6 +949,7 @@ type VehicleDetail struct {
|
||||
|
||||
type VehicleProfile struct {
|
||||
VIN string `json:"vin"`
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
CompanyName string `json:"companyName"`
|
||||
@@ -958,6 +968,7 @@ type VehicleProfile struct {
|
||||
}
|
||||
|
||||
type VehicleProfileInput struct {
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
CompanyName string `json:"companyName"`
|
||||
@@ -971,6 +982,7 @@ type VehicleProfileInput struct {
|
||||
|
||||
type VehicleProfileSyncItem struct {
|
||||
VIN string `json:"vin"`
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
CompanyName string `json:"companyName"`
|
||||
@@ -1337,6 +1349,7 @@ type LatestTelemetryValue struct {
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
Value any `json:"value"`
|
||||
DisplayValue string `json:"displayValue,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceEndpoint string `json:"sourceEndpoint,omitempty"`
|
||||
FrameID string `json:"frameId"`
|
||||
|
||||
@@ -7,26 +7,21 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProductionStore struct {
|
||||
db *sql.DB
|
||||
tdengine *sql.DB
|
||||
tdDatabase string
|
||||
redisOnline redisOnlineKeyCounter
|
||||
capacityCheck capacityChecker
|
||||
alertStreamGroup string
|
||||
alertStreamMode string
|
||||
accessSchemaOnce sync.Once
|
||||
accessSchemaErr error
|
||||
alertSchemaOnce sync.Once
|
||||
alertSchemaErr error
|
||||
profileSchemaOnce sync.Once
|
||||
profileSchemaErr error
|
||||
reconciliationSchemaOnce sync.Once
|
||||
reconciliationSchemaErr error
|
||||
db *sql.DB
|
||||
tdengine *sql.DB
|
||||
tdDatabase string
|
||||
redisOnline redisOnlineKeyCounter
|
||||
capacityCheck capacityChecker
|
||||
alertStreamGroup string
|
||||
alertStreamMode string
|
||||
accessSchema schemaReadyGate
|
||||
alertSchema schemaReadyGate
|
||||
profileSchema schemaReadyGate
|
||||
reconciliationSchema schemaReadyGate
|
||||
}
|
||||
|
||||
type redisOnlineKeyCounter interface {
|
||||
|
||||
@@ -100,6 +100,7 @@ func normalizeVehicleProfileSyncRequest(request VehicleProfileSyncRequest) Vehic
|
||||
item := &request.Items[index]
|
||||
item.VIN = strings.ToUpper(strings.TrimSpace(item.VIN))
|
||||
normalized := normalizeVehicleProfileInput(vehicleProfileSyncInput(*item, request.Actor))
|
||||
item.BrandName = normalized.BrandName
|
||||
item.ModelName = normalized.ModelName
|
||||
item.VehicleType = normalized.VehicleType
|
||||
item.CompanyName = normalized.CompanyName
|
||||
@@ -149,7 +150,7 @@ func validateVehicleProfileSyncRequest(request VehicleProfileSyncRequest) error
|
||||
|
||||
func vehicleProfileSyncInput(item VehicleProfileSyncItem, actor string) VehicleProfileInput {
|
||||
return VehicleProfileInput{
|
||||
ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName,
|
||||
BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName,
|
||||
OperationStatus: item.OperationStatus, AccessProvider: item.AccessProvider,
|
||||
FirstAccessAt: item.FirstAccessAt, RuntimeSeconds: item.RuntimeSeconds, Actor: actor,
|
||||
}
|
||||
@@ -172,7 +173,7 @@ func vehicleProfileSyncDecision(current VehicleProfile, exists bool, request Veh
|
||||
}
|
||||
|
||||
func vehicleProfileSyncFieldsEqual(current VehicleProfile, item VehicleProfileSyncItem) bool {
|
||||
return current.ModelName == item.ModelName && current.VehicleType == item.VehicleType &&
|
||||
return current.BrandName == item.BrandName && current.ModelName == item.ModelName && current.VehicleType == item.VehicleType &&
|
||||
current.CompanyName == item.CompanyName && current.OperationStatus == item.OperationStatus &&
|
||||
current.AccessProvider == item.AccessProvider && current.FirstAccessAt == item.FirstAccessAt &&
|
||||
equalOptionalInt64(current.RuntimeSeconds, item.RuntimeSeconds)
|
||||
@@ -221,6 +222,7 @@ func validateProfileVIN(vin string) error {
|
||||
}
|
||||
|
||||
func normalizeVehicleProfileInput(input VehicleProfileInput) VehicleProfileInput {
|
||||
input.BrandName = strings.TrimSpace(input.BrandName)
|
||||
input.ModelName = strings.TrimSpace(input.ModelName)
|
||||
input.VehicleType = strings.TrimSpace(input.VehicleType)
|
||||
input.CompanyName = strings.TrimSpace(input.CompanyName)
|
||||
@@ -240,7 +242,7 @@ func validateVehicleProfileInput(input VehicleProfileInput) error {
|
||||
max int
|
||||
name string
|
||||
}{
|
||||
{input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"},
|
||||
{input.BrandName, 128, "品牌"}, {input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"},
|
||||
{input.CompanyName, 128, "所属企业"}, {input.AccessProvider, 128, "接入服务商"},
|
||||
}
|
||||
for _, field := range lengths {
|
||||
@@ -278,7 +280,10 @@ func parseVehicleProfileTime(value string) (time.Time, error) {
|
||||
}
|
||||
|
||||
func decorateVehicleProfile(profile VehicleProfile) VehicleProfile {
|
||||
missing := make([]string, 0, 7)
|
||||
missing := make([]string, 0, 8)
|
||||
if profile.BrandName == "" {
|
||||
missing = append(missing, "brandName")
|
||||
}
|
||||
if profile.ModelName == "" {
|
||||
missing = append(missing, "modelName")
|
||||
}
|
||||
@@ -301,7 +306,7 @@ func decorateVehicleProfile(profile VehicleProfile) VehicleProfile {
|
||||
missing = append(missing, "runtimeSeconds")
|
||||
}
|
||||
profile.MissingFields = missing
|
||||
profile.Completeness = (7 - len(missing)) * 100 / 7
|
||||
profile.Completeness = (8 - len(missing)) * 100 / 8
|
||||
if profile.OperationStatus == "" {
|
||||
profile.OperationStatus = "unknown"
|
||||
}
|
||||
|
||||
@@ -8,17 +8,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const vehicleProfileSelect = `SELECT vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by,updated_at FROM vehicle_profile `
|
||||
const vehicleProfileSelect = `SELECT vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by,updated_at FROM vehicle_profile `
|
||||
|
||||
func (s *ProductionStore) ensureProfileSchema(ctx context.Context) error {
|
||||
s.profileSchemaOnce.Do(func() {
|
||||
return s.profileSchema.ensure(func() error {
|
||||
var present int
|
||||
s.profileSchemaErr = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`).Scan(&present)
|
||||
if s.profileSchemaErr == nil && present != 2 {
|
||||
s.profileSchemaErr = sql.ErrNoRows
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`).Scan(&present); err != nil {
|
||||
return err
|
||||
}
|
||||
if present != 2 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s.profileSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, bool, error) {
|
||||
@@ -48,7 +50,7 @@ func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, in
|
||||
if input.Version != 0 {
|
||||
return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_NOT_FOUND", Message: "待更新车辆档案不存在"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`, vin, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`, vin, input.BrandName, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor)
|
||||
current = 0
|
||||
} else if err != nil {
|
||||
return VehicleProfile{}, err
|
||||
@@ -56,7 +58,7 @@ func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, in
|
||||
if input.Version != current {
|
||||
return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_VERSION_CONFLICT", Message: "车辆档案已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
result, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_profile SET model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system='manual',source_version='',synced_at=NULL,version=version+1,updated_by=? WHERE vin=? AND version=?`, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor, vin, current)
|
||||
result, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_profile SET brand_name=?,model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system='manual',source_version='',synced_at=NULL,version=version+1,updated_by=? WHERE vin=? AND version=?`, input.BrandName, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor, vin, current)
|
||||
err = updateErr
|
||||
if err == nil {
|
||||
rows, _ := result.RowsAffected()
|
||||
@@ -154,10 +156,10 @@ func (s *ProductionStore) SyncVehicleProfiles(ctx context.Context, request Vehic
|
||||
|
||||
firstAccess := nullableVehicleProfileTime(item.FirstAccessAt)
|
||||
if itemResult.Status == profileSyncCreated {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`, item.VIN, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?)`, item.VIN, item.BrandName, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor)
|
||||
} else {
|
||||
var updateResult sql.Result
|
||||
updateResult, err = tx.ExecContext(ctx, `UPDATE vehicle_profile SET model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system=?,source_version=?,synced_at=?,version=version+1,updated_by=? WHERE vin=? AND version=?`, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor, item.VIN, current.Version)
|
||||
updateResult, err = tx.ExecContext(ctx, `UPDATE vehicle_profile SET brand_name=?,model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system=?,source_version=?,synced_at=?,version=version+1,updated_by=? WHERE vin=? AND version=?`, item.BrandName, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor, item.VIN, current.Version)
|
||||
if err == nil {
|
||||
rowsAffected, rowsErr := updateResult.RowsAffected()
|
||||
if rowsErr != nil {
|
||||
@@ -207,7 +209,7 @@ func scanVehicleProfile(scanner vehicleProfileScanner) (VehicleProfile, error) {
|
||||
var firstAccess, syncedAt sql.NullTime
|
||||
var runtime sql.NullInt64
|
||||
var updatedAt time.Time
|
||||
err := scanner.Scan(&profile.VIN, &profile.ModelName, &profile.VehicleType, &profile.CompanyName, &profile.OperationStatus, &profile.AccessProvider, &firstAccess, &runtime, &profile.SourceSystem, &profile.SourceVersion, &syncedAt, &profile.Version, &profile.UpdatedBy, &updatedAt)
|
||||
err := scanner.Scan(&profile.VIN, &profile.BrandName, &profile.ModelName, &profile.VehicleType, &profile.CompanyName, &profile.OperationStatus, &profile.AccessProvider, &firstAccess, &runtime, &profile.SourceSystem, &profile.SourceVersion, &syncedAt, &profile.Version, &profile.UpdatedBy, &updatedAt)
|
||||
if err != nil {
|
||||
return VehicleProfile{}, err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 7 {
|
||||
if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 8 {
|
||||
t.Fatalf("expected explicit empty profile, got %+v", empty)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) {
|
||||
func TestSaveVehicleProfileCreatesAndUsesOptimisticVersion(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
runtime := int64(3600)
|
||||
input := VehicleProfileInput{ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime, Actor: "admin-a"}
|
||||
input := VehicleProfileInput{BrandName: "现代", ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime, Actor: "admin-a"}
|
||||
profile, err := service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -174,7 +174,7 @@ func TestVehicleProfileHandlersReadAndUpdate(t *testing.T) {
|
||||
t.Fatalf("empty profile read status=%d body=%s", read.Code, read.Body.String())
|
||||
}
|
||||
update := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"modelName":"氢燃料重卡","vehicleType":"重卡","companyName":"示范物流","operationStatus":"active","accessProvider":"车厂平台","firstAccessAt":"2026-07-01T08:30","runtimeSeconds":3600,"version":0}`)
|
||||
body := bytes.NewBufferString(`{"brandName":"现代","modelName":"氢燃料重卡","vehicleType":"重卡","companyName":"示范物流","operationStatus":"active","accessProvider":"车厂平台","firstAccessAt":"2026-07-01T08:30","runtimeSeconds":3600,"version":0}`)
|
||||
handler.ServeHTTP(update, httptest.NewRequest(http.MethodPut, "/api/v2/vehicles/LNXNEGRR7SR318212/profile", body))
|
||||
if update.Code != http.StatusOK {
|
||||
t.Fatalf("profile update status=%d body=%s", update.Code, update.Body.String())
|
||||
@@ -216,12 +216,12 @@ func TestProductionVehicleProfileCreateIsTransactionalAndAudited(t *testing.T) {
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM vehicle_profile WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"version"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`)).WithArgs("VIN001", "现代", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "admin-a", "create", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
columns := []string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=?`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows(columns).AddRow("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", now, runtime, "manual", "", nil, 1, "admin-a", now))
|
||||
profile, err := store.SaveVehicleProfile(context.Background(), "VIN001", VehicleProfileInput{ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"})
|
||||
columns := []string{"vin", "brand_name", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=?`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows(columns).AddRow("VIN001", "现代", "示范车型", "重卡", "示范物流", "active", "车厂平台", now, runtime, "manual", "", nil, 1, "admin-a", now))
|
||||
profile, err := store.SaveVehicleProfile(context.Background(), "VIN001", VehicleProfileInput{BrandName: "现代", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -243,13 +243,13 @@ func TestProductionVehicleProfileSyncCreatesAndAuditsInOneTransaction(t *testing
|
||||
runtime := int64(3600)
|
||||
request := VehicleProfileSyncRequest{
|
||||
SourceSystem: "oem-tsp", SourceVersion: "snapshot-1", Actor: "sync-admin",
|
||||
Items: []VehicleProfileSyncItem{{VIN: "VIN001", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}},
|
||||
Items: []VehicleProfileSyncItem{{VIN: "VIN001", BrandName: "现代", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}},
|
||||
}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IN (?)`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "oem-tsp", "snapshot-1", sqlmock.AnyArg(), "sync-admin").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "brand_name", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,brand_name,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?)`)).WithArgs("VIN001", "现代", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "oem-tsp", "snapshot-1", sqlmock.AnyArg(), "sync-admin").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "sync-admin", "sync_created", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
result, err := store.SyncVehicleProfiles(context.Background(), request)
|
||||
|
||||
@@ -42,18 +42,17 @@ type reconciliationExisting struct {
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
|
||||
s.reconciliationSchemaOnce.Do(func() {
|
||||
return s.reconciliationSchema.ensure(func() error {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name IN ('vehicle_reconciliation_run','vehicle_reconciliation_issue','vehicle_reconciliation_action')`).Scan(&count); err != nil {
|
||||
s.reconciliationSchemaErr = err
|
||||
return
|
||||
return err
|
||||
}
|
||||
if count != 3 {
|
||||
s.reconciliationSchemaErr = fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql")
|
||||
return fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s.reconciliationSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
import "sync"
|
||||
|
||||
// schemaReadyGate serializes schema probes and caches only a successful result.
|
||||
// Request cancellation and transient database failures must remain retryable.
|
||||
type schemaReadyGate struct {
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
}
|
||||
|
||||
func (gate *schemaReadyGate) ensure(check func() error) error {
|
||||
gate.mu.Lock()
|
||||
defer gate.mu.Unlock()
|
||||
if gate.ready {
|
||||
return nil
|
||||
}
|
||||
if err := check(); err != nil {
|
||||
return err
|
||||
}
|
||||
gate.ready = true
|
||||
return nil
|
||||
}
|
||||
@@ -608,9 +608,13 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
zoom = 20
|
||||
}
|
||||
provinceMode := zoom <= monitorProvinceMaxZoom
|
||||
bounds, hasBounds, err := parseMonitorBounds(query.Get("bounds"))
|
||||
if err != nil {
|
||||
return MonitorMapResponse{}, err
|
||||
bounds, hasBounds, boundsErr := parseMonitorBounds(query.Get("bounds"))
|
||||
if boundsErr != nil {
|
||||
// The viewport is an optional performance hint. Wide, wrapped or
|
||||
// intermediate map frames can briefly exceed WGS-84 bounds; falling
|
||||
// back to the bounded unfiltered response keeps the map usable.
|
||||
bounds = monitorBounds{}
|
||||
hasBounds = false
|
||||
}
|
||||
result := MonitorMapResponse{
|
||||
Mode: "clusters",
|
||||
@@ -1602,6 +1606,63 @@ func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[
|
||||
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
type trackSourceCandidate struct {
|
||||
Protocol string
|
||||
Page Page[HistoryLocationRow]
|
||||
Raw []HistoryLocationRow
|
||||
Clean []HistoryLocationRow
|
||||
Quality TrackQuality
|
||||
}
|
||||
|
||||
var trackCandidateProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"}
|
||||
|
||||
func cloneURLValues(values url.Values) url.Values {
|
||||
next := make(url.Values, len(values))
|
||||
for key, items := range values {
|
||||
next[key] = append([]string(nil), items...)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func sortedHistoryLocations(items []HistoryLocationRow) []HistoryLocationRow {
|
||||
points := append([]HistoryLocationRow(nil), items...)
|
||||
sort.SliceStable(points, func(i, j int) bool {
|
||||
left, leftOK := parseVehicleServiceTime(points[i].DeviceTime)
|
||||
right, rightOK := parseVehicleServiceTime(points[j].DeviceTime)
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.Before(right)
|
||||
}
|
||||
return points[i].DeviceTime < points[j].DeviceTime
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
||||
func (s *Service) trackSourceCandidates(ctx context.Context, query url.Values, requestedProtocol string) ([]trackSourceCandidate, error) {
|
||||
protocols := trackCandidateProtocols
|
||||
if requestedProtocol != "" {
|
||||
protocols = []string{requestedProtocol}
|
||||
}
|
||||
candidates := make([]trackSourceCandidate, 0, len(protocols))
|
||||
for _, protocol := range protocols {
|
||||
sourceQuery := cloneURLValues(query)
|
||||
sourceQuery.Set("protocol", protocol)
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, sourceQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := sortedHistoryLocations(page.Items)
|
||||
clean, quality := analyzeTrackPoints(raw)
|
||||
candidates = append(candidates, trackSourceCandidate{
|
||||
Protocol: protocol,
|
||||
Page: page,
|
||||
Raw: raw,
|
||||
Clean: clean,
|
||||
Quality: quality,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPlaybackResponse, error) {
|
||||
keyword := strings.TrimSpace(firstNonEmpty(query.Get("keyword"), query.Get("vin")))
|
||||
if keyword == "" {
|
||||
@@ -1627,37 +1688,41 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
}
|
||||
resolvedQuery.Set("limit", "5000")
|
||||
resolvedQuery.Set("offset", "0")
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
requestedProtocol := strings.ToUpper(strings.TrimSpace(query.Get("protocol")))
|
||||
candidates, err := s.trackSourceCandidates(ctx, resolvedQuery, requestedProtocol)
|
||||
if err != nil {
|
||||
return TrackPlaybackResponse{}, err
|
||||
}
|
||||
rawPoints := append([]HistoryLocationRow(nil), page.Items...)
|
||||
sort.SliceStable(rawPoints, func(i, j int) bool {
|
||||
left, leftOK := parseVehicleServiceTime(rawPoints[i].DeviceTime)
|
||||
right, rightOK := parseVehicleServiceTime(rawPoints[j].DeviceTime)
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.Before(right)
|
||||
allCleanPoints := make([]HistoryLocationRow, 0)
|
||||
for _, candidate := range candidates {
|
||||
allCleanPoints = append(allCleanPoints, candidate.Clean...)
|
||||
}
|
||||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(allCleanPoints, requestedProtocol)
|
||||
selected := trackSourceCandidate{Protocol: selectedProtocol, Clean: []HistoryLocationRow{}}
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(candidate.Protocol, selectedProtocol) {
|
||||
selected = candidate
|
||||
break
|
||||
}
|
||||
return rawPoints[i].DeviceTime < rawPoints[j].DeviceTime
|
||||
})
|
||||
cleanPoints, quality := analyzeTrackPoints(rawPoints)
|
||||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(cleanPoints, query.Get("protocol"))
|
||||
}
|
||||
quality := selected.Quality
|
||||
quality.SelectedProtocol = selectedProtocol
|
||||
quality.AlternateSourcePoints = alternateSourcePoints
|
||||
quality.ValidPoints = len(points)
|
||||
applyTrackSequenceQuality(points, &quality)
|
||||
truncated := selected.Page.Total > len(selected.Raw)
|
||||
result := TrackPlaybackResponse{
|
||||
Points: []HistoryLocationRow{},
|
||||
Events: []TrackPlaybackEvent{},
|
||||
Sources: summarizeTrackSources(cleanPoints),
|
||||
Sources: summarizeTrackSources(allCleanPoints),
|
||||
Segments: []TrackSegment{},
|
||||
Stops: []TrackStop{},
|
||||
Total: page.Total,
|
||||
Truncated: page.Total > len(rawPoints),
|
||||
Total: selected.Page.Total,
|
||||
Truncated: truncated,
|
||||
Quality: quality,
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false)
|
||||
result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), 0, result.Truncated, false)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
if len(points) == 0 {
|
||||
return result, nil
|
||||
@@ -1696,7 +1761,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
for index := range result.Stops {
|
||||
result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes)
|
||||
}
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
result.Coverage.ActualStart = result.Summary.StartTime
|
||||
result.Coverage.ActualEnd = result.Summary.EndTime
|
||||
@@ -1755,9 +1820,13 @@ type metricCatalogStore interface {
|
||||
|
||||
func (s *Service) metricDefinitions(ctx context.Context) ([]MetricDefinition, error) {
|
||||
if store, ok := s.store.(metricCatalogStore); ok {
|
||||
return store.MetricDefinitions(ctx)
|
||||
definitions, err := store.MetricDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return enrichMetricDefinitions(definitions), nil
|
||||
}
|
||||
return metricDefinitions(), nil
|
||||
return enrichMetricDefinitions(metricDefinitions()), nil
|
||||
}
|
||||
|
||||
func (s *Service) MetricCatalog(ctx context.Context) (MetricCatalog, error) {
|
||||
@@ -1865,7 +1934,8 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
if unified {
|
||||
key = definition.Key
|
||||
}
|
||||
if previous, exists := candidates[key]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||||
candidateKey := normalizedProtocol + "\x00" + key
|
||||
if previous, exists := candidates[candidateKey]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||||
continue
|
||||
}
|
||||
label, unit := latestTelemetryRawMetadata(sourceField)
|
||||
@@ -1873,6 +1943,11 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
valueType := "dynamic"
|
||||
description := ""
|
||||
value := rawValue
|
||||
if reference, ok := gb32960ReferenceForSource(sourceField); ok {
|
||||
label = reference.Label
|
||||
unit = reference.Unit
|
||||
description = reference.Description
|
||||
}
|
||||
if unified {
|
||||
label = definition.Label
|
||||
unit = definition.Unit
|
||||
@@ -1880,11 +1955,18 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
valueType = definition.ValueType
|
||||
description = definition.Description
|
||||
value = normalizeTelemetryValue(rawValue, definition.ValueType)
|
||||
if reference, ok := gb32960ReferenceForSource(sourceField); ok {
|
||||
label = reference.Label
|
||||
unit = reference.Unit
|
||||
description = reference.Description
|
||||
}
|
||||
label, description = protocolTelemetryMetadata(normalizedProtocol, key, label, description)
|
||||
}
|
||||
candidates[key] = latestTelemetryCandidate{
|
||||
displayValue := protocolDisplayValue(sourceField, value)
|
||||
candidates[candidateKey] = latestTelemetryCandidate{
|
||||
value: LatestTelemetryValue{
|
||||
Key: key, SourceField: sourceField, Label: label, Description: description, Unit: unit,
|
||||
Category: category, ValueType: valueType, Value: value, Protocol: normalizedProtocol,
|
||||
Category: category, ValueType: valueType, Value: value, DisplayValue: displayValue, Protocol: normalizedProtocol,
|
||||
SourceEndpoint: frame.SourceEndpoint, FrameID: frame.ID, DeviceTime: frame.DeviceTime, ServerTime: frame.ServerTime,
|
||||
Quality: quality, QualityReason: qualityReason, FreshnessSeconds: freshness, DataDelaySeconds: delay,
|
||||
},
|
||||
@@ -1896,6 +1978,9 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
response.Values = append(response.Values, candidate.value)
|
||||
}
|
||||
sort.Slice(response.Values, func(i, j int) bool {
|
||||
if left, right := telemetryProtocolRank(response.Values[i].Protocol), telemetryProtocolRank(response.Values[j].Protocol); left != right {
|
||||
return left < right
|
||||
}
|
||||
left, right := telemetryCategoryRank(response.Values[i].Category), telemetryCategoryRank(response.Values[j].Category)
|
||||
if left != right {
|
||||
return left < right
|
||||
@@ -1915,10 +2000,24 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
}
|
||||
response.Categories[index].Count++
|
||||
}
|
||||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; selected %d newest scalar values by unified metric/source field", len(frames), len(response.Values))
|
||||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; retained %d newest scalar values independently by protocol and metric/source field", len(frames), len(response.Values))
|
||||
return response
|
||||
}
|
||||
|
||||
func protocolTelemetryMetadata(protocol, key, label, description string) (string, string) {
|
||||
if key != "total_mileage_km" {
|
||||
return label, description
|
||||
}
|
||||
switch protocol {
|
||||
case "JT808":
|
||||
return "GPS 总里程", "JT/T 808 定位终端累计的 GPS 里程,不等同于车辆仪表盘里程"
|
||||
case "GB32960", "YUTONG_MQTT":
|
||||
return "仪表盘总里程", "车辆总线或车厂平台上报的仪表盘累计里程"
|
||||
default:
|
||||
return label, description
|
||||
}
|
||||
}
|
||||
|
||||
func telemetrySourceLookupKey(protocol, sourceField string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(protocol)) + "\x00" + strings.TrimSpace(sourceField)
|
||||
}
|
||||
@@ -2036,6 +2135,10 @@ func normalizeTelemetryCategory(category string) string {
|
||||
return "location"
|
||||
case "quality":
|
||||
return "quality"
|
||||
case "fuel-cell", "fuel_cell", "fuelcell", "hydrogen":
|
||||
return "fuel-cell"
|
||||
case "motor", "engine":
|
||||
return "motor"
|
||||
default:
|
||||
return "extension"
|
||||
}
|
||||
@@ -2046,7 +2149,7 @@ func telemetrySourceCategory(sourceField string) string {
|
||||
switch {
|
||||
case strings.Contains(normalized, "alarm") || strings.Contains(normalized, "fault") || strings.Contains(normalized, "warning"):
|
||||
return "alarm"
|
||||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack"):
|
||||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack") || strings.Contains(normalized, "hydrogen"):
|
||||
return "fuel-cell"
|
||||
case strings.Contains(normalized, "motor") || strings.Contains(normalized, "engine") || strings.Contains(normalized, "rpm"):
|
||||
return "motor"
|
||||
@@ -2070,6 +2173,15 @@ func telemetryCategoryRank(category string) int {
|
||||
return 99
|
||||
}
|
||||
|
||||
func telemetryProtocolRank(protocol string) int {
|
||||
for index, candidate := range canonicalVehicleProtocols {
|
||||
if protocol == candidate {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(canonicalVehicleProtocols)
|
||||
}
|
||||
|
||||
func telemetryCategoryLabel(category string) string {
|
||||
labels := map[string]string{"vehicle": "整车数据", "motor": "驱动电机", "fuel-cell": "燃料电池", "battery": "电池", "location": "定位", "alarm": "报警", "quality": "数据质量", "extension": "扩展字段"}
|
||||
if label := labels[category]; label != "" {
|
||||
@@ -2086,9 +2198,28 @@ func metricDefinitions() []MetricDefinition {
|
||||
{Key: "alarm_active", Label: "协议告警位", Description: "协议报文是否携带活动告警", Unit: "", Category: "safety", ValueType: "boolean", Protocols: []string{"GB32960", "JT808"}, SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag", "JT808": "jt808.location.alarm_flag"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "freshness_sec", Label: "离线时长", Description: "最新数据距当前时间的秒数", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.updated_at"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "data_delay_sec", Label: "数据延迟", Description: "服务接收时间与设备事件时间的差值", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "received_at-event_time"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong.vehicle.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程;JT808 为 GPS 里程,其余为仪表盘里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong_mqtt.data.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||||
{Key: "longitude", Label: "经度", Description: "WGS84/协议归一化经度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.longitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||||
{Key: "latitude", Label: "纬度", Description: "WGS84/协议归一化纬度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.latitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||||
{Key: "vehicle_status", Label: "车辆状态", Description: "GB/T 32960 整车状态码", Unit: "", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.vehicle_status"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "charge_status", Label: "充电状态", Description: "GB/T 32960 充电状态码", Unit: "", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.charge_status"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "total_voltage_v", Label: "总电压", Description: "动力电池总电压", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "total_current_a", Label: "总电流", Description: "动力电池总电流", Unit: "A", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_current_a"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "insulation_kohm", Label: "绝缘电阻", Description: "整车绝缘电阻", Unit: "kΩ", Category: "safety", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.insulation_kohm"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_cell_voltage_v", Label: "燃料电池电压", Description: "燃料电池系统输出电压", Unit: "V", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_cell_current_a", Label: "燃料电池电流", Description: "燃料电池系统输出电流", Unit: "A", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_current_a"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_consumption_kg_per_100km", Label: "百公里氢耗", Description: "燃料电池系统百公里氢气消耗量", Unit: "kg/100km", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Description: "氢气浓度最高值", Unit: "%", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_pressure_mpa", Label: "最高氢气压力", Description: "燃料电池系统氢气压力探针的最高读数", Unit: "MPa", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_pressure_mpa"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_temperature_c", Label: "最高氢气温度", Description: "燃料电池系统氢气温度探针的最高读数", Unit: "℃", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_temperature_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "engine_speed_rpm", Label: "发动机转速", Description: "发动机曲轴转速", Unit: "rpm", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.engine.crank_speed_rpm"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "max_cell_voltage_v", Label: "单体最高电压", Description: "动力电池单体电压最高值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "min_cell_voltage_v", Label: "单体最低电压", Description: "动力电池单体电压最低值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "max_battery_temperature_c", Label: "电池最高温度", Description: "动力电池温度探针最高值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_temp_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "min_battery_temperature_c", Label: "电池最低温度", Description: "动力电池温度探针最低值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_temp_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "gnss_satellite_count", Label: "GNSS 卫星数", Description: "JT/T 808 定位终端参与定位的卫星数量", Unit: "颗", Category: "location", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.gnss_satellite_count"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "network_signal_strength", Label: "通信信号强度", Description: "JT/T 808 终端无线通信信号强度", Unit: "", Category: "quality", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.network_signal_strength"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_l", Label: "油量", Description: "JT/T 808 终端上报的车辆油量", Unit: "L", Category: "vehicle", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.fuel_l"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2593,19 +2724,51 @@ func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDat
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
label, unit := rawMetricMetadata(key)
|
||||
result = append(result, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7})
|
||||
metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7}
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
metric.Label = reference.Label + " · GB32960"
|
||||
metric.Description = reference.Description
|
||||
metric.Unit = reference.Unit
|
||||
metric.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
result = append(result, metric)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rawMetricMetadata(key string) (string, string) {
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
return reference.Label + " · GB32960", reference.Unit
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
tail := parts[len(parts)-1]
|
||||
protocol := strings.ToUpper(parts[0])
|
||||
labels := map[string]string{"speed_kmh": "速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "current_a": "电流", "temperature_c": "温度"}
|
||||
labels := map[string]string{
|
||||
"speed_kmh": "速度", "recorder_speed_kmh": "行驶记录仪速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压",
|
||||
"soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "direction_deg": "方向角", "altitude_m": "海拔",
|
||||
"current_a": "电流", "temperature_c": "温度", "vehicle_status": "车辆状态", "charge_status": "充电状态", "running_mode": "运行模式",
|
||||
"total_voltage_v": "总电压", "total_current_a": "总电流", "dc_dc_status": "DC/DC 状态", "gear": "挡位", "insulation_kohm": "绝缘电阻",
|
||||
"accelerator_pct": "加速踏板开度", "brake_pct": "制动踏板状态", "state": "工作状态", "controller_temperature_c": "控制器温度",
|
||||
"speed_rpm": "转速", "torque_nm": "转矩", "motor_temperature_c": "驱动电机温度", "controller_voltage_v": "控制器输入电压",
|
||||
"controller_current_a": "控制器母线电流", "fuel_cell_voltage_v": "燃料电池电压", "fuel_cell_current_a": "燃料电池电流",
|
||||
"hydrogen_consumption_kg_per_100km": "百公里氢耗", "temperature_probe_count": "温度探针数量", "max_hydrogen_temperature_c": "最高氢气温度",
|
||||
"max_hydrogen_temperature_probe_id": "最高氢温探针编号", "max_hydrogen_concentration_fraction": "最高氢气浓度占比",
|
||||
"max_hydrogen_concentration_percent": "最高氢浓度", "max_hydrogen_concentration_ppm": "最高氢浓度原始值", "max_hydrogen_concentration_probe_id": "最高氢浓度探针编号",
|
||||
"max_hydrogen_pressure_mpa": "最高氢气压力", "max_hydrogen_pressure_probe_id": "最高氢压探针编号",
|
||||
"engine_status": "发动机状态", "crank_speed_rpm": "发动机转速", "fuel_rate": "燃料消耗率",
|
||||
"max_voltage_subsystem_no": "最高电压子系统编号", "max_voltage_cell_no": "最高电压单体编号", "max_voltage_v": "单体最高电压",
|
||||
"min_voltage_subsystem_no": "最低电压子系统编号", "min_voltage_cell_no": "最低电压单体编号", "min_voltage_v": "单体最低电压",
|
||||
"max_temp_subsystem_no": "最高温度子系统编号", "max_temp_probe_no": "最高温度探针编号", "max_temp_c": "电池最高温度",
|
||||
"min_temp_subsystem_no": "最低温度子系统编号", "min_temp_probe_no": "最低温度探针编号", "min_temp_c": "电池最低温度",
|
||||
"max_alarm_level": "最高告警等级", "general_alarm_flag": "通用告警标志", "alarm_flag": "告警标志", "status_flag": "状态标志",
|
||||
"fuel_l": "油量", "manual_alarm_event_id": "人工确认告警事件编号", "carriage_temperature_c": "车厢温度",
|
||||
"network_signal_strength": "通信信号强度", "gnss_satellite_count": "GNSS 卫星数", "device_time": "设备时间",
|
||||
"hydrogen_mass_kg": "储氢质量", "gd_fc_vehicle_hydrogen_mass_kg": "储氢质量", "stack_temp_c": "电堆温度",
|
||||
"gd_fc_demo_stack_temp_c": "电堆温度", "controller_temp_c": "控制器温度", "gd_fc_dcdc_controller_temp_c": "燃料电池 DC/DC 控制器温度",
|
||||
}
|
||||
label := labels[tail]
|
||||
if label == "" {
|
||||
label = strings.ReplaceAll(tail, "_", " ")
|
||||
label = humanizeRawMetricTail(tail)
|
||||
}
|
||||
if protocol != "" {
|
||||
label += " · " + protocol
|
||||
@@ -2614,6 +2777,8 @@ func rawMetricMetadata(key string) (string, string) {
|
||||
switch {
|
||||
case strings.HasSuffix(tail, "_kmh"):
|
||||
unit = "km/h"
|
||||
case strings.HasSuffix(tail, "_kg_per_100km"):
|
||||
unit = "kg/100km"
|
||||
case strings.HasSuffix(tail, "_km"):
|
||||
unit = "km"
|
||||
case strings.HasSuffix(tail, "_percent"):
|
||||
@@ -2624,10 +2789,63 @@ func rawMetricMetadata(key string) (string, string) {
|
||||
unit = "A"
|
||||
case strings.HasSuffix(tail, "_temperature_c") || strings.HasSuffix(tail, "_c"):
|
||||
unit = "℃"
|
||||
case strings.HasSuffix(tail, "_rpm"):
|
||||
unit = "rpm"
|
||||
case strings.HasSuffix(tail, "_nm"):
|
||||
unit = "N·m"
|
||||
case strings.HasSuffix(tail, "_mpa"):
|
||||
unit = "MPa"
|
||||
case strings.HasSuffix(tail, "_kohm"):
|
||||
unit = "kΩ"
|
||||
case strings.HasSuffix(tail, "_ppm"):
|
||||
unit = "ppm"
|
||||
case strings.HasSuffix(tail, "_kg"):
|
||||
unit = "kg"
|
||||
case strings.HasSuffix(tail, "_deg"):
|
||||
unit = "°"
|
||||
case strings.HasSuffix(tail, "_m"):
|
||||
unit = "m"
|
||||
case strings.HasSuffix(tail, "_l"):
|
||||
unit = "L"
|
||||
}
|
||||
return label, unit
|
||||
}
|
||||
|
||||
func humanizeRawMetricTail(tail string) string {
|
||||
tokenLabels := map[string]string{
|
||||
"max": "最高", "min": "最低", "avg": "平均", "total": "总", "vehicle": "车辆", "battery": "电池",
|
||||
"fuel": "燃料", "cell": "电池", "hydrogen": "氢气", "motor": "电机", "engine": "发动机", "controller": "控制器",
|
||||
"stack": "电堆", "inlet": "入口", "outlet": "出口", "pressure": "压力", "temperature": "温度", "temp": "温度",
|
||||
"voltage": "电压", "current": "电流", "speed": "速度", "mileage": "里程", "status": "状态", "count": "数量",
|
||||
"probe": "探针", "serial": "序号", "subsystem": "子系统", "signal": "信号", "strength": "强度", "alarm": "告警",
|
||||
"level": "等级", "rate": "速率", "mass": "质量", "consumption": "消耗量", "capacity": "容量", "soc": "SOC",
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, token := range strings.Split(tail, "_") {
|
||||
if label := tokenLabels[token]; label != "" {
|
||||
builder.WriteString(label)
|
||||
} else if token != "" && !isRawMetricUnitToken(token) {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
builder.WriteString(strings.ToUpper(token))
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return strings.ReplaceAll(tail, "_", " ")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func isRawMetricUnitToken(token string) bool {
|
||||
switch token {
|
||||
case "kmh", "km", "v", "a", "c", "rpm", "nm", "mpa", "kohm", "ppm", "kg", "pct", "percent", "deg", "m", "l":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
@@ -3170,7 +3388,12 @@ func historyExportColumns(category string, requested []string) []HistoryMetricDe
|
||||
continue
|
||||
}
|
||||
label, unit := rawMetricMetadata(key)
|
||||
selected = append(selected, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true})
|
||||
metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true}
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
metric.Description = reference.Description
|
||||
metric.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
selected = append(selected, metric)
|
||||
known[key] = true
|
||||
}
|
||||
return selected
|
||||
@@ -3200,7 +3423,11 @@ func historyExportRecord(row HistoryDataRow, columns []HistoryMetricDefinition)
|
||||
record = append(record, "")
|
||||
continue
|
||||
}
|
||||
record = append(record, fmt.Sprint(value))
|
||||
if display := protocolDisplayValue(column.Key, value); display != "" {
|
||||
record = append(record, display)
|
||||
} else {
|
||||
record = append(record, fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
@@ -3368,27 +3595,37 @@ func analyzeTrackPoints(raw []HistoryLocationRow) ([]HistoryLocationRow, TrackQu
|
||||
|
||||
func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol string) ([]HistoryLocationRow, string, int) {
|
||||
requestedProtocol = strings.ToUpper(strings.TrimSpace(requestedProtocol))
|
||||
counts := map[string]int{}
|
||||
latest := map[string]time.Time{}
|
||||
grouped := map[string][]HistoryLocationRow{}
|
||||
for _, point := range points {
|
||||
protocol := strings.ToUpper(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"))
|
||||
counts[protocol]++
|
||||
if observedAt, ok := parseVehicleServiceTime(point.DeviceTime); ok && observedAt.After(latest[protocol]) {
|
||||
latest[protocol] = observedAt
|
||||
}
|
||||
grouped[protocol] = append(grouped[protocol], point)
|
||||
}
|
||||
selected := requestedProtocol
|
||||
if selected == "" {
|
||||
protocols := make([]string, 0, len(counts))
|
||||
for protocol := range counts {
|
||||
protocols := make([]string, 0, len(grouped))
|
||||
for protocol := range grouped {
|
||||
protocols = append(protocols, protocol)
|
||||
}
|
||||
sort.Slice(protocols, func(i, j int) bool {
|
||||
if counts[protocols[i]] != counts[protocols[j]] {
|
||||
return counts[protocols[i]] > counts[protocols[j]]
|
||||
left := trackSourceSelectionScore(grouped[protocols[i]])
|
||||
right := trackSourceSelectionScore(grouped[protocols[j]])
|
||||
if left.HasMovement != right.HasMovement {
|
||||
return left.HasMovement
|
||||
}
|
||||
if !latest[protocols[i]].Equal(latest[protocols[j]]) {
|
||||
return latest[protocols[i]].After(latest[protocols[j]])
|
||||
if left.DurationSeconds != right.DurationSeconds {
|
||||
return left.DurationSeconds > right.DurationSeconds
|
||||
}
|
||||
if left.CoordinateChanges != right.CoordinateChanges {
|
||||
return left.CoordinateChanges > right.CoordinateChanges
|
||||
}
|
||||
if math.Abs(left.DistanceKm-right.DistanceKm) > 0.001 {
|
||||
return left.DistanceKm > right.DistanceKm
|
||||
}
|
||||
if left.MovingPoints != right.MovingPoints {
|
||||
return left.MovingPoints > right.MovingPoints
|
||||
}
|
||||
if len(grouped[protocols[i]]) != len(grouped[protocols[j]]) {
|
||||
return len(grouped[protocols[i]]) > len(grouped[protocols[j]])
|
||||
}
|
||||
return protocols[i] < protocols[j]
|
||||
})
|
||||
@@ -3396,13 +3633,48 @@ func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol str
|
||||
selected = protocols[0]
|
||||
}
|
||||
}
|
||||
result := make([]HistoryLocationRow, 0, counts[selected])
|
||||
for _, point := range points {
|
||||
if strings.EqualFold(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"), selected) {
|
||||
result = append(result, point)
|
||||
result := grouped[selected]
|
||||
return result, selected, len(points) - len(result)
|
||||
}
|
||||
|
||||
type trackSourceScore struct {
|
||||
HasMovement bool
|
||||
DurationSeconds int64
|
||||
CoordinateChanges int
|
||||
MovingPoints int
|
||||
DistanceKm float64
|
||||
}
|
||||
|
||||
func trackSourceSelectionScore(points []HistoryLocationRow) trackSourceScore {
|
||||
score := trackSourceScore{}
|
||||
if len(points) == 0 {
|
||||
return score
|
||||
}
|
||||
if start, startOK := parseVehicleServiceTime(points[0].DeviceTime); startOK {
|
||||
if end, endOK := parseVehicleServiceTime(points[len(points)-1].DeviceTime); endOK && end.After(start) {
|
||||
score.DurationSeconds = int64(end.Sub(start).Seconds())
|
||||
}
|
||||
}
|
||||
return result, selected, len(points) - len(result)
|
||||
for index, point := range points {
|
||||
if point.SpeedKmh > trackStopSpeedKmh {
|
||||
score.MovingPoints++
|
||||
}
|
||||
if index == 0 || !validTrackCoordinate(points[index-1]) || !validTrackCoordinate(point) {
|
||||
continue
|
||||
}
|
||||
previousTime, previousOK := parseVehicleServiceTime(points[index-1].DeviceTime)
|
||||
currentTime, currentOK := parseVehicleServiceTime(point.DeviceTime)
|
||||
if !previousOK || !currentOK || !currentTime.After(previousTime) || currentTime.Sub(previousTime) > time.Duration(trackGapThresholdSeconds)*time.Second {
|
||||
continue
|
||||
}
|
||||
distance := haversineKm(points[index-1].Latitude, points[index-1].Longitude, point.Latitude, point.Longitude)
|
||||
score.DistanceKm += distance
|
||||
if distance >= 0.03 {
|
||||
score.CoordinateChanges++
|
||||
}
|
||||
}
|
||||
score.HasMovement = score.CoordinateChanges >= 2 || score.DistanceKm >= 0.5 || score.MovingPoints >= 3
|
||||
return score
|
||||
}
|
||||
|
||||
func applyTrackSequenceQuality(points []HistoryLocationRow, quality *TrackQuality) {
|
||||
|
||||
@@ -445,6 +445,31 @@ func TestTrackPrimarySourcePreventsParallelProtocolsFromBecomingOneRoute(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackPrimarySourcePrefersUsefulMovementOverHighFrequencyStaticHeartbeat(t *testing.T) {
|
||||
points := make([]HistoryLocationRow, 0, 120)
|
||||
for index := 0; index < 100; index++ {
|
||||
points = append(points, HistoryLocationRow{
|
||||
DeviceTime: time.Date(2026, 7, 16, 8, 0, index, 0, time.UTC).Format(time.RFC3339),
|
||||
Protocol: "YUTONG_MQTT",
|
||||
Longitude: 121.400000,
|
||||
Latitude: 31.200000,
|
||||
})
|
||||
}
|
||||
for index := 0; index < 20; index++ {
|
||||
points = append(points, HistoryLocationRow{
|
||||
DeviceTime: time.Date(2026, 7, 16, 8, index, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
Protocol: "JT808",
|
||||
Longitude: 121.400000 + float64(index)*0.002,
|
||||
Latitude: 31.200000 + float64(index)*0.001,
|
||||
SpeedKmh: 36,
|
||||
})
|
||||
}
|
||||
selected, protocol, alternate := selectTrackPrimarySource(points, "")
|
||||
if protocol != "JT808" || len(selected) != 20 || alternate != 100 {
|
||||
t.Fatalf("moving source must beat static heartbeat volume: protocol=%s selected=%d alternate=%d", protocol, len(selected), alternate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackSegmentsExposeStopsAndDataGapsWithoutClaimingIgnition(t *testing.T) {
|
||||
points := []HistoryLocationRow{
|
||||
{DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1},
|
||||
@@ -487,6 +512,23 @@ func TestMonitorBoundsAreValidatedAndApplied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorMapIgnoresInvalidOptionalBounds(t *testing.T) {
|
||||
vehicles := Page[VehicleRealtimeRow]{
|
||||
Items: []VehicleRealtimeRow{{
|
||||
VIN: "LTEST000000000001", Plate: "粤A00001", Online: true, LocationAvailable: true,
|
||||
Longitude: 113.2, Latitude: 23.1, LastSeen: "2026-07-17T15:41:00+08:00",
|
||||
}},
|
||||
Total: 1,
|
||||
}
|
||||
result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"12"}, "bounds": {"105,31,103,29"}})
|
||||
if err != nil {
|
||||
t.Fatalf("optional invalid bounds must not fail the map: %v", err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Points) != 1 {
|
||||
t.Fatalf("invalid bounds should fall back to the bounded unfiltered map: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorQueryKeepsServerOwnedKeywordAndMotionStatus(t *testing.T) {
|
||||
query := normalizeMonitorQuery(url.Values{"keyword": {"沪A"}, "status": {"driving"}})
|
||||
if query.Get("vin") != "沪A" || query.Get("limit") != "10000" {
|
||||
@@ -921,13 +963,14 @@ func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testi
|
||||
definitions := []MetricDefinition{
|
||||
{Key: "speed_kmh", Label: "速度", Description: "车辆最新行驶速度", Unit: "km/h", Category: "driving", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}},
|
||||
{Key: "alarm_active", Label: "协议告警位", Unit: "", Category: "safety", ValueType: "boolean", SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag"}},
|
||||
{Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Unit: "%", Category: "fuel-cell", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}},
|
||||
}
|
||||
frames := []RawFrameRow{
|
||||
{ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "vendor.custom_temperature_c": 31.2}},
|
||||
{ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "gb32960.fuel_cell.max_hydrogen_concentration_percent": 0.032, "vendor.custom_temperature_c": 31.2}},
|
||||
{ID: "old", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:20:00", ServerTime: "2026-07-14 09:20:01", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10.0}},
|
||||
}
|
||||
response := buildLatestTelemetryResponse("VIN001", frames, definitions, now)
|
||||
if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 3 || len(response.Categories) != 3 {
|
||||
if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 4 || len(response.Categories) != 4 {
|
||||
t.Fatalf("unexpected response: %+v", response)
|
||||
}
|
||||
byKey := map[string]LatestTelemetryValue{}
|
||||
@@ -940,11 +983,58 @@ func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testi
|
||||
if alarm := byKey["alarm_active"]; alarm.Value != true || alarm.Category != "alarm" {
|
||||
t.Fatalf("catalog boolean should be normalized: %+v", alarm)
|
||||
}
|
||||
if hydrogen := byKey["hydrogen_concentration_percent"]; hydrogen.Value != 0.032 || hydrogen.Category != "fuel-cell" || hydrogen.Unit != "%" {
|
||||
t.Fatalf("fuel-cell catalog metric should retain its business category: %+v", hydrogen)
|
||||
}
|
||||
if extension := byKey["vendor.custom_temperature_c"]; extension.Category != "extension" || extension.Unit != "℃" || extension.Protocol != "GB32960" {
|
||||
t.Fatalf("manufacturer extension should retain source semantics: %+v", extension)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGB32960ReferenceFormatsStatusAndHistoryMetadata(t *testing.T) {
|
||||
if display := protocolDisplayValue("gb32960.vehicle.vehicle_status", float64(1)); display != "启动(1)" {
|
||||
t.Fatalf("vehicle status display = %q", display)
|
||||
}
|
||||
if display := protocolDisplayValue("gb32960.position.position_status", 6); display != "有效定位 · 南纬 · 西经(6)" {
|
||||
t.Fatalf("position status display = %q", display)
|
||||
}
|
||||
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), []HistoryDataRow{{Values: map[string]any{
|
||||
"gb32960.drive_motor.motors.motor_1.state": 2,
|
||||
}}})
|
||||
for _, column := range columns {
|
||||
if column.Key == "gb32960.drive_motor.motors.motor_1.state" {
|
||||
if column.Label != "驱动电机状态 · GB32960" || len(column.ValueMappings) == 0 || column.ValueMappings[1].Label != "发电" {
|
||||
t.Fatalf("unexpected protocol reference column: %+v", column)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("GB32960 reference column missing: %+v", columns)
|
||||
}
|
||||
|
||||
func TestBuildLatestTelemetryResponseRetainsSameMetricAcrossProtocols(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
definitions := []MetricDefinition{{
|
||||
Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric",
|
||||
SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km"},
|
||||
}}
|
||||
frames := []RawFrameRow{
|
||||
{ID: "gb", VIN: "VIN001", Protocol: "GB32960", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.total_mileage_km": 1000.0}},
|
||||
{ID: "jt", VIN: "VIN001", Protocol: "JT808", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"jt808.location.total_mileage_km": 980.0}},
|
||||
}
|
||||
response := buildLatestTelemetryResponse("VIN001", frames, definitions, now)
|
||||
if len(response.Values) != 2 {
|
||||
t.Fatalf("same unified metric from two protocols must be retained independently: %+v", response.Values)
|
||||
}
|
||||
byProtocol := map[string]LatestTelemetryValue{}
|
||||
for _, value := range response.Values {
|
||||
byProtocol[value.Protocol] = value
|
||||
}
|
||||
if byProtocol["GB32960"].Label != "仪表盘总里程" || byProtocol["JT808"].Label != "GPS 总里程" {
|
||||
t.Fatalf("mileage semantics were not preserved: %+v", byProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTelemetryQualityDistinguishesStaleAndWarnings(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
stale, reason, freshness, delay := latestTelemetryQuality(RawFrameRow{DeviceTime: "2026-07-14 09:19:59", ServerTime: "2026-07-14 09:20:00", ParseStatus: "ok"}, now)
|
||||
|
||||
@@ -6,9 +6,12 @@ const assets = readdirSync(assetsDirectory);
|
||||
const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name));
|
||||
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
|
||||
const monitorAssets = assets.filter((name) => /^MonitorPage-[\w-]+\.js$/.test(name));
|
||||
const vehicleAssets = assets.filter((name) => /^VehiclePage-[\w-]+\.js$/.test(name));
|
||||
const profileSyncAssets = assets.filter((name) => /^VehicleProfileSyncPanel-[\w-]+\.js$/.test(name));
|
||||
const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8');
|
||||
const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8');
|
||||
const builtIndex = readFileSync(resolve(process.cwd(), 'dist/index.html'), 'utf8');
|
||||
const builtSemiPrototype = readFileSync(resolve(process.cwd(), 'dist/semi-prototype.html'), 'utf8');
|
||||
const releaseManifest = readFileSync(resolve(process.cwd(), 'dist/.release-assets'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
|
||||
if (excelAssets.length !== 1) {
|
||||
@@ -20,6 +23,9 @@ if (mileageWorkers.length !== 1) {
|
||||
if (monitorAssets.length !== 1) {
|
||||
throw new Error(`production build must contain exactly one monitor page asset, found ${monitorAssets.length}: ${monitorAssets.join(', ') || 'none'}`);
|
||||
}
|
||||
if (vehicleAssets.length !== 1 || profileSyncAssets.length !== 1) {
|
||||
throw new Error(`vehicle profile sync must remain one deferred route asset: vehicle=${vehicleAssets.join(', ') || 'none'} sync=${profileSyncAssets.join(', ') || 'none'}`);
|
||||
}
|
||||
if (runtimeConfig !== safeRuntimeTemplate) {
|
||||
throw new Error('production dist/app-config.js must match the credential-free runtime template');
|
||||
}
|
||||
@@ -33,6 +39,12 @@ if (!mainAsset || !assets.includes(mainAsset)
|
||||
|| !readFileSync(resolve(assetsDirectory, mainAsset), 'utf8').includes('vehicle-platform:ready')) {
|
||||
throw new Error('production entry must retain the React boot-ready handshake');
|
||||
}
|
||||
const semiPrototypeAsset = builtSemiPrototype.match(/<script[^>]+type="module"[^>]+src="\/assets\/([^"/]+\.js)"/)?.[1];
|
||||
if (!builtSemiPrototype.includes('Semi UI 原型 · 羚牛车辆数据中台')
|
||||
|| !semiPrototypeAsset
|
||||
|| !assets.includes(semiPrototypeAsset)) {
|
||||
throw new Error('production build must contain the standalone Semi UI prototype entry and asset');
|
||||
}
|
||||
if (releaseManifest.length !== assets.length || releaseManifest.some((name, index) => name !== [...assets].sort()[index])) {
|
||||
throw new Error('production dist/.release-assets must list every generated asset exactly once in sorted order');
|
||||
}
|
||||
@@ -60,5 +72,14 @@ const monitorBytes = statSync(resolve(assetsDirectory, monitorAssets[0])).size;
|
||||
if (monitorBytes > 35_000) {
|
||||
throw new Error(`monitor critical-path asset exceeds 35000 bytes: ${monitorBytes}`);
|
||||
}
|
||||
const vehicleBytes = statSync(resolve(assetsDirectory, vehicleAssets[0])).size;
|
||||
if (vehicleBytes > 30_000) {
|
||||
throw new Error(`vehicle detail critical-path asset exceeds 30000 bytes: ${vehicleBytes}`);
|
||||
}
|
||||
const profileSyncBytes = statSync(resolve(assetsDirectory, profileSyncAssets[0])).size;
|
||||
const vehicleSource = readFileSync(resolve(assetsDirectory, vehicleAssets[0]), 'utf8');
|
||||
if (!vehicleSource.includes(profileSyncAssets[0])) {
|
||||
throw new Error('vehicle profile sync asset must be loaded only through the vehicle page dynamic import');
|
||||
}
|
||||
const qrBytes = statSync(resolve(assetsDirectory, qrAssets[0])).size;
|
||||
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1 boot_recovery=1\n`);
|
||||
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} vehicle_bytes=${vehicleBytes} profile_sync_deferred=1 profile_sync_bytes=${profileSyncBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1 boot_recovery=1\n`);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand-mark.svg" />
|
||||
<title>Semi UI 原型 · 羚牛车辆数据中台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/semi-prototype/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -261,13 +261,14 @@ export interface HistoryMetricCatalog {
|
||||
}
|
||||
|
||||
export interface MetricCatalog { metrics: MetricDefinition[]; asOf: string; }
|
||||
export interface MetricValueMapping { value: string; label: string; description?: string; }
|
||||
export interface MetricDefinition {
|
||||
key: string; label: string; description: string; unit: string; category: string; valueType: 'numeric' | 'boolean';
|
||||
protocols: string[]; sourceFields: Record<string, string>; searchable: boolean; chartable: boolean; alertable: boolean;
|
||||
protocols: string[]; sourceFields: Record<string, string>; valueMappings?: MetricValueMapping[]; searchable: boolean; chartable: boolean; alertable: boolean;
|
||||
}
|
||||
|
||||
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 HistoryMetricDefinition { key: string; label: string; description?: string; unit: string; category: string; valueType: string; valueMappings?: MetricValueMapping[]; defaultVisible: boolean; }
|
||||
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; }
|
||||
@@ -447,6 +448,7 @@ export interface VehicleDetail {
|
||||
|
||||
export interface VehicleProfile {
|
||||
vin: string;
|
||||
brandName: string;
|
||||
modelName: string;
|
||||
vehicleType: string;
|
||||
companyName: string;
|
||||
@@ -465,6 +467,7 @@ export interface VehicleProfile {
|
||||
}
|
||||
|
||||
export interface VehicleProfileInput {
|
||||
brandName?: string;
|
||||
modelName: string;
|
||||
vehicleType: string;
|
||||
companyName: string;
|
||||
@@ -477,6 +480,7 @@ export interface VehicleProfileInput {
|
||||
|
||||
export interface VehicleProfileSyncItem {
|
||||
vin: string;
|
||||
brandName?: string;
|
||||
modelName: string;
|
||||
vehicleType: string;
|
||||
companyName: string;
|
||||
@@ -768,7 +772,7 @@ export interface RawFrameRow {
|
||||
export interface LatestTelemetryCategory { key: string; label: string; count: number; }
|
||||
export interface LatestTelemetryValue {
|
||||
key: string; sourceField: string; label: string; description?: string; unit: string; category: string;
|
||||
valueType: string; value: unknown; protocol: string; sourceEndpoint?: string; frameId: string;
|
||||
valueType: string; value: unknown; displayValue?: string; protocol: string; sourceEndpoint?: string; frameId: string;
|
||||
deviceTime: string; serverTime: string; quality: 'good' | 'stale' | 'warning'; qualityReason: string;
|
||||
freshnessSeconds: number; dataDelaySeconds?: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { AppV2 } from './v2/AppV2';
|
||||
import './v2/styles/semi-theme.scss';
|
||||
import './v2/styles/v2.css';
|
||||
import './v2/styles/workspace.css';
|
||||
|
||||
if ('scrollRestoration' in window.history) {
|
||||
window.history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Layout,
|
||||
Nav,
|
||||
Pagination,
|
||||
Progress,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
TagInput,
|
||||
Typography
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconAlarm,
|
||||
IconBarChartHStroked,
|
||||
IconBox,
|
||||
IconDownload,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconMapPin,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconUser
|
||||
} from '@douyinfe/semi-icons';
|
||||
import '@douyinfe/semi-theme-default/scss/index.scss';
|
||||
import './prototype.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type MileageRow = {
|
||||
key: string;
|
||||
plate: string;
|
||||
vin: string;
|
||||
protocol: 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
d1: number;
|
||||
d2: number;
|
||||
d3: number;
|
||||
d4: number;
|
||||
d5: number;
|
||||
d6: number;
|
||||
d7: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
const mileageRows: MileageRow[] = [
|
||||
{ key: '1', plate: '粤A·6K21F', vin: 'LHGCM82633A004352', protocol: 'GB32960', d1: 42.6, d2: 86.4, d3: 73.2, d4: 112.8, d5: 69.3, d6: 91.7, d7: 62.8, total: 538.8 },
|
||||
{ key: '2', plate: '粤A·3M82Q', vin: 'LHGCM82633A004718', protocol: 'JT808', d1: 126.3, d2: 94.8, d3: 105.4, d4: 88.7, d5: 132.6, d6: 116.9, d7: 103.2, total: 767.9 },
|
||||
{ key: '3', plate: '粤A·8P16D', vin: 'LHGCM82633A004966', protocol: 'YUTONG_MQTT', d1: 38.1, d2: 44.9, d3: 0, d4: 59.4, d5: 66.8, d6: 31.2, d7: 48.6, total: 289.0 },
|
||||
{ key: '4', plate: '粤A·1T53L', vin: 'LHGCM82633A005104', protocol: 'GB32960', d1: 76.8, d2: 103.7, d3: 92.5, d4: 81.4, d5: 109.2, d6: 98.6, d7: 87.1, total: 649.3 },
|
||||
{ key: '5', plate: '粤A·9R20H', vin: 'LHGCM82633A005339', protocol: 'JT808', d1: 54.4, d2: 61.8, d3: 48.9, d4: 70.6, d5: 83.4, d6: 77.3, d7: 65.7, total: 462.1 },
|
||||
{ key: '6', plate: '粤A·5V79C', vin: 'LHGCM82633A005581', protocol: 'GB32960', d1: 144.2, d2: 118.4, d3: 136.5, d4: 129.7, d5: 152.3, d6: 97.8, d7: 121.6, total: 900.5 },
|
||||
{ key: '7', plate: '粤A·2X41N', vin: 'LHGCM82633A005846', protocol: 'YUTONG_MQTT', d1: 21.5, d2: 35.2, d3: 40.6, d4: 28.3, d5: 51.7, d6: 46.4, d7: 37.9, total: 261.6 },
|
||||
{ key: '8', plate: '粤A·7B66K', vin: 'LHGCM82633A006093', protocol: 'GB32960', d1: 89.7, d2: 97.6, d3: 84.3, d4: 110.5, d5: 101.9, d6: 93.8, d7: 108.4, total: 686.2 }
|
||||
];
|
||||
|
||||
const navigation = [
|
||||
{ itemKey: 'monitor', text: '全局监控', icon: <IconHome /> },
|
||||
{ itemKey: 'vehicles', text: '车辆查询', icon: <IconSearch /> },
|
||||
{ itemKey: 'tracks', text: '轨迹回放', icon: <IconMapPin /> },
|
||||
{ itemKey: 'history', text: '历史数据', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'statistics', text: '里程查询', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'alerts', text: '告警中心', icon: <IconAlarm /> },
|
||||
{ itemKey: 'access', text: '接入管理', icon: <IconBox /> },
|
||||
{ itemKey: 'users', text: '账号管理', icon: <IconUser /> }
|
||||
];
|
||||
|
||||
function useMobile() {
|
||||
const [mobile, setMobile] = useState(() => window.matchMedia('(max-width: 767px)').matches);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 767px)');
|
||||
const update = () => setMobile(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function Filters({ mobile, onDone }: { mobile: boolean; onDone?: () => void }) {
|
||||
return (
|
||||
<div className={mobile ? 'semi-prototype-filter-mobile' : 'semi-prototype-filter-grid'}>
|
||||
<div className="semi-prototype-field is-vehicle">
|
||||
<Text type="secondary">车辆范围</Text>
|
||||
<TagInput
|
||||
maxTagCount={mobile ? 1 : 2}
|
||||
showClear
|
||||
placeholder="车牌多选;未选择时查询全部授权车辆"
|
||||
defaultValue={['粤A·6K21F', '粤A·3M82Q']}
|
||||
/>
|
||||
</div>
|
||||
<div className="semi-prototype-field">
|
||||
<Text type="secondary">开始日期</Text>
|
||||
<DatePicker type="date" defaultValue="2026-07-11" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="semi-prototype-field">
|
||||
<Text type="secondary">结束日期</Text>
|
||||
<DatePicker type="date" defaultValue="2026-07-17" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div className="semi-prototype-field">
|
||||
<Text type="secondary">数据源策略</Text>
|
||||
<Select
|
||||
defaultValue="all"
|
||||
style={{ width: '100%' }}
|
||||
optionList={[
|
||||
{ value: 'all', label: '全部启用 · 自动择优' },
|
||||
{ value: 'meter', label: '仪表盘里程优先' },
|
||||
{ value: 'gps', label: 'GPS 里程优先' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Space className="semi-prototype-filter-actions" spacing={8}>
|
||||
<Button theme="solid" type="primary" icon={<IconSearch />} onClick={onDone}>查询</Button>
|
||||
<Button icon={<IconRefresh />}>重置</Button>
|
||||
</Space>
|
||||
<div className="semi-prototype-quick-ranges">
|
||||
<Text type="tertiary">快捷日期</Text>
|
||||
<Button size="small" theme="borderless">今天</Button>
|
||||
<Button size="small" theme="borderless">昨天</Button>
|
||||
<Button size="small" theme="light" type="primary">近 7 天</Button>
|
||||
<Button size="small" theme="borderless">近 30 天</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, note, tone, percent }: { label: string; value: string; note: string; tone?: string; percent?: number }) {
|
||||
return (
|
||||
<Card className={`semi-prototype-stat ${tone ? `is-${tone}` : ''}`} shadows="hover">
|
||||
<Text type="tertiary">{label}</Text>
|
||||
<strong>{value}</strong>
|
||||
<div>
|
||||
<Text type="secondary" size="small">{note}</Text>
|
||||
{percent != null ? <Progress percent={percent} showInfo={false} stroke="var(--prototype-primary)" /> : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MileageMatrix({ mobile }: { mobile: boolean }) {
|
||||
const dateColumns = [
|
||||
['d1', '7/11'], ['d2', '7/12'], ['d3', '7/13'], ['d4', '7/14'],
|
||||
['d5', '7/15'], ['d6', '7/16'], ['d7', '7/17']
|
||||
] as const;
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '车辆',
|
||||
dataIndex: 'plate',
|
||||
fixed: 'left' as const,
|
||||
width: mobile ? 128 : 190,
|
||||
render: (_: string, row: MileageRow) => (
|
||||
<div className="semi-prototype-vehicle-cell">
|
||||
<strong>{row.plate}</strong>
|
||||
<Text type="tertiary" size="small" ellipsis={{ showTooltip: true }}>{row.vin}</Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
...dateColumns.map(([key, label]) => ({
|
||||
title: label,
|
||||
dataIndex: key,
|
||||
align: 'right' as const,
|
||||
width: 112,
|
||||
render: (value: number) => (
|
||||
<span className={`semi-prototype-mileage${value === 0 ? ' is-zero' : ''}`}>
|
||||
{value.toFixed(1)} <small>km</small>
|
||||
</span>
|
||||
)
|
||||
})),
|
||||
{
|
||||
title: '区间总里程',
|
||||
dataIndex: 'total',
|
||||
fixed: 'right' as const,
|
||||
align: 'right' as const,
|
||||
width: mobile ? 126 : 150,
|
||||
render: (value: number, row: MileageRow) => (
|
||||
<div className="semi-prototype-total-cell">
|
||||
<strong>{value.toFixed(1)} km</strong>
|
||||
<Tag size="small" color={row.protocol === 'JT808' ? 'cyan' : row.protocol === 'GB32960' ? 'blue' : 'violet'}>
|
||||
{row.protocol}
|
||||
</Tag>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [mobile]);
|
||||
|
||||
return (
|
||||
<Card className="semi-prototype-results" bodyStyle={{ padding: 0 }}>
|
||||
<div className="semi-prototype-results-head">
|
||||
<div>
|
||||
<Title heading={4}>车辆每日里程</Title>
|
||||
<Text type="secondary">固定车辆与总里程列,中间日期区域横向滚动</Text>
|
||||
</div>
|
||||
<Space spacing={8}>
|
||||
<Button icon={<IconRefresh />}>{mobile ? null : '刷新'}</Button>
|
||||
<Button theme="solid" type="primary" icon={<IconDownload />}>{mobile ? '导出' : '导出 Excel'}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
className="semi-prototype-table"
|
||||
columns={columns}
|
||||
dataSource={mileageRows}
|
||||
pagination={false}
|
||||
rowKey="key"
|
||||
size="middle"
|
||||
scroll={{ x: mobile ? 1038 : 1124, y: mobile ? 440 : 430 }}
|
||||
/>
|
||||
<div className="semi-prototype-results-footer">
|
||||
<Text type="tertiary">当前 8 辆 / 共 1,024 辆</Text>
|
||||
<Pagination total={1024} pageSize={20} showSizeChanger={false} size={mobile ? 'small' : 'default'} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavigation() {
|
||||
const items = [
|
||||
['监控', IconHome], ['车辆', IconSearch], ['轨迹', IconMapPin], ['里程', IconBarChartHStroked], ['更多', IconSetting]
|
||||
] as const;
|
||||
return (
|
||||
<nav className="semi-prototype-mobile-nav" aria-label="移动端主导航">
|
||||
{items.map(([label, Icon]) => (
|
||||
<button key={label} className={label === '里程' ? 'is-active' : ''} type="button">
|
||||
<Icon size="large" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const mobile = useMobile();
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
return (
|
||||
<Layout className="semi-prototype-shell">
|
||||
{!mobile ? (
|
||||
<Sider className="semi-prototype-sider" aria-label="主导航">
|
||||
<div className="semi-prototype-brand">
|
||||
<img src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<Tag color="blue" size="small">Semi UI</Tag>
|
||||
</div>
|
||||
<Nav
|
||||
className="semi-prototype-nav"
|
||||
selectedKeys={['statistics']}
|
||||
items={navigation}
|
||||
footer={{ collapseButton: true }}
|
||||
/>
|
||||
<div className="semi-prototype-sider-foot">
|
||||
<IconHelpCircle />
|
||||
<span>帮助与使用说明</span>
|
||||
</div>
|
||||
</Sider>
|
||||
) : null}
|
||||
<Layout className="semi-prototype-main">
|
||||
<Header className="semi-prototype-header">
|
||||
<div>
|
||||
{mobile ? <img src="/brand-mark.svg" alt="" /> : null}
|
||||
<div>
|
||||
<Text type="tertiary" size="small">数据分析 / 里程</Text>
|
||||
<Title heading={3}>里程查询</Title>
|
||||
</div>
|
||||
</div>
|
||||
<Space spacing={8}>
|
||||
{mobile ? <Button icon={<IconFilter />} onClick={() => setFilterOpen(true)}>筛选</Button> : null}
|
||||
<Avatar size="small" color="blue">管</Avatar>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content className="semi-prototype-content">
|
||||
<div className="semi-prototype-page-intro">
|
||||
<div>
|
||||
<Tag color="light-blue">交互原型</Tag>
|
||||
<Title heading={2}>把复杂数据变成清晰的工作台</Title>
|
||||
<Text type="secondary">
|
||||
默认近 7 天,车牌多选;未选择车辆时分页查询全部授权车辆。
|
||||
</Text>
|
||||
</div>
|
||||
<Text type="tertiary">数据更新时间 14:32:18</Text>
|
||||
</div>
|
||||
|
||||
{!mobile ? <Card className="semi-prototype-filter-card"><Filters mobile={false} /></Card> : null}
|
||||
|
||||
<section className="semi-prototype-stats" aria-label="统计概览">
|
||||
<StatCard label="区间总里程" value="6,842.7 km" note="较前 7 天 +8.4%" tone="primary" percent={68} />
|
||||
<StatCard label="有里程车辆" value="986 辆" note="车队覆盖率 96.3%" percent={96} />
|
||||
<StatCard label="日均里程" value="142.6 km" note="按有效车辆日计算" />
|
||||
<StatCard label="数据异常" value="12 辆" note="可下钻查看差异明细" tone="warning" />
|
||||
</section>
|
||||
|
||||
<MileageMatrix mobile={mobile} />
|
||||
</Content>
|
||||
</Layout>
|
||||
{mobile ? <MobileNavigation /> : null}
|
||||
<SideSheet
|
||||
visible={filterOpen}
|
||||
placement="bottom"
|
||||
height="min(78vh, 620px)"
|
||||
title="查询条件"
|
||||
onCancel={() => setFilterOpen(false)}
|
||||
footer={<Button block theme="solid" type="primary" onClick={() => setFilterOpen(false)}>应用筛选</Button>}
|
||||
bodyStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<Filters mobile onDone={() => setFilterOpen(false)} />
|
||||
</SideSheet>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
:root {
|
||||
--prototype-primary: #1268f3;
|
||||
--prototype-bg: #f4f7fb;
|
||||
--prototype-border: #e5eaf1;
|
||||
--prototype-text: #182230;
|
||||
--prototype-muted: #667085;
|
||||
--prototype-radius: 12px;
|
||||
--prototype-shadow: 0 8px 28px rgba(25, 45, 74, .055);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--prototype-bg); color: var(--prototype-text); font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
|
||||
button, input { font: inherit; }
|
||||
|
||||
.semi-prototype-shell { min-height: 100vh; background: var(--prototype-bg); }
|
||||
.semi-prototype-sider { position: fixed; inset: 0 auto 0 0; z-index: 10; display: flex; width: 216px; flex-direction: column; border-right: 1px solid var(--prototype-border); background: #fff; }
|
||||
.semi-prototype-brand { display: flex; height: 68px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--prototype-border); padding: 0 16px; }
|
||||
.semi-prototype-brand img { width: 132px; }
|
||||
.semi-prototype-nav { min-height: 0; flex: 1; border: 0; padding: 12px 8px; }
|
||||
.semi-prototype-nav .semi-navigation-item { margin-bottom: 3px; border-radius: 8px; }
|
||||
.semi-prototype-sider-foot { display: flex; height: 52px; align-items: center; gap: 10px; border-top: 1px solid var(--prototype-border); padding: 0 20px; color: var(--prototype-muted); font-size: 12px; }
|
||||
.semi-prototype-main { min-width: 0; margin-left: 216px; }
|
||||
.semi-prototype-header { position: sticky; top: 0; z-index: 9; display: flex; height: 68px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(229,234,241,.9); background: rgba(255,255,255,.9); padding: 0 24px; backdrop-filter: blur(16px); }
|
||||
.semi-prototype-header > div:first-child { display: flex; align-items: center; gap: 10px; }
|
||||
.semi-prototype-header h3 { margin: 2px 0 0; font-size: 20px; }
|
||||
.semi-prototype-content { width: 100%; max-width: 1680px; margin: 0 auto; padding: 24px; }
|
||||
.semi-prototype-page-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 18px; }
|
||||
.semi-prototype-page-intro > div { display: grid; gap: 8px; }
|
||||
.semi-prototype-page-intro h2 { margin: 0; font-size: clamp(24px, 2.1vw, 32px); letter-spacing: -.035em; }
|
||||
.semi-prototype-filter-card, .semi-prototype-stat, .semi-prototype-results { border-color: var(--prototype-border); border-radius: var(--prototype-radius); box-shadow: var(--prototype-shadow); }
|
||||
.semi-prototype-filter-card .semi-card-body { padding: 16px; }
|
||||
.semi-prototype-filter-grid { display: grid; grid-template-columns: minmax(240px, 1.45fr) repeat(3, minmax(155px, .75fr)) auto; align-items: end; gap: 14px 12px; }
|
||||
.semi-prototype-field { display: grid; min-width: 0; gap: 7px; }
|
||||
.semi-prototype-field > span { font-size: 12px; font-weight: 600; }
|
||||
.semi-prototype-filter-actions { align-self: end; }
|
||||
.semi-prototype-quick-ranges { display: flex; grid-column: 1 / -1; align-items: center; gap: 2px; border-top: 1px solid #f0f2f5; padding-top: 12px; }
|
||||
.semi-prototype-quick-ranges > span { margin-right: 8px; font-size: 12px; }
|
||||
.semi-prototype-stats { display: grid; grid-template-columns: 1.35fr repeat(3, 1fr); gap: 14px; margin: 14px 0; }
|
||||
.semi-prototype-stat .semi-card-body { display: flex; min-height: 126px; flex-direction: column; padding: 16px 18px; }
|
||||
.semi-prototype-stat strong { margin: 10px 0 11px; color: var(--prototype-text); font-size: clamp(23px, 2vw, 30px); line-height: 1; letter-spacing: -.035em; font-variant-numeric: tabular-nums; }
|
||||
.semi-prototype-stat > .semi-card-body > div { display: grid; gap: 9px; margin-top: auto; }
|
||||
.semi-prototype-stat.is-primary { overflow: hidden; border-color: #cadeff; background: linear-gradient(135deg, #f0f6ff 0%, #fff 72%); }
|
||||
.semi-prototype-stat.is-primary strong { color: #0959cf; }
|
||||
.semi-prototype-stat.is-warning { border-color: #f6dfbd; background: linear-gradient(135deg, #fff8ec 0%, #fff 72%); }
|
||||
.semi-prototype-stat.is-warning strong { color: #b86408; }
|
||||
.semi-prototype-results { overflow: hidden; }
|
||||
.semi-prototype-results-head { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--prototype-border); padding: 13px 16px; }
|
||||
.semi-prototype-results-head h4 { margin: 0 0 4px; font-size: 16px; }
|
||||
.semi-prototype-table .semi-table-thead > .semi-table-row > .semi-table-row-head { height: 44px; background: #f7f9fc; color: #596579; font-size: 12px; }
|
||||
.semi-prototype-table .semi-table-row { height: 60px; }
|
||||
.semi-prototype-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell { background: #f7faff; }
|
||||
.semi-prototype-vehicle-cell, .semi-prototype-total-cell { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.semi-prototype-vehicle-cell strong { color: #263448; font-size: 13px; }
|
||||
.semi-prototype-vehicle-cell > span { max-width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
|
||||
.semi-prototype-mileage, .semi-prototype-total-cell strong { font-variant-numeric: tabular-nums; }
|
||||
.semi-prototype-mileage { color: #344054; font-weight: 650; }
|
||||
.semi-prototype-mileage small { color: #98a2b3; font-size: 9px; font-weight: 500; }
|
||||
.semi-prototype-mileage.is-zero { color: #b4bdc9; font-weight: 500; }
|
||||
.semi-prototype-total-cell { align-items: flex-end; }
|
||||
.semi-prototype-total-cell strong { color: #0959cf; font-size: 13px; }
|
||||
.semi-prototype-results-footer { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 20px; border-top: 1px solid var(--prototype-border); padding: 10px 16px; }
|
||||
.semi-prototype-mobile-nav { display: none; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.semi-prototype-filter-grid { grid-template-columns: minmax(240px, 1.4fr) repeat(2, minmax(150px, .8fr)) auto; }
|
||||
.semi-prototype-field:nth-child(4) { grid-column: 1 / 2; }
|
||||
.semi-prototype-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
|
||||
.semi-prototype-shell { min-height: 100dvh; }
|
||||
.semi-prototype-main { margin-left: 0; }
|
||||
.semi-prototype-header { height: 58px; padding: 0 12px; }
|
||||
.semi-prototype-header > div:first-child > img { width: 30px; height: 30px; }
|
||||
.semi-prototype-header h3 { font-size: 17px; }
|
||||
.semi-prototype-header .semi-typography-tertiary { display: none; }
|
||||
.semi-prototype-content { padding: 12px; }
|
||||
.semi-prototype-page-intro { align-items: flex-start; margin: 2px 0 12px; }
|
||||
.semi-prototype-page-intro > div { gap: 5px; }
|
||||
.semi-prototype-page-intro > div > .semi-tag { display: none; }
|
||||
.semi-prototype-page-intro h2 { font-size: 21px; }
|
||||
.semi-prototype-page-intro > span { display: none; }
|
||||
.semi-prototype-page-intro .semi-typography-secondary { font-size: 12px; line-height: 1.55; }
|
||||
.semi-prototype-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 10px 0; }
|
||||
.semi-prototype-stat .semi-card-body { min-height: 102px; padding: 12px; }
|
||||
.semi-prototype-stat strong { margin: 8px 0; font-size: clamp(18px, 5.2vw, 23px); }
|
||||
.semi-prototype-stat .semi-typography-tertiary, .semi-prototype-stat .semi-typography-secondary { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.semi-prototype-results-head { min-height: 62px; padding: 10px 12px; }
|
||||
.semi-prototype-results-head h4 { font-size: 15px; }
|
||||
.semi-prototype-results-head > div > .semi-typography-secondary { display: none; }
|
||||
.semi-prototype-results-head .semi-button { min-width: 36px; padding: 0 9px; }
|
||||
.semi-prototype-table .semi-table-row { height: 56px; }
|
||||
.semi-prototype-table .semi-table-thead > .semi-table-row > .semi-table-row-head { height: 40px; font-size: 11px; }
|
||||
.semi-prototype-table .semi-table-row-cell { padding: 8px 10px; }
|
||||
.semi-prototype-vehicle-cell strong, .semi-prototype-total-cell strong { font-size: 12px; }
|
||||
.semi-prototype-vehicle-cell > span { font-size: 8px; }
|
||||
.semi-prototype-results-footer { min-height: 52px; padding: 8px 10px; }
|
||||
.semi-prototype-results-footer > span { display: none; }
|
||||
.semi-prototype-results-footer .semi-page { width: 100%; justify-content: center; }
|
||||
.semi-prototype-mobile-nav { position: fixed; z-index: 30; inset: auto 0 0; display: grid; height: calc(62px + env(safe-area-inset-bottom)); grid-template-columns: repeat(5, 1fr); border-top: 1px solid var(--prototype-border); background: rgba(255,255,255,.96); padding-bottom: env(safe-area-inset-bottom); box-shadow: 0 -8px 24px rgba(28,44,68,.08); backdrop-filter: blur(18px); }
|
||||
.semi-prototype-mobile-nav button { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 3px; border: 0; background: transparent; color: #7b8798; font-size: 10px; }
|
||||
.semi-prototype-mobile-nav button.is-active { color: var(--prototype-primary); font-weight: 700; }
|
||||
.semi-prototype-filter-mobile { display: grid; gap: 16px; }
|
||||
.semi-prototype-filter-mobile .semi-prototype-field { gap: 8px; }
|
||||
.semi-prototype-filter-mobile .semi-prototype-filter-actions { display: none; }
|
||||
.semi-prototype-filter-mobile .semi-prototype-quick-ranges { overflow-x: auto; flex-wrap: nowrap; border-top: 0; padding-top: 0; }
|
||||
.semi-prototype-filter-mobile .semi-prototype-quick-ranges .semi-button { flex: 0 0 auto; }
|
||||
.semi-sidesheet-bottom .semi-sidesheet-inner-wrap { border-radius: 18px 18px 0 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.semi-prototype-stat strong { font-size: 17px; }
|
||||
.semi-prototype-results-head .semi-button:first-child { display: none; }
|
||||
}
|
||||
@@ -53,6 +53,7 @@ Object.defineProperty(navigator, 'clipboard', {
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type AuthContextValue = {
|
||||
session: PlatformSession;
|
||||
logout: () => void;
|
||||
@@ -76,32 +79,34 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
if (session.isPending) {
|
||||
return <div className="v2-auth-screen"><div className="v2-auth-card v2-auth-loading"><img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" /><i className="v2-auth-spinner" /><strong>正在验证登录状态…</strong></div></div>;
|
||||
return <div className="v2-auth-screen"><Card className="v2-auth-card v2-auth-loading"><img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" /><Spin size="large" /><Text strong>正在验证登录状态…</Text></Card></div>;
|
||||
}
|
||||
if (!session.data) {
|
||||
const error = loginError || (attempted && session.error ? session.error.message : '');
|
||||
return <div className="v2-auth-screen">
|
||||
<section className="v2-auth-intro" aria-hidden="true">
|
||||
<img src="/brand-logo.svg" alt="" />
|
||||
<h2>让每一辆车的数据<br />清晰、可控、可信</h2>
|
||||
<p>统一查看实时状态、车辆轨迹与里程数据。账号权限由管理员按车辆和功能精确分配。</p>
|
||||
<div><span>车辆级数据隔离</span><span>登录审计</span><span>可扩展身份接入</span></div>
|
||||
<Title heading={1}>让每一辆车的数据<br />清晰、可控、可信</Title>
|
||||
<Text>统一查看实时状态、车辆轨迹与里程数据。账号权限由管理员按车辆和功能精确分配。</Text>
|
||||
<div><Tag color="blue">车辆级数据隔离</Tag><Tag color="cyan">登录审计</Tag><Tag color="green">可扩展身份接入</Tag></div>
|
||||
</section>
|
||||
<form className="v2-auth-card" onSubmit={login}>
|
||||
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<h1>登录车辆数据中台</h1>
|
||||
<p>{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</p>
|
||||
{legacyMode ? <label><span>运维访问令牌</span><input autoFocus required type="password" autoComplete="off" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label> : <>
|
||||
<label><span>用户名</span><input autoFocus required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入用户名" /></label>
|
||||
<label><span>密码</span><input required type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></label>
|
||||
</>}
|
||||
{error ? <em role="alert">{error}</em> : null}
|
||||
<button type="submit" disabled={loginPending || (legacyMode ? !draftToken.trim() : !username.trim() || !password)}>{loginPending ? '正在登录…' : '登录'}</button>
|
||||
<button className="v2-auth-mode-switch" type="button" onClick={() => { setLegacyMode((value) => !value); setLoginError(''); }}>
|
||||
{legacyMode ? '返回账号密码登录' : '使用运维令牌登录'}
|
||||
</button>
|
||||
<small>登录即表示你同意遵守平台数据安全规范</small>
|
||||
</form>
|
||||
<Card className="v2-auth-card" bodyStyle={{ padding: 0 }}>
|
||||
<form className="v2-auth-form" onSubmit={login}>
|
||||
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<Title heading={3}>登录车辆数据中台</Title>
|
||||
<Text type="secondary">{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</Text>
|
||||
{legacyMode ? <label><span>运维访问令牌</span><Input autoFocus required type="password" autoComplete="off" value={draftToken} onChange={setDraftToken} placeholder="Bearer token" size="large" /></label> : <>
|
||||
<label><span>用户名</span><Input autoFocus required autoComplete="username" value={username} onChange={setUsername} placeholder="请输入用户名" size="large" /></label>
|
||||
<label><span>密码</span><Input required type="password" autoComplete="current-password" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
|
||||
</>}
|
||||
{error ? <Text type="danger" role="alert">{error}</Text> : null}
|
||||
<Button block htmlType="submit" loading={loginPending} disabled={loginPending || (legacyMode ? !draftToken.trim() : !username.trim() || !password)} theme="solid" type="primary" size="large">{loginPending ? '正在登录…' : '登录'}</Button>
|
||||
<Button block className="v2-auth-mode-switch" type="tertiary" theme="borderless" onClick={() => { setLegacyMode((value) => !value); setLoginError(''); }}>
|
||||
{legacyMode ? '返回账号密码登录' : '使用运维令牌登录'}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">登录即表示你同意遵守平台数据安全规范</Text>
|
||||
</form>
|
||||
</Card>
|
||||
</div>;
|
||||
}
|
||||
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('history domain', () => {
|
||||
it('formats units without fabricating missing values', () => {
|
||||
expect(formatHistoryValue(undefined)).toBe('—');
|
||||
expect(formatHistoryValue(42.5, { unit: 'km/h' } as never)).toBe('42.5 km/h');
|
||||
expect(formatHistoryValue(1, { valueMappings: [{ value: '1', label: '启动' }] } as never)).toBe('启动(1)');
|
||||
});
|
||||
|
||||
it('builds only numeric series with at least two evidence points', () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ export function parseHistoryKeywords(value: string) {
|
||||
|
||||
export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) {
|
||||
if (value == null || value === '') return '—';
|
||||
const raw = typeof value === 'number' && Number.isInteger(value) ? String(value) : String(value);
|
||||
const mapped = metric?.valueMappings?.find((item) => item.value === raw);
|
||||
if (mapped) return `${mapped.label}(${mapped.value})`;
|
||||
const formatted = typeof value === 'number' ? formatZhNumber(value, 6) : String(value);
|
||||
return metric?.unit ? `${formatted} ${metric.unit}` : formatted;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatNumber, statusLabel, vehicleStatus } from './monitor';
|
||||
import { formatNumber, normalizeMonitorBounds, statusLabel, vehicleStatus } from './monitor';
|
||||
|
||||
function vehicle(overrides: Partial<VehicleRealtimeRow> = {}): VehicleRealtimeRow {
|
||||
return {
|
||||
@@ -37,4 +37,11 @@ describe('monitor domain', () => {
|
||||
expect(formatNumber(12560)).toBe('12,560');
|
||||
expect(statusLabel('driving')).toBe('行驶');
|
||||
});
|
||||
|
||||
it('normalizes valid WGS-84 viewport bounds and drops unsafe map ranges', () => {
|
||||
expect(normalizeMonitorBounds('113,22,114,24')).toBe('113.000000,22.000000,114.000000,24.000000');
|
||||
expect(normalizeMonitorBounds('105,31,103,29')).toBe('');
|
||||
expect(normalizeMonitorBounds('-181,0,181,1')).toBe('');
|
||||
expect(normalizeMonitorBounds('113,22,Infinity,24')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,3 +34,12 @@ export function relativeFreshness(value: string) {
|
||||
export function formatNumber(value: number, maximumFractionDigits = 0) {
|
||||
return formatZhNumber(value, maximumFractionDigits);
|
||||
}
|
||||
|
||||
export function normalizeMonitorBounds(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const coordinates = value.split(',').map(Number);
|
||||
if (coordinates.length !== 4 || coordinates.some((coordinate) => !Number.isFinite(coordinate))) return '';
|
||||
const [west, south, east, north] = coordinates;
|
||||
if (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north) return '';
|
||||
return coordinates.map((coordinate) => coordinate.toFixed(6)).join(',');
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from './profi
|
||||
|
||||
describe('parseVehicleProfileSyncCSV', () => {
|
||||
it('parses quoted values, BOM, CRLF and normalizes VIN/status', () => {
|
||||
const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`);
|
||||
expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]);
|
||||
const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,宇通,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`);
|
||||
expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', brandName: '宇通', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]);
|
||||
});
|
||||
|
||||
it('rejects duplicate VINs and malformed source rows before upload', () => {
|
||||
const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,unknown,,,\nvin001,,,,unknown,,,`;
|
||||
const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,,unknown,,,\nvin001,,,,,unknown,,,`;
|
||||
expect(() => parseVehicleProfileSyncCSV(duplicate)).toThrow(/VIN 重复/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,active,,,1.5`)).toThrow(/累计运行秒数/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,,active,,,1.5`)).toThrow(/累计运行秒数/);
|
||||
expect(() => parseVehicleProfileSyncCSV('vin,modelName\nVIN001,车型')).toThrow(/表头/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,"车型"x,,,active,,,`)).toThrow(/引号结束/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,宇通,"车型"x,,,active,,,`)).toThrow(/引号结束/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { VehicleProfileSyncItem } from '../../api/types';
|
||||
|
||||
const headers = ['vin', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const;
|
||||
const headers = ['vin', 'brandName', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const;
|
||||
const allowedStatuses = new Set(['', 'unknown', 'active', 'inactive', 'maintenance', 'retired']);
|
||||
|
||||
export const vehicleProfileSyncCSVHeader = headers.join(',');
|
||||
@@ -18,7 +18,7 @@ export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem
|
||||
return dataRows.map((row, index) => {
|
||||
const line = index + 2;
|
||||
if (row.length !== headers.length) throw new Error(`CSV 第 ${line} 行列数不正确`);
|
||||
const [rawVIN, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim());
|
||||
const [rawVIN, brandName, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim());
|
||||
const vin = rawVIN.toUpperCase();
|
||||
if (!vin || vin.length > 32) throw new Error(`CSV 第 ${line} 行 VIN 无效`);
|
||||
if (seen.has(vin)) throw new Error(`CSV 第 ${line} 行 VIN 重复:${vin}`);
|
||||
@@ -28,7 +28,7 @@ export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem
|
||||
const runtimeSeconds = rawRuntime === '' ? null : Number(rawRuntime);
|
||||
if (runtimeSeconds !== null && (!Number.isSafeInteger(runtimeSeconds) || runtimeSeconds < 0)) throw new Error(`CSV 第 ${line} 行累计运行秒数无效`);
|
||||
return {
|
||||
vin, modelName, vehicleType, companyName,
|
||||
vin, brandName, modelName, vehicleType, companyName,
|
||||
operationStatus: (operationStatus || 'unknown') as VehicleProfileSyncItem['operationStatus'],
|
||||
accessProvider, firstAccessAt, runtimeSeconds
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('latest telemetry presentation', () => {
|
||||
expect(formatTelemetryValue(42.567)).toBe('42.57');
|
||||
expect(formatTelemetryValue(true)).toBe('是');
|
||||
expect(formatTelemetryValue(null)).toBe('—');
|
||||
expect(formatTelemetryValue(1, '启动(1)')).toBe('启动(1)');
|
||||
});
|
||||
|
||||
it('translates server quality states', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatZhNumber } from './formatters';
|
||||
|
||||
export function formatTelemetryValue(value: unknown) {
|
||||
export function formatTelemetryValue(value: unknown, displayValue?: string) {
|
||||
if (displayValue) return displayValue;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return formatZhNumber(value, 2);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,13 @@ describe('monitor query params', () => {
|
||||
it('drops stale viewport bounds for a direct vehicle search', () => {
|
||||
const viewport = { zoom: 13, bounds: '103,29,105,31' };
|
||||
expect(monitorMapQueryParams({ keyword: '粤A1', protocol: '', status: '' }, viewport).has('bounds')).toBe(false);
|
||||
expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe(viewport.bounds);
|
||||
expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe('103.000000,29.000000,105.000000,31.000000');
|
||||
});
|
||||
|
||||
it('omits wrapped or out-of-range viewport hints instead of failing the map query', () => {
|
||||
for (const bounds of ['105,31,103,29', '-181,0,181,1', '103,29,Infinity,31']) {
|
||||
expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, { zoom: 5, bounds }).has('bounds')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes pasted plates, removes duplicates, and sends one batch parameter', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types';
|
||||
import { normalizeMonitorBounds } from '../domain/monitor';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
|
||||
export type MonitorFilters = {
|
||||
@@ -116,7 +117,8 @@ export function monitorQueryParams(filters: MonitorFilters, limit: number) {
|
||||
export function monitorMapQueryParams(filters: MonitorFilters, viewport: MonitorViewport) {
|
||||
const params = monitorQueryParams(filters, 10_000);
|
||||
params.set('zoom', String(viewport.zoom));
|
||||
if (viewport.bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', viewport.bounds);
|
||||
const bounds = normalizeMonitorBounds(viewport.bounds);
|
||||
if (bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', bounds);
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -203,6 +205,15 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime,
|
||||
...LIVE_QUERY_POLICY
|
||||
});
|
||||
const telemetry = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'telemetry', vin],
|
||||
queryFn: ({ signal }) => api.latestTelemetry(vin, signal),
|
||||
enabled: enabled && vehicle?.primaryProtocol === 'GB32960',
|
||||
staleTime: 10_000,
|
||||
refetchInterval: enabled && activelyTracked && vehicle?.primaryProtocol === 'GB32960' ? 20_000 : false,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime,
|
||||
...LIVE_QUERY_POLICY
|
||||
});
|
||||
const address = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'address', addressPoint?.vin, addressPoint?.key],
|
||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
|
||||
@@ -214,5 +225,5 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
|
||||
return { detail, activeAlerts, address };
|
||||
return { detail, activeAlerts, telemetry, address };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
export function useSideSheetA11y(open: boolean, selector: string, id: string, label: string, closeLabel: string) {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const apply = () => {
|
||||
const dialog = document.querySelector(`${selector} .semi-sidesheet-inner`);
|
||||
dialog?.setAttribute('id', id);
|
||||
dialog?.setAttribute('aria-label', label);
|
||||
dialog?.querySelector('.semi-sidesheet-close')?.setAttribute('aria-label', closeLabel);
|
||||
};
|
||||
apply();
|
||||
const frame = window.requestAnimationFrame(apply);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [closeLabel, id, label, open, selector]);
|
||||
}
|
||||
@@ -8,19 +8,23 @@ const routing = vi.hoisted(() => ({
|
||||
scheduleIdleRoutePreloads: vi.fn(() => vi.fn()),
|
||||
shouldPreloadRouteOnIntent: vi.fn(() => true)
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ session: { name: 'test-user', role: 'viewer' as const } as any }));
|
||||
const auth = vi.hoisted(() => ({
|
||||
session: { name: 'test-user', username: 'test-user', role: 'viewer' as const } as any,
|
||||
logout: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../routing/routeModules', () => routing);
|
||||
vi.mock('../auth/AuthGate', () => ({
|
||||
usePlatformSession: () => ({ session: auth.session, logout: vi.fn() })
|
||||
usePlatformSession: () => ({ session: auth.session, logout: auth.logout })
|
||||
}));
|
||||
|
||||
import { AppShell } from './AppShell';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
auth.session = { name: 'test-user', role: 'viewer' };
|
||||
auth.session = { name: 'test-user', username: 'test-user', role: 'viewer' };
|
||||
});
|
||||
|
||||
test('renders only explicitly assigned customer menus', () => {
|
||||
@@ -61,6 +65,22 @@ test('reschedules likely route preloads when the active module changes', async (
|
||||
expect(secondCleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('marks the content as a full-height track workspace after sidebar navigation', async () => {
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: scrollTo });
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
expect(document.querySelector('.v2-content')).not.toHaveClass('is-track-workspace');
|
||||
scrollTo.mockClear();
|
||||
fireEvent.click(screen.getByRole('link', { name: '轨迹回放' }));
|
||||
|
||||
await waitFor(() => expect(document.querySelector('.v2-content')).toHaveClass('is-track-workspace'));
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
|
||||
expect(screen.getByRole('heading', { name: '轨迹回放' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens contextual help for the active module and closes it without navigating', () => {
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
|
||||
@@ -72,7 +92,87 @@ test('opens contextual help for the active module and closes it without navigati
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效');
|
||||
expect(help).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭帮助' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('关闭帮助'));
|
||||
expect(help).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.style.overflow).not.toBe('hidden');
|
||||
expect(screen.getByText('页面内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('uses one Semi account menu for identity, password and logout actions', async () => {
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '账号菜单,test-user' });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(await screen.findByText('@test-user')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '账号菜单,test-user' })).toHaveAttribute('aria-expanded', 'true'));
|
||||
fireEvent.click(screen.getByText('修改登录密码'));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '修改登录密码' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByText('退出登录'));
|
||||
expect(auth.logout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('uses the compact mobile navigation and opens secondary modules in a Semi SideSheet', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(max-width: 680px)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
});
|
||||
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('link', { name: '全局监控' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '车辆查询' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '轨迹回放' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '里程查询' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '历史数据' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多功能' }));
|
||||
|
||||
const moreSheet = screen.getByRole('dialog', { name: '更多功能' });
|
||||
expect(moreSheet).toHaveTextContent('数据分析与系统管理');
|
||||
expect(screen.getByRole('link', { name: '历史数据' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '告警中心' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '接入管理' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '账号管理' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '运维质量' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭更多功能'));
|
||||
expect(screen.getByRole('button', { name: '更多功能' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.style.overflow).not.toBe('hidden');
|
||||
});
|
||||
|
||||
test('automatically collapses the Semi sidebar at tablet width', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
matches: query === '(max-width: 900px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}));
|
||||
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
|
||||
<Routes><Route element={<AppShell />}><Route path="*" element={<span>页面内容</span>} /></Route></Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('link', { name: '全局监控' })).toHaveAttribute('title', '全局监控');
|
||||
expect(screen.queryByRole('button', { name: '收起侧栏' })).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-sidebar')).toHaveClass('is-collapsed');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
IconAlarm,
|
||||
IconBarChartHStroked,
|
||||
IconBox,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
@@ -12,13 +13,19 @@ import {
|
||||
IconUser,
|
||||
IconExit
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Avatar, Button, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { hasMenu } from '../auth/session';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const navigation = [
|
||||
{ to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
@@ -54,21 +61,17 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
|
||||
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
|
||||
};
|
||||
|
||||
function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) {
|
||||
function ContextHelp({ section, visible, onClose }: { section: string; visible: boolean; onClose: () => void }) {
|
||||
const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] };
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [onClose]);
|
||||
return <div className="v2-help-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<aside className="v2-help-panel" role="dialog" aria-modal="true" aria-labelledby="v2-help-title">
|
||||
<header><div><small>当前页面帮助</small><h2 id="v2-help-title">{pageNames[section] ?? '车辆数据中台'}</h2></div><button type="button" autoFocus aria-label="关闭帮助" onClick={onClose}><span aria-hidden="true">×</span></button></header>
|
||||
<p>{content.summary}</p>
|
||||
const title = pageNames[section] ?? '车辆数据中台';
|
||||
useSideSheetA11y(visible, '.v2-help-sheet', 'v2-context-help', title, '关闭帮助');
|
||||
return <SideSheet className="v2-help-sheet" visible={visible} aria-label={title} width={410} title={<div><Text type="tertiary" size="small">当前页面帮助</Text><Title heading={4}>{title}</Title></div>} onCancel={onClose} footer={<Button block onClick={onClose}>关闭帮助</Button>}>
|
||||
<div className="v2-help-panel">
|
||||
<Text type="secondary">{content.summary}</Text>
|
||||
<ol>{content.tips.map((tip, index) => <li key={tip}><span>{index + 1}</span>{tip}</li>)}</ol>
|
||||
<footer><kbd>Esc</kbd><span>关闭帮助</span></footer>
|
||||
</aside>
|
||||
</div>;
|
||||
</div>
|
||||
</SideSheet>;
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
@@ -77,36 +80,78 @@ export function AppShell() {
|
||||
const activeRoutePath = `/${section}`;
|
||||
const { session, logout } = usePlatformSession();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role];
|
||||
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 680px)');
|
||||
const update = () => setMobileLayout(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
update();
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const content = document.querySelector<HTMLElement>('.v2-content');
|
||||
if (!content) return;
|
||||
const reset = () => {
|
||||
content.scrollTop = 0;
|
||||
content.scrollLeft = 0;
|
||||
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
|
||||
};
|
||||
reset();
|
||||
let trailingFrame = 0;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
reset();
|
||||
trailingFrame = window.requestAnimationFrame(reset);
|
||||
});
|
||||
const timer = window.setTimeout(reset, 120);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.cancelAnimationFrame(trailingFrame);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="v2-shell">
|
||||
{mobileLayout ? <MobileNavigation /> : <Sidebar />}
|
||||
<div className="v2-main">
|
||||
<header className="v2-topbar">
|
||||
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
|
||||
<Layout className="v2-shell">
|
||||
{mobileLayout ? <MobileNavigation /> : <Sidebar activePath={activeRoutePath} />}
|
||||
<Layout className="v2-main">
|
||||
<Header className="v2-topbar">
|
||||
<div className="v2-topbar-title"><Text type="tertiary" size="small">羚牛车辆数据中台</Text><Title heading={4}>{pageNames[section] ?? '车辆数据中台'}</Title></div>
|
||||
<div className="v2-topbar-actions">
|
||||
<button type="button" aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><IconHelpCircle /></button>
|
||||
<button type="button" className="v2-current-user" title="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /><b>{session.name}</b><small>{roleLabel}</small></button>
|
||||
<button type="button" className="v2-password-mobile" aria-label="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /></button>
|
||||
<button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button>
|
||||
<Button theme="borderless" icon={<IconHelpCircle />} aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)} />
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
visible={accountOpen}
|
||||
onVisibleChange={setAccountOpen}
|
||||
contentClassName="v2-account-dropdown"
|
||||
render={<Dropdown.Menu>
|
||||
<Dropdown.Title className="v2-account-dropdown-profile">
|
||||
<Avatar size="small" color="blue">{session.name.slice(0, 1)}</Avatar>
|
||||
<span><strong>{session.name}</strong><small>{session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号'}</small></span>
|
||||
<Tag color="blue" size="small">{roleLabel}</Tag>
|
||||
</Dropdown.Title>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item icon={<IconUser />} onClick={() => { setAccountOpen(false); setPasswordOpen(true); }}>修改登录密码</Dropdown.Item>
|
||||
<Dropdown.Item type="danger" icon={<IconExit />} onClick={() => { setAccountOpen(false); logout(); }}>退出登录</Dropdown.Item>
|
||||
</Dropdown.Menu>}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
className="v2-current-user"
|
||||
aria-label={`账号菜单,${session.name}`}
|
||||
aria-expanded={accountOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Avatar size="extra-small" color="blue">{session.name.slice(0, 1)}</Avatar>
|
||||
<b>{session.name}</b>
|
||||
<Tag color="blue" size="small">{roleLabel}</Tag>
|
||||
<IconChevronDown className="v2-account-chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
<main className="v2-content"><Outlet /></main>
|
||||
</div>
|
||||
{helpOpen ? <div id="v2-context-help"><ContextHelp section={section} onClose={() => setHelpOpen(false)} /></div> : null}
|
||||
</Header>
|
||||
<Content className={`v2-content${section === 'tracks' ? ' is-track-workspace' : ''}`}><Outlet /></Content>
|
||||
</Layout>
|
||||
<ContextHelp section={section} visible={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,19 +172,16 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
|
||||
setError(reason instanceof Error ? reason.message : '密码修改失败');
|
||||
} finally { setPending(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !pending) onClose(); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [onClose, pending]);
|
||||
return <div className="v2-password-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget && !pending) onClose(); }}><form className="v2-password-dialog" role="dialog" aria-modal="true" aria-labelledby="v2-password-title" onSubmit={submit}>
|
||||
<header><div><h2 id="v2-password-title">修改登录密码</h2><p>修改成功后,所有设备需要重新登录。</p></div><button type="button" aria-label="关闭修改密码" onClick={onClose}>×</button></header>
|
||||
<label><span>当前密码</span><input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} /></label>
|
||||
<label><span>新密码</span><input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>确认新密码</span><input required minLength={10} type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></label>
|
||||
{error ? <em role="alert">{error}</em> : null}
|
||||
<footer><button type="button" onClick={onClose} disabled={pending}>取消</button><button type="submit" disabled={pending || !currentPassword || !newPassword || !confirmPassword}>{pending ? '正在修改…' : '确认修改'}</button></footer>
|
||||
</form></div>;
|
||||
return <Modal className="v2-password-modal" visible title="修改登录密码" aria-label="修改登录密码" onCancel={onClose} closeOnEsc={!pending} maskClosable={!pending} footer={null}>
|
||||
<form className="v2-password-dialog" onSubmit={submit}>
|
||||
<Text type="secondary">修改成功后,所有设备需要重新登录。</Text>
|
||||
<label><span>当前密码</span><Input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
|
||||
<label><span>新密码</span><Input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={setNewPassword} placeholder="至少 10 位,包含三类字符" size="large" /></label>
|
||||
<label><span>确认新密码</span><Input required minLength={10} type="password" autoComplete="new-password" value={confirmPassword} onChange={setConfirmPassword} size="large" /></label>
|
||||
{error ? <Text type="danger" role="alert">{error}</Text> : null}
|
||||
<footer><Button type="tertiary" onClick={onClose} disabled={pending}>取消</Button><Button htmlType="submit" theme="solid" type="primary" loading={pending} disabled={pending || !currentPassword || !newPassword || !confirmPassword}>{pending ? '正在修改…' : '确认修改'}</Button></footer>
|
||||
</form>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]];
|
||||
@@ -154,51 +196,55 @@ function MobileNavigation() {
|
||||
const moreActive = more.some((item) => location.pathname.startsWith(item.to));
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
useEffect(() => setMoreOpen(false), [location.pathname]);
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMoreOpen(false); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [moreOpen]);
|
||||
useSideSheetA11y(moreOpen, '.v2-mobile-more-sidesheet', 'v2-mobile-more', '更多功能', '关闭更多功能');
|
||||
|
||||
const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
|
||||
return <>
|
||||
{moreOpen && more.length ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong>更多功能</strong><span>数据分析与系统管理</span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{more.map(link)}</nav></section></div> : null}
|
||||
{more.length ? <SideSheet className="v2-mobile-more-sidesheet" visible={moreOpen} placement="bottom" height="auto" title={<div><strong>更多功能</strong><span>数据分析与系统管理</span></div>} aria-label="更多功能" footer={null} onCancel={() => setMoreOpen(false)}><nav>{more.map(link)}</nav></SideSheet> : null}
|
||||
<nav className="v2-mobile-navigation" aria-label="主导航">
|
||||
{primary.map(link)}
|
||||
{more.length ? <button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span>更多</span></button> : null}
|
||||
{more.length ? <Button theme="borderless" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} aria-controls="v2-mobile-more" icon={<IconMore size="large" />} onClick={() => setMoreOpen((value) => !value)}><span>更多</span></Button> : null}
|
||||
</nav>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
function Sidebar({ activePath }: { activePath: string }) {
|
||||
const { session } = usePlatformSession();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const compactLayout = useMobileLayout(900);
|
||||
const effectiveCollapsed = collapsed || compactLayout;
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
const visibleNavigation = navigation.filter((item) => hasMenu(session, item.menu));
|
||||
const visibleNavigation = [
|
||||
...navigation.filter((item) => hasMenu(session, item.menu)),
|
||||
...(hasMenu(session, 'operations') ? [{ to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }] : [])
|
||||
];
|
||||
const items = visibleNavigation.map(({ to, label, icon: Icon }) => ({
|
||||
itemKey: to,
|
||||
text: label,
|
||||
icon: <Icon size="large" />,
|
||||
link: to,
|
||||
linkOptions: {
|
||||
'aria-label': label,
|
||||
title: effectiveCollapsed ? label : undefined,
|
||||
onClick: (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); navigate(to); },
|
||||
onPointerEnter: () => warmRoute(to),
|
||||
onFocus: () => warmRoute(to),
|
||||
onPointerDown: () => warmRoute(to)
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
|
||||
<Sider className={`v2-sidebar${effectiveCollapsed ? ' is-collapsed' : ''}`} aria-label="主导航">
|
||||
<div className="v2-brand" aria-label="灵牛智能车辆数据中台">
|
||||
<img className="v2-brand-logo" src="/brand-logo.svg" alt="灵牛智能" />
|
||||
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<nav className="v2-navigation" aria-label="主导航">
|
||||
{visibleNavigation.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} aria-label={label} title={collapsed ? label : undefined} onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
|
||||
<Icon size="large" />
|
||||
<span className="v2-nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{hasMenu(session, 'operations') ? <NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
|
||||
<IconSetting size="large" />
|
||||
<span className="v2-nav-label">运维质量</span>
|
||||
</NavLink> : null}
|
||||
<button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
|
||||
<Nav className="v2-navigation" aria-label="主导航" items={items} selectedKeys={[activePath]} isCollapsed={effectiveCollapsed} tooltipShowDelay={300} tooltipHideDelay={300} />
|
||||
{!compactLayout ? <Button className="v2-collapse" theme="borderless" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
|
||||
<IconChevronLeft />
|
||||
<span>收起</span>
|
||||
</button>
|
||||
</aside>
|
||||
</Button> : null}
|
||||
</Sider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -344,7 +344,9 @@ test('recovers in place after a transient AMap failure and renders data that arr
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ }));
|
||||
const retry = screen.getByRole('button', { name: /重新加载地图/ });
|
||||
expect(retry).toHaveClass('semi-button', 'v2-map-retry-action');
|
||||
fireEvent.click(retry);
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
@@ -520,6 +522,23 @@ test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data'
|
||||
expect(Math.abs(bounds[3] - 24)).toBeLessThan(0.0005);
|
||||
});
|
||||
|
||||
test('drops a wrapped world viewport instead of emitting invalid WGS-84 bounds', async () => {
|
||||
getZoom.mockReturnValue(3);
|
||||
getBounds.mockReturnValue({
|
||||
getSouthWest: () => ({ getLng: () => -80, getLat: () => -20 }),
|
||||
getNorthEast: () => ({ getLng: () => 260, getLat: () => 70 })
|
||||
});
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
const onViewportChange = vi.fn();
|
||||
|
||||
render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} onViewportChange={onViewportChange} />);
|
||||
|
||||
await waitFor(() => expect(onViewportChange).toHaveBeenCalled());
|
||||
const viewport = onViewportChange.mock.calls[onViewportChange.mock.calls.length - 1]?.[0] as { zoom: number; bounds: string };
|
||||
expect(viewport).toEqual({ zoom: 3, bounds: '' });
|
||||
});
|
||||
|
||||
test('renders one selected plate and smoothly follows it until the map is dragged', async () => {
|
||||
useFastAnimationFrames();
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
@@ -702,7 +721,8 @@ test('renders crisp province totals and drills into the existing cluster layer',
|
||||
expect(setData).toHaveBeenLastCalledWith([]);
|
||||
const provinceIndex = markerOptions.findIndex((options) => String(options.content).includes('广东省'));
|
||||
expect(String(markerOptions[provinceIndex]?.content)).toContain('<strong>3</strong>');
|
||||
expect(String(markerOptions[provinceIndex]?.content)).toContain('在线 2');
|
||||
expect(String(markerOptions[provinceIndex]?.content)).toContain('<span>广东</span>');
|
||||
expect(String(markerOptions[provinceIndex]?.content)).not.toContain('在线');
|
||||
|
||||
markerInstances[provinceIndex]?.handlers.get('click')?.();
|
||||
expect(setZoomAndCenter).toHaveBeenLastCalledWith(7, [113.266, 23.132]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconEyeClosed, IconEyeOpened, IconMapPin, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { IconEyeClosed, IconEyeOpened, IconMapPin } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
import {
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
type AMapOverlay
|
||||
} from '../../integrations/amap';
|
||||
import type { MonitorMapProvincePoint, MonitorMapResponse, VehicleRealtimeRow } from '../../api/types';
|
||||
import { vehicleStatus } from '../domain/monitor';
|
||||
import { normalizeMonitorBounds, vehicleStatus } from '../domain/monitor';
|
||||
import type { MonitorViewport } from '../hooks/useMonitorData';
|
||||
import { MapRetryAction } from '../shared/RecoveryActions';
|
||||
|
||||
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
|
||||
const CLUSTER_VISUAL_CACHE_LIMIT = 256;
|
||||
@@ -78,11 +79,9 @@ function viewportFromMap(map: AMapMap): MonitorViewport | null {
|
||||
];
|
||||
const longitudes = wgsCorners.map(([longitude]) => longitude);
|
||||
const latitudes = wgsCorners.map(([, latitude]) => latitude);
|
||||
return {
|
||||
zoom,
|
||||
bounds: [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)]
|
||||
.map((value) => value.toFixed(6)).join(',')
|
||||
};
|
||||
const viewportBounds = [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)]
|
||||
.map((value) => value.toFixed(6)).join(',');
|
||||
return { zoom, bounds: normalizeMonitorBounds(viewportBounds) };
|
||||
}
|
||||
|
||||
function viewportCenter(viewport?: MonitorViewport) {
|
||||
@@ -757,13 +756,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
existing.marker.off?.('click', existing.click);
|
||||
existing.marker.setMap?.(null);
|
||||
}
|
||||
const onlinePercent = province.count ? Math.round(province.online / province.count * 100) : 0;
|
||||
const displayName = compactMarkers ? compactProvinceName(province.name) : province.name;
|
||||
const displayName = compactProvinceName(province.name);
|
||||
const marker = new AMap.Marker({
|
||||
position: [province.longitude, province.latitude],
|
||||
offset: new AMap.Pixel(0, 0),
|
||||
zIndex: 180,
|
||||
content: `<div class="v2-province-placement" style="--province-x:${placement.dx}px;--province-y:${placement.dy}px;--province-line:${connectorLength}px;--province-angle:${connectorAngle}deg"><i class="v2-province-anchor"></i>${connectorLength ? '<i class="v2-province-connector"></i>' : ''}<button type="button" tabindex="-1" class="v2-province-marker${compactMarkers ? ' is-compact' : ''}" aria-label="${escapeHtml(province.name)} ${province.count} 辆"><span>${escapeHtml(displayName)}</span><strong>${province.count.toLocaleString('zh-CN')}</strong><small>在线 ${province.online.toLocaleString('zh-CN')}</small><i><b style="width:${onlinePercent}%"></b></i></button></div>`
|
||||
content: `<div class="v2-province-placement" style="--province-x:${placement.dx}px;--province-y:${placement.dy}px;--province-line:${connectorLength}px;--province-angle:${connectorAngle}deg"><i class="v2-province-anchor"></i>${connectorLength ? '<i class="v2-province-connector"></i>' : ''}<button type="button" tabindex="-1" class="v2-province-marker${compactMarkers ? ' is-compact' : ''}" aria-label="${escapeHtml(province.name)} ${province.count} 辆"><span>${escapeHtml(displayName)}</span><strong>${province.count.toLocaleString('zh-CN')}</strong></button></div>`
|
||||
});
|
||||
const click = () => map.setZoomAndCenter?.(PROVINCE_DRILL_ZOOM, [province.longitude, province.latitude]);
|
||||
marker.on?.('click', click);
|
||||
@@ -1053,7 +1051,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
<div className={`v2-map-state is-${state}`}>
|
||||
{state === 'loading' ? <><span className="v2-spinner" />高德地图加载中</> : null}
|
||||
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德 Key、域名白名单和网络</span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh />重新加载地图</button></> : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德 Key、域名白名单和网络</span><MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} /></> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="v2-map-legend" aria-label="车辆状态图例">
|
||||
|
||||
@@ -128,7 +128,9 @@ test('defers the map runtime until valid data exists, retries a transient failur
|
||||
onFollowChange={() => undefined}
|
||||
/>);
|
||||
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ }));
|
||||
const retry = screen.getByRole('button', { name: /重新加载地图/ });
|
||||
expect(retry).toHaveClass('semi-button', 'v2-map-retry-action');
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(mapInstances).toHaveLength(1));
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { HistoryLocationRow, TrackStop } from '../../api/types';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap';
|
||||
import { MapRetryAction } from '../shared/RecoveryActions';
|
||||
|
||||
function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) {
|
||||
if (kind === 'current') return '<div class="v2-track-current-marker" aria-hidden="true"><i></i><span></span></div>';
|
||||
@@ -153,7 +153,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
|
||||
{state === 'loading' ? <><span className="v2-spinner" />轨迹地图加载中</> : null}
|
||||
{state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null}
|
||||
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德地图配置或网络</span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh />重新加载地图</button></> : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德地图配置或网络</span><MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} /></> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@ const mocks = vi.hoisted(() => ({
|
||||
accessThresholds: vi.fn(), updateAccessThresholds: vi.fn()
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' }));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
auth.role = 'admin';
|
||||
layout.mobile = false;
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
@@ -44,9 +47,23 @@ test('removes old access rows immediately when the vehicle filter scope changes'
|
||||
? Promise.resolve({ items: [accessRow('OLDVIN', '旧接入车牌')], total: 1, limit: 50, offset: 0 })
|
||||
: new Promise<Page<AccessVehicleRow>>((resolve) => { resolveNew = resolve; }));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=OLDVIN']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=OLDVIN']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('旧接入车牌')).toBeInTheDocument();
|
||||
for (const className of ['v2-access-filter-card-v3', 'v2-access-kpis-card-v3', 'v2-access-table-v3']) {
|
||||
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
|
||||
}
|
||||
expect(view.container.querySelector('.v2-access-semi-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-access-table-scroll-v3 > table')).not.toBeInTheDocument();
|
||||
const desktopRow = screen.getByTestId('access-row-OLDVIN');
|
||||
expect(desktopRow).toHaveAttribute('role', 'button');
|
||||
expect(desktopRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.keyDown(desktopRow, { key: 'Enter' });
|
||||
expect(await screen.findByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument();
|
||||
expect(desktopRow).toHaveAttribute('aria-expanded', 'true');
|
||||
const inspectorHeader = screen.getByRole('heading', { level: 5, name: '旧接入车牌' }).closest<HTMLElement>('.v2-workspace-panel-header');
|
||||
expect(inspectorHeader).toBeInTheDocument();
|
||||
expect(within(inspectorHeader!).getByText('OLDVIN')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
@@ -66,13 +83,23 @@ test('reports and independently retries unresolved identity and threshold failur
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /接入治理/ }));
|
||||
expect(await screen.findByRole('dialog', { name: '接入治理配置' })).toBeInTheDocument();
|
||||
const identityError = await screen.findByText('待绑定身份服务超时');
|
||||
const thresholdError = await screen.findByText('阈值配置读取失败');
|
||||
fireEvent.click(within(identityError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ }));
|
||||
fireEvent.click(within(thresholdError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ }));
|
||||
|
||||
expect(await screen.findByText('另有 1 条来源身份待绑定')).toBeInTheDocument();
|
||||
expect(await screen.findByText('在线判定阈值 · v1')).toBeInTheDocument();
|
||||
const identityTitle = await screen.findByText('来源身份待绑定');
|
||||
const identityCollapse = identityTitle.closest<HTMLElement>('.semi-collapse');
|
||||
expect(identityCollapse).toHaveClass('v2-access-identity-queue-v3');
|
||||
expect(within(identityCollapse!).getByText('1 条').closest('.semi-tag')).toBeTruthy();
|
||||
expect(within(identityCollapse!).getByText('138****0001').closest('.semi-card')).toHaveClass('v2-access-identity-card');
|
||||
|
||||
const thresholdTitle = await screen.findByText('在线判定阈值');
|
||||
const thresholdCollapse = thresholdTitle.closest<HTMLElement>('.semi-collapse');
|
||||
expect(thresholdCollapse).toHaveClass('v2-access-settings');
|
||||
expect(within(thresholdCollapse!).getByText('v1').closest('.semi-tag')).toBeTruthy();
|
||||
expect(screen.queryByText('待绑定身份服务超时')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('阈值配置读取失败')).not.toBeInTheDocument();
|
||||
expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2);
|
||||
@@ -97,3 +124,26 @@ test('does not request or render admin-only thresholds for a read-only session',
|
||||
expect(mocks.accessThresholds).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders mobile access vehicles as selectable Semi cards', async () => {
|
||||
layout.mobile = true;
|
||||
prepareBaseData();
|
||||
mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const action = await screen.findByRole('button', { name: '查看 粤A00001 接入详情' });
|
||||
expect(action).toHaveClass('semi-button', 'v2-access-mobile-action');
|
||||
expect(action.closest('.semi-card')).toHaveClass('v2-access-mobile-card');
|
||||
expect(action).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(action);
|
||||
expect(action).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'true');
|
||||
const dialog = await screen.findByRole('dialog', { name: '车辆接入详情' });
|
||||
expect(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument();
|
||||
expect(dialog.querySelector('.v2-access-inspector-v3.semi-card')).toBeInTheDocument();
|
||||
expect(dialog.querySelector('.v2-access-inspector-summary.semi-descriptions')).toBeInTheDocument();
|
||||
expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument();
|
||||
expect(dialog.querySelectorAll('.v2-access-protocol-detail.semi-card')).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
@@ -6,11 +7,18 @@ import { api } from '../../api/client';
|
||||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
|
||||
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MetricActionButton } from '../shared/MetricActionButton';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||||
const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
@@ -35,19 +43,27 @@ function compactTime(value: string) {
|
||||
function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) {
|
||||
const state = !status?.connected ? 'missing' : status.onlineState;
|
||||
const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
|
||||
const color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey';
|
||||
if (detailed) {
|
||||
return <article className={`v2-access-protocol-detail is-${state}`}>
|
||||
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
|
||||
<dl>
|
||||
<div><dt>接入厂家</dt><dd>{status?.provider || '—'}</dd></div>
|
||||
<div><dt>首次接入</dt><dd>{formatAccessTime(status?.firstSeenAt || '')}</dd></div>
|
||||
<div><dt>最新上报</dt><dd>{formatAccessTime(status?.latestReceivedAt || '')}</dd></div>
|
||||
<div><dt>当前离线</dt><dd>{formatSeconds(status?.freshnessSec)}</dd></div>
|
||||
<div><dt>上报间隔</dt><dd>{formatSeconds(status?.reportIntervalSec)}</dd></div>
|
||||
<div><dt>数据延迟</dt><dd className={status?.delayAbnormal ? 'is-danger' : ''}>{formatSeconds(status?.dataDelaySec)}</dd></div>
|
||||
</dl>
|
||||
<p>{status?.firstSeenEvidence || '当前未发现该协议来源'}</p>
|
||||
</article>;
|
||||
return <Card
|
||||
className={`v2-access-protocol-detail is-${state}`}
|
||||
title={<strong>{status?.protocol}</strong>}
|
||||
headerExtraContent={<Tag className={`v2-access-protocol-tag is-${state}`} color={color} type="light" size="small"><i />{label}</Tag>}
|
||||
headerLine
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
|
||||
{ key: '接入厂家', value: status?.provider || '—' },
|
||||
{ key: '首次接入', value: formatAccessTime(status?.firstSeenAt || '') },
|
||||
{ key: '最新上报', value: formatAccessTime(status?.latestReceivedAt || '') },
|
||||
{ key: '当前离线', value: formatSeconds(status?.freshnessSec) },
|
||||
{ key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) },
|
||||
{ key: '数据延迟', value: <Typography.Text type={status?.delayAbnormal ? 'danger' : 'primary'}>{formatSeconds(status?.dataDelaySec)}</Typography.Text> }
|
||||
]} />
|
||||
<p className="v2-access-protocol-evidence" title={status?.firstSeenEvidence || '当前未发现该协议来源'}>
|
||||
{status?.firstSeenEvidence || '当前未发现该协议来源'}
|
||||
</p>
|
||||
</Card>;
|
||||
}
|
||||
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
|
||||
<span><i />{label}</span>
|
||||
@@ -57,7 +73,8 @@ function ProtocolState({ status, detailed = false }: { status?: AccessProtocolSt
|
||||
}
|
||||
|
||||
function ConnectionState({ row }: { row: AccessVehicleRow }) {
|
||||
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} 个真实来源</span></div>;
|
||||
const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange';
|
||||
return <div className={`v2-access-connection is-${row.connectionState}`}><Tag className="v2-access-connection-tag" color={color} type="light" size="small">{connectionLabels[row.connectionState]}</Tag><span>{row.actualProtocols.length} 个真实来源</span></div>;
|
||||
}
|
||||
|
||||
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||||
@@ -69,23 +86,81 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) {
|
||||
return <aside className="v2-access-inspector-v3">
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><button type="button" onClick={onClose} aria-label="关闭车辆接入详情"><IconClose /></button></header>
|
||||
<section className="v2-access-inspector-summary"><div><span>品牌 / 车型</span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span>真实接入来源</span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源'}</strong></div><div><span>资料状态</span><strong>{row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护'}</strong></div><div><span>综合状态</span><ConnectionState row={row} /></div></section>
|
||||
<div className="v2-access-protocol-details">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</div>
|
||||
function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) {
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '车辆', dataIndex: 'plate', width: 165,
|
||||
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div>
|
||||
},
|
||||
{
|
||||
title: '品牌 / 车型', dataIndex: 'oem', width: 140,
|
||||
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></div>
|
||||
},
|
||||
{
|
||||
title: '真实来源', dataIndex: 'actualProtocols', width: 115,
|
||||
render: (_: string[], row: AccessVehicleRow) => <div className="v2-access-source-cell"><b>{row.actualProtocols.length} 个</b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></div>
|
||||
},
|
||||
...PROTOCOLS.map((protocol) => ({
|
||||
title: protocol, dataIndex: protocol, width: 160,
|
||||
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState status={statusByProtocol(row, protocol)} />
|
||||
})),
|
||||
{
|
||||
title: '综合状态', dataIndex: 'connectionState', width: 120,
|
||||
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
|
||||
}
|
||||
], []);
|
||||
|
||||
return <Table
|
||||
className="v2-access-semi-table"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="vin"
|
||||
pagination={false}
|
||||
empty={null}
|
||||
onRow={(row) => row ? detailTriggerRow({
|
||||
className: selectedVIN === row.vin ? 'is-selected' : '',
|
||||
expanded: selectedVIN === row.vin,
|
||||
label: `查看 ${row.plate || row.vin} 接入详情`,
|
||||
testId: `access-row-${row.vin}`,
|
||||
onOpen: () => onSelect(row.vin)
|
||||
}) : ({})}
|
||||
/>;
|
||||
}
|
||||
|
||||
function VehicleInspector({ row, onClose, sheet = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean }) {
|
||||
return <Card className={`v2-access-inspector-v3${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader className="v2-access-inspector-header" title={row.plate || '未绑定车牌'} description={row.vin} actions={sheet ? undefined : <Button theme="borderless" icon={<IconClose />} onClick={onClose} aria-label="关闭车辆接入详情" />} />
|
||||
<Descriptions className="v2-access-inspector-summary" align="left" size="small" data={[
|
||||
{ key: '品牌 / 车型', value: [row.oem, row.model].filter(Boolean).join(' / ') || '未维护' },
|
||||
{ key: '真实接入来源', value: row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源' },
|
||||
{ key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护' },
|
||||
{ key: '综合状态', value: <ConnectionState row={row} /> }
|
||||
]} />
|
||||
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</CardGroup>
|
||||
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>查看车辆详情</Link></footer>
|
||||
</aside>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
|
||||
if (!total) return null;
|
||||
return <details id="access-identity-queue" className="v2-access-identity-queue-v3"><summary><strong>另有 {total.toLocaleString('zh-CN')} 条来源身份待绑定</strong><span>这些来源不计入主车辆,绑定权威 VIN 后再归档</span></summary><div>{items.slice(0, 6).map((item) => <article key={item.id}><b>{item.identifierMasked}</b><span>{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span><small>{item.recommendedAction}</small></article>)}</div></details>;
|
||||
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" keepDOM>
|
||||
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong>来源身份待绑定</strong><small>这些来源不计入主车辆,绑定权威 VIN 后再归档</small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} 条</Tag></span>}>
|
||||
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
|
||||
<header><b>{item.identifierMasked}</b><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header>
|
||||
<span>{item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span>
|
||||
<small>{item.recommendedAction}</small>
|
||||
</Card>)}</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>;
|
||||
}
|
||||
|
||||
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
|
||||
if (!draft) return null;
|
||||
return <details className="v2-access-settings"><summary>在线判定阈值 · v{config?.version ?? '—'}</summary><fieldset disabled={!editable}><label><span>全局默认</span><input type="number" value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })} /></label><label><span>长离线</span><input type="number" value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <button type="button" onClick={onSave} disabled={saving}><IconSave />{saving ? '保存中' : '保存阈值'}</button> : <small>当前账户为只读角色</small>}</fieldset></details>;
|
||||
return <Collapse className="v2-access-settings" keepDOM>
|
||||
<Collapse.Panel itemKey="access-thresholds" header={<span className="v2-access-collapse-title"><span><strong>在线判定阈值</strong><small>统一控制来源在线、长离线和协议新鲜度判定</small></span><Tag color="blue" type="light" size="small">v{config?.version ?? '—'}</Tag></span>}>
|
||||
<fieldset disabled={!editable}><label><span>全局默认</span><Input suffix="秒" type="number" value={String(draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, defaultThresholdSec: Number(value) })} /></label><label><span>长离线</span><Input suffix="秒" type="number" value={String(draft.longOfflineSec)} onChange={(value) => onChange({ ...draft, longOfflineSec: Number(value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><Input suffix="秒" type="number" value={String(draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button> : <small>当前账户为只读角色</small>}</fieldset>
|
||||
</Collapse.Panel>
|
||||
</Collapse>;
|
||||
}
|
||||
|
||||
function downloadRows(rows: AccessVehicleRow[]) {
|
||||
@@ -100,7 +175,8 @@ export default function AccessPage() {
|
||||
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(() => mobileLayout ? 20 : 50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [governanceOpen, setGovernanceOpen] = useState(false);
|
||||
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
|
||||
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||||
const vehicleScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
|
||||
@@ -110,7 +186,14 @@ export default function AccessPage() {
|
||||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), enabled: editable, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
|
||||
useEffect(() => {
|
||||
if (!mobileLayout) return;
|
||||
setLimit(20);
|
||||
setOffset(0);
|
||||
}, [mobileLayout]);
|
||||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||||
useSideSheetA11y(mobileLayout && Boolean(selected), '.v2-access-detail-sidesheet', 'v2-access-detail', '车辆接入详情', '关闭车辆接入详情');
|
||||
useSideSheetA11y(editable && governanceOpen, '.v2-access-governance-sidesheet', 'v2-access-governance', '接入治理配置', '关闭接入治理配置');
|
||||
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
|
||||
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); setFiltersCollapsed(true); };
|
||||
@@ -118,18 +201,81 @@ export default function AccessPage() {
|
||||
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
|
||||
|
||||
return <div className="v2-access-page v2-access-page-v3">
|
||||
<header className="v2-access-heading"><div><h2>车辆接入管理</h2><p>只展示车辆真实存在的来源、在线健康和待维护资料;未接业务系统前不推断应接协议</p></div><div><span>数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh />刷新</button></div></header>
|
||||
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>筛选条件</b><small>{Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button>
|
||||
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">需关注</option><option value="healthy">已接来源正常</option><option value="master_data">资料待维护</option><option value="degraded">部分来源异常</option><option value="offline">已接来源离线</option><option value="not_connected">尚无来源</option></select></label><label><span>真实协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
<PageHeader
|
||||
title="车辆接入管理"
|
||||
description="核对车辆真实存在的数据来源、在线健康和待维护资料,不对尚未接入的业务协议作推断。"
|
||||
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
|
||||
meta={<Typography.Text type="tertiary">数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</Typography.Text>}
|
||||
actions={<>{editable ? <Button theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>接入治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}<Button theme="light" icon={<IconRefresh />} onClick={() => void refresh()}>刷新</Button></>}
|
||||
/>
|
||||
<MobileFilterToggle summary={Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-access-filter-card-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}><form className="v2-access-filter-v3" onSubmit={submit}>
|
||||
<label className="is-search"><span>车辆</span><Input aria-label="车辆" prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN" /></label>
|
||||
<label><span id="access-connection-label">接入状态</span><Select aria-labelledby="access-connection-label" value={draft.connectionState} onChange={(value) => setDraft({ ...draft, connectionState: String(value) })} optionList={[{ value: '', label: '全部状态' }, { value: 'attention', label: '需关注' }, { value: 'healthy', label: '已接来源正常' }, { value: 'master_data', label: '资料待维护' }, { value: 'degraded', label: '部分来源异常' }, { value: 'offline', label: '已接来源离线' }, { value: 'not_connected', label: '尚无来源' }]} /></label>
|
||||
<label><span id="access-protocol-label">真实协议</span><Select aria-labelledby="access-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
|
||||
<label><span id="access-oem-label">车辆品牌</span><Select aria-labelledby="access-oem-label" value={draft.oem} onChange={(value) => setDraft({ ...draft, oem: String(value) })} optionList={[{ value: '', label: '全部品牌' }, ...(summary?.oems.map((item) => ({ value: item.name, label: item.name })) ?? [])]} /></label>
|
||||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button><Button className="v2-secondary-button" theme="light" htmlType="button" onClick={() => apply(EMPTY_FILTERS)}>重置</Button>
|
||||
</form></Card>
|
||||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||||
<section className="v2-access-kpis-v3">{[
|
||||
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['需关注', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['资料待维护', summary?.masterDataIncompleteVehicles ?? 0, 'incomplete', 'master_data'], ['尚无来源', summary?.neverReported ?? 0, 'never', 'not_connected']
|
||||
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em>车辆主档</em> : null}</button>)}</section>
|
||||
<Card className="v2-access-kpis-card-v3" bodyStyle={{ padding: 0 }}><section className="v2-access-kpis-v3">{[
|
||||
{ label: '主车辆', value: summary?.totalVehicles ?? 0, tone: 'all', connectionState: '', hint: '车辆主档' },
|
||||
{ label: '需关注', value: Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), tone: 'attention', connectionState: 'attention' },
|
||||
{ label: '资料待维护', value: summary?.masterDataIncompleteVehicles ?? 0, tone: 'incomplete', connectionState: 'master_data' },
|
||||
{ label: '尚无来源', value: summary?.neverReported ?? 0, tone: 'never', connectionState: 'not_connected' }
|
||||
].map((item) => {
|
||||
const value = Number(item.value).toLocaleString('zh-CN');
|
||||
return <MetricActionButton key={item.label} label={item.label} value={value} hint={item.hint} tone={item.tone} active={criteria.connectionState === item.connectionState} ariaLabel={`筛选${item.label},共 ${value} 辆`} onClick={() => apply({ ...criteria, connectionState: item.connectionState })} />;
|
||||
})}</section></Card>
|
||||
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
|
||||
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong>车辆真实接入来源</strong><span>缺席协议只表示“当前未发现”,不会被推断成应接缺失;时间为各来源最后接收时间</span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button></div></header><div className="v2-access-table-scroll-v3">{mobileLayout ? <div className="v2-access-mobile-list">{rows.map((row) => <button type="button" key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><div>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</div></button>)}</div> : <table><thead><tr><th>车辆</th><th>品牌 / 车型</th><th>真实来源</th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th>综合状态</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.actualProtocols.length} 个</b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>}{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新车辆接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆</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 aria-label="每页数量" 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>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
|
||||
{editable && unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
{editable ? <IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} /> : null}
|
||||
{editable && thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
{editable ? <ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /> : null}
|
||||
<div className={`v2-access-workspace-v3 ${selected && !mobileLayout ? 'is-inspector-open' : ''}`}>
|
||||
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆真实接入来源"
|
||||
description="缺席协议只表示“当前未发现”,不会被推断成应接缺失;时间为各来源最后接收时间"
|
||||
actionsClassName="v2-access-table-actions"
|
||||
actions={<><ProtocolCoverage summary={summary} /><Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}>导出当前页</Button></>}
|
||||
/>
|
||||
<div className="v2-access-table-scroll-v3">
|
||||
{mobileLayout
|
||||
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setSelectedVIN(row.vin)}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</span><footer>查看接入详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
|
||||
{vehiclesQuery.isFetching ? <div className="v2-access-loading" role="status"><Spin size="middle" tip="正在更新车辆接入状态…" /></div> : null}
|
||||
{!vehiclesQuery.isFetching && !rows.length ? <Empty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}
|
||||
</div>
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>
|
||||
{!mobileLayout && selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}
|
||||
</div>
|
||||
<SideSheet
|
||||
className="v2-access-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selected)}
|
||||
aria-label="车辆接入详情"
|
||||
width="100%"
|
||||
title={<div className="v2-access-sheet-title"><strong>车辆接入详情</strong><span>{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}</span></div>}
|
||||
onCancel={() => setSelectedVIN('')}
|
||||
>
|
||||
{mobileLayout && selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} sheet /> : null}
|
||||
</SideSheet>
|
||||
{editable ? <SideSheet
|
||||
className="v2-access-governance-sidesheet"
|
||||
visible={governanceOpen}
|
||||
aria-label="接入治理配置"
|
||||
width={560}
|
||||
title={<div className="v2-access-sheet-title"><strong>接入治理</strong><span>集中处理待绑定来源与在线判定阈值</span></div>}
|
||||
onCancel={() => setGovernanceOpen(false)}
|
||||
footer={<Button theme="solid" onClick={() => setGovernanceOpen(false)}>完成</Button>}
|
||||
>
|
||||
{governanceOpen ? <div className="v2-access-governance">
|
||||
<Card className="v2-access-governance-summary" bodyStyle={{ padding: 0 }}>
|
||||
<div><small>待绑定来源</small><strong>{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}</strong></div>
|
||||
<div><small>阈值版本</small><strong>v{thresholdQuery.data?.version ?? '—'}</strong></div>
|
||||
<div><small>主车辆口径</small><strong>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div>
|
||||
</Card>
|
||||
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
|
||||
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
|
||||
</div> : null}
|
||||
</SideSheet> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { AlertEvent, Page } from '../../api/types';
|
||||
import type { AlertEvent, AlertRule, Page } from '../../api/types';
|
||||
import AlertsPage from './AlertsPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
@@ -11,11 +11,14 @@ const mocks = vi.hoisted(() => ({
|
||||
alertRulesV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(),
|
||||
saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn()
|
||||
}));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
layout.mobile = false;
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
@@ -27,6 +30,17 @@ function alertEvent(id: string, vin: string, plate: string): AlertEvent {
|
||||
};
|
||||
}
|
||||
|
||||
function alertRule(): AlertRule {
|
||||
return {
|
||||
id: 'speed-rule', name: '测试超速规则', description: '测试规则', severity: 'major', valueType: 'numeric',
|
||||
metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 0, durationSec: 60,
|
||||
recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600,
|
||||
scopeProtocols: ['JT808'], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
|
||||
notificationChannels: ['in_app'], enabled: true, version: 3,
|
||||
createdBy: 'admin', updatedBy: 'admin', createdAt: '', updatedAt: ''
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
@@ -38,9 +52,9 @@ test('preserves an unsubmitted event filter draft across alert tab URL changes',
|
||||
|
||||
const keyword = await screen.findByPlaceholderText('车牌 / VIN / 规则名称');
|
||||
fireEvent.change(keyword, { target: { value: '保留筛选草稿' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /站内通知/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /站内通知/ }));
|
||||
expect(await screen.findByText('仅站内通道具备真实送达与已读状态')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /告警事件/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /告警事件/ }));
|
||||
|
||||
expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿');
|
||||
});
|
||||
@@ -63,9 +77,15 @@ test('loads only event dependencies on the default alert tab', async () => {
|
||||
test('loads one full notification query on a direct notifications entry', async () => {
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByText('仅站内通道具备真实送达与已读状态');
|
||||
await screen.findByText('暂无站内通知');
|
||||
expect(view.container.querySelector('.v2-alert-notifications')).toHaveClass('semi-card');
|
||||
expect(screen.getByRole('heading', { level: 5, name: '站内通知' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-notification-empty.semi-empty')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-channel-card.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-channel-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(mocks.alertRulesV2).not.toHaveBeenCalled();
|
||||
expect(mocks.metricCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
|
||||
@@ -78,7 +98,10 @@ test('ends notification mutation feedback with a visible retryable error instead
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '标为已读' }));
|
||||
const markRead = await screen.findByRole('button', { name: '标为已读' });
|
||||
expect(markRead.closest('.v2-alert-notification-card.semi-card')).toBeInTheDocument();
|
||||
expect(markRead.closest('.v2-alert-notification-list')?.querySelector('article')).not.toBeInTheDocument();
|
||||
fireEvent.click(markRead);
|
||||
|
||||
expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled();
|
||||
@@ -86,6 +109,7 @@ test('ends notification mutation feedback with a visible retryable error instead
|
||||
|
||||
test('clears old alert rows and inspector evidence when the event scope changes', async () => {
|
||||
const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌');
|
||||
oldEvent.actions = [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'unprocessed', actor: 'alert-evaluator', note: '规则首次命中', createdAt: '2026-07-16T04:00:00Z' }];
|
||||
const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌');
|
||||
let resolveNew!: (value: Page<AlertEvent>) => void;
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
@@ -96,9 +120,39 @@ test('clears old alert rows and inspector evidence when the event scope changes'
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
|
||||
for (const className of ['v2-alert-filter-card', 'v2-alert-kpis-card', 'v2-alert-table-card']) {
|
||||
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
|
||||
}
|
||||
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-workspace')).not.toHaveClass('is-inspector-open');
|
||||
expect(view.container.querySelectorAll('.v2-alert-kpis .v2-metric-action')).toHaveLength(4);
|
||||
expect(view.container.querySelectorAll('.v2-alert-secondary-states .semi-button')).toHaveLength(3);
|
||||
expect(screen.getByRole('button', { name: '查看未读通知,共 0 条' })).toHaveClass('is-notice');
|
||||
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-table-scroll > table')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-mobile-list')).not.toBeInTheDocument();
|
||||
const desktopAlertRow = screen.getByTestId('alert-row-old-event');
|
||||
expect(desktopAlertRow).toHaveAttribute('role', 'button');
|
||||
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.keyDown(desktopAlertRow, { key: 'Enter' });
|
||||
|
||||
expect(await screen.findByText('old-event')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-inspector.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-workspace')).toHaveClass('is-inspector-open');
|
||||
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('事件信息').closest('.semi-card')).toHaveClass('v2-alert-detail-card');
|
||||
expect(screen.getByText('证据对比').closest('.semi-card')).toHaveClass('v2-alert-evidence-card');
|
||||
expect(view.container.querySelector('.v2-alert-inspector-heading')).toHaveClass('v2-workspace-panel-header');
|
||||
expect(view.container.querySelectorAll('.v2-alert-descriptions.semi-descriptions')).toHaveLength(2);
|
||||
expect(screen.getByLabelText('告警处理进度')).toHaveClass('semi-timeline');
|
||||
expect(screen.getByText('规则首次命中')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
|
||||
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
|
||||
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(desktopAlertRow);
|
||||
expect(await screen.findByText('old-event')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN / 规则名称'), { target: { value: 'NEWVIN' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
@@ -106,10 +160,100 @@ test('clears old alert rows and inspector evidence when the event scope changes'
|
||||
expect(await screen.findByText('正在更新事件…')).toBeInTheDocument();
|
||||
expect(screen.queryByText('旧告警车牌')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('old-event')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('选择告警事件')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-workspace')).not.toHaveClass('is-inspector-open');
|
||||
|
||||
await act(async () => resolveNew({ items: [newEvent], total: 1, limit: 20, offset: 0 }));
|
||||
expect((await screen.findAllByText('新告警车牌')).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('new-event')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('alert-row-new-event'));
|
||||
expect(await screen.findByText('new-event')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('正在更新事件…')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('renders only selectable Semi alert cards on mobile', async () => {
|
||||
layout.mobile = true;
|
||||
const event = alertEvent('mobile-event', 'MOBILEVIN', '粤A移动01');
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
|
||||
mocks.alertEventV2.mockResolvedValue(event);
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const action = await screen.findByRole('button', { name: '查看 粤A移动01 粤A移动01速度告警 告警详情' });
|
||||
expect(action).toHaveClass('semi-button', 'v2-alert-mobile-action');
|
||||
expect(action.closest('.semi-card')).toHaveClass('v2-alert-mobile-card');
|
||||
expect(view.container.querySelector('.v2-alert-event-table')).not.toBeInTheDocument();
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(action);
|
||||
expect(action).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByRole('dialog', { name: '告警事件详情' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('mobile-event')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
|
||||
expect(screen.queryByText('mobile-event')).not.toBeInTheDocument();
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('keeps the primary alert query compact and applies advanced filters from a SideSheet', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByText('当前筛选条件没有告警事件');
|
||||
expect(view.container.querySelector('.v2-alert-filter-primary')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('协议')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /更多筛选/ }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '告警高级筛选' });
|
||||
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' }));
|
||||
fireEvent.click(await screen.findByText('JT808'));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' }));
|
||||
|
||||
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ protocol: 'JT808' }), expect.anything()));
|
||||
expect(screen.getByRole('button', { name: /更多筛选 · 1/ })).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('creates auditable drafts from simple offline and hydrogen rule templates', async () => {
|
||||
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||
mocks.metricCatalog.mockResolvedValue({
|
||||
metrics: [
|
||||
{ key: 'freshness_sec', label: '离线时长', unit: 's', category: 'quality', valueType: 'numeric', protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], sourceFields: {}, searchable: true, chartable: true, alertable: true },
|
||||
{ key: 'hydrogen_concentration_percent', label: '最高氢浓度', unit: '%', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: {}, searchable: true, chartable: true, alertable: true }
|
||||
],
|
||||
asOf: ''
|
||||
});
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ });
|
||||
expect(view.container.querySelector('.v2-alert-rule-list')).toHaveClass('semi-card');
|
||||
expect(view.container.querySelector('.v2-alert-rule-editor')).toHaveClass('semi-card');
|
||||
expect(view.container.querySelector('.v2-alert-rule-list-heading')).toHaveClass('v2-workspace-panel-header');
|
||||
expect(view.container.querySelector('.v2-alert-rule-editor-heading')).toHaveClass('v2-workspace-panel-header');
|
||||
expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item');
|
||||
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
|
||||
expect(within(ruleItem).getByText('重要').closest('.semi-tag')).toBeTruthy();
|
||||
expect(within(ruleItem).getByText('已启用').closest('.semi-tag')).toBeTruthy();
|
||||
|
||||
const offlineTemplate = await screen.findByRole('button', { name: /离线超过 10 小时/ });
|
||||
expect(offlineTemplate).toHaveClass('semi-button', 'v2-alert-template-card');
|
||||
expect(within(offlineTemplate).getByText('GB32960 / JT808 / YUTONG_MQTT').closest('.semi-tag')).toBeTruthy();
|
||||
await waitFor(() => expect(offlineTemplate).toBeEnabled());
|
||||
fireEvent.click(offlineTemplate);
|
||||
expect(screen.getByDisplayValue('车辆离线超过 10 小时')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('36000')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('GB32960,JT808,YUTONG_MQTT')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /最高氢浓度/ }));
|
||||
expect(screen.getByDisplayValue('最高氢浓度超限')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toBeRequired();
|
||||
expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toHaveValue(null);
|
||||
expect(screen.getByDisplayValue('GB32960')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { IconAlarm, IconBell, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Descriptions, Empty, Input, Radio, Select, SideSheet, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
|
||||
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MetricActionButton } from '../shared/MetricActionButton';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, canOperate } from '../auth/session';
|
||||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
type Tab = 'events' | 'rules' | 'notifications';
|
||||
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
|
||||
@@ -16,34 +26,124 @@ const EMPTY_FILTERS: Filters = { keyword: '', severity: '', status: '', ruleId:
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const NUMERIC_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between', 'outside'];
|
||||
const BOOLEAN_OPERATORS = ['eq', 'neq', 'changed'];
|
||||
type RuleTemplate = { id: string; title: string; summary: string; draft: AlertRuleInput };
|
||||
|
||||
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) { return <span className={`v2-alert-severity is-${severity}`}><i />{severityLabels[severity]}</span>; }
|
||||
function StatusTag({ status }: Pick<AlertEvent, 'status'>) { return <span className={`v2-alert-status is-${status}`}>{statusLabels[status]}</span>; }
|
||||
const RULE_TEMPLATES: RuleTemplate[] = [
|
||||
{
|
||||
id: 'offline-10h',
|
||||
title: '离线超过 10 小时',
|
||||
summary: '全部协议 · 自动恢复 · 每小时最多提醒一次',
|
||||
draft: {
|
||||
id: '', name: '车辆离线超过 10 小时', description: '车辆任一有效来源持续 10 小时未上报时告警。',
|
||||
severity: 'major', valueType: 'numeric', metric: 'freshness_sec', operator: 'gt', threshold: 36_000, thresholdHigh: 0,
|
||||
durationSec: 0, recoveryOperator: 'lte', recoveryThreshold: 300, repeatIntervalSec: 3_600,
|
||||
scopeProtocols: [...PROTOCOLS], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
|
||||
notificationChannels: ['in_app'], enabled: true, version: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hydrogen-concentration',
|
||||
title: '最高氢浓度',
|
||||
summary: 'GB32960 · 阈值由车型或厂家标准确定',
|
||||
draft: {
|
||||
id: '', name: '最高氢浓度超限', description: '监控 GB32960 燃料电池系统最高氢浓度;保存前须按车型、厂家和安全规范确认百分比阈值。',
|
||||
severity: 'critical', valueType: 'numeric', metric: 'hydrogen_concentration_percent', operator: 'gt', threshold: Number.NaN, thresholdHigh: 0,
|
||||
durationSec: 0, recoveryOperator: '', recoveryThreshold: 0, repeatIntervalSec: 600,
|
||||
scopeProtocols: ['GB32960'], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
|
||||
notificationChannels: ['in_app'], enabled: true, version: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const AlertRows = memo(function AlertRows({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
|
||||
return <>{rows.map((event) => <tr key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => onSelect(event.id)}>
|
||||
<td><input type="radio" name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} /></td>
|
||||
<td><SeverityTag severity={event.severity} /></td><td><strong>{event.plate || '—'}</strong><small>{event.vin}</small></td><td>{event.ruleName}</td><td>{event.protocol || '—'}</td>
|
||||
<td>{formatAlertTime(event.triggeredAt)}</td><td>{formatAlertTime(event.recoveredAt)}</td><td><StatusTag status={event.status} /></td><td>{alertValue(event)}</td><td>{thresholdText(event)}</td><td title={event.location}>{event.location || '—'}</td><td>{event.handler || '—'}</td>
|
||||
</tr>)}</>;
|
||||
});
|
||||
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) {
|
||||
const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
|
||||
return <Tag className={`v2-alert-severity is-${severity}`} color={color} type="light" size="small"><i />{severityLabels[severity]}</Tag>;
|
||||
}
|
||||
function StatusTag({ status }: Pick<AlertEvent, 'status'>) {
|
||||
const color = status === 'unprocessed' ? 'red' : status === 'processing' ? 'blue' : status === 'recovered' ? 'green' : 'grey';
|
||||
return <Tag className={`v2-alert-status is-${status}`} color={color} type="light" size="small">{statusLabels[status]}</Tag>;
|
||||
}
|
||||
|
||||
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void }) {
|
||||
if (!event) return <aside className="v2-alert-inspector"><div className="v2-alert-side-empty"><IconAlarm /><strong>选择告警事件</strong><span>查看触发证据、状态时间线和处置动作。</span></div></aside>;
|
||||
return <aside className="v2-alert-inspector"><header><div><strong>{event.ruleName}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></div></header>
|
||||
<section><h3>事件信息</h3><dl><div><dt>事件 ID</dt><dd>{event.id}</dd></div><div><dt>规则 / 版本</dt><dd>{event.ruleId} / v{event.ruleVersion}</dd></div><div><dt>车辆 / VIN</dt><dd>{event.plate || '—'} / {event.vin}</dd></div><div><dt>触发时间</dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt>恢复时间</dt><dd>{formatAlertTime(event.recoveredAt)}</dd></div></dl></section>
|
||||
<section><h3>证据对比</h3><div className="v2-alert-evidence"><div><small>触发值</small><strong>{alertValue(event)}</strong></div><b>VS</b><div><small>阈值条件</small><strong>{thresholdText(event)}</strong></div></div><dl><div><dt>来源事件 ID</dt><dd>{event.sourceEventId || '—'}</dd></div><div><dt>协议</dt><dd>{event.protocol || '—'}</dd></div><div><dt>事件 / 接收</dt><dd>{formatAlertTime(event.eventAt)} / {formatAlertTime(event.receivedAt)}</dd></div></dl></section>
|
||||
<section><h3>处理进度</h3><div className="v2-alert-timeline">{event.actions?.map((item) => <article key={item.id}><i /><div><strong>{actionLabels[item.action] ?? item.action}</strong><span>{item.actor} · {formatAlertTime(item.createdAt)}</span>{item.note ? <p>{item.note}</p> : null}</div></article>)}</div></section>
|
||||
<section><h3>处置与备注</h3>{editable ? <><textarea maxLength={200} placeholder="请输入处置说明(选填)" value={note} onChange={(e) => onNote(e.target.value)} /><small className="v2-alert-note-count">{note.length}/200</small>{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><button className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}>确认告警</button><button disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}>关闭</button><button disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}>忽略</button></div></> : <p className="v2-role-notice">当前为只读角色,可查看完整证据与处置记录。</p>}</section>
|
||||
function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '', dataIndex: 'selection', width: 42,
|
||||
render: (_: unknown, event: AlertEvent) => <Radio name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} />
|
||||
},
|
||||
{ title: '严重程度', dataIndex: 'severity', width: 90, render: (_: AlertEvent['severity'], event: AlertEvent) => <SeverityTag severity={event.severity} /> },
|
||||
{
|
||||
title: '车牌 / VIN', dataIndex: 'plate', width: 142,
|
||||
render: (_: string, event: AlertEvent) => <div className="v2-alert-event-vehicle"><strong>{event.plate || '未绑定车牌'}</strong><small>{event.vin}</small></div>
|
||||
},
|
||||
{ title: '规则', dataIndex: 'ruleName', width: 170, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
|
||||
{ title: '协议', dataIndex: 'protocol', width: 92, render: (value: string) => value || '—' },
|
||||
{ title: '触发时间', dataIndex: 'triggeredAt', width: 132, render: (value: string) => formatAlertTime(value) },
|
||||
{ title: '恢复时间', dataIndex: 'recoveredAt', width: 132, render: (value: string) => formatAlertTime(value) },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (_: AlertEvent['status'], event: AlertEvent) => <StatusTag status={event.status} /> },
|
||||
{ title: '触发值', dataIndex: 'triggerValue', width: 105, render: (_: unknown, event: AlertEvent) => alertValue(event) },
|
||||
{ title: '阈值', dataIndex: 'threshold', width: 125, render: (_: unknown, event: AlertEvent) => thresholdText(event) },
|
||||
{ title: '位置', dataIndex: 'location', width: 180, render: (value: string) => <span className="v2-alert-event-location" title={value}>{value || '—'}</span> },
|
||||
{ title: '处理人', dataIndex: 'handler', width: 110, render: (value: string) => value || '—' }
|
||||
], [onSelect, selectedID]);
|
||||
|
||||
return <Table
|
||||
className="v2-alert-event-table"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
empty={null}
|
||||
onRow={(event) => event ? detailTriggerRow({
|
||||
className: selectedID === event.id ? 'is-selected' : '',
|
||||
expanded: selectedID === event.id,
|
||||
label: `查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`,
|
||||
testId: `alert-row-${event.id}`,
|
||||
onOpen: () => onSelect(event.id)
|
||||
}) : ({})}
|
||||
/>;
|
||||
}
|
||||
|
||||
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction, onClose, sheet = false }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void; onClose: () => void; sheet?: boolean }) {
|
||||
if (!event) return <Card className="v2-alert-inspector" bodyStyle={{ padding: 0 }}><Empty className="v2-alert-inspector-empty" image={<IconAlarm />} title="选择告警事件" description="查看触发证据、状态时间线和处置动作。" /></Card>;
|
||||
return <Card className={`v2-alert-inspector${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader className="v2-alert-inspector-heading" title={event.ruleName} actions={<><span className="v2-alert-inspector-status"><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span>{sheet ? null : <Button className="v2-alert-inspector-close" theme="borderless" icon={<IconClose />} aria-label="关闭告警详情" onClick={onClose} />}</>} />
|
||||
<div className="v2-alert-inspector-content">
|
||||
<Card className="v2-alert-detail-card" title={<strong>事件信息</strong>} headerLine bodyStyle={{ padding: 0 }}>
|
||||
<Descriptions className="v2-alert-descriptions" align="left" size="small" data={[
|
||||
{ key: '事件 ID', value: <code>{event.id}</code> },
|
||||
{ key: '规则 / 版本', value: `${event.ruleId} / v${event.ruleVersion}` },
|
||||
{ key: '车辆 / VIN', value: `${event.plate || '—'} / ${event.vin}` },
|
||||
{ key: '触发时间', value: formatAlertTime(event.triggeredAt) },
|
||||
{ key: '恢复时间', value: formatAlertTime(event.recoveredAt) }
|
||||
]} />
|
||||
</Card>
|
||||
<Card className="v2-alert-detail-card v2-alert-evidence-card" title={<strong>证据对比</strong>} headerLine bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-alert-evidence"><Card className="v2-alert-evidence-value is-trigger"><small>触发值</small><strong>{alertValue(event)}</strong></Card><Tag color="grey" type="light" size="small">VS</Tag><Card className="v2-alert-evidence-value"><small>阈值条件</small><strong>{thresholdText(event)}</strong></Card></div>
|
||||
<Descriptions className="v2-alert-descriptions" align="left" size="small" data={[
|
||||
{ key: '来源事件 ID', value: event.sourceEventId || '—' },
|
||||
{ key: '协议', value: <Tag color="blue" type="light" size="small">{event.protocol || '—'}</Tag> },
|
||||
{ key: '事件 / 接收', value: `${formatAlertTime(event.eventAt)} / ${formatAlertTime(event.receivedAt)}` }
|
||||
]} />
|
||||
</Card>
|
||||
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<strong>处理进度</strong>} headerLine>
|
||||
{event.actions?.length ? <Timeline className="v2-alert-timeline" aria-label="告警处理进度">{event.actions.map((item, index) => <Timeline.Item key={item.id} type={index === event.actions!.length - 1 ? 'ongoing' : 'default'} time={<span>{item.actor} · {formatAlertTime(item.createdAt)}</span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}</Timeline> : <Empty className="v2-alert-timeline-empty" title="暂无处理记录" description="事件被确认、关闭或忽略后,将在这里形成审计履历。" />}
|
||||
</Card>
|
||||
<Card className="v2-alert-detail-card v2-alert-disposition-card" title={<strong>处置与备注</strong>} headerLine>
|
||||
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 5 }} placeholder="请输入处置说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><Button theme="solid" className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}>确认告警</Button><Button theme="light" disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}>关闭</Button><Button theme="borderless" disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}>忽略</Button></div></> : <p className="v2-role-notice">当前为只读角色,可查看完整证据与处置记录。</p>}
|
||||
</Card>
|
||||
</div>
|
||||
<nav className="v2-alert-links"><Link to={`/vehicles/${encodeURIComponent(event.vin)}`}>查看车辆</Link><Link to={`/tracks?vin=${encodeURIComponent(event.vin)}`}>查看轨迹</Link><Link to={`/history?vin=${encodeURIComponent(event.vin)}`}>历史数据</Link></nav>
|
||||
</aside>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selection, setSelection] = useState<{ scope: string; id: string }>(); const [note, setNote] = useState(''); const queryClient = useQueryClient();
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [selection, setSelection] = useState<{ scope: string; id: string }>();
|
||||
const [note, setNote] = useState('');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches);
|
||||
useEffect(() => { const media = window.matchMedia('(max-width: 680px)'); const update = () => setMobileLayout(media.matches); media.addEventListener('change', update); update(); return () => media.removeEventListener('change', update); }, []);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
|
||||
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
|
||||
const eventScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
|
||||
@@ -51,18 +151,104 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
|
||||
const events = useQuery<Page<AlertEvent>>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope<Page<AlertEvent>>(eventScope), staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const rows = events.data?.items ?? [];
|
||||
const selectedID = selection?.scope === eventScope ? selection.id : '';
|
||||
useEffect(() => { if (!mobileLayout && rows.length && !rows.some((item) => item.id === selectedID)) setSelection({ scope: eventScope, id: rows[0].id }); }, [eventScope, mobileLayout, rows, selectedID]);
|
||||
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); setFiltersCollapsed(true); };
|
||||
const closeDetail = () => { setSelection(undefined); setNote(''); };
|
||||
const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID);
|
||||
useSideSheetA11y(advancedOpen, '.v2-alert-filter-sidesheet', 'v2-alert-advanced-filters', '告警高级筛选', '关闭告警高级筛选');
|
||||
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-alert-detail-sidesheet', 'v2-alert-detail', '告警事件详情', '关闭告警详情');
|
||||
const applyDraft = () => {
|
||||
setFilters(draft);
|
||||
setOffset(0);
|
||||
setFiltersCollapsed(true);
|
||||
};
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); applyDraft(); };
|
||||
const resetFilters = () => {
|
||||
setDraft(EMPTY_FILTERS);
|
||||
setFilters(EMPTY_FILTERS);
|
||||
setOffset(0);
|
||||
setAdvancedOpen(false);
|
||||
};
|
||||
const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); };
|
||||
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const sums = summary.data;
|
||||
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit));
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const sums = summary.data;
|
||||
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||
return <><button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>筛选条件</b><small>{activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部告警'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button><form className={`v2-alert-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label><span>关键词</span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span>严重程度</span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value="">全部</option><option value="critical">紧急</option><option value="major">重要</option><option value="minor">一般</option></select></label><label><span>状态</span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value="">全部</option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span>规则</span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value="">全部</option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span>协议</span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value="">全部</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>起始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button">查询</button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}>重置</button></form>
|
||||
const advancedFilterCount = [filters.ruleId, filters.protocol, filters.dateFrom, filters.dateTo].filter(Boolean).length;
|
||||
const inspector = <EventInspector event={selectedEvent} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} />;
|
||||
return <>
|
||||
<MobileFilterToggle summary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部告警'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-alert-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<form className="v2-alert-filter v2-alert-filter-primary" onSubmit={submit}>
|
||||
<label><span>关键词</span><Input prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN / 规则名称" /></label>
|
||||
<label><span id="alert-severity-label">严重程度</span><Select aria-labelledby="alert-severity-label" value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) })} optionList={[{ value: '', label: '全部' }, { value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
|
||||
<label><span id="alert-status-label">状态</span><Select aria-labelledby="alert-status-label" value={draft.status} onChange={(value) => setDraft({ ...draft, status: String(value) })} optionList={[{ value: '', label: '全部' }, ...Object.entries(statusLabels).map(([value, label]) => ({ value, label }))]} /></label>
|
||||
<Button className="v2-alert-more-filter" theme="light" htmlType="button" icon={<IconFilter />} aria-haspopup="dialog" aria-controls="v2-alert-advanced-filters" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen(true)}>更多筛选{advancedFilterCount ? ` · ${advancedFilterCount}` : ''}</Button>
|
||||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button>
|
||||
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}>重置</Button>
|
||||
</form>
|
||||
</Card>
|
||||
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
|
||||
<section className="v2-alert-kpis">{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
|
||||
<Card className="v2-alert-kpis-card" bodyStyle={{ padding: 0 }}>
|
||||
<section className="v2-alert-kpis">{[
|
||||
{ label: '活跃告警', value: sums?.active, tone: 'active', status: '' },
|
||||
{ label: '未处理', value: sums?.unprocessed, tone: 'unprocessed', status: 'unprocessed' },
|
||||
{ label: '处理中', value: sums?.processing, tone: 'processing', status: 'processing' },
|
||||
{ label: '未读通知', value: unread, tone: 'notice', status: 'notice' }
|
||||
].map((item) => {
|
||||
const value = Number(item.value ?? 0).toLocaleString('zh-CN');
|
||||
return <MetricActionButton key={item.label} label={item.label} value={value} tone={item.tone} active={item.status !== 'notice' && filters.status === item.status} ariaLabel={item.status === 'notice' ? `查看未读通知,共 ${value} 条` : `筛选${item.label},共 ${value} 条`} onClick={() => item.status === 'notice' ? onTab('notifications') : quickStatus(item.status)} />;
|
||||
})}</section>
|
||||
<nav className="v2-alert-secondary-states" aria-label="已结束告警状态">{
|
||||
[
|
||||
{ label: '已恢复', value: sums?.recovered, status: 'recovered' },
|
||||
{ label: '已关闭', value: sums?.closed, status: 'closed' },
|
||||
{ label: '已忽略', value: sums?.ignored, status: 'ignored' }
|
||||
].map((item) => <Button key={item.status} theme="borderless" type="tertiary" aria-pressed={filters.status === item.status} onClick={() => quickStatus(item.status)}><span>{item.label}</span><b>{Number(item.value ?? 0).toLocaleString('zh-CN')}</b></Button>)
|
||||
}</nav>
|
||||
</Card>
|
||||
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
|
||||
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong>告警事件</strong><div><span>共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}><IconRefresh />刷新</button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th>严重程度</th><th>车牌 / VIN</th><th>规则</th><th>协议</th><th>触发时间</th><th>恢复时间</th><th>状态</th><th>触发值</th><th>阈值</th><th>位置</th><th>处理人</th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} /></tbody></table><div className="v2-alert-mobile-list">{rows.map((event) => <button type="button" key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => setSelection({ scope: eventScope, id: event.id })}><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt>触发时间</dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt>数据来源</dt><dd>{event.protocol || '—'}</dd></div><div><dt>触发值</dt><dd>{alertValue(event)}</dd></div><div><dt>阈值</dt><dd>{thresholdText(event)}</dd></div></dl></button>)}</div>{events.isFetching ? <div className="v2-alert-loading"><i />正在更新事件…</div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty">当前筛选条件没有告警事件</div> : null}</div><footer><span>第 {page} / {totalPages} 页</span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
|
||||
<div className={`v2-alert-workspace${selectedID && !mobileLayout ? ' is-inspector-open' : ''}`}>
|
||||
<Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader title="告警事件" meta={`共 ${(events.data?.total ?? 0).toLocaleString('zh-CN')} 条`} actions={<Button theme="borderless" icon={<IconRefresh />} onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}>刷新</Button>} />
|
||||
<div className="v2-alert-table-scroll">
|
||||
{mobileLayout
|
||||
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`} onClick={() => setSelection({ scope: eventScope, id: event.id })}><span className="v2-alert-mobile-card-content"><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt>触发时间</dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt>数据来源</dt><dd>{event.protocol || '—'}</dd></div><div><dt>触发值</dt><dd>{alertValue(event)}</dd></div><div><dt>阈值</dt><dd>{thresholdText(event)}</dd></div></dl><footer>查看处置详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <AlertEventTable rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} />}
|
||||
{events.isFetching ? <div className="v2-alert-loading" role="status"><Spin size="middle" tip="正在更新事件…" /></div> : null}
|
||||
{!events.isFetching && !rows.length ? <Empty className="v2-alert-empty" title="当前筛选条件没有告警事件" description="调整车辆、严重程度、状态或时间范围后重试。" /> : null}
|
||||
</div>
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(events.data?.total ?? 0).toLocaleString('zh-CN')} 条`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
|
||||
</Card>
|
||||
{!mobileLayout && selectedID ? inspector : null}
|
||||
</div>
|
||||
<SideSheet
|
||||
className="v2-alert-filter-sidesheet"
|
||||
visible={advancedOpen}
|
||||
aria-label="告警高级筛选"
|
||||
width={430}
|
||||
title={<div className="v2-alert-sheet-title"><strong>更多筛选</strong><span>按规则、协议和触发时间收窄告警范围</span></div>}
|
||||
onCancel={() => setAdvancedOpen(false)}
|
||||
footer={<div className="v2-alert-sheet-footer"><Button theme="light" onClick={() => setDraft({ ...draft, ruleId: '', protocol: '', dateFrom: '', dateTo: '' })}>清空高级条件</Button><Button theme="solid" onClick={() => { applyDraft(); setAdvancedOpen(false); }}>应用筛选</Button></div>}
|
||||
>
|
||||
<div className="v2-alert-advanced-filter">
|
||||
<label><span id="alert-rule-label">规则</span><Select aria-labelledby="alert-rule-label" value={draft.ruleId} onChange={(value) => setDraft({ ...draft, ruleId: String(value) })} optionList={[{ value: '', label: '全部规则' }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} /></label>
|
||||
<label><span id="alert-protocol-label">协议</span><Select aria-labelledby="alert-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
|
||||
<label><span>起始时间</span><Input aria-label="告警起始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft({ ...draft, dateFrom: value })} /></label>
|
||||
<label><span>结束时间</span><Input aria-label="告警结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft({ ...draft, dateTo: value })} /></label>
|
||||
</div>
|
||||
</SideSheet>
|
||||
<SideSheet
|
||||
className="v2-alert-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selectedID)}
|
||||
aria-label="告警事件详情"
|
||||
width="100%"
|
||||
title={<div className="v2-alert-sheet-title"><strong>告警详情</strong><span>{selectedEvent ? `${selectedEvent.plate || selectedEvent.vin} · ${selectedEvent.ruleName}` : '证据、状态与处置履历'}</span></div>}
|
||||
onCancel={closeDetail}
|
||||
>
|
||||
{mobileLayout && selectedID ? <EventInspector event={selectedEvent} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} sheet /> : null}
|
||||
</SideSheet>
|
||||
</>;
|
||||
}
|
||||
|
||||
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
|
||||
@@ -88,31 +274,86 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
|
||||
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
|
||||
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
|
||||
const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) });
|
||||
const applyTemplate = (template: RuleTemplate) => {
|
||||
setSelectedID('__new__');
|
||||
setDraft({ ...template.draft, scopeProtocols: [...template.draft.scopeProtocols], notificationChannels: [...template.draft.notificationChannels] });
|
||||
};
|
||||
const severityColor = (severity: AlertRule['severity']) => severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
|
||||
return <div className="v2-alert-rules">
|
||||
<section className="v2-alert-rule-list"><header><strong>规则配置</strong><button onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>+ 新建规则</button></header>{rules.map((rule) => <button className={selectedID === rule.id ? 'is-selected' : ''} key={rule.id} onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}><i className={`is-${rule.severity}`} /><span><strong>{rule.name}</strong><small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small></span><em className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '已启用' : '已停用'}</em></button>)}</section>
|
||||
<form className="v2-alert-rule-editor" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
|
||||
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span>数值、状态与主数据范围均纳入版本审计</span></div>{draft.version ? <button type="button" disabled={toggle.isPending || save.isPending} onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
|
||||
<div className="v2-rule-form-grid">
|
||||
<label><span>规则名称</span><input required maxLength={80} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
|
||||
<label><span>严重程度</span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value as AlertRuleInput['severity'] })}><option value="critical">紧急</option><option value="major">重要</option><option value="minor">一般</option></select></label>
|
||||
<label><span>值类型</span><select value={draft.valueType} onChange={(e) => { const valueType = e.target.value as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }}><option value="numeric">数值</option><option value="boolean">布尔</option></select></label>
|
||||
<label><span>指标</span><select required disabled={!availableMetrics.length} value={draft.metric} onChange={(e) => setDraft({ ...draft, metric: e.target.value })}>{availableMetrics.map((metric) => <option key={metric.key} value={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</option>)}</select></label>
|
||||
<label><span>触发比较符</span><select value={draft.operator} onChange={(e) => setDraft({ ...draft, operator: e.target.value, durationSec: e.target.value === 'changed' ? 0 : draft.durationSec })}>{operators.map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
|
||||
{draft.operator === 'changed' ? <label><span>变化语义</span><input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span>目标值</span><select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(e) => setDraft({ ...draft, booleanThreshold: e.target.value === 'true' })}><option value="true">是</option><option value="false">否</option></select></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><input type="number" step="0.1" value={draft.threshold} onChange={(e) => setDraft({ ...draft, threshold: Number(e.target.value) })} /></label>}
|
||||
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span>区间上限</span><input type="number" step="0.1" value={draft.thresholdHigh} onChange={(e) => setDraft({ ...draft, thresholdHigh: Number(e.target.value) })} /></label> : null}
|
||||
<label><span>持续时间(秒)</span><input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={draft.durationSec} onChange={(e) => setDraft({ ...draft, durationSec: Number(e.target.value) })} /></label>
|
||||
<label><span>恢复比较符</span><select value={draft.recoveryOperator} onChange={(e) => setDraft({ ...draft, recoveryOperator: e.target.value })}><option value="">未配置</option>{['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
|
||||
<label><span>恢复阈值</span><input type="number" step="0.1" value={draft.recoveryThreshold} onChange={(e) => setDraft({ ...draft, recoveryThreshold: Number(e.target.value) })} /></label>
|
||||
<label><span>重复间隔(秒)</span><input type="number" min="0" max="604800" value={draft.repeatIntervalSec} onChange={(e) => setDraft({ ...draft, repeatIntervalSec: Number(e.target.value) })} /></label>
|
||||
<label className="is-wide"><span>协议范围(逗号分隔;空为全部)</span><input value={draft.scopeProtocols.join(',')} onChange={(e) => setList('scopeProtocols', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>车辆 VIN 范围(逗号分隔;空为全部)</span><input value={draft.scopeVins.join(',')} onChange={(e) => setList('scopeVins', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>厂家范围(逗号分隔;空为全部)</span><input value={draft.scopeOems.join(',')} onChange={(e) => setList('scopeOems', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>车型范围(来自车辆主档;空为全部)</span><input value={draft.scopeModels.join(',')} onChange={(e) => setList('scopeModels', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>企业范围(来自车辆主档;空为全部)</span><input value={draft.scopeCompanies.join(',')} onChange={(e) => setList('scopeCompanies', e.target.value)} /></label>
|
||||
<label className="is-wide"><span>说明</span><textarea maxLength={500} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /></label>
|
||||
<Card className="v2-alert-rule-list" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-alert-rule-list-heading"
|
||||
title="规则配置"
|
||||
description={rules.length ? `${rules.length} 条可审计规则` : '尚未创建规则'}
|
||||
actions={<Button theme="light" type="primary" icon={<IconPlus />} onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>新建规则</Button>}
|
||||
/>
|
||||
<div className="v2-alert-rule-items">
|
||||
{rules.map((rule) => <Button
|
||||
className={`v2-alert-rule-item${selectedID === rule.id ? ' is-selected' : ''}`}
|
||||
key={rule.id}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
aria-pressed={selectedID === rule.id}
|
||||
onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}
|
||||
>
|
||||
<span className={`v2-alert-rule-dot is-${rule.severity}`} aria-hidden="true" />
|
||||
<span className="v2-alert-rule-copy">
|
||||
<span><strong>{rule.name}</strong><Tag size="small" color={severityColor(rule.severity)} type="light">{severityLabels[rule.severity]}</Tag></span>
|
||||
<small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small>
|
||||
</span>
|
||||
<Tag className="v2-alert-rule-state" size="small" color={rule.enabled ? 'green' : 'grey'} type="light">{rule.enabled ? '已启用' : '已停用'}</Tag>
|
||||
</Button>)}
|
||||
{!rules.length ? <Empty className="v2-alert-rule-empty" title="暂无告警规则" description="从右侧模板快速创建,或新建一条自定义规则。" /> : null}
|
||||
</div>
|
||||
<footer><div><b>通知通道</b><span>站内通知已启用;短信、邮件、企微为预留 / 未启用。</span></div>{mutationError ? <em>{mutationError.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
|
||||
</form>
|
||||
</Card>
|
||||
<Card className="v2-alert-rule-editor" bodyStyle={{ padding: 0 }}>
|
||||
<form className="v2-alert-rule-editor-form" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-alert-rule-editor-heading"
|
||||
title={draft.version ? '编辑规则' : '新建规则'}
|
||||
description="数值、状态与主数据范围均纳入版本审计"
|
||||
actions={draft.version ? <Button htmlType="button" theme="light" disabled={toggle.isPending || save.isPending} onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</Button> : null}
|
||||
/>
|
||||
<section className="v2-alert-rule-templates" aria-label="告警规则模板">
|
||||
<div><strong>快速创建</strong><span>从常用业务场景开始,再按车辆、车型或企业收窄范围。</span></div>
|
||||
<nav>{RULE_TEMPLATES.map((template) => {
|
||||
const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable);
|
||||
return <Button
|
||||
className="v2-alert-template-card"
|
||||
key={template.id}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
disabled={!available}
|
||||
onClick={() => applyTemplate(template)}
|
||||
>
|
||||
<span className="v2-alert-template-icon"><IconAlarm /></span>
|
||||
<span className="v2-alert-template-copy"><b>{template.title}</b><small>{available ? template.summary : '当前字段目录尚未启用此指标'}</small></span>
|
||||
<Tag size="small" color={available ? 'blue' : 'grey'} type="light">{available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'}</Tag>
|
||||
</Button>;
|
||||
})}</nav>
|
||||
</section>
|
||||
<div className="v2-rule-form-grid">
|
||||
<label><span>规则名称</span><Input required maxLength={80} value={draft.name} onChange={(value) => setDraft({ ...draft, name: value })} /></label>
|
||||
<label><span>严重程度</span><Select value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
|
||||
<label><span>值类型</span><Select value={draft.valueType} onChange={(value) => { const valueType = String(value) as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }} optionList={[{ value: 'numeric', label: '数值' }, { value: 'boolean', label: '布尔' }]} /></label>
|
||||
<label><span>指标</span><Select disabled={!availableMetrics.length} value={draft.metric} onChange={(value) => setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /></label>
|
||||
<label><span>触发比较符</span><Select value={draft.operator} onChange={(value) => { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /></label>
|
||||
{draft.operator === 'changed' ? <label><span>变化语义</span><Input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span>目标值</span><Select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(value) => setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><Input required type="number" step="0.01" placeholder={draft.metric === 'hydrogen_concentration_percent' ? '请按厂家标准填写百分比' : undefined} value={Number.isFinite(draft.threshold) ? String(draft.threshold) : ''} onChange={(value) => setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} /></label>}
|
||||
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span>区间上限</span><Input type="number" step="0.1" value={String(draft.thresholdHigh)} onChange={(value) => setDraft({ ...draft, thresholdHigh: Number(value) })} /></label> : null}
|
||||
<label><span>持续时间(秒)</span><Input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={String(draft.durationSec)} onChange={(value) => setDraft({ ...draft, durationSec: Number(value) })} /></label>
|
||||
<label><span>恢复比较符</span><Select value={draft.recoveryOperator} onChange={(value) => setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /></label>
|
||||
<label><span>恢复阈值</span><Input type="number" step="0.1" value={String(draft.recoveryThreshold)} onChange={(value) => setDraft({ ...draft, recoveryThreshold: Number(value) })} /></label>
|
||||
<label><span>重复间隔(秒)</span><Input type="number" min="0" max="604800" value={String(draft.repeatIntervalSec)} onChange={(value) => setDraft({ ...draft, repeatIntervalSec: Number(value) })} /></label>
|
||||
<label className="is-wide"><span>协议范围(逗号分隔;空为全部)</span><Input value={draft.scopeProtocols.join(',')} onChange={(value) => setList('scopeProtocols', value)} /></label>
|
||||
<label className="is-wide"><span>车辆 VIN 范围(逗号分隔;空为全部)</span><Input value={draft.scopeVins.join(',')} onChange={(value) => setList('scopeVins', value)} /></label>
|
||||
<label className="is-wide"><span>厂家范围(逗号分隔;空为全部)</span><Input value={draft.scopeOems.join(',')} onChange={(value) => setList('scopeOems', value)} /></label>
|
||||
<label className="is-wide"><span>车型范围(来自车辆主档;空为全部)</span><Input value={draft.scopeModels.join(',')} onChange={(value) => setList('scopeModels', value)} /></label>
|
||||
<label className="is-wide"><span>企业范围(来自车辆主档;空为全部)</span><Input value={draft.scopeCompanies.join(',')} onChange={(value) => setList('scopeCompanies', value)} /></label>
|
||||
<label className="is-wide"><span>说明</span><TextArea maxCount={500} autosize={{ minRows: 3, maxRows: 7 }} value={draft.description} onChange={(value) => setDraft({ ...draft, description: value })} /></label>
|
||||
</div>
|
||||
<footer><div><b>通知通道</b><span>站内通知已启用;短信、邮件、企微为预留 / 未启用。</span></div>{mutationError ? <em>{mutationError.message}</em> : null}<Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</Button></footer>
|
||||
</form>
|
||||
</Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -120,13 +361,25 @@ type NotificationsQuery = {
|
||||
data?: Page<AlertNotification>;
|
||||
error: Error | null;
|
||||
isError: boolean;
|
||||
isPending: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
|
||||
function NotificationsWorkspace({ editable, notifications }: { editable: boolean; notifications: NotificationsQuery }) {
|
||||
const queryClient = useQueryClient();
|
||||
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
|
||||
return <div className="v2-alert-notifications"><header><div><strong>站内通知</strong><span>仅站内通道具备真实送达与已读状态</span></div>{editable ? <button disabled={read.isPending || !notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>{read.isPending ? '正在更新' : '全部标为已读'}</button> : <span className="v2-role-badge">只读</span>}</header>{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</button> : null}</article>)}</div><footer><b>外部通知通道</b><span>短信(SMS)— 预留 / 未启用</span><span>邮件(Email)— 预留 / 未启用</span><span>企业微信(WeCom)— 预留 / 未启用</span></footer></div>;
|
||||
const items = notifications.data?.items ?? [];
|
||||
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="站内通知" description="仅站内通道具备真实送达与已读状态" meta={`${items.length.toLocaleString('zh-CN')} 条`} actions={editable ? <Button theme="light" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : '全部标为已读'}</Button> : <span className="v2-role-badge">只读</span>} />{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
|
||||
<div className="v2-alert-notification-list">{notifications.isPending ? <div className="v2-alert-notification-loading"><Spin size="middle" tip="正在读取站内通知" /></div> : items.length ? <CardGroup className="v2-alert-notification-cards" type="grid" spacing={0}>{items.map((item) => <Card className={`v2-alert-notification-card${item.read ? ' is-read' : ' is-unread'}`} key={item.id} title={<span className="v2-alert-notification-title"><SeverityTag severity={item.severity} /><span title={item.title}>{item.title}</span></span>} headerExtraContent={<Tag color={item.read ? 'grey' : 'orange'} type="light" size="small">{item.read ? '已读' : '未读'}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
|
||||
<p className="v2-alert-notification-content" title={item.content}>{item.content}</p>
|
||||
<footer><Typography.Text type="tertiary">{formatAlertTime(item.createdAt)}</Typography.Text>{editable && !item.read ? <Button theme="borderless" disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</Button> : null}</footer>
|
||||
</Card>)}</CardGroup> : !notifications.isError ? <Empty className="v2-alert-notification-empty" image={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>
|
||||
<Card className="v2-alert-channel-card" title={<strong>外部通知通道</strong>} headerLine bodyStyle={{ padding: 0 }}><Descriptions className="v2-alert-channel-descriptions" align="left" size="small" data={[
|
||||
{ key: '短信(SMS)', value: <Tag color="grey" type="light" size="small">预留 · 未启用</Tag> },
|
||||
{ key: '邮件(Email)', value: <Tag color="grey" type="light" size="small">预留 · 未启用</Tag> },
|
||||
{ key: '企业微信(WeCom)', value: <Tag color="grey" type="light" size="small">预留 · 未启用</Tag> }
|
||||
]} /></Card>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
@@ -144,5 +397,6 @@ export default function AlertsPage() {
|
||||
: notices.data?.total ?? 0;
|
||||
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
|
||||
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
|
||||
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2>告警中心</h2><p>统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。</p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm />告警事件</button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}>规则配置</button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />站内通知{unread > 0 ? <b>{unread}</b> : null}</button></nav>{activeTab !== 'notifications' && rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={unread} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} notifications={notifications} />}</div>;
|
||||
const tabs = [{ key: 'events' as const, label: '告警事件', icon: <IconAlarm /> }, ...(admin ? [{ key: 'rules' as const, label: '规则配置' }] : []), { key: 'notifications' as const, label: '站内通知', icon: <IconBell />, count: unread || undefined }];
|
||||
return <div className="v2-alert-page"><PageHeader title="告警中心" description="统一监控车辆运行异常,串联触发证据、处理进度与通知状态。" status={unread ? `${unread} 条未读` : '通知已读'} statusColor={unread ? 'orange' : 'green'} meta={<Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text>} /><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} />{activeTab !== 'notifications' && rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={unread} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} notifications={notifications} />}</div>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
|
||||
@@ -16,13 +16,16 @@ const mocks = vi.hoisted(() => ({
|
||||
downloadHistoryExport: vi.fn()
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' }));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
auth.role = 'admin';
|
||||
layout.mobile = false;
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
@@ -83,9 +86,38 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
|
||||
? Promise.resolve(historySeries('OLDVIN', '旧车牌', 'old-as-of'))
|
||||
: new Promise<HistorySeriesResponse>((resolve) => { resolveNewSeries = resolve; }));
|
||||
|
||||
renderPage();
|
||||
const view = renderPage();
|
||||
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('OLDVIN-evidence')).not.toBeInTheDocument();
|
||||
for (const className of ['v2-history-filter-card', 'v2-history-metrics-card', 'v2-history-summary', 'v2-history-trend', 'v2-history-table-card']) {
|
||||
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
|
||||
}
|
||||
expect(view.container.querySelector('.v2-history-evidence')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-workspace')).not.toHaveClass('is-inspector-open');
|
||||
expect(screen.getByRole('heading', { level: 5, name: '聚合趋势' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-trend')).toHaveClass('is-collapsed');
|
||||
expect(screen.getByRole('button', { name: /展开趋势/ })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('heading', { level: 5, name: '导出任务' })).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-data-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-table-scroll > table')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-mobile-list')).not.toBeInTheDocument();
|
||||
const desktopHistoryRow = screen.getByTestId('history-row-OLDVIN-row');
|
||||
expect(desktopHistoryRow).toHaveAttribute('role', 'button');
|
||||
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.keyDown(desktopHistoryRow, { key: 'Enter' });
|
||||
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByText('OLDVIN-evidence')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-evidence-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-evidence-values.semi-descriptions')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-quality-tag.semi-tag')).toHaveTextContent('正常');
|
||||
expect(view.container.querySelector('.v2-history-workspace')).toHaveClass('is-inspector-open');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭数据详情' }));
|
||||
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(view.container.querySelector('.v2-history-workspace')).not.toHaveClass('is-inspector-open');
|
||||
fireEvent.click(desktopHistoryRow);
|
||||
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: /展开趋势/ }));
|
||||
await waitFor(() => expect(mocks.historySeries).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN,多台用逗号分隔'), { target: { value: 'NEWVIN' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
@@ -100,10 +132,54 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
|
||||
resolveNewSeries(historySeries('NEWVIN', '新车牌', 'new-as-of'));
|
||||
});
|
||||
expect((await screen.findAllByText('新车牌')).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('NEWVIN-evidence')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('history-row-NEWVIN-row'));
|
||||
expect(await screen.findByText('NEWVIN-evidence')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('renders only selectable Semi history cards on mobile', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
|
||||
mocks.historySeries.mockResolvedValue(historySeries('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
|
||||
mocks.historyExports.mockResolvedValue([]);
|
||||
const view = renderPage('/history?keywords=MOBILEVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00');
|
||||
|
||||
const action = await screen.findByRole('button', { name: '查看 粤A移动历史 2026-07-16 04:00:00 数据详情' });
|
||||
expect(action).toHaveClass('semi-button', 'v2-history-mobile-action');
|
||||
expect(action.closest('.semi-card')).toHaveClass('v2-history-mobile-card');
|
||||
expect(view.container.querySelector('.v2-history-data-table')).not.toBeInTheDocument();
|
||||
expect(action).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
|
||||
expect((mocks.historyData.mock.calls[0]?.[0] as URLSearchParams).get('limit')).toBe('20');
|
||||
expect(screen.queryByLabelText('每页数量')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('表格密度')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-history-side')).not.toBeInTheDocument();
|
||||
expect(mocks.historyExports).not.toHaveBeenCalled();
|
||||
fireEvent.click(action);
|
||||
expect(action).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('MOBILEVIN-evidence')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据详情' }));
|
||||
expect(action).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.style.overflow).not.toBe('hidden');
|
||||
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
|
||||
|
||||
const exportJobsButton = screen.getByRole('button', { name: '查看导出任务' });
|
||||
fireEvent.click(exportJobsButton);
|
||||
expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByText('暂无导出任务')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' }));
|
||||
expect(exportJobsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.style.overflow).not.toBe('hidden');
|
||||
expect(screen.queryByText('暂无导出任务')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('releases export job state immediately after leaving the history page', async () => {
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
@@ -111,6 +187,7 @@ test('releases export job state immediately after leaving the history page', asy
|
||||
mocks.historyExports.mockResolvedValue([{ id: 'export-running', name: '运行中导出', status: 'running', progress: 30, format: 'csv', category: 'location', keywords: ['OLDVIN'], rowCount: 0, totalRows: 100, processedRows: 30, fileSizeBytes: 0, createdAt: '2026-07-16T04:00:00Z', updatedAt: '2026-07-16T04:00:01Z', evidence: 'test export' }]);
|
||||
|
||||
const { client, unmount } = renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
|
||||
expect(await screen.findByText('运行中导出')).toBeInTheDocument();
|
||||
expect(client.getQueryCache().find({ queryKey: ['history-exports'] })).toBeDefined();
|
||||
|
||||
@@ -126,20 +203,30 @@ test('opens a working column visibility panel and preserves non-hideable identit
|
||||
mocks.historyData.mockResolvedValue(data);
|
||||
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historyExports.mockResolvedValue([]);
|
||||
renderPage();
|
||||
const view = renderPage();
|
||||
|
||||
expect(await screen.findByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '列设置' }));
|
||||
const metricScroll = view.container.querySelector('.v2-history-metric-scroll');
|
||||
const manageMetrics = screen.getByRole('button', { name: '管理字段' });
|
||||
expect(metricScroll).toBeInTheDocument();
|
||||
expect(metricScroll).toHaveAttribute('aria-label', '当前显示字段');
|
||||
expect(metricScroll?.contains(screen.getByLabelText(/已选字段 速度/))).toBe(true);
|
||||
expect(metricScroll?.contains(manageMetrics)).toBe(false);
|
||||
expect(manageMetrics.parentElement).toHaveClass('v2-history-metrics');
|
||||
const columnSettingsButton = screen.getByRole('button', { name: '列设置' });
|
||||
fireEvent.click(columnSettingsButton);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '列显示设置' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索字段名 / 字段 key'), { target: { value: 'soc' } });
|
||||
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.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();
|
||||
const historyTable = document.querySelector<HTMLElement>('.v2-history-table-scroll table');
|
||||
expect(historyTable).toBeInTheDocument();
|
||||
expect(within(historyTable!).getByRole('columnheader', { name: 'VIN' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示全部' }));
|
||||
expect(screen.getByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
|
||||
@@ -148,7 +235,8 @@ test('opens a working column visibility panel and preserves non-hideable identit
|
||||
expect(screen.getByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' }));
|
||||
expect(screen.queryByRole('dialog', { name: '列显示设置' })).not.toBeInTheDocument();
|
||||
expect(columnSettingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.style.overflow).not.toBe('hidden');
|
||||
});
|
||||
|
||||
test('keeps history readable without mounting operator-only export requests for a viewer', async () => {
|
||||
@@ -156,10 +244,11 @@ test('keeps history readable without mounting operator-only export requests for
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
renderPage();
|
||||
const view = renderPage();
|
||||
|
||||
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('只读 · 导出需操作员权限')).toBeInTheDocument();
|
||||
expect(screen.getByText('只读').closest('.semi-tag')).toHaveClass('v2-history-permission-tag');
|
||||
expect(view.container.querySelector('.v2-history-toolbar-actions')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /创建导出/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('导出任务')).not.toBeInTheDocument();
|
||||
expect(mocks.historyExports).not.toHaveBeenCalled();
|
||||
@@ -180,8 +269,9 @@ test('shows only the authenticated customer export workspace with owner and scop
|
||||
}]);
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
|
||||
expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument();
|
||||
expect(screen.getByText('导出任务')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument();
|
||||
expect(mocks.historyExports).toHaveBeenCalled();
|
||||
});
|
||||
@@ -199,11 +289,13 @@ test('uses selected chartable fields for trends and omits empty RAW evidence UI'
|
||||
renderPage();
|
||||
|
||||
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
|
||||
expect(mocks.historySeries).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /展开趋势/ }));
|
||||
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('总里程'));
|
||||
const mileageToggle = screen.getAllByLabelText(/已选字段 .*点击取消显示/).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'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
||||
@@ -8,11 +9,20 @@ import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, for
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canOperate } from '../auth/session';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const DESKTOP_HISTORY_PAGE_SIZE = 50;
|
||||
const MOBILE_HISTORY_PAGE_SIZE = 20;
|
||||
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 historyQualityLabel(quality: string) {
|
||||
@@ -22,20 +32,53 @@ function historyQualityLabel(quality: string) {
|
||||
return quality || '未知';
|
||||
}
|
||||
|
||||
function HistoryTrend({ response, category, loading, error, hasMetrics }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string; hasMetrics: boolean }) {
|
||||
function HistoryQualityTag({ quality }: { quality: string }) {
|
||||
const color = quality === 'normal' || quality === 'good' ? 'green' : quality === 'error' || quality === 'critical' ? 'red' : 'orange';
|
||||
return <Tag className="v2-history-quality-tag" color={color} type="light" size="small">{historyQualityLabel(quality)}</Tag>;
|
||||
}
|
||||
|
||||
function HistoryTrend({ response, category, loading, error, hasMetrics, expanded, onToggle }: {
|
||||
response?: HistorySeriesResponse;
|
||||
category: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
hasMetrics: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
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>
|
||||
{error ? <div className="v2-history-chart-empty">趋势加载失败:{error}</div> : loading && !response ? <div className="v2-history-chart-empty">正在聚合时间序列…</div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} 个桶点</span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
|
||||
const description = category === 'raw'
|
||||
? '原始报文通过明细和导出核验证据'
|
||||
: category === 'mileage'
|
||||
? '日里程按自然日通过明细核对'
|
||||
: '按查询时间窗汇总连续指标';
|
||||
const header = <WorkspacePanelHeader
|
||||
title="聚合趋势"
|
||||
description={description}
|
||||
meta={expanded && summary ? `${formatSeriesGrain(summary.grainSeconds)}粒度 · 覆盖 ${coverage.toFixed(1)}% · ${summary.rawPointCount.toLocaleString('zh-CN')} 原始点` : undefined}
|
||||
actions={<Button
|
||||
className={`v2-history-trend-toggle${expanded ? ' is-expanded' : ''}`}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={<IconChevronRight />}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
>{expanded ? '收起趋势' : '展开趋势'}</Button>}
|
||||
/>;
|
||||
if (!expanded) return <Card className="v2-history-trend is-collapsed" bodyStyle={{ padding: 0 }}>{header}</Card>;
|
||||
if (category !== 'location') return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title={category === 'raw' ? '原始报文不生成趋势' : '日里程按自然日展示'} description={category === 'raw' ? '离散报文请通过明细与导出核验证据。' : '请通过明细表核对每日起止里程。'} /></Card>;
|
||||
if (!hasMetrics) return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title="尚未选择趋势指标" description="选择“速度”或“总里程”后展示聚合趋势。" /></Card>;
|
||||
return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}
|
||||
{error ? <Empty className="v2-history-chart-empty is-error" title="趋势加载失败" description={error} /> : loading && !response ? <div className="v2-history-chart-loading"><Spin size="middle" tip="正在聚合时间序列…" /></div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} 个桶点</span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
|
||||
<g className="v2-chart-grid"><line x1="54" y1="10" x2="786" y2="10" /><line x1="54" y1="53" x2="786" y2="53" /><line x1="54" y1="96" x2="786" y2="96" /></g>
|
||||
<g className="v2-chart-axis"><text x="49" y="14">{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="49" y="100">{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="54" y="112">{formatAxisTime(panel.start)}</text><text x="786" y="112" textAnchor="end">{formatAxisTime(panel.end)}</text></g>
|
||||
{panel.lines.flatMap((line) => line.paths.map((path, index) => <path key={`${line.key}-${index}`} d={path} fill="none" stroke={line.color} strokeWidth="2" vectorEffect="non-scaling-stroke"><title>{line.label}</title></path>))}
|
||||
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <div className="v2-history-chart-empty">当前时间窗没有可聚合的数值点;空窗未补值。</div>}
|
||||
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <Empty className="v2-history-chart-empty" title="当前时间窗没有趋势点" description="没有可聚合的数值点,空窗不会被人工补值。" />}
|
||||
{summary ? <small className="v2-history-trend-evidence">{summary.evidence} · 缺失 {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} 桶 · 查询 {summary.queryDurationMs} ms</small> : null}
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export function formatAxisTime(value: string) {
|
||||
@@ -53,7 +96,7 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({ mutationFn: api.createHistoryExport, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history-exports'] }) });
|
||||
const label = mutation.isPending ? '任务排队中' : mutation.isError ? '导出失败,重试' : '创建导出';
|
||||
return <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
|
||||
return <Button className="v2-secondary-button" theme="light" icon={<IconDownload />} disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}>{label}</Button>;
|
||||
}
|
||||
|
||||
function ExportDownloadButton({ id }: { id: string }) {
|
||||
@@ -61,10 +104,10 @@ function ExportDownloadButton({ id }: { id: string }) {
|
||||
mutationFn: () => api.downloadHistoryExport(id),
|
||||
onSuccess: ({ blob, filename }) => downloadBlob(blob, filename)
|
||||
});
|
||||
return <button type="button" className="v2-export-download" disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}><IconDownload />{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</button>;
|
||||
return <Button className="v2-export-download" theme="borderless" icon={<IconDownload />} disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}>{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</Button>;
|
||||
}
|
||||
|
||||
function ExportJobsPanel() {
|
||||
function ExportJobsPanel({ showHeader = true }: { showHeader?: boolean }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['history-exports'],
|
||||
queryFn: ({ signal }) => api.historyExports(signal),
|
||||
@@ -73,17 +116,37 @@ function ExportJobsPanel() {
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const jobs = query.data ?? [];
|
||||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small><small className="v2-export-owner">{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} 辆 · {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')} 至 ${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">当前账号尚未创建导出任务</div> : null}</div></section>;
|
||||
const statusLabel = (status: string) => status === 'queued' ? '排队中' : status === 'running' ? '执行中' : status === 'completed' ? '已完成' : '失败';
|
||||
const statusColor = (status: string) => status === 'completed' ? 'green' : status === 'running' ? 'blue' : status === 'failed' ? 'red' : 'grey';
|
||||
return <Card className="v2-export-jobs" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="导出任务" description="异步生成并保留可追溯下载记录" meta={`${jobs.length.toLocaleString('zh-CN')} 个任务`} /> : null}
|
||||
<div className="v2-export-job-list">{query.isPending ? <div className="v2-history-side-loading"><Spin size="middle" tip="正在读取导出任务" /></div> : query.isError ? <Empty className="v2-history-side-empty" title="导出任务加载失败" description="请稍后刷新重试。" /> : !jobs.length ? <Empty className="v2-history-side-empty" title="暂无导出任务" description="创建任务后可在这里查看进度并下载。" /> : <CardGroup type="grid" spacing={0}>{jobs.slice(0, 6).map((job) => <Card className={`v2-export-job-card is-${job.status}`} key={job.id} title={<span className="v2-export-job-name" title={job.name}>{job.name}</span>} headerExtraContent={<Tag color={statusColor(job.status)} type="light" size="small">{statusLabel(job.status)}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-export-job-summary"><strong>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'}</strong><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} 辆</small><small>{job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')} 至 ${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>
|
||||
{job.status === 'running' ? <Progress className="v2-export-job-progress" percent={job.progress} showInfo /> : null}
|
||||
{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}
|
||||
</Card>)}</CardGroup>}</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
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.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>;
|
||||
function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void; showHeader?: boolean }) {
|
||||
return <Card className="v2-history-evidence" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="数据详情" description={row ? `${row.plate || '未绑定车牌'} · ${row.protocol}` : '选择明细后核对来源与质量'} actions={row ? <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭数据详情" /> : null} /> : null}
|
||||
{row ? <><Descriptions className="v2-history-evidence-descriptions" align="left" size="small" data={[
|
||||
{ key: '车牌', value: row.plate || '—' },
|
||||
{ key: 'VIN', value: row.vin },
|
||||
{ key: '设备时间', value: row.deviceTime },
|
||||
{ key: '服务时间', value: row.serverTime },
|
||||
{ key: '数据来源', value: <Tag color="blue" type="light" size="small">{row.protocol}</Tag> },
|
||||
{ key: '数据质量', value: <HistoryQualityTag quality={row.quality} /> },
|
||||
{ key: '质量原因', value: row.qualityReason || '平台未返回质量说明' }
|
||||
]} />
|
||||
<div className="v2-evidence-section-title"><strong>已选字段</strong><Tag type="light" color="grey" size="small">{metrics.length}</Tag></div>
|
||||
{metrics.length ? <Descriptions className="v2-evidence-values" align="left" size="small" data={metrics.slice(0, 20).map((metric) => ({ key: <span>{metric.label}<small>{metric.key}</small></span>, value: formatHistoryValue(row.values[metric.key], metric) }))} /> : <Empty className="v2-evidence-values-empty" title="没有选中业务字段" description="通过列设置选择需要核对的字段。" />}
|
||||
{row.evidenceId ? <footer><Tag color="blue" type="light" size="small">RAW 证据</Tag><Typography.Text copyable={{ content: row.evidenceId }}>{row.evidenceId}</Typography.Text></footer> : null}</> : <Empty className="v2-history-side-empty" title="选择一条数据" description="查看来源、质量原因、业务字段与 RAW 证据。" />}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
|
||||
function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
|
||||
metrics: HistoryMetricDefinition[];
|
||||
visible: boolean;
|
||||
visibleKeys: string[];
|
||||
onToggle: (key: string) => void;
|
||||
onShowAll: () => void;
|
||||
@@ -94,20 +157,75 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
|
||||
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));
|
||||
return metrics.filter((metric) => `${metric.label} ${metric.key} ${metric.unit} ${metric.description ?? ''} ${metric.valueMappings?.map((item) => `${item.value} ${item.label}`).join(' ') ?? ''}`.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);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [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>
|
||||
<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>;
|
||||
useSideSheetA11y(visible, '.v2-history-column-sidesheet', 'v2-history-column-settings', '列显示设置', '关闭列显示设置');
|
||||
return <SideSheet
|
||||
className="v2-history-column-sidesheet"
|
||||
visible={visible}
|
||||
aria-label="列显示设置"
|
||||
width={460}
|
||||
title={<div className="v2-history-column-title"><strong>列显示设置</strong><span>基础身份与时间列保持显示</span></div>}
|
||||
onCancel={onClose}
|
||||
footer={<div className="v2-history-column-footer"><span>已选 {visibleKeys.length} / {metrics.length}</span><Button theme="light" onClick={onShowAll} disabled={!metrics.length}>显示全部</Button><Button theme="light" onClick={onReset} disabled={!metrics.length}>恢复默认</Button></div>}
|
||||
>
|
||||
<div className="v2-history-column-content">
|
||||
<label className="v2-history-column-search"><Input prefix={<IconSearch />} value={search} onChange={setSearch} placeholder="搜索中文、状态含义或字段 key" /></label>
|
||||
<div className="v2-history-column-list">{displayedMetrics.map((metric) => <label key={metric.key} title={metric.description}><Checkbox aria-label={`${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`} checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}{metric.valueMappings?.length ? <em>{metric.valueMappings.slice(0, 4).map((item) => `${item.value}=${item.label}`).join(' · ')}</em> : null}</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>
|
||||
</div>
|
||||
</SideSheet>;
|
||||
}
|
||||
|
||||
function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: HistoryDataRow[]; metrics: HistoryMetricDefinition[]; selectedRowID?: string; onSelect: (row: HistoryDataRow) => void }) {
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '', dataIndex: 'selection', width: 42,
|
||||
render: (_: unknown, row: HistoryDataRow) => <Checkbox checked={selectedRowID === row.id} onChange={() => onSelect(row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} />
|
||||
},
|
||||
{ title: '设备时间', dataIndex: 'deviceTime', width: 150 },
|
||||
{ title: '服务时间', dataIndex: 'serverTime', width: 150 },
|
||||
{ title: '车牌', dataIndex: 'plate', width: 110, render: (value: string) => value || '—' },
|
||||
{ title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => <span className="v2-history-vin" title={value}>{value}</span> },
|
||||
{ title: '协议', dataIndex: 'protocol', width: 100 },
|
||||
...metrics.map((metric) => ({
|
||||
title: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`,
|
||||
dataIndex: metric.key,
|
||||
key: metric.key,
|
||||
width: 120,
|
||||
render: (_: unknown, row: HistoryDataRow) => formatHistoryValue(row.values[metric.key], metric)
|
||||
})),
|
||||
{
|
||||
title: '质量', dataIndex: 'quality', width: 90,
|
||||
render: (_: string, row: HistoryDataRow) => <span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span>
|
||||
},
|
||||
{
|
||||
title: '质量原因', dataIndex: 'qualityReason', width: 220, className: 'v2-history-quality-reason',
|
||||
render: (value: string) => <span title={value}>{value || '—'}</span>
|
||||
},
|
||||
{
|
||||
title: '操作', dataIndex: 'action', width: 90,
|
||||
render: (_: unknown, row: HistoryDataRow) => <Button size="small" theme="borderless" onClick={() => onSelect(row)}>查看详情</Button>
|
||||
}
|
||||
], [metrics, onSelect, selectedRowID]);
|
||||
const minWidth = 1112 + metrics.length * 120;
|
||||
|
||||
return <Table
|
||||
className="v2-history-data-table"
|
||||
style={{ minWidth }}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
empty={null}
|
||||
onRow={(row) => row ? detailTriggerRow({
|
||||
className: selectedRowID === row.id ? 'is-selected' : '',
|
||||
expanded: selectedRowID === row.id,
|
||||
label: `查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`,
|
||||
testId: `history-row-${row.id}`,
|
||||
onOpen: () => onSelect(row)
|
||||
}) : ({})}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
@@ -120,12 +238,20 @@ export default function HistoryPage() {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [criteria, setCriteria] = useState(initial);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [limit, setLimit] = useState(() => mobileLayout ? MOBILE_HISTORY_PAGE_SIZE : DESKTOP_HISTORY_PAGE_SIZE);
|
||||
const [visibleByCategory, setVisibleByCategory] = useState<Record<string, string[]>>({});
|
||||
const [selectedRowRef, setSelectedRowRef] = useState<{ scope: string; id: string }>();
|
||||
const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
|
||||
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
|
||||
const [trendExpanded, setTrendExpanded] = useState(false);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const [exportJobsOpen, setExportJobsOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!mobileLayout) return;
|
||||
setLimit(MOBILE_HISTORY_PAGE_SIZE);
|
||||
setOffset(0);
|
||||
}, [mobileLayout]);
|
||||
const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]);
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, limit: String(limit), offset: String(offset) });
|
||||
@@ -160,13 +286,22 @@ export default function HistoryPage() {
|
||||
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 seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: trendExpanded && 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(() => {
|
||||
const first = result?.rows[0];
|
||||
setSelectedRowRef(first ? { scope: dataScope, id: first.id } : undefined);
|
||||
}, [dataScope, result]);
|
||||
const scopedSelectedRowRef = selectedRowRef?.scope === dataScope ? selectedRowRef : undefined;
|
||||
const selectedRow = scopedSelectedRowRef?.id === ''
|
||||
? undefined
|
||||
: scopedSelectedRowRef
|
||||
? result?.rows.find((row) => row.id === scopedSelectedRowRef.id)
|
||||
: undefined;
|
||||
const selectRow = useCallback((row: HistoryDataRow) => {
|
||||
setSelectedRowRef({ scope: dataScope, id: row.id });
|
||||
}, [dataScope]);
|
||||
const closeSelectedRow = useCallback(() => {
|
||||
setSelectedRowRef({ scope: dataScope, id: '' });
|
||||
}, [dataScope]);
|
||||
useSideSheetA11y(mobileLayout && Boolean(selectedRow), '.v2-history-detail-sidesheet', 'v2-history-detail', '历史数据详情', '关闭历史数据详情');
|
||||
useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -190,26 +325,74 @@ export default function HistoryPage() {
|
||||
const setVisibleMetrics = (keys: string[]) => setVisibleByCategory((current) => ({ ...current, [criteria.category]: keys }));
|
||||
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const summarySources = result?.summary.sources.join('、') || '—';
|
||||
|
||||
return <div className="v2-history-page">
|
||||
<MonitorReturnBar />
|
||||
<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>
|
||||
<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>{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} 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>
|
||||
<PageHeader
|
||||
title="历史数据"
|
||||
description="按车辆、时间与协议查询可追溯数据,统一核对设备时间、服务时间、字段质量和原始证据。"
|
||||
status="证据可追溯"
|
||||
meta={<Typography.Text type="tertiary">{keywords.length ? `${keywords.length} 辆车辆` : '等待选择车辆'}</Typography.Text>}
|
||||
/>
|
||||
<MobileFilterToggle title="查询条件" summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'} expanded={!filtersCollapsed} collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-history-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||
<label className="v2-history-vehicles"><span>车辆(最多 5 台)</span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN,多台用逗号分隔" /></label>
|
||||
<fieldset className="v2-history-range"><legend>时间范围</legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span>至</span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
|
||||
<label><span id="history-category-label">数据类型</span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
|
||||
<label><span id="history-protocol-label">数据来源</span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
|
||||
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}>查询</Button><Button className="v2-secondary-button" theme="light" 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' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large">只读</Tag>}</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card className="v2-history-metrics-card" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-history-metrics">
|
||||
<strong>已选字段</strong>
|
||||
<div className="v2-history-metric-scroll" aria-label="当前显示字段">
|
||||
{visibleMetrics.map((metric) => <Tag className="v2-history-metric-tag" color="blue" type="light" size="large" closable tabIndex={0} aria-label={`已选字段 ${metric.label}${metric.unit ? ` ${metric.unit}` : ''},点击取消显示`} onClick={() => toggleMetric(metric.key)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMetric(metric.key); } }} onClose={(_, event) => { event.stopPropagation(); toggleMetric(metric.key); }} key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</Tag>)}
|
||||
{allMetrics.length > visibleMetrics.length ? <span>另有 {allMetrics.length - visibleMetrics.length} 项可选</span> : !allMetrics.length ? <span>查询后加载可用指标</span> : null}
|
||||
</div>
|
||||
<Button className="v2-history-metrics-manage" theme="borderless" icon={<IconSetting />} aria-label="管理字段" onClick={() => setColumnSettingsOpen(true)} disabled={!allMetrics.length}>管理字段</Button>
|
||||
</div>
|
||||
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
|
||||
</Card>
|
||||
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
|
||||
<div className={`v2-history-workspace${selectedRow && !mobileLayout ? ' is-inspector-open' : ''}`}>
|
||||
<div className={`v2-history-main${trendExpanded ? ' has-expanded-trend' : ''}`}>
|
||||
<Card className="v2-history-summary" bodyStyle={{ padding: 0 }}><div><small>结果行数</small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small>车辆数</small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small>数据源</small><strong className="is-source" title={summarySources}>{summarySources}</strong></div><div><small>查询耗时</small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></Card>
|
||||
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} />
|
||||
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader title="数据明细" actions={<><Button theme="borderless" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} aria-controls="v2-history-column-settings" icon={<IconSetting />} onClick={() => setColumnSettingsOpen((value) => !value)}>列设置</Button>{exportAllowed ? <Button theme="borderless" aria-label="查看导出任务" aria-haspopup="dialog" aria-expanded={exportJobsOpen} aria-controls="v2-history-export-jobs" icon={<IconDownload />} onClick={() => setExportJobsOpen(true)}>导出任务</Button> : null}{!mobileLayout ? <Select aria-label="表格密度" value={density} onChange={(value) => setDensity(String(value) as typeof density)} optionList={[{ value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '舒适' }]} /> : null}<Button theme="borderless" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据" icon={<IconRefresh />} /></>} />
|
||||
<ColumnVisibilityPanel visible={columnSettingsOpen} 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)} />
|
||||
<div className="v2-history-table-scroll">
|
||||
{mobileLayout
|
||||
? <div className="v2-history-mobile-list">{result?.rows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><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><footer>查看完整详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <HistoryDataTable rows={result?.rows ?? []} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
|
||||
{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><Spin size="middle" tip="正在加载当前筛选范围的历史数据…" /></div> : !result?.rows.length ? <Empty className="v2-history-empty" title={keywords.length ? '当前条件没有历史记录' : '等待查询历史数据'} description={keywords.length ? '调整车辆、时间或数据来源后重试。' : '输入车牌或 VIN,最多可同时查询 5 台车辆。'} /> : null}
|
||||
</div>
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(result?.total ?? 0).toLocaleString('zh-CN')} 条`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }, { value: 100, label: '100 条/页' }]} /></footer>
|
||||
</Card>
|
||||
</div>
|
||||
{!mobileLayout && selectedRow ? <aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} /></aside> : null}
|
||||
</div>
|
||||
<SideSheet
|
||||
className="v2-history-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selectedRow)}
|
||||
aria-label="历史数据详情"
|
||||
width="100%"
|
||||
title={<div className="v2-history-mobile-sheet-title"><strong>数据详情</strong><span>{selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}</span></div>}
|
||||
onCancel={closeSelectedRow}
|
||||
>
|
||||
{mobileLayout && selectedRow ? <EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} showHeader={false} /> : null}
|
||||
</SideSheet>
|
||||
<SideSheet
|
||||
className="v2-history-export-sidesheet"
|
||||
visible={exportAllowed && exportJobsOpen}
|
||||
aria-label="历史数据导出任务"
|
||||
width={mobileLayout ? '100%' : 520}
|
||||
title={<div className="v2-history-mobile-sheet-title"><strong>导出任务</strong><span>查看生成进度与下载记录</span></div>}
|
||||
onCancel={() => setExportJobsOpen(false)}
|
||||
>
|
||||
{exportJobsOpen ? <ExportJobsPanel showHeader={false} /> : null}
|
||||
</SideSheet>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -24,6 +24,7 @@ const vehicleCardArgsSpy = vi.hoisted(() => vi.fn());
|
||||
const monitorDataArgsSpy = vi.hoisted(() => vi.fn());
|
||||
const qrToDataURLSpy = vi.hoisted(() => vi.fn());
|
||||
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
|
||||
const vehicleCardFixture = vi.hoisted(() => ({ detail: undefined as unknown }));
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
vi.mock('../map/FleetMap', () => ({
|
||||
@@ -68,7 +69,7 @@ vi.mock('../hooks/useMonitorData', () => ({
|
||||
},
|
||||
useMonitorVehicleCard: (...args: unknown[]) => {
|
||||
vehicleCardArgsSpy(...args);
|
||||
return { detail: {}, activeAlerts: {}, address: {} };
|
||||
return { detail: { data: vehicleCardFixture.detail }, activeAlerts: {}, address: {} };
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -77,6 +78,7 @@ afterEach(() => {
|
||||
monitorQueryFlags.isLoading = false;
|
||||
monitorQueryFlags.isFetching = false;
|
||||
monitorQueryFlags.isPlaceholderData = false;
|
||||
vehicleCardFixture.detail = undefined;
|
||||
vehicleCardArgsSpy.mockClear();
|
||||
monitorDataArgsSpy.mockClear();
|
||||
qrToDataURLSpy.mockReset();
|
||||
@@ -85,6 +87,27 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('never labels the latest historical mileage row as today when today has no mileage evidence', () => {
|
||||
vehicleCardFixture.detail = {
|
||||
sources: ['JT808'],
|
||||
sourceStatus: [],
|
||||
mileage: {
|
||||
items: [{ vin: vehicles[1].vin, plate: vehicles[1].plate, date: '2026-07-16', startMileageKm: 2100, endMileageKm: 2234, dailyMileageKm: 134, source: 'JT808' }],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0
|
||||
}
|
||||
};
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ }));
|
||||
|
||||
const todayMetric = screen.getByText('今日里程').parentElement;
|
||||
expect(todayMetric).toHaveTextContent('今日里程—');
|
||||
expect(todayMetric).not.toHaveTextContent('134');
|
||||
});
|
||||
|
||||
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -92,13 +115,26 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
|
||||
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
|
||||
|
||||
expect(firstVehicle).toHaveClass('semi-button', 'v2-vehicle-row');
|
||||
expect(view.container.querySelector('.v2-filterbar')).toHaveClass('semi-card');
|
||||
const kpis = view.container.querySelector('.v2-kpis');
|
||||
expect(kpis).toHaveClass('semi-card');
|
||||
expect(Array.from(kpis?.querySelectorAll('.v2-kpi small') ?? []).map((item) => item.textContent)).toEqual([
|
||||
'车辆总数', '当前在线', '当前离线', '行驶车辆', '静止车辆', '告警车辆', '今日上报', '无实时位置'
|
||||
]);
|
||||
expect(kpis?.querySelector('.v2-kpi.is-fleet')).toHaveClass('is-primary');
|
||||
expect(kpis?.querySelector('.v2-kpi.is-online')).toHaveClass('is-primary');
|
||||
expect(kpis?.querySelector('.v2-kpi.is-today')).toHaveClass('is-support');
|
||||
expect(kpis?.querySelector('.v2-kpi:last-child')).toHaveClass('is-missing', 'is-support');
|
||||
expect(view.container.querySelector('.v2-event-strip')).toHaveClass('semi-card');
|
||||
expect(view.container.querySelector('.v2-monitor-live-tag')).toHaveClass('semi-tag');
|
||||
expect(workspace).not.toHaveClass('is-detail-open');
|
||||
expect(workspace).not.toHaveClass('is-detail-collapsed');
|
||||
expect(firstVehicle).not.toHaveClass('is-selected');
|
||||
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||
expect(screen.getByText('车辆总数')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('无实时位置').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('无实时位置')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
|
||||
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
|
||||
@@ -107,6 +143,18 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(workspace).toHaveClass('is-detail-open');
|
||||
expect(firstVehicle).toHaveClass('is-selected');
|
||||
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-vehicle-detail')).toHaveClass('semi-card');
|
||||
expect(screen.getByRole('region', { name: '粤A12345车辆详情' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: '车辆快捷操作' })).toBeInTheDocument();
|
||||
for (const name of ['最新上报', '实时状态', '车辆信息']) {
|
||||
expect(screen.getByRole('heading', { name, level: 5 })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('车辆最近一次有效实时数据')).toBeInTheDocument();
|
||||
expect(screen.getByText('身份、车型与来源覆盖')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-status-text')).toHaveClass('semi-tag');
|
||||
expect(screen.getByRole('button', { name: '取消选择车辆' })).toHaveClass('semi-button');
|
||||
expect(screen.getByRole('button', { name: '查看总里程全部来源' })).toHaveClass('semi-button', 'v2-metric-source-link');
|
||||
expect(screen.getByRole('button', { name: '查看全部位置来源' })).toHaveClass('semi-button', 'v2-detail-source-link');
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
|
||||
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
|
||||
expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
|
||||
@@ -117,6 +165,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(workspace).toHaveClass('is-detail-collapsed');
|
||||
expect(firstVehicle).toHaveClass('is-selected');
|
||||
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-detail-peek')).toHaveClass('semi-card');
|
||||
expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection);
|
||||
expect(vehicleCardArgsSpy).toHaveBeenCalledTimes(cardCallsAfterSelection);
|
||||
|
||||
@@ -142,8 +191,8 @@ test('restores URL-backed monitor context and carries it into every vehicle work
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[entry]}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345');
|
||||
expect(screen.getByRole('combobox', { name: '协议' })).toHaveValue('JT808');
|
||||
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveValue('online');
|
||||
expect(screen.getByRole('combobox', { name: '协议' })).toHaveTextContent('JT808');
|
||||
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveTextContent('在线');
|
||||
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-zoom', '13');
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-bounds', '113.100000,23.000000,113.400000,23.300000');
|
||||
@@ -164,16 +213,35 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
|
||||
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
const modeGroup = screen.getByRole('group', { name: '监控视图' });
|
||||
const mapMode = within(modeGroup).getByRole('button', { name: /地图/ });
|
||||
const listMode = within(modeGroup).getByRole('button', { name: /列表/ });
|
||||
expect(mapMode).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(mapMode).toHaveClass('is-active');
|
||||
expect(listMode).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(listMode);
|
||||
expect(mapMode).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(listMode).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(listMode).toHaveClass('is-active');
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '车辆实时列表', level: 5 })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-monitor-table-panel')).toHaveClass('semi-card');
|
||||
expect(view.container.querySelector('.v2-monitor-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-monitor-table-scroll > table')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析')).toBeInTheDocument();
|
||||
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
|
||||
expect(screen.getAllByText('42')).toHaveLength(1);
|
||||
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
|
||||
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
|
||||
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
|
||||
expect(screen.getByText('JT808')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
|
||||
expect(screen.queryByText('推荐来源')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('当日里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('协议来源')).toBeInTheDocument();
|
||||
expect(screen.queryByText('最新上报')).not.toBeInTheDocument();
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByRole('button', { name: '解析粤A12345位置' })).toHaveClass('semi-button', 'v2-monitor-address-action');
|
||||
fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' }));
|
||||
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
|
||||
expect(screen.getByText(/解析于 \d{2}:\d{2}/)).toBeInTheDocument();
|
||||
@@ -218,7 +286,6 @@ test('keeps authorized vehicles without realtime coordinates in the list without
|
||||
|
||||
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
|
||||
expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
|
||||
expect(screen.getByText('从未上报')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /解析粤A无定位位置/ })).not.toBeInTheDocument();
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -260,13 +327,15 @@ test('closes the mobile entry with Escape and reports clipboard success', async
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /手机端/ }));
|
||||
expect(await screen.findByRole('dialog', { name: '手机端全局监控' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '关闭手机端入口' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制访问地址' }));
|
||||
expect(await screen.findByRole('button', { name: '已复制访问地址' })).toBeInTheDocument();
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/monitor$/));
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: '手机端入口' })).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape', keyCode: 27 });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: '手机端全局监控' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('shows a retry action when on-demand QR generation fails', async () => {
|
||||
@@ -301,7 +370,7 @@ test('mounts only the mobile list representation and removes its viewport listen
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards article')).toHaveLength(1));
|
||||
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
|
||||
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
|
||||
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
|
||||
IconQrCode, IconRefresh, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, ButtonGroup, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
import { EmptyState, InlineError } from '../shared/AsyncState';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
|
||||
import { formatTelemetryValue } from '../domain/telemetry';
|
||||
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
|
||||
@@ -20,21 +27,24 @@ const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
|
||||
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [onClose]);
|
||||
|
||||
return <div className="v2-batch-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<section className="v2-batch-search-dialog" role="dialog" aria-modal="true" aria-labelledby="batch-search-title">
|
||||
<header><div><strong id="batch-search-title">批量搜索车辆</strong><span>从 Excel、文本或聊天记录中直接粘贴车牌</span></div><button type="button" onClick={onClose} aria-label="关闭批量搜索"><IconClose /></button></header>
|
||||
return <Modal
|
||||
className="v2-batch-search-modal"
|
||||
visible
|
||||
centered
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
width={520}
|
||||
title={<div className="v2-batch-search-title"><strong id="batch-search-title">批量搜索车辆</strong><span>从 Excel、文本或聊天记录中直接粘贴车牌</span></div>}
|
||||
onCancel={onClose}
|
||||
footer={<div className="v2-batch-search-actions"><Button onClick={onClose}>取消</Button><Button theme="solid" disabled={!terms.length} onClick={() => onApply(terms.join(','))}>应用搜索({terms.length})</Button></div>}
|
||||
>
|
||||
<div className="v2-batch-search-dialog">
|
||||
<label htmlFor="batch-vehicle-search">每行一个车牌,也支持空格、逗号或分号分隔</label>
|
||||
<textarea id="batch-vehicle-search" autoFocus value={draft} onChange={(event) => setDraft(event.target.value)} placeholder={'粤A12345\n粤B67890\n粤C24680'} />
|
||||
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
|
||||
<div className="v2-batch-search-summary"><span>已识别 <strong>{terms.length}</strong> 辆,重复项已自动去除</span><em>最多 {MAX_MONITOR_SEARCH_TERMS} 辆</em></div>
|
||||
<footer><button type="button" className="v2-secondary-button" onClick={onClose}>取消</button><button type="button" className="v2-primary-button" disabled={!terms.length} onClick={() => onApply(terms.join(','))}>应用搜索({terms.length})</button></footer>
|
||||
</section>
|
||||
</div>;
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function useMobileMonitorLayout() {
|
||||
@@ -66,6 +76,10 @@ function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
|
||||
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
|
||||
}
|
||||
|
||||
function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtimeRow & { todayMileageKm: number } {
|
||||
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
|
||||
}
|
||||
|
||||
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
|
||||
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
|
||||
}
|
||||
@@ -97,33 +111,37 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
|
||||
const moved = Boolean(current && requested && current.key !== requested.key);
|
||||
|
||||
if (!current) return <span className="v2-monitor-address-empty">暂无实时位置</span>;
|
||||
if (!requested) return <button type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin />解析位置</button>;
|
||||
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" />解析中</span>;
|
||||
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}>解析失败,重试</button>;
|
||||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</button> : null}</div>;
|
||||
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}>解析位置</Button>;
|
||||
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><Spin size="small" />解析中</span>;
|
||||
if (addressQuery.isError) return <Button className="v2-monitor-address-action is-error" size="small" theme="light" type="danger" aria-label={`${vehicle.plate || vehicle.vin}位置解析失败,重试`} onClick={() => void addressQuery.refetch()}>解析失败,重试</Button>;
|
||||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</Button> : null}</div>;
|
||||
});
|
||||
|
||||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||||
const mobile = useMobileMonitorLayout();
|
||||
return <section className="v2-monitor-table-panel">
|
||||
<header><div><strong>车辆实时列表</strong><span>覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析</span></div><b>{total.toLocaleString('zh-CN')} 辆车辆</b></header>
|
||||
{!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th>车辆</th><th>推荐来源</th><th>速度</th><th>里程</th><th>经纬度</th><th>地理位置</th><th>最新上报</th></tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.vin}>
|
||||
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
|
||||
<td><span className="v2-monitor-source-badge">{row.primaryProtocol || '—'}</span></td>
|
||||
<td><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</td>
|
||||
<td><div className="v2-monitor-mileage"><span><small>总</small><strong>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</strong></span><span><small>今日</small><strong>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</strong></span></div></td>
|
||||
<td>{hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable">—</span>}</td>
|
||||
<td><MonitorAddressCell vehicle={row} /></td>
|
||||
<td><div className="v2-monitor-last-seen"><strong>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</strong><span>{row.lastSeen || '—'}</span></div></td>
|
||||
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
|
||||
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin} · {row.primaryProtocol || '暂无来源'}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
|
||||
<dl><div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt>今日里程</dt><dd>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</dd></div><div><dt>经纬度</dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div><dt>最新上报</dt><dd>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</dd></div><div className="is-address"><dt>地理位置</dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
|
||||
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</button></footer>
|
||||
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
|
||||
<footer><span>第 {page} / {totalPages} 页</span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}>下一页</button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer>
|
||||
</section>;
|
||||
const columns = useMemo(() => [
|
||||
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
|
||||
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
|
||||
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
|
||||
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
|
||||
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => <div className="v2-monitor-protocol"><Tag color={row.primaryProtocol ? 'blue' : 'grey'} type="light" size="small">{row.primaryProtocol || '未知'}</Tag>{row.protocols.length > 1 ? <small>+{row.protocols.length - 1} 路</small> : null}</div> },
|
||||
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable">—</span> },
|
||||
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> }
|
||||
], [onSelect]);
|
||||
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆实时列表"
|
||||
description="覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析"
|
||||
meta={`${total.toLocaleString('zh-CN')} 辆车辆`}
|
||||
/>
|
||||
{!mobile ? <div className="v2-monitor-table-scroll"><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <Card className="v2-monitor-mobile-card" key={row.vin}>
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
|
||||
<dl><div><dt>当日里程</dt><dd className="is-today">{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}</dd></div><div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt>协议来源</dt><dd><span className="v2-monitor-mobile-protocol">{row.primaryProtocol || '未知'}{row.protocols.length > 1 ? ` · ${row.protocols.length} 路` : ''}</span></dd></div><div><dt>经纬度</dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div className="is-address"><dt>地理位置</dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
|
||||
<footer><Button className="v2-monitor-card-locate" theme="borderless" type="primary" icon={<IconMapPin />} onClick={() => onSelect(row.vin)}>{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</Button></footer>
|
||||
</Card>)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function MobileEntry({ onClose }: { onClose: () => void }) {
|
||||
@@ -132,11 +150,9 @@ function MobileEntry({ onClose }: { onClose: () => void }) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const url = `${window.location.origin}/monitor`;
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [onClose]);
|
||||
useLayoutEffect(() => {
|
||||
document.querySelector('.v2-monitor-qr-modal .semi-modal-close')?.setAttribute('aria-label', '关闭手机端入口');
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (copyState === 'idle') return;
|
||||
const timer = window.setTimeout(() => setCopyState('idle'), 1_800);
|
||||
@@ -161,17 +177,35 @@ function MobileEntry({ onClose }: { onClose: () => void }) {
|
||||
setCopyState('error');
|
||||
}
|
||||
};
|
||||
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section><button type="button" autoFocus onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3>手机端全局监控</h3><p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><button type="button" onClick={() => setAttempt((value) => value + 1)}>重新生成</button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}<code>{url}</code><button type="button" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</button></section></div>;
|
||||
return <Modal
|
||||
className="v2-monitor-qr-modal"
|
||||
visible
|
||||
centered
|
||||
width={390}
|
||||
title="手机端全局监控"
|
||||
closeIcon={<IconClose aria-label="关闭手机端入口" />}
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
onCancel={onClose}
|
||||
footer={<Button block theme="solid" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</Button>}
|
||||
>
|
||||
<div className="v2-monitor-qr-content">
|
||||
<span className="v2-monitor-qr-icon"><IconQrCode /></span>
|
||||
<p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>
|
||||
{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><Button theme="light" type="danger" onClick={() => setAttempt((value) => value + 1)}>重新生成</Button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}
|
||||
<code>{url}</code>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
|
||||
const status = vehicleStatus(vehicle);
|
||||
return (
|
||||
<button type="button" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
|
||||
<Button theme="borderless" type="tertiary" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
|
||||
<i className={`v2-status-dot is-${status}`} />
|
||||
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
|
||||
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -192,60 +226,94 @@ function VehicleDetailCard({
|
||||
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
|
||||
const detail = card.detail.data;
|
||||
const activeAlerts = card.activeAlerts.data;
|
||||
const telemetry = card.telemetry?.data;
|
||||
const address = card.address.data;
|
||||
const status = vehicleStatus(vehicle);
|
||||
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
|
||||
// The realtime row is joined to vehicle_daily_mileage with stat_date = CURDATE().
|
||||
// Vehicle detail mileage is ordered by recency and may start with yesterday, so it
|
||||
// must never be used as a fallback for a metric explicitly labelled "今日里程".
|
||||
const dailyMileage = vehicle.todayMileageAvailable === true ? vehicle.todayMileageKm : undefined;
|
||||
const latestAlert = activeAlerts?.items[0];
|
||||
const mileageLabel = vehicle.primaryProtocol === 'JT808' ? 'GPS 总里程' : '仪表盘总里程';
|
||||
const encodedVin = encodeURIComponent(vehicle.vin);
|
||||
const accessSource = detail?.profile?.accessProvider || vehicle.locationSource;
|
||||
const workflowLinks = [
|
||||
['单车详情', `/vehicles/${encodedVin}`],
|
||||
['轨迹回放', `/tracks?vin=${encodedVin}`],
|
||||
['历史数据', `/history?vin=${encodedVin}`],
|
||||
['里程查询', `/statistics?vins=${encodedVin}`]
|
||||
] as const;
|
||||
const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
|
||||
'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
|
||||
'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
|
||||
'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
|
||||
].includes(item.key)).slice(0, 9);
|
||||
return (
|
||||
<aside className="v2-vehicle-detail">
|
||||
<div className="v2-detail-controls">
|
||||
<button type="button" aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse}><IconChevronRight /></button>
|
||||
<button type="button" aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear}><IconClose /></button>
|
||||
</div>
|
||||
<div className="v2-detail-title">
|
||||
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><span className={`v2-status-text is-${status}`}>{statusLabel(status)}</span></div>
|
||||
<small>{vehicle.vin}</small>
|
||||
</div>
|
||||
<div className="v2-detail-actions">
|
||||
<Link to={withMonitorReturn(`/vehicles/${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>单车详情</Link>
|
||||
<Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>轨迹回放</Link>
|
||||
<Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>历史数据</Link>
|
||||
<Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}>里程查询</Link>
|
||||
</div>
|
||||
<section>
|
||||
<h3>车辆信息</h3>
|
||||
<Card className="v2-vehicle-detail" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-detail-body" role="region" aria-label={`${vehicle.plate || vehicle.vin}车辆详情`}>
|
||||
<div className="v2-detail-shell-header">
|
||||
<div className="v2-detail-controls">
|
||||
<Button size="small" theme="light" type="tertiary" icon={<IconChevronRight />} aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse} />
|
||||
<Button size="small" theme="light" type="danger" icon={<IconClose />} aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear} />
|
||||
</div>
|
||||
<div className="v2-detail-title">
|
||||
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><Tag className={`v2-status-text is-${status}`} color={status === 'driving' ? 'blue' : status === 'idle' || status === 'online' ? 'green' : status === 'alert' ? 'red' : 'grey'} type="light" size="small">{statusLabel(status)}</Tag></div>
|
||||
<small>{vehicle.vin}</small>
|
||||
</div>
|
||||
<nav className="v2-detail-actions" aria-label="车辆快捷操作">
|
||||
{workflowLinks.map(([label, path]) => <Link key={label} to={withMonitorReturn(path, monitorReturn)}>{label}</Link>)}
|
||||
</nav>
|
||||
</div>
|
||||
<section className="v2-detail-section v2-detail-report">
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="最新上报"
|
||||
description="车辆最近一次有效实时数据"
|
||||
meta={<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{relativeFreshness(vehicle.lastSeen)}</Tag>}
|
||||
/>
|
||||
<div className="v2-detail-report-time"><small>平台接收时间</small><strong>{vehicle.lastSeen || '暂无上报'}</strong></div>
|
||||
<div className="v2-detail-report-source"><span><small>主协议</small><b>{vehicle.primaryProtocol || '未知'}</b></span><span><small>接入来源</small><b>{accessSource || '待补充'}</b></span></div>
|
||||
<dl className="v2-detail-list">
|
||||
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
|
||||
<div><dt>厂家</dt><dd>{vehicle.oem || '待补充'}</dd></div>
|
||||
<div><dt>协议</dt><dd>{vehicle.primaryProtocol || vehicle.protocols.join('、') || '未知'}</dd></div>
|
||||
<div><dt>数据来源</dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
|
||||
<div><dt>接入供应商</dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
|
||||
<div><dt>来源覆盖</dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
|
||||
<div><dt>坐标</dt><dd><Button theme="borderless" type="tertiary" className="v2-detail-source-link" aria-label="查看全部位置来源" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</Button></dd></div>
|
||||
<div><dt>位置</dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>实时状态</h3>
|
||||
<section className="v2-detail-section">
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="实时状态"
|
||||
description={accessSource || '来源待补充'}
|
||||
meta={<Tag color="blue" type="light" size="small">{vehicle.primaryProtocol || '未知协议'}</Tag>}
|
||||
/>
|
||||
<div className="v2-metric-grid">
|
||||
<div><small>速度</small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
|
||||
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
|
||||
<div><small>总里程</small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></button></div>
|
||||
<div><small>今日里程</small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></button></div>
|
||||
<div><small>{mileageLabel}</small><Button theme="borderless" type="tertiary" className="v2-metric-source-link" aria-label="查看总里程全部来源" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}><span>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></span><IconChevronRight /></Button></div>
|
||||
<div><small>今日里程</small><Button theme="borderless" type="tertiary" className="v2-metric-source-link" aria-label="查看今日里程全部来源" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}><span>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></span><IconChevronRight /></Button></div>
|
||||
<div><small>状态</small><strong>{statusLabel(status)}</strong></div>
|
||||
<div><small>当前告警</small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em>条</em></strong></div>
|
||||
</div>
|
||||
{gbHighlights.length ? <div className="v2-gb-highlight-grid">{gbHighlights.map((item) => <div key={`${item.key}-${item.sourceField}`} title={item.description}><small>{item.label}</small><strong>{formatTelemetryValue(item.value, item.displayValue)}<em>{item.unit}</em></strong></div>)}</div> : null}
|
||||
</section>
|
||||
<section>
|
||||
<h3>最新上报</h3>
|
||||
<section className="v2-detail-section">
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="车辆信息"
|
||||
description="身份、车型与来源覆盖"
|
||||
meta={`${vehicle.onlineSourceCount}/${vehicle.sourceCount} 在线`}
|
||||
/>
|
||||
<dl className="v2-detail-list">
|
||||
<div><dt>时间</dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
|
||||
<div><dt>新鲜度</dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
|
||||
<div><dt>坐标</dt><dd><button type="button" className="v2-detail-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</button></dd></div>
|
||||
<div><dt>位置</dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
|
||||
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
|
||||
<div><dt>品牌 / 车型</dt><dd>{[detail?.profile?.brandName, detail?.profile?.modelName].filter(Boolean).join(' / ') || vehicle.oem || '待补充'}</dd></div>
|
||||
<div><dt>可用协议</dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
|
||||
<div><dt>接入供应商</dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
|
||||
<div><dt>来源覆盖</dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
|
||||
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
|
||||
</aside>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -343,13 +411,13 @@ export default function MonitorPage() {
|
||||
|
||||
return (
|
||||
<div className="v2-monitor-page">
|
||||
<section className="v2-filterbar" aria-label="车辆筛选">
|
||||
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
|
||||
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
|
||||
<IconSearch />
|
||||
<input
|
||||
<Input
|
||||
aria-label="搜索车辆"
|
||||
prefix={<IconSearch />}
|
||||
value={keyword}
|
||||
onChange={(event) => { setKeyword(event.target.value); setListOffset(0); }}
|
||||
onChange={(value) => { setKeyword(value); setListOffset(0); }}
|
||||
onPaste={(event) => {
|
||||
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
|
||||
if (pastedTerms.length <= 1) return;
|
||||
@@ -358,35 +426,36 @@ export default function MonitorPage() {
|
||||
setListOffset(0);
|
||||
}}
|
||||
placeholder="车牌 / VIN;可批量粘贴车牌"
|
||||
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}>批量</Button>}
|
||||
/>
|
||||
<button type="button" className="v2-search-batch-action" onClick={() => setBatchSearchOpen(true)}>批量</button>
|
||||
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
|
||||
</div>
|
||||
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
|
||||
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
||||
</select>
|
||||
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
|
||||
{statuses.map((item) => <option key={item} value={item}>{item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态'}</option>)}
|
||||
</select>
|
||||
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh />清空</button>
|
||||
<span className="v2-sr-only" id="monitor-protocol-filter-label">协议</span>
|
||||
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
|
||||
<span className="v2-sr-only" id="monitor-status-filter-label">在线状态</span>
|
||||
<Select value={status} onChange={(value) => { setStatus(String(value)); setListOffset(0); }} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
|
||||
<Button className="v2-filter-reset" icon={<IconRefresh />} onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}>清空</Button>
|
||||
<span className="v2-filter-live-status" role="status" title="搜索和筛选条件修改后自动生效"><IconFilter />实时筛选</span>
|
||||
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin />地图</button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList />列表</button></div>
|
||||
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode />手机端</button>
|
||||
</section>
|
||||
<ButtonGroup className="v2-monitor-mode" aria-label="监控视图">
|
||||
<Button aria-pressed={mode === 'map'} className={mode === 'map' ? 'is-active' : ''} theme={mode === 'map' ? 'solid' : 'borderless'} type={mode === 'map' ? 'primary' : 'tertiary'} icon={<IconMapPin />} onClick={() => setMode('map')}>地图</Button>
|
||||
<Button aria-pressed={mode === 'list'} className={mode === 'list' ? 'is-active' : ''} theme={mode === 'list' ? 'solid' : 'borderless'} type={mode === 'list' ? 'primary' : 'tertiary'} icon={<IconList />} onClick={() => { setMode('list'); setDetailOpen(false); }}>列表</Button>
|
||||
</ButtonGroup>
|
||||
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} onClick={() => setMobileEntryOpen(true)}>手机端</Button>
|
||||
</Card>
|
||||
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
|
||||
|
||||
<section className="v2-kpis" aria-label="车辆整体统计">
|
||||
<Card className="v2-kpis" bodyStyle={{ padding: 0 }} aria-label="车辆整体统计">
|
||||
{[
|
||||
['车辆总数', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
||||
['无实时位置', formatNumber(summary.data?.noLocationVehicles ?? 0), 'missing'],
|
||||
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
|
||||
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
|
||||
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
|
||||
['静止车辆', formatNumber(summary.data?.idleVehicles ?? idle), 'idle'],
|
||||
['告警车辆', summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', 'alert'],
|
||||
['今日上报', formatNumber(summary.data?.frameToday ?? 0), 'today']
|
||||
].map(([label, value, tone]) => <div key={label} className={`v2-kpi is-${tone}`}><small>{label}</small><strong>{value}</strong></div>)}
|
||||
</section>
|
||||
{ label: '车辆总数', value: formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), tone: 'fleet', priority: 'primary', unit: '辆' },
|
||||
{ label: '当前在线', value: formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), tone: 'online', priority: 'primary', unit: '辆' },
|
||||
{ label: '当前离线', value: formatNumber(summary.data?.offlineVehicles ?? offline), tone: 'offline', priority: 'standard', unit: '辆' },
|
||||
{ label: '行驶车辆', value: formatNumber(summary.data?.drivingVehicles ?? driving), tone: 'driving', priority: 'standard', unit: '辆' },
|
||||
{ label: '静止车辆', value: formatNumber(summary.data?.idleVehicles ?? idle), tone: 'idle', priority: 'standard', unit: '辆' },
|
||||
{ label: '告警车辆', value: summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', tone: 'alert', priority: 'standard', unit: '辆' },
|
||||
{ label: '今日上报', value: formatNumber(summary.data?.frameToday ?? 0), tone: 'today', priority: 'support', unit: '条' },
|
||||
{ label: '无实时位置', value: formatNumber(summary.data?.noLocationVehicles ?? 0), tone: 'missing', priority: 'support', unit: '辆' }
|
||||
].map(({ label, value, tone, priority, unit }) => <div key={label} className={`v2-kpi is-${tone} is-${priority}`}><small>{label}</small><strong>{value}<em>{unit}</em></strong></div>)}
|
||||
</Card>
|
||||
|
||||
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
|
||||
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
|
||||
@@ -419,23 +488,23 @@ export default function MonitorPage() {
|
||||
/>
|
||||
) : null}
|
||||
{selected && !detailOpen ? (
|
||||
<aside className="v2-detail-peek" aria-label="已收起的车辆详情">
|
||||
<button type="button" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
|
||||
<Card className="v2-detail-peek" bodyStyle={{ padding: 0 }}>
|
||||
<Button theme="borderless" type="tertiary" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
|
||||
<IconChevronLeft />
|
||||
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
|
||||
<span>{selected.plate || '未绑定车牌'}</span>
|
||||
</button>
|
||||
</aside>
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
|
||||
|
||||
<section className="v2-event-strip">
|
||||
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }}>
|
||||
<strong>实时数据状态</strong>
|
||||
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
|
||||
<Tag className="v2-monitor-live-tag" color="green" type="light" size="small"><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</Tag>
|
||||
<span>{mode === 'map' ? `列表 ${visibleRows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`}</span>
|
||||
<span className="v2-refresh-cadence"><b>智能刷新</b> 重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒</span>
|
||||
<span className="v2-refresh-cadence"><Tag color="blue" type="light" size="small">智能刷新</Tag>重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒</span>
|
||||
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
|
||||
</section>
|
||||
</Card>
|
||||
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,11 @@ const mocks = vi.hoisted(() => ({
|
||||
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
|
||||
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
|
||||
}));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); });
|
||||
afterEach(() => { cleanup(); layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); });
|
||||
|
||||
function seedSession() {
|
||||
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
|
||||
@@ -41,7 +43,7 @@ function seedSession() {
|
||||
test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
linkHealth: [{ name: 'MySQL', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
@@ -57,10 +59,42 @@ test('renders reconciliation queue, loads evidence on demand and records review
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('数据差异中心')).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByText('多来源实时位置漂移'));
|
||||
expect(screen.getByRole('tab', { name: '差异处置', selected: true })).toHaveClass('semi-button');
|
||||
expect(screen.getByRole('tabpanel', { name: '差异处置' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('数据差异中心').closest('.semi-card')).toHaveClass('v2-reconcile-center');
|
||||
expect(screen.getByText('数据差异中心').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading');
|
||||
expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('活跃差异');
|
||||
expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(4);
|
||||
expect(screen.getByText('超过 24 小时').closest('.semi-card')).toHaveClass('v2-reconcile-sla');
|
||||
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
|
||||
const trendButton = screen.getByRole('button', { name: '30 天趋势' });
|
||||
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(trendButton);
|
||||
expect(screen.getByText('近 30 天趋势').closest('.semi-card')).toHaveClass('v2-reconcile-trend');
|
||||
expect(screen.getByText('近 30 天趋势').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
|
||||
expect(trendButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
const issueTitles = await screen.findAllByText('多来源实时位置漂移');
|
||||
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-reconcile-table-wrap > table')).not.toBeInTheDocument();
|
||||
const reconcileRow = screen.getByTestId('reconcile-row-issue-1');
|
||||
expect(reconcileRow).toHaveAttribute('role', 'button');
|
||||
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.keyDown(reconcileRow, { key: ' ' });
|
||||
expect(await screen.findByText('规则证据')).toBeInTheDocument();
|
||||
expect(reconcileRow).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('规则证据').closest('.semi-card')).toHaveClass('v2-reconcile-evidence');
|
||||
expect(screen.getByText('复核结论').closest('.semi-card')).toHaveClass('v2-reconcile-review');
|
||||
expect(screen.getByText('处理履历').closest('.semi-card')).toHaveClass('v2-reconcile-actions');
|
||||
expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
|
||||
expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).toBeInTheDocument();
|
||||
expect(screen.getByText('1286')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('处置状态'), { target: { value: 'confirmed_source_a' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
|
||||
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(issueTitles[0]);
|
||||
expect(await screen.findByText('规则证据')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('radio', { name: '确认来源 A' }));
|
||||
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存复核结论' }));
|
||||
await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
|
||||
@@ -68,20 +102,65 @@ test('renders reconciliation queue, loads evidence on demand and records review
|
||||
}));
|
||||
});
|
||||
|
||||
test('reconciles service identities with bound and identity-required vehicles', async () => {
|
||||
test('renders only the compact mobile reconciliation surface and defers secondary controls', async () => {
|
||||
layout.mobile = true;
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('多来源实时位置漂移')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).not.toBeInTheDocument();
|
||||
expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }), expect.any(AbortSignal));
|
||||
const mobileIssueAction = screen.getByRole('button', { name: '查看 粤A00001 差异证据' });
|
||||
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(mobileIssueAction);
|
||||
expect(await screen.findByRole('dialog', { name: '差异证据与处置' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: '关闭差异详情' })).toBeInTheDocument();
|
||||
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
|
||||
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const toolbar = document.querySelector('.v2-reconcile-toolbar');
|
||||
expect(toolbar).toHaveClass('is-mobile-collapsed');
|
||||
fireEvent.click(screen.getByRole('button', { name: /修改筛选差异/ }));
|
||||
expect(toolbar).not.toHaveClass('is-mobile-collapsed');
|
||||
|
||||
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '30 天趋势' }));
|
||||
const trend = screen.getByText('近 30 天趋势').closest('.v2-reconcile-trend');
|
||||
expect(trend).toBeInTheDocument();
|
||||
expect(screen.getByText(/最近运行/)).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-reconcile-trend-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起趋势' }));
|
||||
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reconciles service identities with bound and identity-required vehicles', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [{ name: 'MySQL', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({
|
||||
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
|
||||
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: []
|
||||
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: [{
|
||||
protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 1024, online: 234, severity: 'ok',
|
||||
evidence: '实时来源覆盖稳定', action: '保持在线率监测', acceptance: '在线率与上报周期持续满足阈值'
|
||||
}]
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全局健康' }));
|
||||
expect(await screen.findByText('1024 / 234')).toBeInTheDocument();
|
||||
for (const key of [['ops-health-v2'], ['ops-source-readiness-v2']]) {
|
||||
const query = client.getQueryCache().find({ queryKey: key });
|
||||
@@ -91,6 +170,22 @@ test('reconciles service identities with bound and identity-required vehicles',
|
||||
}
|
||||
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
|
||||
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务身份 / 在线').closest('.semi-card')).toHaveClass('v2-ops-kpi-card');
|
||||
expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime');
|
||||
expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources');
|
||||
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '运行时安全', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '协议来源就绪度', level: 5 })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-ops-link-card.semi-card')).toHaveTextContent('MySQL主库连接正常');
|
||||
expect(document.querySelector('.v2-ops-runtime-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(screen.getByText('容量检查通过').closest('.semi-empty')).toHaveClass('v2-ops-capacity-empty');
|
||||
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).toBeInTheDocument();
|
||||
expect(screen.getByText('GB32960').closest('.semi-card')).toHaveClass('v2-ops-source-card');
|
||||
expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card');
|
||||
expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
|
||||
expect(screen.getByText('先选择一辆车').closest('.semi-empty')).toHaveClass('v2-source-empty');
|
||||
expect(screen.getByText('单车多来源诊断').closest('.semi-card')).toHaveClass('v2-source-diagnostic');
|
||||
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -108,8 +203,10 @@ test('keeps health evidence visible when source readiness fails and retries that
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全局健康' }));
|
||||
expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-release')).toBeInTheDocument();
|
||||
expect(screen.getByText('版本 test-release')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重试/ }));
|
||||
|
||||
expect(await screen.findByText('1024 / 234')).toBeInTheDocument();
|
||||
@@ -146,6 +243,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
|
||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
|
||||
const candidateButton = await waitFor(() => {
|
||||
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
||||
@@ -154,8 +252,21 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
});
|
||||
fireEvent.click(candidateButton);
|
||||
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.v2-source-summary-card.semi-card')).toHaveLength(4);
|
||||
expect(document.querySelectorAll('.v2-source-summary-card')[1]?.querySelector('.semi-tag')).toHaveTextContent('JT808');
|
||||
expect(screen.getByText('推荐说明').closest('.semi-card')).toHaveClass('v2-source-recommendation');
|
||||
expect(screen.getByText('最近策略审计').closest('.semi-card')).toHaveClass('v2-source-audit');
|
||||
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
|
||||
expect(screen.getByText('10s')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-table-wrap > table')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-mobile-list')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('columnheader', { name: '来源 / 终端' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('columnheader', { name: '运维策略' })).toBeInTheDocument();
|
||||
expect(screen.getByText('优先级 20')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '维护策略' }));
|
||||
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-policy-sidesheet .semi-sidesheet-inner')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('G7 提供方'), { target: { value: 'G7s' } });
|
||||
const save = screen.getByRole('button', { name: '保存策略' });
|
||||
expect(save).toBeDisabled();
|
||||
@@ -175,6 +286,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
});
|
||||
|
||||
test('allows provider maintenance but keeps canonical source policy read only', async () => {
|
||||
layout.mobile = true;
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
@@ -202,6 +314,7 @@ test('allows provider maintenance but keeps canonical source policy read only',
|
||||
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
|
||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
|
||||
const candidateButton = await waitFor(() => {
|
||||
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
||||
@@ -210,6 +323,14 @@ test('allows provider maintenance but keeps canonical source policy read only',
|
||||
});
|
||||
fireEvent.click(candidateButton);
|
||||
|
||||
expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(screen.getByText('优先级 100')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('JT808 优先级')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '维护提供方' }));
|
||||
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText('JT808 优先级')).toBeDisabled();
|
||||
expect(screen.getByLabelText('JT808 策略备注')).toBeDisabled();
|
||||
expect(screen.getByRole('checkbox', { name: '启用' })).toBeDisabled();
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
import ReconciliationCenter from './ReconciliationCenter';
|
||||
|
||||
type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||||
}
|
||||
|
||||
function HealthTag({ status, children }: { status: string; children?: string }) {
|
||||
const color = status === 'ok' ? 'green' : status === 'warning' ? 'orange' : status === 'error' ? 'red' : 'grey';
|
||||
return <Tag className={`v2-ops-health-tag is-${status}`} color={color} type="light" size="small"><i />{children ?? statusLabel(status)}</Tag>;
|
||||
}
|
||||
|
||||
function fmt(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value.replace(' ', 'T'));
|
||||
@@ -21,7 +35,7 @@ function number(value?: number, digits = 1) {
|
||||
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
source: VehicleLocationSourceEvidence;
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
@@ -56,28 +70,140 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
const policyEditable = source.sourceKind !== 'CANONICAL';
|
||||
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
|
||||
const changed = (policyEditable && policyChanged) || providerChanged;
|
||||
return <tr className={source.recommended ? 'is-recommended' : ''}>
|
||||
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
|
||||
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
|
||||
<td><i className={source.online ? 'is-online' : 'is-offline'} />{source.online ? '在线' : '离线'}<span>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</span></td>
|
||||
<td><strong>{fmt(source.firstSeenAt)}</strong><span>最近 {fmt(source.receivedAt || source.eventTime)}</span></td>
|
||||
<td><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><span>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</span></td>
|
||||
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
|
||||
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
|
||||
<td className="v2-source-policy-cell">
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={(event) => setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
|
||||
<input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={(event) => setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||||
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</button>
|
||||
return <div className="v2-source-policy-cell">
|
||||
<Checkbox checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(Boolean(event.target.checked))} aria-label="启用">启用</Checkbox>
|
||||
<Input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={String(priority)} disabled={!editable || !policyEditable || save.isPending} onChange={(value) => setPriority(Number(value))} />
|
||||
<Input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={setProviderName} placeholder="提供方,如 G7s" />
|
||||
<Input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={setProviderEvidence} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||||
<Input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={setRemark} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||||
<Button theme="solid" size="small" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</Button>
|
||||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||||
</td>
|
||||
</tr>;
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SourceIdentity({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell v2-source-identity-cell"><span><strong>{source.sourceLabel}</strong>{source.recommended ? <Tag color="green" type="light" size="small">推荐</Tag> : null}</span><small>{source.terminalLabel || source.sourceKind || '未维护终端'}</small></div>;
|
||||
}
|
||||
|
||||
function SourceProtocol({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</small></div>;
|
||||
}
|
||||
|
||||
function SourceHealth({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell v2-source-health-cell"><span><i className={source.online ? 'is-online' : 'is-offline'} /><strong>{source.online ? '在线' : '离线'}</strong></span><small>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</small></div>;
|
||||
}
|
||||
|
||||
function SourceTiming({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><strong>{fmt(source.firstSeenAt)}</strong><small>最近 {fmt(source.receivedAt || source.eventTime)}</small></div>;
|
||||
}
|
||||
|
||||
function SourceInterval({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><small>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</small></div>;
|
||||
}
|
||||
|
||||
function SourcePosition({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell"><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><small>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</small></div>;
|
||||
}
|
||||
|
||||
function SourceDecision({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <div className="v2-source-cell v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><small>{source.selectionReason || '等待选举说明'}</small></div>;
|
||||
}
|
||||
|
||||
function sourceRowKey(source?: VehicleLocationSourceEvidence) {
|
||||
return source?.sourceRef || `${source?.protocol ?? ''}-${source?.sourceLabel ?? ''}-${source?.terminalLabel ?? ''}`;
|
||||
}
|
||||
|
||||
function SourcePolicySummary({ source, onEdit }: {
|
||||
source: VehicleLocationSourceEvidence;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return <div className="v2-source-policy-summary">
|
||||
<span><Tag color={source.enabled ? 'green' : 'grey'} type="light" size="small">{source.enabled ? '已启用' : '已禁用'}</Tag><b>优先级 {source.priority}</b></span>
|
||||
<small>{source.providerOverride || '提供方未维护'}</small>
|
||||
<Button theme="light" size="small" onClick={onEdit}>{source.sourceKind === 'CANONICAL' ? '维护提供方' : '维护策略'}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SourcePolicySheet({ vin, source, diagnostic, editable, onClose, onSaved }: {
|
||||
vin: string;
|
||||
source?: VehicleLocationSourceEvidence;
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
editable: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
useSideSheetA11y(Boolean(source), '.v2-source-policy-sidesheet', 'v2-source-policy-sheet', '车辆来源策略', '关闭车辆来源策略');
|
||||
return <SideSheet
|
||||
className="v2-source-policy-sidesheet"
|
||||
visible={Boolean(source)}
|
||||
width={440}
|
||||
aria-label="车辆来源策略"
|
||||
title={source ? <div className="v2-source-policy-sheet-title"><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.protocol} · {source.sourceKind === 'CANONICAL' ? '仅维护提供方' : '来源策略'}</span></div> : '来源策略'}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
>
|
||||
{source ? <div className="v2-source-policy-sheet-content"><div className="v2-source-policy-sheet-summary"><SourceHealth source={source} /><SourceDecision source={source} /></div><SourcePolicyEditor vin={vin} source={source} diagnostic={diagnostic} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => { onSaved(next); onClose(); }} /></div> : null}
|
||||
</SideSheet>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticTable({ vin, sources, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
sources: VehicleLocationSourceEvidence[];
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
editable: boolean;
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
|
||||
const columns = [
|
||||
{ title: '来源 / 终端', dataIndex: 'sourceLabel', width: 180, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceIdentity source={source} /> },
|
||||
{ title: '协议', dataIndex: 'protocol', width: 120, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceProtocol source={source} /> },
|
||||
{ title: '在线 / 质量', dataIndex: 'online', width: 190, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceHealth source={source} /> },
|
||||
{ title: '首次 / 最近上报', dataIndex: 'firstSeenAt', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceTiming source={source} /> },
|
||||
{ title: '上报周期', dataIndex: 'reportIntervalSec', width: 145, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceInterval source={source} /> },
|
||||
{ title: '位置 / 里程', dataIndex: 'longitude', width: 230, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePosition source={source} /> },
|
||||
{ title: '选举结论', dataIndex: 'recommended', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceDecision source={source} /> },
|
||||
{ title: '运维策略', dataIndex: 'policy', width: 200, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /> }
|
||||
];
|
||||
return <><div className="v2-source-table-wrap"><Table
|
||||
className="v2-source-table"
|
||||
columns={columns}
|
||||
dataSource={sources}
|
||||
rowKey={sourceRowKey}
|
||||
pagination={false}
|
||||
scroll={{ x: 1505 }}
|
||||
empty={null}
|
||||
onRow={(source) => source ? ({ className: source.recommended ? 'is-recommended' : '' }) : ({})}
|
||||
/></div>
|
||||
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticCards({ vin, sources, diagnostic, editable, onSaved }: {
|
||||
vin: string;
|
||||
sources: VehicleLocationSourceEvidence[];
|
||||
diagnostic: VehicleSourceDiagnostic;
|
||||
editable: boolean;
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
|
||||
return <><div className="v2-source-mobile-list">{sources.map((source) => <Card className={`v2-source-mobile-card${source.recommended ? ' is-recommended' : ''}`} bodyStyle={{ padding: 0 }} key={sourceRowKey(source)}>
|
||||
<header><SourceIdentity source={source} /><SourceHealth source={source} /></header>
|
||||
<Descriptions className="v2-source-mobile-descriptions" align="left" size="small" data={[
|
||||
{ key: '协议状态', value: <SourceProtocol source={source} /> },
|
||||
{ key: '首次 / 最近上报', value: <SourceTiming source={source} /> },
|
||||
{ key: '上报周期', value: <SourceInterval source={source} /> },
|
||||
{ key: '位置 / 里程', value: <SourcePosition source={source} /> },
|
||||
{ key: '选举结论', value: <SourceDecision source={source} /> }
|
||||
]} />
|
||||
<section><strong>运维策略</strong><SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /></section>
|
||||
</Card>)}</div>
|
||||
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||||
@@ -109,71 +235,109 @@ function SourceDiagnosticWorkspace() {
|
||||
};
|
||||
const data = diagnostic.data;
|
||||
const editable = session.data?.role === 'admin';
|
||||
return <section className="v2-source-diagnostic">
|
||||
<header>
|
||||
<div><small>专业运维工作台</small><strong>单车多来源诊断</strong><span>按需查询,不加载全量 RAW;普通客户无权访问。</span></div>
|
||||
{selected ? <button type="button" onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}><IconRefresh />刷新该车</button> : null}
|
||||
</header>
|
||||
const onSourceSaved = (next: VehicleSourceDiagnostic) => {
|
||||
queryClient.setQueryData(['ops-source-diagnostic', next.evidence.vin], next);
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||||
]);
|
||||
};
|
||||
return <Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="单车多来源诊断"
|
||||
description="按需查询,不加载全量 RAW;普通客户无权访问。"
|
||||
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||||
/>
|
||||
<form className="v2-source-search" onSubmit={submit}>
|
||||
<label><IconSearch /><input aria-label="按车牌或 VIN 搜索诊断车辆" value={keyword} onChange={(event) => { setKeyword(event.target.value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" /></label>
|
||||
<button type="submit" disabled={!candidates.data?.items.length}>诊断首个结果</button>
|
||||
{deferredKeyword && !selected ? <div className="v2-source-candidates">
|
||||
{candidates.isFetching ? <p>正在查询车辆…</p> : candidates.data?.items.map((vehicle) => <button type="button" key={vehicle.vin} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(vehicle)}>
|
||||
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>{vehicle.protocols.join(' / ') || '暂无来源'}</em>
|
||||
</button>)}
|
||||
{!candidates.isFetching && !candidates.data?.items.length ? <p>没有匹配的已绑定车辆</p> : null}
|
||||
{(candidates.data?.total ?? 0) > 20 ? <footer><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><button type="button" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</button><button type="button" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</button></div></footer> : null}
|
||||
</div> : null}
|
||||
<Input aria-label="按车牌或 VIN 搜索诊断车辆" prefix={<IconSearch />} value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" />
|
||||
<Button htmlType="submit" theme="solid" disabled={!candidates.data?.items.length}>诊断首个结果</Button>
|
||||
{deferredKeyword && !selected ? <VehicleCandidateList
|
||||
className="v2-source-candidates"
|
||||
items={candidates.data?.items ?? []}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在查询车辆…"
|
||||
emptyText="没有匹配的已绑定车辆"
|
||||
actionLabel="诊断"
|
||||
showProtocols
|
||||
onSelect={choose}
|
||||
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</Button></div></> : undefined}
|
||||
/> : null}
|
||||
</form>
|
||||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||||
{!selected ? <div className="v2-source-empty"><strong>先选择一辆车</strong><span>将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。</span></div> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-empty"><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{data ? <>
|
||||
<div className="v2-source-summary">
|
||||
<article><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></article>
|
||||
<article><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><span>{data.evidence.recommendedLocationProtocol || '—'}</span></article>
|
||||
<article><small>来源状态</small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></article>
|
||||
<article><small>策略版本</small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></article>
|
||||
<Card className="v2-source-summary-card"><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></Card>
|
||||
<Card className="v2-source-summary-card"><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><Tag color="blue" type="light" size="small">{data.evidence.recommendedLocationProtocol || '—'}</Tag></Card>
|
||||
<Card className={`v2-source-summary-card${data.evidence.locationConflict ? ' is-warning' : ' is-ok'}`}><small>来源状态</small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></Card>
|
||||
<Card className="v2-source-summary-card"><small>策略版本</small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></Card>
|
||||
</div>
|
||||
<div className="v2-source-recommendation"><strong>推荐说明</strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
|
||||
{!editable ? <p className="v2-source-readonly">当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</p> : null}
|
||||
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th>来源 / 终端</th><th>协议</th><th>在线 / 质量</th><th>首次 / 最近上报</th><th>上报周期</th><th>位置 / 里程</th><th>选举结论</th><th>运维策略</th></tr></thead><tbody>
|
||||
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => {
|
||||
queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next);
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||||
]);
|
||||
}} />)}
|
||||
</tbody></table></div>
|
||||
<section className="v2-source-audit"><header><strong>最近策略审计</strong><span>仅记录实际变更,原始 source_key 不对前端暴露</span></header>
|
||||
<Card className="v2-source-recommendation"><header><Tag color="blue" type="light" size="small">推荐说明</Tag><span>{data.refreshHint}</span></header><p>{data.recommendationReason}</p></Card>
|
||||
{!editable ? <div className="v2-source-readonly"><Tag color="orange" type="light" size="small">只读权限</Tag><span>当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</span></div> : null}
|
||||
{mobileLayout
|
||||
? <SourceDiagnosticCards vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />
|
||||
: <SourceDiagnosticTable vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />}
|
||||
<Card className="v2-source-audit" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="最近策略审计" description="仅记录实际变更,原始 source_key 不对前端暴露" />
|
||||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p>该车辆尚无人工来源策略或提供方变更。</p>}
|
||||
</section>
|
||||
</Card>
|
||||
</> : null}
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const [workspace, setWorkspace] = useState<OperationsWorkspace>('reconciliation');
|
||||
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
|
||||
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
|
||||
const data = health.data; const sources = readiness.data;
|
||||
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
|
||||
return <div className="v2-ops-page">
|
||||
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新全局证据</button></header>
|
||||
<ReconciliationCenter />
|
||||
<SourceDiagnosticWorkspace />
|
||||
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
|
||||
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
|
||||
<section className="v2-ops-kpis">
|
||||
<article><small>运行版本</small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
|
||||
<article><small>活跃连接</small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
|
||||
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
|
||||
<article><small>Redis 在线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>实时探针</span></article>
|
||||
<article><small>服务身份 / 在线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></article>
|
||||
<PageHeader
|
||||
title="运维质量"
|
||||
description="先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。"
|
||||
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
|
||||
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
|
||||
meta={<Typography.Text type="tertiary">{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
|
||||
actions={<Button theme="light" icon={<IconRefresh />} loading={health.isFetching || readiness.isFetching} onClick={refresh}>刷新全局证据</Button>}
|
||||
/>
|
||||
<SegmentedTabs
|
||||
className="v2-ops-tabs"
|
||||
variant="filled"
|
||||
ariaLabel="运维质量工作区"
|
||||
value={workspace}
|
||||
onChange={setWorkspace}
|
||||
items={[
|
||||
{ key: 'reconciliation', label: '差异处置' },
|
||||
{ key: 'diagnostic', label: '单车诊断' },
|
||||
{ key: 'health', label: '全局健康' }
|
||||
]}
|
||||
/>
|
||||
<section className={`v2-ops-workspace is-${workspace}`} role="tabpanel" aria-label={workspace === 'reconciliation' ? '差异处置' : workspace === 'diagnostic' ? '单车诊断' : '全局健康'}>
|
||||
{workspace === 'reconciliation' ? <ReconciliationCenter /> : null}
|
||||
{workspace === 'diagnostic' ? <SourceDiagnosticWorkspace /> : null}
|
||||
{workspace === 'health' ? <>
|
||||
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
|
||||
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
|
||||
<section className="v2-ops-kpis">
|
||||
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>运行模式</small><strong>{data?.runtime.dataMode === 'production' ? '生产数据' : '待确认'}</strong><HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.platformRelease ? '版本已注入' : '缺少版本'}</HealthTag></Card>
|
||||
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>活跃连接</small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>网关实时连接</span></Card>
|
||||
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><HealthTag status={data?.kafkaLag === 0 ? 'ok' : 'warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</HealthTag></Card>
|
||||
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>Redis 在线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>在线状态快照</span></Card>
|
||||
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>服务身份 / 在线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></Card>
|
||||
</section>
|
||||
<div className="v2-ops-grid"><Card className="v2-ops-panel v2-ops-links" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="数据链路" meta="15 秒自动刷新" /><div className="v2-ops-link-list">{data?.linkHealth.length ? data.linkHealth.map((item) => <Card className={`v2-ops-link-card is-${item.status}`} key={item.name} bodyStyle={{ padding: 0 }}><span className="v2-ops-link-status"><i /><strong>{item.name}</strong></span><p>{item.detail || '无补充信息'}</p><HealthTag status={item.status} /></Card>) : <Empty className="v2-ops-link-empty" title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
|
||||
<Card className="v2-ops-panel v2-ops-runtime" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="运行时安全" /><Descriptions className="v2-ops-runtime-descriptions" align="left" size="small" data={[
|
||||
{ key: '生产数据模式', value: <HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</HealthTag> },
|
||||
{ key: 'MySQL 写探针', value: <HealthTag status={data?.mysqlWritable ? 'ok' : 'error'}>{data?.mysqlWritable ? '正常' : '异常'}</HealthTag> },
|
||||
{ key: 'TDengine 写探针', value: <HealthTag status={data?.tdengineWritable ? 'ok' : 'error'}>{data?.tdengineWritable ? '正常' : '异常'}</HealthTag> },
|
||||
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
|
||||
{ key: '高德安全代理', value: <HealthTag status={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'ok' : 'warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</HealthTag> }
|
||||
]} />{data?.capacityFindings?.length ? <div className="v2-ops-capacity-findings">{data.capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning">待处理</HealthTag><p>{item}</p></Card>)}</div> : <Empty className="v2-ops-capacity-empty" image={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
|
||||
<Card className="v2-ops-panel v2-ops-sources" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="验收口径与处置建议" />{sources ? sources.sources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sources.sources.map((source) => <Card className={`v2-ops-source-card is-${source.severity}`} key={source.protocol} title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>} headerExtraContent={<HealthTag status={source.severity}>{`${source.online} / ${source.total} 在线`}</HealthTag>} headerLine>
|
||||
<div className="v2-ops-source-evidence"><strong>当前证据</strong><p>{source.evidence}</p></div><div className="v2-ops-source-action"><strong>建议动作</strong><p>{source.action}</p></div><footer><Tag color="green" type="light" size="small">验收标准</Tag><span>{source.acceptance}</span></footer>
|
||||
</Card>)}</CardGroup> : <Empty className="v2-ops-source-empty" title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
|
||||
</> : null}
|
||||
</section>
|
||||
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong>数据链路</strong><span>15 秒自动刷新</span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
|
||||
<section className="v2-ops-runtime"><header><strong>运行时安全</strong></header><dl><div><dt>生产数据模式</dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL 写探针</dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine 写探针</dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt>请求超时</dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt>高德安全代理</dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear">容量检查无待处理项</p>}</section></div>
|
||||
<section className="v2-ops-sources"><header><strong>协议来源就绪度</strong><span>验收口径与处置建议</span></header><div>{sources?.sources.map((source) => <article key={source.protocol}><div><i className={`is-${source.severity}`} /><strong>{source.protocol}</strong><span>{source.role}</span></div><b>{source.online} / {source.total} 在线</b><p>{source.evidence}</p><p>{source.action}</p><em>{source.acceptance}</em></article>)}</div></section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Empty, Input, RadioGroup, Select, SideSheet, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { ReconciliationIssue } from '../../api/types';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const DESKTOP_PAGE_SIZE = 50;
|
||||
const MOBILE_PAGE_SIZE = 20;
|
||||
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
|
||||
const reviewStatuses = [
|
||||
{ value: 'pending', label: '待处理' },
|
||||
@@ -31,6 +39,16 @@ function severityLabel(severity: string) {
|
||||
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
|
||||
}
|
||||
|
||||
function ReconciliationSeverityTag({ severity }: { severity: string }) {
|
||||
const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'grey';
|
||||
return <Tag className={`v2-reconcile-severity is-${severity}`} color={color} type="light" size="small">{severityLabel(severity)}</Tag>;
|
||||
}
|
||||
|
||||
function ReconciliationStatusTag({ status }: { status: string }) {
|
||||
const color = status === 'pending' ? 'orange' : status === 'confirmed_source_a' || status === 'confirmed_source_b' ? 'blue' : status === 'fixed' || status === 'recovered' ? 'green' : 'grey';
|
||||
return <Tag className={`v2-reconcile-status is-${status}`} color={color} type="light" size="small">{statusLabel(status)}</Tag>;
|
||||
}
|
||||
|
||||
function ruleLabel(rule: string) {
|
||||
return {
|
||||
DUPLICATE_PLATE: '重复车牌',
|
||||
@@ -59,7 +77,7 @@ function evidenceValue(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
|
||||
function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: ReconciliationIssue; onClose: () => void; sheet?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
|
||||
const [note, setNote] = useState(issue.resolutionNote ?? '');
|
||||
@@ -78,11 +96,14 @@ function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue;
|
||||
}
|
||||
});
|
||||
const requiresNote = status !== 'pending';
|
||||
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
|
||||
<header>
|
||||
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
|
||||
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
|
||||
</header>
|
||||
return <Card className={`v2-reconcile-detail${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }} aria-label="差异证据与处置">
|
||||
<WorkspacePanelHeader
|
||||
className="v2-reconcile-detail-heading"
|
||||
title={issue.title}
|
||||
description={ruleLabel(issue.ruleCode)}
|
||||
meta={<ReconciliationSeverityTag severity={issue.severity} />}
|
||||
actions={sheet ? undefined : <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
|
||||
/>
|
||||
<div className="v2-reconcile-detail-body">
|
||||
<section className="v2-reconcile-identity">
|
||||
<div><small>车辆</small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
|
||||
@@ -90,26 +111,32 @@ function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue;
|
||||
<div><small>时间</small><strong>{fmt(issue.lastSeenAt)}</strong><span>首次 {fmt(issue.firstSeenAt)}</span></div>
|
||||
</section>
|
||||
<p className="v2-reconcile-summary">{issue.summary}</p>
|
||||
<section className="v2-reconcile-evidence">
|
||||
<header><strong>规则证据</strong><span>保留来源值、时间与影响对象;未知来源不自动判对错</span></header>
|
||||
<Card className="v2-reconcile-evidence" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="规则证据"
|
||||
description="保留来源值、时间与影响对象;未知来源不自动判对错"
|
||||
/>
|
||||
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
|
||||
</section>
|
||||
<section className="v2-reconcile-review">
|
||||
<header><strong>复核结论</strong><span>版本 v{issue.version}</span></header>
|
||||
<label><span>处置状态</span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
|
||||
<label><span>说明{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
|
||||
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
|
||||
</Card>
|
||||
<Card className="v2-reconcile-review" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="复核结论" meta={`版本 v${issue.version}`} />
|
||||
<label><span id="reconcile-review-status-label">处置状态</span><RadioGroup aria-labelledby="reconcile-review-status-label" type="button" buttonSize="small" value={status} options={reviewStatuses} onChange={(event) => setStatus(String(event.target.value))} /></label>
|
||||
<label><span>说明{requiresNote ? '(必填)' : '(可选)'}</span><TextArea aria-label={`说明${requiresNote ? '(必填)' : '(可选)'}`} value={note} maxCount={500} autosize={{ minRows: 3, maxRows: 6 }} onChange={setNote} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
|
||||
<Button theme="solid" disabled={save.isPending || (requiresNote && !note.trim())} loading={save.isPending} onClick={() => save.mutate()}>保存复核结论</Button>
|
||||
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
|
||||
</section>
|
||||
<section className="v2-reconcile-actions">
|
||||
<header><strong>处理履历</strong><span>{issue.actions?.length ?? 0} 条</span></header>
|
||||
</Card>
|
||||
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="处理履历" meta={`${issue.actions?.length ?? 0} 条`} />
|
||||
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p>暂无处理记录。</p>}
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</aside>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export default function ReconciliationCenter() {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const pageSize = mobileLayout ? MOBILE_PAGE_SIZE : DESKTOP_PAGE_SIZE;
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [status, setStatus] = useState('active');
|
||||
@@ -117,7 +144,11 @@ export default function ReconciliationCenter() {
|
||||
const [ruleCode, setRuleCode] = useState('all');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selectedID, setSelectedID] = useState('');
|
||||
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const [trendExpanded, setTrendExpanded] = useState(false);
|
||||
const closeDetail = () => setSelectedID('');
|
||||
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-reconcile-detail-sidesheet', 'v2-reconcile-detail-sheet', '差异证据与处置', '关闭差异详情');
|
||||
useEffect(() => setOffset(0), [deferredKeyword, mobileLayout, ruleCode, severity, status]);
|
||||
|
||||
const summary = useQuery({
|
||||
queryKey: ['reconciliation-summary', 30],
|
||||
@@ -126,9 +157,9 @@ export default function ReconciliationCenter() {
|
||||
gcTime: QUERY_MEMORY.summaryGcTime
|
||||
});
|
||||
const issues = useQuery({
|
||||
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
|
||||
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, pageSize, offset],
|
||||
queryFn: ({ signal }) => api.reconciliationIssues({
|
||||
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
|
||||
keyword: deferredKeyword, ruleCode, severity, status, limit: pageSize, offset
|
||||
}, signal),
|
||||
staleTime: 20_000,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
@@ -149,60 +180,142 @@ export default function ReconciliationCenter() {
|
||||
const data = summary.data;
|
||||
const page = issues.data;
|
||||
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
|
||||
const currentPage = Math.floor(offset / pageSize) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil((page?.total ?? 0) / pageSize));
|
||||
const issueRows = page?.items ?? [];
|
||||
const activeFilterCount = Number(Boolean(deferredKeyword)) + Number(severity !== 'all') + Number(ruleCode !== 'all');
|
||||
const filterSummary = `${status === 'active' ? '活跃差异' : statusLabel(status)}${activeFilterCount ? ` · 另 ${activeFilterCount} 项` : ''}`;
|
||||
const selectedIssue = detail.data ?? issueRows.find((item) => item.id === selectedID);
|
||||
const detailPanel = selectedID && detail.isPending
|
||||
? <Card className={`v2-reconcile-detail${mobileLayout ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><div className="v2-reconcile-detail-loading" role="status"><Spin size="middle" tip="正在读取差异证据…" /></div></Card>
|
||||
: selectedID && detail.isError
|
||||
? <Card className={`v2-reconcile-detail${mobileLayout ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
|
||||
: detail.data
|
||||
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet={mobileLayout} />
|
||||
: null;
|
||||
const columns = [
|
||||
{ title: '等级', dataIndex: 'severity', width: 76, render: (value: string) => <ReconciliationSeverityTag severity={value} /> },
|
||||
{ title: '差异 / 规则', dataIndex: 'title', width: 230, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.title}</strong><small>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</small></span> },
|
||||
{ title: '车辆', dataIndex: 'plate', width: 176, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.plate || '非单车差异'}</strong><small>{item.vin || '平台级口径'}</small></span> },
|
||||
{ title: '来源', dataIndex: 'protocolA', width: 150, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><small>{item.category}</small></span> },
|
||||
{ title: '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{fmt(item.lastSeenAt)}</strong><small>首次 {fmt(item.firstSeenAt)}</small></span> },
|
||||
{ title: '状态', dataIndex: 'status', width: 112, render: (value: string) => <ReconciliationStatusTag status={value} /> }
|
||||
];
|
||||
|
||||
return <section className="v2-reconcile-center">
|
||||
<header className="v2-reconcile-heading">
|
||||
<div><small>每日自动对账</small><strong>数据差异中心</strong><span>自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。</span></div>
|
||||
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh />刷新结果</button>
|
||||
</header>
|
||||
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-reconcile-heading"
|
||||
title="数据差异中心"
|
||||
description="自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。"
|
||||
meta="每日自动对账"
|
||||
actions={<>
|
||||
<Button
|
||||
className="v2-reconcile-trend-open"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
icon={trendExpanded ? <IconChevronUp /> : <IconChevronDown />}
|
||||
aria-label="30 天趋势"
|
||||
aria-expanded={trendExpanded}
|
||||
aria-controls="v2-reconcile-trend"
|
||||
onClick={() => setTrendExpanded((value) => !value)}
|
||||
>30 天趋势</Button>
|
||||
<Button theme="light" icon={<IconRefresh />} loading={summary.isFetching || issues.isFetching} onClick={refresh}>刷新结果</Button>
|
||||
</>}
|
||||
/>
|
||||
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
|
||||
<div className="v2-reconcile-kpis">
|
||||
<article className="is-active"><small>活跃差异</small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span>待处理与已确认来源</span></article>
|
||||
<article><small>待复核</small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span>尚未形成结论</span></article>
|
||||
<article><small>已确认来源</small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span>保留为活跃差异</span></article>
|
||||
<article><small>自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>检测不再命中</span></article>
|
||||
<article className={data?.overSla ? 'is-overdue' : ''}><small>超过 24 小时</small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span>需要优先跟进</span></article>
|
||||
<div className="v2-reconcile-overview">
|
||||
<div className="v2-reconcile-kpis">
|
||||
<Card className="v2-reconcile-kpi-card is-active" bodyStyle={{ padding: 0 }}><small>活跃差异</small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span>待处理与已确认来源</span></Card>
|
||||
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>待复核</small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span>尚未形成结论</span></Card>
|
||||
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>已确认来源</small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span>保留为活跃差异</span></Card>
|
||||
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small>自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>检测不再命中</span></Card>
|
||||
</div>
|
||||
<Card className={`v2-reconcile-sla${data?.overSla ? ' is-overdue' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<span><Tag color={data?.overSla ? 'red' : 'green'} type="light" size="small">SLA</Tag><small>超过 24 小时</small></span>
|
||||
<strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong>
|
||||
<p>{data?.overSla ? '优先复核高等级差异' : '当前没有超时差异'}</p>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="v2-reconcile-layout">
|
||||
<div className={`v2-reconcile-layout${trendExpanded ? ' is-trend-open' : ''}`}>
|
||||
<div className="v2-reconcile-main">
|
||||
<div className="v2-reconcile-toolbar">
|
||||
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
|
||||
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="active">活跃差异</option><option value="pending">待处理</option><option value="confirmed_source_a">确认来源 A</option><option value="confirmed_source_b">确认来源 B</option><option value="recovered">已恢复</option><option value="fixed">已修复</option><option value="no_action">无需处理</option><option value="all">全部状态</option>
|
||||
</select>
|
||||
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
|
||||
<option value="all">全部等级</option><option value="critical">严重</option><option value="major">重要</option><option value="minor">一般</option>
|
||||
</select>
|
||||
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
|
||||
<option value="all">全部规则</option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
|
||||
</select>
|
||||
<MobileFilterToggle
|
||||
title="筛选差异"
|
||||
summary={filterSummary}
|
||||
expanded={!filtersCollapsed}
|
||||
collapsedLabel="修改"
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
/>
|
||||
<div className={`v2-reconcile-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`}>
|
||||
<Input className="v2-reconcile-search" prefix={<IconSearch />} aria-label="搜索差异车辆或规则" value={keyword} onChange={setKeyword} placeholder="车牌、VIN、标题或说明" />
|
||||
<span className="v2-sr-only" id="reconcile-status-filter-label">筛选差异状态</span>
|
||||
<Select aria-labelledby="reconcile-status-filter-label" value={status} onChange={(value) => setStatus(String(value))} optionList={[
|
||||
{ value: 'active', label: '活跃差异' }, { value: 'pending', label: '待处理' },
|
||||
{ value: 'confirmed_source_a', label: '确认来源 A' }, { value: 'confirmed_source_b', label: '确认来源 B' },
|
||||
{ value: 'recovered', label: '已恢复' }, { value: 'fixed', label: '已修复' },
|
||||
{ value: 'no_action', label: '无需处理' }, { value: 'all', label: '全部状态' }
|
||||
]} />
|
||||
<span className="v2-sr-only" id="reconcile-severity-filter-label">筛选严重程度</span>
|
||||
<Select aria-labelledby="reconcile-severity-filter-label" value={severity} onChange={(value) => setSeverity(String(value))} optionList={[
|
||||
{ value: 'all', label: '全部等级' }, { value: 'critical', label: '严重' },
|
||||
{ value: 'major', label: '重要' }, { value: 'minor', label: '一般' }
|
||||
]} />
|
||||
<span className="v2-sr-only" id="reconcile-rule-filter-label">筛选差异规则</span>
|
||||
<Select aria-labelledby="reconcile-rule-filter-label" value={ruleCode} onChange={(value) => setRuleCode(String(value))} optionList={[
|
||||
{ value: 'all', label: '全部规则' },
|
||||
...(data?.byRule.map((item) => ({ value: item.name, label: `${ruleLabel(item.name)} · ${item.count}` })) ?? [])
|
||||
]} />
|
||||
</div>
|
||||
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
|
||||
<div className="v2-reconcile-table-wrap">
|
||||
<table className="v2-reconcile-table"><thead><tr><th>等级</th><th>差异 / 规则</th><th>车辆</th><th>来源</th><th>最近发现</th><th>状态</th></tr></thead>
|
||||
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
|
||||
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
|
||||
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span></td>
|
||||
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
|
||||
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
|
||||
<td><strong>{fmt(item.lastSeenAt)}</strong><span>首次 {fmt(item.firstSeenAt)}</span></td>
|
||||
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{issues.isPending ? <p className="v2-reconcile-empty">正在读取差异队列…</p> : null}
|
||||
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty">当前筛选条件下没有差异。</p> : null}
|
||||
{!mobileLayout ? <Table className="v2-reconcile-table" columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: 934 }} onRow={(item) => item ? detailTriggerRow({
|
||||
className: selectedID === item.id ? 'is-selected' : '',
|
||||
expanded: selectedID === item.id,
|
||||
label: `查看 ${item.plate || item.title} 差异证据`,
|
||||
testId: `reconcile-row-${item.id}`,
|
||||
onOpen: () => setSelectedID(item.id)
|
||||
}) : ({})} /> : <div className="v2-reconcile-mobile-list">{issueRows.map((item) => <Card key={item.id} className={`v2-reconcile-mobile-card${selectedID === item.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<Button theme="borderless" type="tertiary" className="v2-reconcile-mobile-action" aria-pressed={selectedID === item.id} aria-expanded={selectedID === item.id} aria-label={`查看 ${item.plate || item.title} 差异证据`} onClick={() => setSelectedID(item.id)}>
|
||||
<span className="v2-reconcile-mobile-content"><header><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span><small>{fmt(item.lastSeenAt)}</small></header><strong>{item.title}</strong><p>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</p><dl><div><dt>车辆</dt><dd>{item.plate || '非单车差异'}</dd></div><div><dt>来源</dt><dd>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</dd></div></dl><footer>查看证据与处置<IconChevronRight /></footer></span>
|
||||
</Button>
|
||||
</Card>)}</div>}
|
||||
{issues.isPending ? <div className="v2-reconcile-loading" role="status"><Spin size="middle" tip="正在读取差异队列…" /></div> : null}
|
||||
{!issues.isPending && !issueRows.length ? <Empty className="v2-reconcile-empty" title="当前筛选条件没有差异" description="调整状态、等级、规则或搜索条件后重试。" /> : null}
|
||||
</div>
|
||||
<footer className="v2-reconcile-pagination"><span>共 {(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 {activeCount} 条活跃</span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>上一页</button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button></div></footer>
|
||||
<footer className="v2-reconcile-pagination"><TablePagination page={currentPage} totalPages={totalPages} info={`共 ${(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 ${activeCount} 条活跃`} onPageChange={(next) => setOffset((next - 1) * pageSize)} /></footer>
|
||||
</div>
|
||||
<aside className="v2-reconcile-trend">
|
||||
<header><strong>近 30 天趋势</strong><span>最近运行 {fmt(data?.lastRunAt)}</span></header>
|
||||
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
|
||||
{trendExpanded ? <div id="v2-reconcile-trend" className="v2-reconcile-trend-slot"><Card className="v2-reconcile-trend" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="近 30 天趋势"
|
||||
meta={`最近运行 ${fmt(data?.lastRunAt)}`}
|
||||
actions={<Button
|
||||
className="v2-reconcile-trend-toggle"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={<IconChevronUp />}
|
||||
aria-label="收起趋势"
|
||||
aria-expanded
|
||||
aria-controls="v2-reconcile-trend"
|
||||
onClick={() => setTrendExpanded(false)}
|
||||
>收起</Button>}
|
||||
/>
|
||||
<><div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
|
||||
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
|
||||
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
|
||||
</aside>
|
||||
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
|
||||
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
|
||||
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
|
||||
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer></>
|
||||
</Card></div> : null}
|
||||
{!mobileLayout ? detailPanel : null}
|
||||
</div>
|
||||
</section>;
|
||||
</Card>
|
||||
<SideSheet
|
||||
className="v2-reconcile-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selectedID)}
|
||||
width="100%"
|
||||
aria-label="差异证据与处置"
|
||||
title={<div className="v2-reconcile-sheet-title"><strong>差异证据与处置</strong><span>{selectedIssue ? `${selectedIssue.plate || '平台级差异'} · ${ruleLabel(selectedIssue.ruleCode)}` : '加载规则证据与处置履历'}</span></div>}
|
||||
onCancel={closeDetail}
|
||||
>
|
||||
{mobileLayout ? detailPanel : null}
|
||||
</SideSheet>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,17 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('已绑定主车辆').closest('.semi-card')).toHaveClass('v2-mileage-summary-card');
|
||||
expect(screen.getByText('车辆每日里程').closest('.semi-card')).toHaveClass('v2-mileage-results');
|
||||
expect(view.container.querySelector('.v2-mileage-query-panel.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-page > .v2-page-heading')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-summary.semi-card')).toHaveAttribute('aria-label', '里程查询统计信息');
|
||||
expect(view.container.querySelector('.v2-mileage-summary-secondary.semi-card-group')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-mileage-summary-card.semi-card')).toHaveLength(4);
|
||||
expect(view.container.querySelector('.v2-mileage-summary-card.is-primary .v2-mileage-summary-value')).toHaveTextContent('193.3 km');
|
||||
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
|
||||
@@ -127,7 +137,10 @@ test('removes the previous mileage scope while a new date range is loading', asy
|
||||
expect(screen.queryByText('193.3 km')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('104.6 km')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('88.7 km')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('2026-07-10 至 2026-07-16').length).toBeGreaterThan(0);
|
||||
const end = new Date();
|
||||
const start = new Date(Date.now() - 6 * 86_400_000);
|
||||
const localDate = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
|
||||
expect(screen.getAllByText(`${localDate(start)} 至 ${localDate(end)}`).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('uses one responsive mileage matrix without viewport listeners', async () => {
|
||||
@@ -137,26 +150,67 @@ test('uses one responsive mileage matrix without viewport listeners', async () =
|
||||
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
||||
await waitFor(() => expect(view.container.querySelectorAll('.v2-mileage-table tbody tr')).toHaveLength(1));
|
||||
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2);
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveTextContent('车牌');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveTextContent('区间总里程');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-vin')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveClass('is-plate');
|
||||
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total');
|
||||
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('routes vertical wheel input from the mileage workspace to the table without hijacking buttons', async () => {
|
||||
prepareData();
|
||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
||||
const page = view.container.querySelector<HTMLElement>('.v2-mileage-page');
|
||||
expect(page).toBeInTheDocument();
|
||||
await waitFor(() => expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument());
|
||||
const scroller = view.container.querySelector<HTMLElement>('.v2-mileage-table-wrap');
|
||||
expect(scroller).toBeInTheDocument();
|
||||
Object.defineProperties(scroller!, {
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollHeight: { configurable: true, value: 900 },
|
||||
scrollTop: { configurable: true, value: 0, writable: true }
|
||||
});
|
||||
|
||||
fireEvent.wheel(page!, { deltaY: 180, deltaX: 0 });
|
||||
expect(scroller!.scrollTop).toBe(180);
|
||||
|
||||
fireEvent.wheel(screen.getByRole('button', { name: '刷新里程数据' }), { deltaY: 180, deltaX: 0 });
|
||||
expect(scroller!.scrollTop).toBe(180);
|
||||
|
||||
fireEvent.wheel(page!, { deltaY: 120, deltaX: 240 });
|
||||
expect(scroller!.scrollTop).toBe(180);
|
||||
});
|
||||
|
||||
test('supports searching and selecting license plates before querying exact VINs', async () => {
|
||||
prepareData();
|
||||
mocks.vehicles.mockResolvedValue({
|
||||
items: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' }
|
||||
],
|
||||
total: 2,
|
||||
limit: 12,
|
||||
offset: 0
|
||||
});
|
||||
renderPage();
|
||||
const search = screen.getByRole('textbox', { name: '搜索车牌' });
|
||||
fireEvent.focus(search);
|
||||
fireEvent.change(search, { target: { value: '粤A12' } });
|
||||
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: /粤A12345/ }));
|
||||
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 GB32960 JT808 选择' });
|
||||
expect(screen.getAllByRole('option')).toHaveLength(1);
|
||||
fireEvent.click(option);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() => {
|
||||
const calls = mocks.mileageStatistics.mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
|
||||
});
|
||||
expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345');
|
||||
expect(document.querySelector('.v2-mileage-chip')).toHaveClass('semi-tag');
|
||||
expect(document.querySelector('.v2-mileage-chip')).toHaveTextContent('粤A12345');
|
||||
});
|
||||
|
||||
test('shows and recovers from a failed license plate candidate query', async () => {
|
||||
@@ -179,8 +233,12 @@ test('lets users disable mileage sources and persists the source priority', asyn
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
|
||||
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.v2-mileage-source-card.semi-card')).toHaveLength(3);
|
||||
expect(screen.getByText('优先级 1').closest('.semi-tag')).toHaveClass('v2-mileage-source-priority');
|
||||
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('GPS 里程').closest('.semi-tag')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('仪表盘里程')).toHaveLength(2);
|
||||
expect(screen.getByRole('button', { name: /数据源.*GB32960 优先.*3\/3/ })).toHaveAttribute('title', '当前优先:国标 GB32960');
|
||||
fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
@@ -206,14 +264,16 @@ test('paginates all unique vehicles when no license plate is selected', async ()
|
||||
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('1/2');
|
||||
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
|
||||
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
|
||||
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('2/2');
|
||||
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Empty, Input, SideSheet, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FormEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
|
||||
@@ -8,8 +9,14 @@ import { createMileageExportStream, type MileageExportStream } from '../domain/m
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const DETAIL_LIMIT = 10_000;
|
||||
@@ -106,6 +113,8 @@ function initialCriteria(searchParams: URLSearchParams): Criteria {
|
||||
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const enabled = value.filter((source) => source.enabled);
|
||||
const primarySource = enabled[0];
|
||||
useSideSheetA11y(open, '.v2-mileage-source-sidesheet', 'v2-mileage-source-strategy', '数据源策略配置', '关闭数据源策略');
|
||||
const update = (sources: MileageSourceOption[]) => {
|
||||
onChange(sources);
|
||||
try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ }
|
||||
@@ -124,19 +133,25 @@ function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onC
|
||||
};
|
||||
|
||||
return <div className="v2-mileage-source-strategy">
|
||||
<button type="button" className="v2-mileage-source-trigger" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
|
||||
<IconSetting /><span>数据源</span><b>{enabled.length}/3</b>
|
||||
</button>
|
||||
{open ? <section className="v2-mileage-source-popover" role="dialog" aria-label="数据源策略配置">
|
||||
<header><div><strong>数据源策略</strong><span>同车同日按优先级选择一个里程来源</span></div><button type="button" aria-label="关闭数据源策略" onClick={() => setOpen(false)}><IconClose /></button></header>
|
||||
<div className="v2-mileage-source-list">{value.map((source, index) => <article key={source.protocol} className={source.enabled ? '' : 'is-disabled'}>
|
||||
<button type="button" className={`v2-mileage-source-switch${source.enabled ? ' is-on' : ''}`} role="switch" aria-checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onClick={() => toggle(source.protocol)}><i /></button>
|
||||
<div><strong>{source.label}</strong><small><b>{source.mileageType}</b><code>{source.protocol}</code></small></div>
|
||||
<em>{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</em>
|
||||
<p><button type="button" aria-label={`上移 ${source.label}`} disabled={index === 0} onClick={() => move(index, -1)}><IconArrowUp /></button><button type="button" aria-label={`下移 ${source.label}`} disabled={index === value.length - 1} onClick={() => move(index, 1)}><IconArrowDown /></button></p>
|
||||
</article>)}</div>
|
||||
<footer><span>同车同日只使用一个来源,避免重复累计;至少保留一个来源。</span><button type="button" onClick={() => setOpen(false)}>完成</button></footer>
|
||||
</section> : null}
|
||||
<Button className="v2-mileage-source-trigger" theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-expanded={open} aria-controls="v2-mileage-source-strategy" title={`当前优先:${primarySource?.label ?? '未配置'}`} onClick={() => setOpen((current) => !current)}>
|
||||
<span>数据源</span><em>{primarySource?.protocol ?? '未配置'} 优先</em><b>{enabled.length}/3</b>
|
||||
</Button>
|
||||
<SideSheet
|
||||
className="v2-mileage-source-sidesheet"
|
||||
visible={open}
|
||||
aria-label="数据源策略"
|
||||
width={430}
|
||||
title={<div className="v2-mileage-source-title"><strong>数据源策略</strong><span>同车同日按优先级选择一个里程来源</span></div>}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={<div className="v2-mileage-source-footer"><span>同车同日只使用一个来源,避免重复累计;至少保留一个来源。</span><Button theme="solid" onClick={() => setOpen(false)}>完成</Button></div>}
|
||||
>
|
||||
<div className="v2-mileage-source-list">{value.map((source, index) => <Card key={source.protocol} className={`v2-mileage-source-card${source.enabled ? '' : ' is-disabled'}`} bodyStyle={{ padding: 0 }}>
|
||||
<Switch className="v2-mileage-source-switch" checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onChange={() => toggle(source.protocol)} />
|
||||
<div className="v2-mileage-source-copy"><strong>{source.label}</strong><small><Tag color="blue" type="light" size="small">{source.mileageType}</Tag><code>{source.protocol}</code></small></div>
|
||||
<Tag className="v2-mileage-source-priority" color={source.enabled ? 'blue' : 'grey'} type="light" size="small">{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</Tag>
|
||||
<div className="v2-mileage-source-order"><Button theme="borderless" aria-label={`上移 ${source.label}`} icon={<IconArrowUp />} disabled={index === 0} onClick={() => move(index, -1)} /><Button theme="borderless" aria-label={`下移 ${source.label}`} icon={<IconArrowDown />} disabled={index === value.length - 1} onClick={() => move(index, 1)} /></div>
|
||||
</Card>)}</div>
|
||||
</SideSheet>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -168,7 +183,7 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
|
||||
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
|
||||
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
|
||||
const add = (vehicle: VehicleRow) => {
|
||||
if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return;
|
||||
@@ -181,20 +196,35 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
||||
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
|
||||
<IconSearch />
|
||||
<div className="v2-mileage-selection">
|
||||
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
|
||||
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
|
||||
</button>)}
|
||||
<input value={search} onFocus={openPicker} onBlur={closePicker} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
|
||||
{value.map((vehicle) => <Tag
|
||||
key={vehicle.vin}
|
||||
className="v2-mileage-chip"
|
||||
color="blue"
|
||||
type="light"
|
||||
closable
|
||||
onClose={(_, event) => {
|
||||
event.stopPropagation();
|
||||
onChange(value.filter((item) => item.vin !== vehicle.vin));
|
||||
}}
|
||||
>
|
||||
{vehicle.plate || vehicle.vin}
|
||||
</Tag>)}
|
||||
<Input borderless value={search} onFocus={openPicker} onBlur={closePicker} onChange={(next) => { setSearch(next); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
|
||||
</div>
|
||||
{open ? <div className="v2-mileage-options" role="listbox">
|
||||
<header><span>车牌候选</span><em>{value.length}/{MAX_SELECTED_VEHICLES} 已选</em></header>
|
||||
{candidates.isLoading ? <p>正在搜索车辆…</p> : null}
|
||||
{!candidates.isLoading && candidates.isError ? <div className="v2-vehicle-option-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '车牌候选加载失败'}</span><button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => void candidates.refetch()}>重试</button></div> : null}
|
||||
{!candidates.isLoading && options.map((vehicle) => <button type="button" role="option" aria-selected={selected.has(vehicle.vin)} key={vehicle.vin} disabled={selected.has(vehicle.vin)} onMouseDown={(event) => event.preventDefault()} onClick={() => add(vehicle)}>
|
||||
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span>{selected.has(vehicle.vin) ? <em>已选择</em> : null}
|
||||
</button>)}
|
||||
{!candidates.isLoading && !candidates.isError && !options.length ? <p>没有匹配的车牌</p> : null}
|
||||
</div> : null}
|
||||
{open ? <VehicleCandidateList
|
||||
className="v2-mileage-options"
|
||||
items={options}
|
||||
loading={candidates.isLoading}
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车牌候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
emptyText="没有匹配的车牌"
|
||||
selectedVins={selected}
|
||||
disableSelected
|
||||
header="车牌候选"
|
||||
meta={`${value.length}/${MAX_SELECTED_VEHICLES} 已选`}
|
||||
showProtocols
|
||||
onSelect={add}
|
||||
/> : null}
|
||||
</div>
|
||||
<small>支持车牌关键字搜索,最多选择 {MAX_SELECTED_VEHICLES} 辆</small>
|
||||
</label>;
|
||||
@@ -216,10 +246,10 @@ function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageSt
|
||||
];
|
||||
const primary = items[2];
|
||||
const secondary = [items[0], items[1], items[3]];
|
||||
return <section className="v2-mileage-summary" aria-label="里程查询统计信息">
|
||||
<article className="is-primary"><small>{primary[0]}</small><strong>{primary[1]}</strong><span>{primary[2]}</span></article>
|
||||
<div className="v2-mileage-summary-secondary">{secondary.map(([label, value, note]) => <article key={label}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</div>
|
||||
</section>;
|
||||
return <Card className="v2-mileage-summary" aria-label="里程查询统计信息" bodyStyle={{ padding: 0 }}>
|
||||
<Card className="v2-mileage-summary-card is-primary" bodyStyle={{ padding: 0 }} aria-label={`${primary[0]}:${primary[1]};${primary[2]}`}><small>{primary[0]}</small><strong className="v2-mileage-summary-value">{primary[1]}</strong><span>{primary[2]}</span></Card>
|
||||
<CardGroup className="v2-mileage-summary-secondary" type="grid" spacing={0}>{secondary.map(([label, value, note]) => <Card className="v2-mileage-summary-card" bodyStyle={{ padding: 0 }} aria-label={`${label}:${value};${note}`} key={label}><small>{label}</small><strong>{value}</strong><span>{note}</span></Card>)}</CardGroup>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; totalMileageKm: number };
|
||||
@@ -240,20 +270,35 @@ function dateLabel(date: string) {
|
||||
return `${Number(month)}/${Number(day)}`;
|
||||
}
|
||||
|
||||
function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) {
|
||||
function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
|
||||
let maxDailyMileage = 1;
|
||||
for (const row of rows) {
|
||||
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
|
||||
}
|
||||
return <div className="v2-mileage-table-wrap">
|
||||
<table className="v2-mileage-table">
|
||||
<thead><tr><th className="is-sticky is-plate">车牌</th><th className="is-sticky is-vin">VIN</th>{dates.map((date) => <th key={date} className="is-number is-date" title={date}>{dateLabel(date)}</th>)}<th className="is-number is-total">区间总里程</th></tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.vin}><td className="is-sticky is-plate"><strong>{row.plate || '未绑定'}</strong></td><td className="is-sticky is-vin"><code>{row.vin}</code></td>{dates.map((date) => {
|
||||
const mileage = row.days.get(date);
|
||||
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
|
||||
return <td key={date} className={`is-number${mileage != null ? ' is-daily' : ' is-empty'}`} title={mileage != null ? `来源:${row.sources.get(date) || '—'}` : undefined} style={intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined}>{mileage != null ? `${formatKm(mileage)} km` : '—'}</td>;
|
||||
})}<td className="is-number is-period is-total">{formatKm(row.totalMileageKm)} km</td></tr>)}</tbody>
|
||||
</table>
|
||||
const columns = [
|
||||
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
|
||||
...dates.map((date) => ({
|
||||
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
|
||||
onHeaderCell: () => ({ title: date }),
|
||||
onCell: (row?: VehicleMileageMatrix) => {
|
||||
const mileage = row?.days.get(date);
|
||||
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
|
||||
return {
|
||||
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
|
||||
title: mileage != null ? `来源:${row?.sources.get(date) || '—'}` : undefined,
|
||||
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
|
||||
};
|
||||
},
|
||||
render: (_value: unknown, row: VehicleMileageMatrix) => {
|
||||
const mileage = row.days.get(date);
|
||||
return mileage != null ? `${formatKm(mileage)} km` : '—';
|
||||
}
|
||||
})),
|
||||
{ title: '区间总里程', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: 128, onCell: () => ({ className: 'is-number is-period is-total' }), render: (value: number) => `${formatKm(value)} km` }
|
||||
];
|
||||
const tableWidth = 120 + dates.length * 96 + 128;
|
||||
return <div className="v2-mileage-table-wrap" ref={scrollRef}>
|
||||
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -269,6 +314,8 @@ export default function StatisticsPage() {
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const exportControllerRef = useRef<AbortController | null>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -277,6 +324,23 @@ export default function StatisticsPage() {
|
||||
exportControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const page = pageRef.current;
|
||||
if (!page) return;
|
||||
const redirectWheelToTable = (event: WheelEvent) => {
|
||||
if (window.matchMedia('(max-width: 680px)').matches || event.ctrlKey || event.shiftKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
||||
if (event.target instanceof Element && event.target.closest('input,button,select,textarea,[role="dialog"],[role="listbox"]')) return;
|
||||
const scroller = tableScrollRef.current;
|
||||
if (!scroller) return;
|
||||
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroller.scrollTop + event.deltaY));
|
||||
if (nextScrollTop === scroller.scrollTop) return;
|
||||
scroller.scrollTop = nextScrollTop;
|
||||
event.preventDefault();
|
||||
};
|
||||
page.addEventListener('wheel', redirectWheelToTable, { passive: false });
|
||||
return () => page.removeEventListener('wheel', redirectWheelToTable);
|
||||
}, []);
|
||||
const hasVehicles = criteria.vehicles.length > 0;
|
||||
const criteriaError = mileageDateRangeError(criteria);
|
||||
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
|
||||
@@ -420,32 +484,38 @@ export default function StatisticsPage() {
|
||||
exportControllerRef.current.abort();
|
||||
};
|
||||
|
||||
return <div className="v2-mileage-page">
|
||||
return <div className="v2-mileage-page" ref={pageRef}>
|
||||
<MonitorReturnBar />
|
||||
<section className="v2-mileage-query-panel">
|
||||
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>查询条件</b><small>{criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天`}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
|
||||
<Card className="v2-mileage-query-panel" bodyStyle={{ padding: 0 }}>
|
||||
<MobileFilterToggle title="查询条件" summary={criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天`} expanded={!filtersCollapsed} collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
|
||||
<label><span>开始日期</span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束日期</span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
|
||||
<button className="v2-primary-button" type="submit">查询</button>
|
||||
<label><span>开始日期</span><Input aria-label="开始日期" type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /></label>
|
||||
<label><span>结束日期</span><Input aria-label="结束日期" type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></label>
|
||||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button>
|
||||
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
|
||||
<div className="v2-mileage-ranges"><span>快捷范围</span><button className={isSameRange(draft, todayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(todayRange)}>今天</button><button className={isSameRange(draft, yesterdayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(yesterdayRange)}>昨天</button><button className={isSameRange(draft, sevenDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(sevenDayRange)}>近 7 天</button><button className={isSameRange(draft, thirtyDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(thirtyDayRange)}>近 30 天</button><button className={isSameRange(draft, ninetyDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(ninetyDayRange)}>近 90 天</button></div>
|
||||
<div className="v2-mileage-ranges"><span>快捷范围</span><Button theme={isSameRange(draft, todayRange) ? 'solid' : 'light'} onClick={() => setRange(todayRange)}>今天</Button><Button theme={isSameRange(draft, yesterdayRange) ? 'solid' : 'light'} onClick={() => setRange(yesterdayRange)}>昨天</Button><Button theme={isSameRange(draft, sevenDayRange) ? 'solid' : 'light'} onClick={() => setRange(sevenDayRange)}>近 7 天</Button><Button theme={isSameRange(draft, thirtyDayRange) ? 'solid' : 'light'} onClick={() => setRange(thirtyDayRange)}>近 30 天</Button><Button theme={isSameRange(draft, ninetyDayRange) ? 'solid' : 'light'} onClick={() => setRange(ninetyDayRange)}>近 90 天</Button></div>
|
||||
</form>
|
||||
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
|
||||
</section>
|
||||
</Card>
|
||||
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
|
||||
<section className="v2-mileage-results">
|
||||
<header><div><strong>车辆每日里程</strong><span>{criteria.dateFrom} 至 {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日</em><button className="is-refresh" type="button" aria-label="刷新里程数据" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新'}</button><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
|
||||
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆每日里程"
|
||||
description={`${criteria.dateFrom} 至 ${criteria.dateTo}`}
|
||||
meta={`${hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · ${dates.length} 个自然日`}
|
||||
actionsClassName="v2-mileage-result-actions"
|
||||
actions={<><Button className="is-refresh" theme="borderless" aria-label="刷新里程数据" icon={<IconRefresh />} onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}>{refreshing ? '更新中' : '刷新'}</Button><Button theme="light" aria-label={isExporting ? '取消导出' : '导出 Excel'} icon={isExporting ? <IconClose /> : <IconDownload />} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? '取消导出' : '导出 Excel'}</Button></>}
|
||||
/>
|
||||
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
|
||||
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
|
||||
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
|
||||
</div> : null}
|
||||
{resultsLoading ? <div className="v2-mileage-loading" role="status" aria-live="polite"><span className="v2-spinner" /><div><strong>正在查询里程</strong><small>新筛选范围返回前不会展示上一范围的数据</small></div></div> : <MileageTable rows={matrixRows} dates={dates} />}
|
||||
{!resultsLoading && !displayVehicles.length ? <div className="v2-mileage-empty">当前没有可展示的车辆</div> : null}
|
||||
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `第 ${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE} 辆`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>下一页</button></div> : null}</footer>
|
||||
</section>
|
||||
{resultsLoading ? <div className="v2-mileage-loading" role="status" aria-live="polite"><Spin size="middle" tip="正在查询里程" /><small>新筛选范围返回前不会展示上一范围的数据</small></div> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
|
||||
{!resultsLoading && !displayVehicles.length ? <Empty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
|
||||
<footer>{!hasVehicles && totalVehicles ? <TablePagination page={page} totalPages={totalPages} info={`共 ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE} 辆${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={fleetVehicles.isFetching} onPageChange={setPage} /> : <span className="v2-table-pagination-info">已选择 {totalVehicles.toLocaleString('zh-CN')} 辆车辆{exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
|
||||
</Card>
|
||||
<footer className="v2-mileage-evidence"><span>数据更新时间:{statistics.data?.asOf || '—'}</span><span>来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}</span><span>当前筛选复用 1 分钟 · 离开后释放明细</span></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,42 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex, followDurationMs, points }: { activeIndex: number; followDurationMs: number; points: unknown[] }) => <div data-testid="track-map" data-active-index={activeIndex} data-follow-duration={followDurationMs} data-point-count={points.length} /> }));
|
||||
vi.mock('@douyinfe/semi-ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@douyinfe/semi-ui')>();
|
||||
return {
|
||||
...actual,
|
||||
Slider: ({ value, onChange, min = 0, max = 100, disabled, ...props }: {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
}) => <input
|
||||
type="range"
|
||||
aria-label={props['aria-label']}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value ?? min}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange?.(Number(event.target.value))}
|
||||
/>,
|
||||
Select: ({ value, onChange, optionList = [], ...props }: {
|
||||
value?: string | number;
|
||||
onChange?: (value: string) => void;
|
||||
optionList?: Array<{ value: string | number; label: React.ReactNode }>;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
}) => <select
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
>
|
||||
{optionList.map((option) => <option key={String(option.value)} value={String(option.value)}>{option.label}</option>)}
|
||||
</select>
|
||||
};
|
||||
});
|
||||
|
||||
const track = {
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z',
|
||||
@@ -51,10 +87,12 @@ test('preserves the exact monitor return after querying another track range', as
|
||||
viewport: { zoom: 14, bounds: '' }, listOffset: 0, listLimit: 50, hasViewport: true
|
||||
});
|
||||
const initialEntry = withMonitorReturn('/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59', monitorPath);
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(view.container.querySelector('.v2-track-page')).toHaveClass('has-monitor-return');
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
|
||||
fireEvent.change(screen.getByLabelText('数据来源'), { target: { value: 'JT808' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '数据来源' }), { target: { value: 'JT808' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询轨迹/ }));
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
|
||||
@@ -62,10 +100,14 @@ test('preserves the exact monitor return after querying another track range', as
|
||||
|
||||
test('keeps committed track criteria authoritative to same-route navigation and browser history', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(view.container.querySelector('.v2-track-page')).not.toHaveClass('has-monitor-return');
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3');
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/07-15 00:00/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
|
||||
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空轨迹路由' }));
|
||||
@@ -76,7 +118,8 @@ test('keeps committed track criteria authoritative to same-route navigation and
|
||||
fireEvent.click(screen.getByRole('button', { name: '后退轨迹路由' }));
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3');
|
||||
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '前进轨迹路由' }));
|
||||
expect(await screen.findByText('先选择车辆,再开始轨迹回放')).toBeInTheDocument();
|
||||
@@ -98,33 +141,86 @@ test('distinguishes a failed vehicle lookup from an empty result and retries in
|
||||
expect(mocks.vehicles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('merges duplicate vehicle sources and keeps the active date shortcut visible', async () => {
|
||||
mocks.vehicles.mockResolvedValue({
|
||||
items: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' }
|
||||
],
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
|
||||
expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const search = screen.getByRole('textbox', { name: '搜索轨迹车辆' });
|
||||
fireEvent.focus(search);
|
||||
fireEvent.change(search, { target: { value: '粤A12345' } });
|
||||
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 JT808 GB32960 选择' });
|
||||
|
||||
expect(document.querySelectorAll('.v2-track-vehicle-options [role="option"]')).toHaveLength(1);
|
||||
expect(option).toHaveTextContent('粤A12345LTEST000000000001JT808GB32960选择');
|
||||
});
|
||||
|
||||
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(view.container.querySelector('.v2-track-query-card.semi-card')).toHaveClass('is-collapsed');
|
||||
expect(view.container.querySelector('.v2-track-rail-result.semi-card')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-current-card.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-playback-dock.semi-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
|
||||
expect(screen.getByText('停留 00:05:00 · 1 个点')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /停留 00:05:00/ })).toHaveClass('semi-button', 'v2-track-list-action');
|
||||
expect(screen.getByRole('button', { name: /停留 00:05:00/ }).closest('.semi-list-item')).toHaveClass('v2-track-evidence-item');
|
||||
expect(view.container.querySelector('.v2-track-evidence-list.semi-list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
|
||||
expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /事件点/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /急加速/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /事件点/ }));
|
||||
const acceleration = screen.getByRole('button', { name: /急加速/ });
|
||||
expect(acceleration).toHaveClass('semi-button', 'v2-track-list-action');
|
||||
expect(acceleration.closest('.semi-list-item')).toHaveClass('v2-track-evidence-item');
|
||||
fireEvent.click(acceleration);
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1');
|
||||
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '概览' }));
|
||||
expect(view.container.querySelectorAll('.v2-track-overview-card.semi-card')).toHaveLength(3);
|
||||
expect(view.container.querySelector('.v2-track-overview-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-source-list.semi-list')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-track-source-item.semi-list-item')).toHaveLength(1);
|
||||
expect(screen.getByText('完整点集').closest('.semi-tag')).toBeInTheDocument();
|
||||
expect(screen.getByText('通过').closest('.semi-tag')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '速度' }), { target: { value: '4' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始轨迹播放' }));
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '65');
|
||||
fireEvent.click(screen.getByRole('button', { name: '暂停轨迹播放' }));
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '180');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起查询面板' }));
|
||||
expect(screen.getByRole('button', { name: /查询与明细/ })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询与明细/ }));
|
||||
expect(screen.getByRole('button', { name: '收起查询面板' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
|
||||
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
|
||||
expect(screen.getByRole('button', { name: '收起条件' })).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起条件' }));
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏查询与明细' }));
|
||||
expect(screen.getByRole('button', { name: '展开查询与明细' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开查询与明细' }));
|
||||
expect(screen.getByRole('button', { name: '隐藏查询与明细' })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
|
||||
@@ -135,6 +231,24 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(0));
|
||||
});
|
||||
|
||||
test('uses a Semi bottom SideSheet for mobile track criteria and evidence', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开查询与明细' }));
|
||||
|
||||
const sheet = await waitFor(() => {
|
||||
const element = document.querySelector('.v2-track-detail-sidesheet .semi-sidesheet-inner');
|
||||
expect(element).toHaveAttribute('aria-label', '轨迹查询与明细');
|
||||
return element;
|
||||
});
|
||||
expect(sheet).toHaveClass('semi-sidesheet-inner');
|
||||
expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-rail')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '关闭轨迹查询与明细' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('coalesces rapid track scrubbing into one address lookup for the final point', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -143,6 +257,7 @@ test('coalesces rapid track scrubbing into one address lookup for the final poin
|
||||
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
|
||||
|
||||
const progress = screen.getByRole('slider', { name: '轨迹播放进度' });
|
||||
expect(progress).toHaveAttribute('max', '2');
|
||||
fireEvent.change(progress, { target: { value: '1' } });
|
||||
fireEvent.change(progress, { target: { value: '2' } });
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Slider, Tag } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
@@ -11,13 +12,20 @@ import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEvent
|
||||
import { TrackMap } from '../map/TrackMap';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const speedOptions = [0.5, 1, 2, 4] as const;
|
||||
type PlaybackSpeed = (typeof speedOptions)[number];
|
||||
type PanelTab = 'stops' | 'events' | 'overview';
|
||||
type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string };
|
||||
type TrackPreset = 'today' | 'yesterday' | 'three-days';
|
||||
const EMPTY_INDEXES: readonly number[] = [];
|
||||
const numberFormatters = new Map<number, Intl.NumberFormat>();
|
||||
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
@@ -76,6 +84,22 @@ function defaultTrackWindow() {
|
||||
return trackWindow();
|
||||
}
|
||||
|
||||
function trackPreset(draft: Pick<Draft, 'dateFrom' | 'dateTo'>, now = new Date()): TrackPreset | undefined {
|
||||
const today = localDateTime(now).slice(0, 10);
|
||||
const yesterdayDate = new Date(now);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = localDateTime(yesterdayDate).slice(0, 10);
|
||||
const threeDayStartDate = new Date(now);
|
||||
threeDayStartDate.setDate(threeDayStartDate.getDate() - 2);
|
||||
const threeDayStart = localDateTime(threeDayStartDate).slice(0, 10);
|
||||
const from = draft.dateFrom.slice(0, 10);
|
||||
const to = draft.dateTo.slice(0, 10);
|
||||
if (from === today && to === today) return 'today';
|
||||
if (from === yesterday && to === yesterday) return 'yesterday';
|
||||
if (from === threeDayStart && to === today) return 'three-days';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function eventTone(type: string) {
|
||||
if (type === 'start') return 'start';
|
||||
if (type === 'end' || type === 'braking' || type === 'gap') return 'end';
|
||||
@@ -103,93 +127,140 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
|
||||
return next;
|
||||
}, [debounced]);
|
||||
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: ({ signal }) => api.vehicles(params, signal), enabled: open, staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime });
|
||||
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
|
||||
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
|
||||
<IconSearch />
|
||||
<input
|
||||
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
|
||||
<Input
|
||||
aria-label="搜索轨迹车辆" prefix={<IconSearch />} autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
|
||||
value={value} onFocus={openPicker} onBlur={closePicker}
|
||||
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
|
||||
onChange={(next) => { onChange(next); setOpen(true); }}
|
||||
/>
|
||||
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
|
||||
{open ? <div className="v2-track-vehicle-options" role="listbox">
|
||||
<header><span>车辆候选</span><em>支持车牌或 VIN</em></header>
|
||||
{candidates.isFetching ? <p><span className="v2-spinner" />正在搜索车辆</p> : null}
|
||||
{!candidates.isFetching && candidates.isError ? <div className="v2-vehicle-option-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败'}</span><button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => void candidates.refetch()}>重试</button></div> : null}
|
||||
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
|
||||
type="button" role="option" aria-selected={false} key={vehicle.vin}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
|
||||
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>选择</em></button>)}
|
||||
{!candidates.isFetching && !candidates.isError && !(candidates.data?.items.length) ? <p>没有匹配车辆</p> : null}
|
||||
</div> : null}
|
||||
{value ? <Button className="v2-track-vehicle-clear" theme="borderless" aria-label="清空车辆" icon={<IconClose />} onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')} /> : null}
|
||||
{open ? <VehicleCandidateList
|
||||
className="v2-track-vehicle-options"
|
||||
items={options}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在搜索车辆"
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
header="车辆候选"
|
||||
meta="车牌优先 · 合并来源"
|
||||
showProtocols
|
||||
onSelect={(vehicle) => { onSelect(vehicle); setOpen(false); }}
|
||||
/> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
|
||||
return <div className="v2-track-overview-panel">
|
||||
<section><header><strong>行程概览</strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
|
||||
<div><dt>开始时间</dt><dd>{dateTime(track.summary.startTime)}</dd></div>
|
||||
<div><dt>结束时间</dt><dd>{dateTime(track.summary.endTime)}</dd></div>
|
||||
<div><dt>行驶里程</dt><dd>{number(track.summary.distanceKm)} km</dd></div>
|
||||
<div><dt>行驶 / 停车</dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
|
||||
<div><dt>平均 / 最高速度</dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
|
||||
<div><dt>停车 / 分段</dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div>
|
||||
</dl></section>
|
||||
<section><header><strong>数据来源</strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点</span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} 点</span><small>{time(source.startTime)}–{time(source.endTime)}</small></article>)}</div></section>
|
||||
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong>轨迹质量</strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small>无效 {track.quality.invalidCoordinatePoints} · 重复 {track.quality.duplicatePoints} · 漂移 {track.quality.driftPoints} · 大间隔 {track.quality.largeGapCount}</small></section>
|
||||
<Card className="v2-track-overview-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="行程概览" meta={<Tag color={track.sampled ? 'orange' : 'green'} type="light" size="small">{track.sampled ? '地图已抽稀' : '完整点集'}</Tag>} />
|
||||
<div className="v2-track-overview-card-body"><Descriptions className="v2-track-overview-descriptions" align="left" size="small" data={[
|
||||
{ key: '开始时间', value: dateTime(track.summary.startTime) },
|
||||
{ key: '结束时间', value: dateTime(track.summary.endTime) },
|
||||
{ key: '行驶里程', value: `${number(track.summary.distanceKm)} km` },
|
||||
{ key: '行驶 / 停车', value: `${formatDuration(track.summary.movingSeconds)} / ${formatDuration(track.summary.stoppedSeconds)}` },
|
||||
{ key: '平均 / 最高速度', value: `${number(track.summary.averageSpeedKmh)} / ${number(track.summary.maximumSpeedKmh)} km/h` },
|
||||
{ key: '停车 / 分段', value: `${track.summary.stopCount} / ${track.summary.segmentCount}` }
|
||||
]} /></div>
|
||||
</Card>
|
||||
<Card className="v2-track-overview-card v2-track-source-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="数据来源" meta={<Tag color="blue" type="light" size="small">{track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点</Tag>} />
|
||||
<div className="v2-track-overview-card-body"><List className="v2-track-source-list">{track.sources.map((source) => <List.Item className="v2-track-source-item" key={source.protocol}><Tag color="blue" type="light" size="small">{source.protocol}</Tag><strong>{source.pointCount.toLocaleString('zh-CN')} 点</strong><small>{time(source.startTime)}–{time(source.endTime)}</small></List.Item>)}</List></div>
|
||||
</Card>
|
||||
<Card className={`v2-track-overview-card v2-track-quality-card is-${track.quality.status}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="轨迹质量" meta={<Tag color={track.quality.status === 'good' ? 'green' : 'orange'} type="light" size="small">{track.quality.status === 'good' ? '通过' : '需关注'}</Tag>} />
|
||||
<div className="v2-track-overview-card-body"><p>{track.quality.evidence}</p><small>无效 {track.quality.invalidCoordinatePoints} · 重复 {track.quality.duplicatePoints} · 漂移 {track.quality.driftPoints} · 大间隔 {track.quality.largeGapCount}</small></div>
|
||||
</Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const TrackRail = memo(function TrackRail({ draft, track, activeStopIndexes, activeEventIndexes, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
|
||||
const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
|
||||
draft: Draft;
|
||||
track?: TrackPlaybackResponse;
|
||||
loading: boolean;
|
||||
activeStopIndexes: readonly number[];
|
||||
activeEventIndexes: readonly number[];
|
||||
tab: PanelTab;
|
||||
queryCollapsed: boolean;
|
||||
onDraft: (draft: Draft) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onTab: (tab: PanelTab) => void;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onToggleQuery: () => void;
|
||||
onCollapse: () => void;
|
||||
}) {
|
||||
const activePreset = trackPreset(draft);
|
||||
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
|
||||
return <aside className="v2-track-rail">
|
||||
<form className="v2-track-query" onSubmit={onSubmit}>
|
||||
<header><div><strong>轨迹查询</strong><span>最长支持连续 7 天</span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
|
||||
<label><span>车辆</span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
|
||||
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}>今天</button><button type="button" onClick={() => choosePreset(1)}>昨天</button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}>近 3 天</button></div>
|
||||
<div className="v2-track-date-grid"><label><span>开始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value="">自动选择最佳来源</option><option value="GB32960">GB32960 · 仪表盘里程</option><option value="JT808">JT808 · GPS 里程</option><option value="YUTONG_MQTT">YUTONG · 仪表盘里程</option></select></label>
|
||||
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch />查询轨迹</button>
|
||||
</form>
|
||||
<Card className={`v2-track-query-card${queryCollapsed ? ' is-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-track-query-header"
|
||||
title="轨迹查询"
|
||||
description={queryCollapsed ? '查询条件已应用' : '最长支持连续 7 天'}
|
||||
actions={<>
|
||||
<Button
|
||||
className="v2-track-query-toggle"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={<IconChevronRight />}
|
||||
aria-label={queryCollapsed ? '修改条件' : '收起条件'}
|
||||
aria-expanded={!queryCollapsed}
|
||||
onClick={onToggleQuery}
|
||||
>{queryCollapsed ? '修改条件' : '收起条件'}</Button>
|
||||
<Button className="v2-track-rail-hide" theme="borderless" aria-label="隐藏查询与明细" icon={<IconChevronLeft />} onClick={onCollapse}><span className="v2-track-rail-hide-label">地图</span></Button>
|
||||
</>}
|
||||
/>
|
||||
{queryCollapsed ? <div className="v2-track-query-summary">
|
||||
<span><strong>{draft.keyword || '尚未选择车辆'}</strong><small>{draft.dateFrom.replace('T', ' ').slice(5)} – {draft.dateTo.replace('T', ' ').slice(5)}</small></span>
|
||||
<Tag color={draft.protocol ? 'blue' : 'grey'} type="light" size="small">{draft.protocol || '自动来源'}</Tag>
|
||||
</div> : <form className="v2-track-query" onSubmit={onSubmit}>
|
||||
<label><span>车辆</span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
|
||||
<div className="v2-track-presets">
|
||||
<Button theme={activePreset === 'today' ? 'solid' : 'light'} aria-pressed={activePreset === 'today'} onClick={() => choosePreset(0)}>今天</Button>
|
||||
<Button theme={activePreset === 'yesterday' ? 'solid' : 'light'} aria-pressed={activePreset === 'yesterday'} onClick={() => choosePreset(1)}>昨天</Button>
|
||||
<Button theme={activePreset === 'three-days' ? 'solid' : 'light'} aria-pressed={activePreset === 'three-days'} onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}>近 3 天</Button>
|
||||
</div>
|
||||
<div className="v2-track-date-grid"><label><span>开始时间</span><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => onDraft({ ...draft, dateFrom: value })} /></label><label><span>结束时间</span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => onDraft({ ...draft, dateTo: value })} /></label></div>
|
||||
<label><span id="track-source-label">数据来源</span><Select aria-labelledby="track-source-label" value={draft.protocol} onChange={(value) => onDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '自动选择最佳来源' }, { value: 'GB32960', label: 'GB32960 · 仪表盘里程' }, { value: 'JT808', label: 'JT808 · GPS 里程' }, { value: 'YUTONG_MQTT', label: 'YUTONG · 仪表盘里程' }]} /></label>
|
||||
<Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}>查询轨迹</Button>
|
||||
</form>}
|
||||
</Card>
|
||||
|
||||
<div className="v2-track-rail-result">
|
||||
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
|
||||
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}>停留点 <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}>事件点 <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}>概览</button></nav>
|
||||
<Card className="v2-track-rail-result" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
className="v2-track-result-header"
|
||||
title={track ? track.plate || track.vin : '轨迹明细'}
|
||||
description={track ? track.vin : '停留、事件与行程证据'}
|
||||
meta={track ? `${number(track.summary.distanceKm)} km` : undefined}
|
||||
/>
|
||||
<SegmentedTabs className="v2-track-rail-tabs" ariaLabel="轨迹明细分类" value={tab} onChange={onTab} items={[{ key: 'stops', label: '停留点', count: track?.stops.length ?? 0 }, { key: 'events', label: '事件点', count: track?.events.length ?? 0 }, { key: 'overview', label: '概览' }]} />
|
||||
<div className="v2-track-rail-scroll">
|
||||
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong>选择车辆后查询轨迹</strong><p>停留点、轨迹事件与行程证据会在这里统一呈现。</p></div> : null}
|
||||
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={activeStopIndexes.includes(index) ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small>停留 {formatDuration(stop.durationSeconds)} · {stop.pointCount} 个点</small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty">当前时间窗没有超过 3 分钟的停留点</p> : null}</div> : null}
|
||||
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={activeEventIndexes.includes(index) ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
|
||||
{!track ? <Empty className="v2-track-rail-empty" image={<IconMapPin />} title="选择车辆后查询轨迹" description="停留点、轨迹事件与行程证据会在这里统一呈现。" /> : null}
|
||||
{track && tab === 'stops' ? <List className="v2-track-evidence-list v2-track-stop-list">{track.stops.map((stop, index) => <List.Item className="v2-track-evidence-item" key={`${stop.startTime}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeStopIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small>停留 {formatDuration(stop.durationSeconds)} · {stop.pointCount} 个点</small></span><em>{time(stop.endTime)}</em></Button></List.Item>)}{!track.stops.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有停留点" description="时间窗内没有超过 3 分钟的连续停留。" /> : null}</List> : null}
|
||||
{track && tab === 'events' ? <List className="v2-track-evidence-list v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <List.Item className="v2-track-evidence-item" key={`${event.type}-${event.time}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeEventIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></Button></List.Item>; })}{!track.events.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有事件点" description="时间窗内没有识别到启停、急加速或异常间隔。" /> : null}</List> : null}
|
||||
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>;
|
||||
});
|
||||
|
||||
const SegmentRail = memo(function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
|
||||
const SegmentRail = memo(function SegmentRail({ track }: { track: TrackPlaybackResponse }) {
|
||||
const segments = track.segments.slice(0, 160);
|
||||
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
|
||||
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <button
|
||||
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <span
|
||||
aria-label={`${segment.title} ${time(segment.startTime)} 至 ${time(segment.endTime)}`}
|
||||
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
|
||||
onClick={() => onSelectIndex(segment.sampledStartIndex)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
|
||||
style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
|
||||
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
|
||||
type="button"
|
||||
/>)}</div>;
|
||||
});
|
||||
|
||||
export default function TrackPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
const fallback = useMemo(defaultTrackWindow, []);
|
||||
const routeKey = searchParams.toString();
|
||||
@@ -210,8 +281,10 @@ export default function TrackPage() {
|
||||
const [showStops, setShowStops] = useState(true);
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
|
||||
const [railCollapsed, setRailCollapsed] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 700px)').matches);
|
||||
const [queryCollapsed, setQueryCollapsed] = useState(() => Boolean(criteria.keyword));
|
||||
const animationRef = useRef<number>();
|
||||
const lastFrameRef = useRef(0);
|
||||
useSideSheetA11y(mobileLayout && !railCollapsed, '.v2-track-detail-sidesheet', 'v2-track-detail-sheet', '轨迹查询与明细', '关闭轨迹查询与明细');
|
||||
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
|
||||
@@ -231,7 +304,10 @@ export default function TrackPage() {
|
||||
const currentAddressPoint = useMemo(() => trackAddressCoordinate(current?.longitude, current?.latitude), [current?.latitude, current?.longitude]);
|
||||
const [addressPoint, setAddressPoint] = useState<TrackAddressCoordinate>();
|
||||
|
||||
useEffect(() => { setDraft(criteria); }, [criteria]);
|
||||
useEffect(() => {
|
||||
setDraft(criteria);
|
||||
setQueryCollapsed(Boolean(criteria.keyword));
|
||||
}, [criteria]);
|
||||
useEffect(() => {
|
||||
if (playing || !currentAddressPoint) return;
|
||||
const timer = window.setTimeout(() => setAddressPoint(currentAddressPoint), TRACK_ADDRESS_SETTLE_MS);
|
||||
@@ -276,6 +352,7 @@ export default function TrackPage() {
|
||||
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setQueryCollapsed(true);
|
||||
setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
|
||||
}, [draft, monitorReturn, setSearchParams]);
|
||||
const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]);
|
||||
@@ -287,51 +364,61 @@ export default function TrackPage() {
|
||||
setPlaying((value) => !value);
|
||||
};
|
||||
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
|
||||
const trackRail = <TrackRail draft={draft} track={track} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onToggleQuery={() => setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />;
|
||||
|
||||
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
|
||||
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
|
||||
<MonitorReturnBar />
|
||||
<TrackRail draft={draft} track={track} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={collapseRail} />
|
||||
{mobileLayout ? <SideSheet
|
||||
className="v2-track-detail-sidesheet"
|
||||
visible={!railCollapsed}
|
||||
placement="bottom"
|
||||
height="min(82dvh, 720px)"
|
||||
aria-label="轨迹查询与明细"
|
||||
title={<div className="v2-track-detail-sheet-title"><strong>轨迹查询与明细</strong><span>{track ? `${track.plate || track.vin} · ${number(track.summary.distanceKm)} km` : '选择车辆和时间范围'}</span></div>}
|
||||
footer={null}
|
||||
onCancel={collapseRail}
|
||||
>{trackRail}</SideSheet> : trackRail}
|
||||
<section className="v2-track-stage">
|
||||
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />
|
||||
|
||||
{railCollapsed ? <Button className="v2-track-rail-expand" theme="light" aria-label="展开查询与明细" icon={<IconList />} onClick={() => setRailCollapsed(false)}>查询与明细</Button> : null}
|
||||
<div className="v2-track-stage-tools">
|
||||
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span>查询与明细</span></button> : null}
|
||||
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
|
||||
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span>停留点</span></button>
|
||||
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span>导出</span></button>
|
||||
<Button theme={follow ? 'solid' : 'light'} aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} icon={<IconMapPin />} disabled={!points.length} onClick={() => setFollow((value) => !value)}>{follow ? '跟随车辆' : '自由浏览'}</Button>
|
||||
<Button theme={showStops ? 'solid' : 'light'} aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} icon={showStops ? <IconEyeOpened /> : <IconEyeClosed />} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>停留点</Button>
|
||||
<Button theme="light" aria-label="导出轨迹 CSV" icon={<IconDownload />} disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}>导出</Button>
|
||||
</div>
|
||||
|
||||
{track?.points.length ? <>
|
||||
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
|
||||
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
|
||||
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong><small title={track.coverage.evidence}>{track.coverage.evidence}</small></span>
|
||||
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} 有效点 → {track.coverage.returnedPoints.toLocaleString('zh-CN')} 地图点</em>
|
||||
</div>
|
||||
<article className="v2-track-current-card">
|
||||
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
|
||||
<Card className="v2-track-current-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title={track.plate || track.vin} description={dateTime(current?.deviceTime)} meta={`${number(progress, 0)}%`} />
|
||||
<div><span><small>速度</small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small>方向</small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
|
||||
<p title={addressSettling ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
|
||||
</article>
|
||||
</> : <div className="v2-track-empty-state"><IconMapPin /><strong>先选择车辆,再开始轨迹回放</strong><p>地图会显示完整路径、停留点、事件点和播放位置。</p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch />选择车辆</button></div>}
|
||||
</Card>
|
||||
</> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放" description="地图会显示完整路径、停留点、事件点和播放位置。"><Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}>选择车辆</Button></Empty></Card>}
|
||||
|
||||
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" />正在读取并加工历史轨迹</div> : null}
|
||||
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
|
||||
|
||||
<footer className="v2-track-playback-dock">
|
||||
<Card className="v2-track-playback-dock" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
|
||||
<div className="v2-track-dock-progress">
|
||||
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
|
||||
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
|
||||
<div><time>{time(current?.deviceTime)}</time><span>数据点 {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
|
||||
{track ? <SegmentRail track={track} /> : <div className="v2-track-segment-placeholder" />}
|
||||
<Slider key={`track-progress-${points.length}`} className="v2-track-progress-slider" aria-label="轨迹播放进度" min={0} max={Math.max(0, points.length - 1)} step={1} value={boundedIndex} onChange={(value) => selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `第 ${Number(value) + 1} / ${points.length} 个数据点`} />
|
||||
<div className="v2-track-progress-meta"><time>{time(current?.deviceTime)}</time><span>数据点 {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
|
||||
</div>
|
||||
<div className="v2-track-dock-controls">
|
||||
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
|
||||
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
|
||||
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
|
||||
<label><span>速度</span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
|
||||
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
|
||||
<Button theme="borderless" aria-label="上一个轨迹点" icon={<IconChevronLeft />} onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex} />
|
||||
<Button theme="solid" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} icon={playing ? <IconPause /> : <IconPlay />} onClick={togglePlayback} disabled={points.length < 2} />
|
||||
<Button theme="borderless" aria-label="下一个轨迹点" icon={<IconChevronRight />} onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1} />
|
||||
<label><span id="track-playback-speed-label">速度</span><Select aria-labelledby="track-playback-speed-label" value={playbackSpeed} onChange={(value) => setPlaybackSpeed(Number(value) as PlaybackSpeed)} optionList={speedOptions.map((speed) => ({ value: speed, label: `${speed}×` }))} /></label>
|
||||
<Button theme="borderless" aria-label="回到起点" icon={<IconRefresh />} onClick={() => selectIndex(0)} disabled={!boundedIndex} />
|
||||
</div>
|
||||
<div className="v2-track-dock-metrics"><span><small>总里程</small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small>数据来源</small><strong>{current?.protocol || '—'}</strong></span><span><small>车辆状态</small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
|
||||
</footer>
|
||||
</Card>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ const mocks = vi.hoisted(() => ({
|
||||
createCustomerUser: vi.fn(),
|
||||
updateCustomerUser: vi.fn()
|
||||
}));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
layout.mobile = false;
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
@@ -38,19 +41,129 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [duplicatedVehicle, duplicatedVehicle], total: 1, limit: 20, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
|
||||
expect((await screen.findByRole('button', { name: '移除 粤A11111' })).closest('article')).toHaveTextContent('粤A11111VIN001');
|
||||
const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ });
|
||||
expect(view.container.querySelector('.v2-user-list.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument();
|
||||
expect(customer).toHaveClass('semi-button', 'v2-user-list-item');
|
||||
expect(customer.closest('.semi-list-item')).toHaveClass('v2-user-list-row');
|
||||
expect(customer).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(customer).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(view.container.querySelector('.v2-user-editor.semi-card')).not.toBeInTheDocument();
|
||||
fireEvent.click(customer);
|
||||
await waitFor(() => expect(customer).toHaveAttribute('aria-expanded', 'true'));
|
||||
expect(view.container.querySelector('.v2-user-editor.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-editor-tabs.semi-tabs')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(3);
|
||||
expect(view.container.querySelector('.v2-user-vehicle-section.semi-card')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /菜单权限/ }));
|
||||
expect(screen.getByText('客户开放菜单').closest('.semi-tag')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
expect(document.querySelector('.v2-user-vehicle-section .v2-user-section-title .semi-tag')).toHaveTextContent('1 辆');
|
||||
const granted = await screen.findByRole('button', { name: '移除 粤A11111' });
|
||||
expect(granted).toHaveClass('semi-button');
|
||||
expect(granted.closest('.v2-assigned-vehicle-card')).toHaveClass('semi-card');
|
||||
expect(granted.closest('.v2-assigned-vehicle-card')).toHaveTextContent('粤A11111VIN001');
|
||||
expect(screen.getByRole('button', { name: '调整 粤A11111 有效期' })).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
||||
expect(await screen.findByLabelText('粤A11111 启用时间')).toHaveValue('2026-06-05T00:00');
|
||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
|
||||
expect(document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner')).toHaveAttribute('aria-label', '车辆授权有效期');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭车辆授权有效期' }));
|
||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||
expect(document.querySelector('.v2-user-avatar')).toHaveClass('semi-avatar');
|
||||
expect(document.querySelector('.v2-user-status-tag')).toHaveClass('semi-tag');
|
||||
expect(document.querySelector('.v2-grant-history')).toHaveClass('semi-collapse');
|
||||
expect(document.querySelector('.v2-grant-history-title .semi-tag')).toHaveTextContent('1 条');
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '按车牌或 VIN 搜索' }), { target: { value: '粤A22222' } });
|
||||
await waitFor(() => expect(mocks.vehicleCoverage).toHaveBeenCalled());
|
||||
const candidates = await screen.findAllByRole('button', { name: /粤A22222.*VIN002.*选择/ });
|
||||
const candidates = await screen.findAllByRole('option', { name: /粤A22222.*VIN002.*选择/ });
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect((mocks.vehicleCoverage.mock.calls[0][0] as URLSearchParams).get('keyword')).toBe('粤A22222');
|
||||
|
||||
fireEvent.click(candidates[0]);
|
||||
expect((await screen.findByRole('button', { name: '移除 粤A22222' })).closest('article')).toHaveTextContent('粤A22222VIN002');
|
||||
expect((await screen.findByRole('button', { name: '移除 粤A22222' })).closest('.v2-assigned-vehicle-card')).toHaveTextContent('粤A22222VIN002');
|
||||
});
|
||||
|
||||
test('pages and filters large vehicle grants without nesting a vehicle-list scrollbar', async () => {
|
||||
const vehicles = Array.from({ length: 12 }, (_, index) => ({
|
||||
vin: `VIN${String(index + 1).padStart(3, '0')}`,
|
||||
plate: `粤A${String(index + 1).padStart(5, '0')}`,
|
||||
validFrom: '2026-06-05T00:00:00+08:00',
|
||||
sourceSystem: 'manual',
|
||||
grantedBy: '平台管理员'
|
||||
}));
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 7,
|
||||
username: 'customer-east',
|
||||
displayName: '华东客户',
|
||||
userType: 'customer',
|
||||
status: 'enabled',
|
||||
customerRef: '',
|
||||
tenantRef: '',
|
||||
authProvider: 'local',
|
||||
menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'],
|
||||
vehicleVins: vehicles.map((vehicle) => vehicle.vin),
|
||||
vehicles,
|
||||
grantHistory: [],
|
||||
createdAt: '2026-07-16T00:00:00Z',
|
||||
updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(10);
|
||||
expect(screen.getByText('第 1–10 条,共 12 辆')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-assigned-pagination')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A00012' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(2);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '筛选已授权车辆' }), { target: { value: '粤A00012' } });
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(1);
|
||||
expect(screen.getByText('1 条匹配结果')).toBeInTheDocument();
|
||||
expect(screen.getByText('第 1–1 条,共 1 辆')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the customer directory visible on mobile and opens details on demand', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled',
|
||||
customerRef: '', tenantRef: '', authProvider: 'local', menuKeys: ['monitor'], vehicleVins: [],
|
||||
vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('客户权限目录')).toBeInTheDocument();
|
||||
const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ });
|
||||
expect(view.container.querySelector('.v2-user-mobile-picker')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument();
|
||||
fireEvent.click(customer);
|
||||
expect(await screen.findByRole('button', { name: '关闭账号详情' })).toBeInTheDocument();
|
||||
expect(customer).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭账号详情' }));
|
||||
expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument();
|
||||
expect(customer).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getAllByRole('button', { name: '新建客户账号' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('uses a standard Semi empty state when no customer account exists', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('还没有客户账号')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-list-empty.semi-empty')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: '创建第一个客户账号' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('submits per-vehicle authorization interval instead of only a VIN list', async () => {
|
||||
@@ -63,8 +176,11 @@ test('submits per-vehicle authorization interval instead of only a VIN list', as
|
||||
mocks.updateCustomerUser.mockResolvedValue({ id: 7 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
||||
fireEvent.change(await screen.findByLabelText('粤A11111 启用时间'), { target: { value: '2026-06-05T00:00' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
|
||||
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled());
|
||||
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { IconChevronRight, IconClose, IconDelete, IconPlus, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
|
||||
const customerMenus = [
|
||||
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
|
||||
@@ -21,6 +30,8 @@ type Draft = {
|
||||
vehicleGrants: CustomerVehicleGrantInput[];
|
||||
};
|
||||
|
||||
type EditorSection = 'identity' | 'menus' | 'vehicles';
|
||||
|
||||
const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
@@ -48,30 +59,42 @@ function formatTime(value?: string) {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function uniqueVehicles<T extends { vin: string; plate: string }>(vehicles: T[]) {
|
||||
const byVIN = new Map<string, T>();
|
||||
for (const vehicle of vehicles) {
|
||||
const vin = vehicle.vin.trim().toUpperCase();
|
||||
const current = byVIN.get(vin);
|
||||
if (!vin || (current?.plate && !vehicle.plate)) continue;
|
||||
byVIN.set(vin, vehicle);
|
||||
}
|
||||
return [...byVIN.values()];
|
||||
function formatGrantTime(value?: string) {
|
||||
if (!value) return '未设置';
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
|
||||
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
|
||||
const [customerKeyword, setCustomerKeyword] = useState('');
|
||||
const visibleCustomers = useMemo(() => {
|
||||
const keyword = customerKeyword.trim().toLocaleLowerCase('zh-CN');
|
||||
if (!keyword) return customers;
|
||||
return customers.filter((user) => [user.displayName, user.username, user.customerRef, user.tenantRef]
|
||||
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
|
||||
}, [customerKeyword, customers]);
|
||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<EditorSection>('identity');
|
||||
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
|
||||
const [vehicleKeyword, setVehicleKeyword] = useState('');
|
||||
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
|
||||
const [assignedKeyword, setAssignedKeyword] = useState('');
|
||||
const [assignedPage, setAssignedPage] = useState(1);
|
||||
const [bulkVINs, setBulkVINs] = useState('');
|
||||
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [editingGrantVIN, setEditingGrantVIN] = useState('');
|
||||
const enabledCustomers = useMemo(() => customers.filter((user) => user.status === 'enabled').length, [customers]);
|
||||
const grantedVehicles = useMemo(() => customers.reduce((total, user) => total + user.vehicles.length, 0), [customers]);
|
||||
const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]);
|
||||
const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : '';
|
||||
const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)));
|
||||
useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期');
|
||||
|
||||
useEffect(() => {
|
||||
if (!creating && selected) {
|
||||
@@ -87,7 +110,20 @@ export default function UsersPage() {
|
||||
staleTime: 30_000
|
||||
});
|
||||
const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]);
|
||||
const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
const candidateVehicles = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
const assignedPageSize = mobileLayout ? 4 : 10;
|
||||
const filteredAssignedGrants = useMemo(() => {
|
||||
const keyword = assignedKeyword.trim().toLocaleLowerCase('zh-CN');
|
||||
if (!keyword) return draft.vehicleGrants;
|
||||
return draft.vehicleGrants.filter((grant) => [grant.vin, vehicleLabels[grant.vin]]
|
||||
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
|
||||
}, [assignedKeyword, draft.vehicleGrants, vehicleLabels]);
|
||||
const assignedTotalPages = Math.max(1, Math.ceil(filteredAssignedGrants.length / assignedPageSize));
|
||||
const safeAssignedPage = Math.min(assignedPage, assignedTotalPages);
|
||||
const visibleAssignedGrants = useMemo(() => {
|
||||
const offset = (safeAssignedPage - 1) * assignedPageSize;
|
||||
return filteredAssignedGrants.slice(offset, offset + assignedPageSize);
|
||||
}, [assignedPageSize, filteredAssignedGrants, safeAssignedPage]);
|
||||
useEffect(() => {
|
||||
if (candidateVehicles.length === 0) return;
|
||||
setVehicleLabels((current) => {
|
||||
@@ -121,26 +157,53 @@ export default function UsersPage() {
|
||||
const startCreate = () => {
|
||||
setCreating(true);
|
||||
setSelectedID(null);
|
||||
setActiveSection('identity');
|
||||
setDraft(draftFromUser());
|
||||
setVehicleKeyword('');
|
||||
setAssignedKeyword('');
|
||||
setAssignedPage(1);
|
||||
setBulkVINs('');
|
||||
setVehicleLabels({});
|
||||
setFeedback('');
|
||||
setEditingGrantVIN('');
|
||||
};
|
||||
const closeEditor = () => {
|
||||
setCreating(false);
|
||||
setSelectedID(null);
|
||||
setDraft(draftFromUser());
|
||||
setVehicleKeyword('');
|
||||
setAssignedKeyword('');
|
||||
setAssignedPage(1);
|
||||
setBulkVINs('');
|
||||
setVehicleLabels({});
|
||||
setFeedback('');
|
||||
setEditingGrantVIN('');
|
||||
};
|
||||
const selectCustomer = (user: AdminUser) => {
|
||||
setCreating(false);
|
||||
setSelectedID(user.id);
|
||||
setActiveSection('vehicles');
|
||||
setDraft(draftFromUser(user));
|
||||
setVehicleKeyword('');
|
||||
setAssignedKeyword('');
|
||||
setAssignedPage(1);
|
||||
setBulkVINs('');
|
||||
setFeedback('');
|
||||
setEditingGrantVIN('');
|
||||
};
|
||||
const toggleVIN = (vin: string) => {
|
||||
if (!assigned.has(vin)) {
|
||||
setAssignedKeyword('');
|
||||
setAssignedPage(1);
|
||||
}
|
||||
setDraft((value) => ({
|
||||
...value,
|
||||
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
|
||||
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
|
||||
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
|
||||
}));
|
||||
if (editingGrantVIN === vin) setEditingGrantVIN('');
|
||||
};
|
||||
const toggleVIN = (vin: string) => setDraft((value) => ({
|
||||
...value,
|
||||
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
|
||||
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
|
||||
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
|
||||
}));
|
||||
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
|
||||
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
|
||||
}));
|
||||
@@ -151,47 +214,174 @@ export default function UsersPage() {
|
||||
const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }));
|
||||
return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) };
|
||||
});
|
||||
setAssignedKeyword('');
|
||||
setAssignedPage(1);
|
||||
setBulkVINs('');
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFeedback('');
|
||||
if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) {
|
||||
setActiveSection('identity');
|
||||
setFeedback('请先完善登录身份中的必填信息');
|
||||
return;
|
||||
}
|
||||
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
|
||||
if (invalidGrant) {
|
||||
setActiveSection('vehicles');
|
||||
setEditingGrantVIN(invalidGrant.vin);
|
||||
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
|
||||
return;
|
||||
}
|
||||
save.mutate();
|
||||
};
|
||||
|
||||
return <div className="v2-user-admin">
|
||||
<header className="v2-user-admin-heading">
|
||||
<div><h2>客户账号与数据权限</h2><p>账号只会看到已分配的菜单和车辆;权限修改最多在 30 秒内对现有会话生效。</p></div>
|
||||
<button type="button" onClick={startCreate}>新建客户账号</button>
|
||||
</header>
|
||||
<div className="v2-user-admin-grid">
|
||||
<aside className="v2-user-list">
|
||||
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span>个客户账号</span></div>
|
||||
{users.isPending ? <p className="v2-user-empty">正在加载账号…</p> : customers.length === 0 ? <p className="v2-user-empty">还没有客户账号</p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
|
||||
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
|
||||
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicles.length} 辆授权车</small></span>
|
||||
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
|
||||
</button>)}
|
||||
</aside>
|
||||
<main className="v2-user-editor">
|
||||
{!creating && !selected ? <div className="v2-user-editor-empty"><strong>选择一个客户账号</strong><p>查看或调整账号状态、菜单和车辆范围。</p><button type="button" onClick={startCreate}>创建第一个客户账号</button></div> : <form onSubmit={submit}>
|
||||
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `最近登录:${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><input type="checkbox" checked={draft.status === 'enabled'} onChange={(event) => setDraft((value) => ({ ...value, status: event.target.checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label></div>
|
||||
<section><h4>登录身份</h4><div className="v2-user-fields">
|
||||
<label><span>用户名</span><input required disabled={!creating} value={draft.username} onChange={(event) => setDraft((value) => ({ ...value, username: event.target.value }))} placeholder="例如 customer-huadong" /></label>
|
||||
<label><span>客户名称</span><input required value={draft.displayName} onChange={(event) => setDraft((value) => ({ ...value, displayName: event.target.value }))} placeholder="显示在平台右上角" /></label>
|
||||
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><input required={creating} type="password" autoComplete="new-password" value={draft.password} onChange={(event) => setDraft((value) => ({ ...value, password: event.target.value }))} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>客户标识(可选)</span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
|
||||
</div></section>
|
||||
<section><h4>菜单权限 <small>只能分配客户开放菜单</small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
|
||||
<section><div className="v2-vehicle-permission-heading"><h4>车辆权限与有效期 <small>已选择 {draft.vehicleGrants.length} 辆</small></h4>{draft.vehicleGrants.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}>清空</button> : null}</div>
|
||||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</button></span></label></div>
|
||||
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p>正在搜索车辆…</p> : candidateVehicles.length === 0 ? <p>没有匹配车辆</p> : candidateVehicles.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={vehicle.vin} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
|
||||
{draft.vehicleGrants.length ? <div className="v2-assigned-vins">{draft.vehicleGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <article key={grant.vin}>
|
||||
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><button type="button" aria-label={`移除 ${plate || grant.vin}`} onClick={() => toggleVIN(grant.vin)}>×</button></header>
|
||||
<div><label><span>启用时间</span><input aria-label={`${plate || grant.vin} 启用时间`} type="datetime-local" required value={grant.validFrom} onChange={(event) => updateGrant(grant.vin, { validFrom: event.target.value })} /></label><label><span>停用时间(可选)</span><input aria-label={`${plate || grant.vin} 停用时间`} type="datetime-local" min={grant.validFrom} value={grant.validTo} onChange={(event) => updateGrant(grant.vin, { validTo: event.target.value })} /></label></div>
|
||||
</article>; })}</div> : <p className="v2-user-empty">尚未分配车辆,客户将无法看到任何车辆数据。</p>}
|
||||
{!creating && selected?.grantHistory?.length ? <details className="v2-grant-history"><summary>历次开放记录 · {selected.grantHistory.length} 条</summary><div>{selected.grantHistory.map((item) => <article key={item.id}><header><strong>{item.plate || '未登记车牌'}</strong><span>{item.vin}</span></header><p>{formatTime(item.validFrom)} → {item.validTo ? formatTime(item.validTo) : '持续有效'}</p><small>开通:{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small></article>)}</div></details> : null}
|
||||
</section>
|
||||
<PageHeader
|
||||
title="客户账号与数据权限"
|
||||
description="客户只会看到已分配的菜单和车辆,权限变更最多在 30 秒内对现有会话生效。"
|
||||
status={`${customers.length} 个客户账号`}
|
||||
meta={<Typography.Text type="tertiary">最小权限原则</Typography.Text>}
|
||||
actions={<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}>新建客户账号</Button>}
|
||||
/>
|
||||
<div className={`v2-user-admin-grid${creating || selected ? ' is-editor-open' : ''}`}>
|
||||
<Card className="v2-user-list" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-user-directory-header"
|
||||
title="客户权限目录"
|
||||
description="按客户查看账号状态、菜单和车辆授权范围"
|
||||
meta={<Tag color="blue" type="light" size="small">{customers.length} 个账号</Tag>}
|
||||
/>
|
||||
<div className="v2-user-directory-body">
|
||||
<div className="v2-user-directory-metrics" aria-label="客户账号概览">
|
||||
<span className="is-primary"><small>启用账号</small><strong>{enabledCustomers}</strong></span>
|
||||
<span><small>停用账号</small><strong>{customers.length - enabledCustomers}</strong></span>
|
||||
<span><small>车辆授权</small><strong>{grantedVehicles}</strong></span>
|
||||
</div>
|
||||
{users.isPending ? <div className="v2-user-list-loading" role="status"><Spin size="middle" tip="正在加载客户账号" /></div> : customers.length === 0 ? <Empty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。"><Button theme="solid" onClick={startCreate}>创建第一个客户账号</Button></Empty> : <>
|
||||
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||||
<List
|
||||
className="v2-customer-list"
|
||||
dataSource={visibleCustomers}
|
||||
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请更换名称或账号关键词。" />}
|
||||
renderItem={(user) => <List.Item key={user.id} className={`v2-user-list-row${selectedID === user.id && !creating ? ' is-active' : ''}`}>
|
||||
<Button
|
||||
className="v2-user-list-item"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
aria-label={`选择客户 ${user.displayName},账号 ${user.username},${user.vehicles.length} 辆授权车`}
|
||||
aria-pressed={selectedID === user.id && !creating}
|
||||
aria-expanded={selectedID === user.id && !creating}
|
||||
onClick={() => selectCustomer(user)}
|
||||
>
|
||||
<Avatar className="v2-user-avatar" color={user.status === 'enabled' ? 'light-blue' : 'grey'} shape="square" size="small">{user.displayName.slice(0, 1)}</Avatar>
|
||||
<span className="v2-user-list-identity"><b>{user.displayName}</b><small>@{user.username}</small></span>
|
||||
<span className="v2-user-list-facts"><small>菜单权限</small><b>{user.menuKeys.length} 项</b></span>
|
||||
<span className="v2-user-list-facts"><small>车辆权限</small><b>{user.vehicles.length} 辆</b></span>
|
||||
<span className="v2-user-list-facts is-login"><small>最近登录</small><b>{formatTime(user.lastLoginAt)}</b></span>
|
||||
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${user.status}`} color={user.status === 'enabled' ? 'green' : 'grey'} type="light" size="small">{user.status === 'enabled' ? '启用' : '停用'}</Tag><IconChevronRight /></span>
|
||||
</Button>
|
||||
</List.Item>}
|
||||
/>
|
||||
</>}
|
||||
</div>
|
||||
</Card>
|
||||
{creating || selected ? <Card className="v2-user-editor" bodyStyle={{ padding: 0 }} aria-label="客户账号详情">
|
||||
<form onSubmit={submit}>
|
||||
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `@${selected?.username} · ${draft.menuKeys.length} 个菜单 · ${draft.vehicleGrants.length} 辆车 · 最近登录 ${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label><Button className="v2-user-editor-close" theme="borderless" type="tertiary" icon={<IconClose />} aria-label="关闭账号详情" onClick={closeEditor} /></div>
|
||||
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => setActiveSection(String(key) as EditorSection)}>
|
||||
<Tabs.TabPane tab="登录身份" itemKey="identity">
|
||||
<Card className="v2-user-editor-section v2-user-identity-section" title="登录身份" headerLine>
|
||||
<div className="v2-user-fields">
|
||||
<label><span>用户名</span><Input required disabled={!creating} value={draft.username} onChange={(value) => setDraft((current) => ({ ...current, username: value }))} placeholder="例如 customer-huadong" /></label>
|
||||
<label><span>客户名称</span><Input required value={draft.displayName} onChange={(value) => setDraft((current) => ({ ...current, displayName: value }))} placeholder="显示在平台右上角" /></label>
|
||||
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><Input required={creating} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>客户标识(可选)</span><Input value={draft.customerRef} onChange={(value) => setDraft((current) => ({ ...current, customerRef: value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
|
||||
</div>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label">菜单权限<Tag color="blue" type="light" size="small">{draft.menuKeys.length}</Tag></span>} itemKey="menus">
|
||||
<Card className="v2-user-editor-section v2-user-menu-section" title={<span className="v2-user-section-title">菜单权限 <Tag color="blue" type="light" size="small">客户开放菜单</Tag></span>} headerLine>
|
||||
<div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><Checkbox checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label">车辆权限<Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length}</Tag></span>} itemKey="vehicles">
|
||||
<Card className="v2-user-editor-section v2-user-vehicle-section" title={<span className="v2-user-section-title">车辆权限与有效期 <Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length} 辆</Tag></span>} headerLine headerExtraContent={draft.vehicleGrants.length ? <Button theme="borderless" type="tertiary" size="small" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}>清空</Button> : null}>
|
||||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><Input aria-label="按车牌或 VIN 搜索" value={vehicleKeyword} onChange={setVehicleKeyword} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><Input value={bulkVINs} onChange={setBulkVINs} placeholder="空格、逗号或换行分隔" /><Button theme="light" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</Button></span></label></div>
|
||||
{deferredVehicleKeyword ? <VehicleCandidateList
|
||||
className="v2-vehicle-candidates"
|
||||
layout="grid"
|
||||
items={candidateVehicles}
|
||||
loading={candidates.isPending}
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
selectedVins={assigned}
|
||||
selectedLabel="已分配"
|
||||
onSelect={(vehicle) => toggleVIN(vehicle.vin)}
|
||||
/> : null}
|
||||
{draft.vehicleGrants.length ? <>
|
||||
<div className="v2-assigned-toolbar">
|
||||
<span><strong>已授权车辆</strong><small>{assignedKeyword ? `${filteredAssignedGrants.length} 条匹配结果` : `${draft.vehicleGrants.length} 辆车拥有当前访问权限`}</small></span>
|
||||
<Input
|
||||
aria-label="筛选已授权车辆"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
value={assignedKeyword}
|
||||
onChange={(value) => {
|
||||
setAssignedKeyword(value);
|
||||
setAssignedPage(1);
|
||||
}}
|
||||
placeholder="筛选已授权车牌或 VIN"
|
||||
/>
|
||||
</div>
|
||||
{visibleAssignedGrants.length ? <div className="v2-assigned-vins">{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <Card key={grant.vin} className="v2-assigned-vehicle-card" bodyStyle={{ padding: 0 }}>
|
||||
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><span className="v2-assigned-vehicle-actions"><Button theme="light" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-grant-window" aria-label={`调整 ${plate || grant.vin} 有效期`} onClick={() => setEditingGrantVIN(grant.vin)}>调整有效期</Button><Button theme="borderless" type="tertiary" size="small" aria-label={`移除 ${plate || grant.vin}`} icon={<IconDelete />} onClick={() => toggleVIN(grant.vin)} /></span></header>
|
||||
<div className="v2-assigned-vehicle-interval"><span><small>启用</small><strong>{formatGrantTime(grant.validFrom)}</strong></span><i aria-hidden="true">→</i><span><small>停用</small><strong>{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}</strong></span></div>
|
||||
</Card>; })}</div> : <Empty className="v2-user-assigned-filter-empty" title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" />}
|
||||
<div className="v2-assigned-pagination">
|
||||
<TablePagination
|
||||
page={safeAssignedPage}
|
||||
totalPages={assignedTotalPages}
|
||||
info={<>第 {(safeAssignedPage - 1) * assignedPageSize + (filteredAssignedGrants.length ? 1 : 0)}–{Math.min(safeAssignedPage * assignedPageSize, filteredAssignedGrants.length)} 条,共 {filteredAssignedGrants.length} 辆</>}
|
||||
onPageChange={setAssignedPage}
|
||||
/>
|
||||
</div>
|
||||
</> : <Empty className="v2-user-vehicle-empty" title="尚未分配车辆" description="客户将无法看到任何车辆数据。" />}
|
||||
{!creating && selected?.grantHistory?.length ? <Collapse className="v2-grant-history">
|
||||
<Collapse.Panel itemKey="grant-history" header={<span className="v2-grant-history-title">历次开放记录 <Tag color="blue" type="light" size="small">{selected.grantHistory.length} 条</Tag></span>}>
|
||||
<div className="v2-grant-history-list">{selected.grantHistory.map((item) => <Card key={item.id} className="v2-grant-history-card" bodyStyle={{ padding: 0 }}>
|
||||
<header><span><strong>{item.plate || '未登记车牌'}</strong><small>{item.vin}</small></span><Tag color={item.validTo ? 'grey' : 'green'} type="light" size="small">{item.validTo ? '已结束' : '有效中'}</Tag></header>
|
||||
<p>{formatTime(item.validFrom)} <i>→</i> {item.validTo ? formatTime(item.validTo) : '持续有效'}</p>
|
||||
<small>开通:{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small>
|
||||
</Card>)}</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse> : null}
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
|
||||
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
|
||||
</form>}
|
||||
</main>
|
||||
<footer><Button theme="solid" htmlType="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</Button></footer>
|
||||
</form>
|
||||
</Card> : null}
|
||||
</div>
|
||||
<SideSheet
|
||||
className="v2-user-grant-sidesheet"
|
||||
visible={Boolean(editingGrant)}
|
||||
width={420}
|
||||
aria-label="车辆授权有效期"
|
||||
title={<div className="v2-user-grant-sheet-title"><strong>调整车辆有效期</strong><span>{editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '车辆授权时间窗口'}</span></div>}
|
||||
onCancel={() => setEditingGrantVIN('')}
|
||||
footer={<div className="v2-user-grant-sheet-footer"><Typography.Text type="tertiary">完成后仍需保存账号权限才会生效</Typography.Text><Button theme="solid" disabled={grantWindowInvalid} onClick={() => setEditingGrantVIN('')}>完成</Button></div>}
|
||||
>
|
||||
{editingGrant ? <div className="v2-user-grant-form">
|
||||
<Card className="v2-user-grant-summary" bodyStyle={{ padding: 0 }}>
|
||||
<span><small>车牌</small><strong>{editingGrantPlate || '未登记车牌'}</strong></span>
|
||||
<span><small>VIN</small><strong>{editingGrant.vin}</strong></span>
|
||||
</Card>
|
||||
<label><span>启用时间</span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 启用时间`} type="datetime-local" required value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
|
||||
<label><span>停用时间(可选)</span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 停用时间`} type="datetime-local" min={editingGrant.validFrom} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} /></label>
|
||||
{grantWindowInvalid ? <p role="alert">请设置启用时间,并确保停用时间晚于启用时间。</p> : <p>不设置停用时间时,车辆授权将持续有效。</p>}
|
||||
</div> : null}
|
||||
</SideSheet>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import VehiclePage from './VehiclePage';
|
||||
|
||||
const fleetMapVehicles = vi.hoisted(() => vi.fn());
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
|
||||
vi.mock('../map/FleetMap', () => ({
|
||||
FleetMap: ({ vehicles }: { vehicles: VehicleRealtimeRow[] }) => {
|
||||
@@ -20,6 +21,7 @@ vi.mock('../map/FleetMap', () => ({
|
||||
vi.mock('../auth/AuthGate', () => ({
|
||||
usePlatformSession: () => ({ session: { name: 'test-viewer', role: 'viewer', authMode: 'enforce' } })
|
||||
}));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
const initialRealtime = {
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocols: ['JT808'], sourceStatus: [],
|
||||
@@ -44,6 +46,7 @@ const detail = {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
layout.mobile = false;
|
||||
vi.restoreAllMocks();
|
||||
fleetMapVehicles.mockReset();
|
||||
});
|
||||
@@ -62,7 +65,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await waitFor(() => expect(realtimeSpy).toHaveBeenCalledTimes(1));
|
||||
const realtimeQuery = client.getQueryCache().find({ queryKey: ['vehicle-detail-realtime', initialRealtime.vin] });
|
||||
@@ -74,12 +77,106 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
|
||||
await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 })
|
||||
]));
|
||||
expect(view.container.querySelector('.v2-identity-band')).toHaveClass('semi-card');
|
||||
expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '实时位置', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '最近事件', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '实时遥测', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument();
|
||||
const telemetryPanel = view.container.querySelector<HTMLElement>('#vehicle-telemetry-panel')!;
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(telemetryPanel, 'scrollIntoView', { configurable: true, value: scrollIntoView });
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳转到实时遥测' }));
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
||||
expect(telemetryPanel).toHaveFocus();
|
||||
expect(view.container.querySelector('.v2-record-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(screen.getByText('待维护').closest('.semi-tag')).toBeInTheDocument();
|
||||
expect(sourceEvidence).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByTitle('展开全部位置来源'));
|
||||
const locationSource = screen.getByRole('button', { name: '查看全部位置来源' });
|
||||
expect(locationSource).toHaveClass('semi-button', 'v2-map-source-link');
|
||||
expect(locationSource.closest('.semi-card')).toHaveClass('v2-single-map-card');
|
||||
const totalMileageSource = screen.getByRole('button', { name: '查看总里程全部来源' });
|
||||
expect(totalMileageSource).toHaveClass('semi-button', 'v2-live-source-link');
|
||||
expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-live-overview');
|
||||
fireEvent.click(locationSource);
|
||||
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('keeps the monitor return on the vehicle page and its nested investigation links', async () => {
|
||||
test('uses the shared Semi empty recovery surface when a vehicle cannot be resolved', async () => {
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, lookupResolved: false });
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [], total: 0, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles/UNKNOWN']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('未找到车辆')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-not-found.semi-card .semi-empty')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /重新查询/ })).toHaveClass('semi-button-primary');
|
||||
});
|
||||
|
||||
test('shows deduplicated Semi vehicle candidates and opens the selected VIN', async () => {
|
||||
const workspace = document.createElement('main');
|
||||
workspace.className = 'v2-content';
|
||||
workspace.scrollTop = 320;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(workspace, 'scrollTo', { configurable: true, value: scrollTo });
|
||||
document.body.appendChild(workspace);
|
||||
const candidate = {
|
||||
vin: initialRealtime.vin,
|
||||
plate: initialRealtime.plate,
|
||||
phone: '13800000000',
|
||||
oem: '测试品牌',
|
||||
protocol: 'JT808',
|
||||
online: true,
|
||||
lastSeen: initialRealtime.lastSeen,
|
||||
locationText: '广东省广州市',
|
||||
bindingScore: 100
|
||||
};
|
||||
const vehicles = vi.spyOn(api, 'vehicles').mockResolvedValue({
|
||||
items: [candidate, { ...candidate }],
|
||||
total: 2,
|
||||
limit: 8,
|
||||
offset: 0
|
||||
});
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const capabilitySummary = screen.getByLabelText('可查询的数据范围');
|
||||
expect(capabilitySummary).toHaveTextContent('统一身份');
|
||||
expect(capabilitySummary).toHaveTextContent('实时状态');
|
||||
expect(capabilitySummary).toHaveTextContent('来源证据');
|
||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(input).toHaveAttribute('aria-controls', 'v2-vehicle-search-options');
|
||||
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
|
||||
expect(vehicles).not.toHaveBeenCalled();
|
||||
fireEvent.focus(input);
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.change(input, { target: { value: initialRealtime.plate } });
|
||||
await waitFor(() => expect(vehicles).toHaveBeenCalled());
|
||||
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT808 选择` });
|
||||
expect(view.container.querySelector('#v2-vehicle-search-options.v2-vehicle-candidate-list')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('#v2-vehicle-search-options')).toHaveAttribute('aria-busy', 'false');
|
||||
expect(screen.getAllByRole('option')).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseDown(option);
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(await screen.findByText(initialRealtime.vin)).toBeInTheDocument();
|
||||
expect(workspace.scrollTop).toBe(0);
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
|
||||
expect(screen.getByRole('group', { name: '车辆快捷操作' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '切换车辆' })).toHaveClass('semi-button', 'semi-button-primary');
|
||||
workspace.remove();
|
||||
});
|
||||
|
||||
test('keeps the monitor return on the vehicle page and its nested investigation actions', async () => {
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
|
||||
@@ -94,21 +191,28 @@ test('keeps the monitor return on the vehicle page and its nested investigation
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
|
||||
const trackLink = screen.getByRole('link', { name: /轨迹回放/ });
|
||||
const historyLink = screen.getByRole('link', { name: /历史数据/ });
|
||||
const mileageLink = screen.getByRole('link', { name: /里程查询/ });
|
||||
expect(new URL(trackLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
|
||||
expect(new URL(historyLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
|
||||
expect(new URL(mileageLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
|
||||
const actionGroup = screen.getByRole('group', { name: '车辆快捷操作' });
|
||||
expect(actionGroup).toHaveTextContent('切换车辆');
|
||||
expect(actionGroup).toHaveTextContent('轨迹回放');
|
||||
expect(actionGroup).toHaveTextContent('历史数据');
|
||||
expect(actionGroup).toHaveTextContent('里程查询');
|
||||
expect(actionGroup).toHaveTextContent('告警事件');
|
||||
expect(withMonitorReturn(`/tracks?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
|
||||
expect(withMonitorReturn(`/history?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
|
||||
expect(withMonitorReturn(`/statistics?vins=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
|
||||
|
||||
const liveOverview = screen.getByText('最新上报').closest('section');
|
||||
const archive = screen.getByText('车辆主档').closest('section');
|
||||
const liveOverview = screen.getByText('最新上报').closest('.semi-card');
|
||||
const archive = screen.getByRole('heading', { name: '车辆主档', level: 5 }).closest('.semi-card');
|
||||
expect(liveOverview).not.toBeNull();
|
||||
expect(archive).not.toBeNull();
|
||||
expect(liveOverview).toHaveClass('v2-record-card', 'v2-live-overview');
|
||||
expect(archive).toHaveClass('v2-record-card', 'v2-archive-card');
|
||||
expect(liveOverview!.compareDocumentPosition(archive!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
expect(addressSpy).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '解析当前位置' }));
|
||||
const addressAction = screen.getByRole('button', { name: '解析当前位置' });
|
||||
expect(addressAction).toHaveClass('semi-button', 'v2-current-address-action');
|
||||
fireEvent.click(addressAction);
|
||||
expect(await screen.findByText('广东省广州市测试道路')).toBeInTheDocument();
|
||||
expect(addressSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -125,17 +229,89 @@ test('shows unavailable live fields as dashes instead of fabricated zeroes', asy
|
||||
todayMileageAvailable: false,
|
||||
todayMileageKm: 0
|
||||
};
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, realtimeSummary: unavailable });
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({
|
||||
...detail,
|
||||
realtimeSummary: unavailable,
|
||||
mileage: {
|
||||
items: [{ vin: unavailable.vin, plate: unavailable.plate, date: '2026-07-15', startMileageKm: 900, endMileageKm: 988.8, dailyMileageKm: 88.8, source: 'JT808' }],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0
|
||||
}
|
||||
});
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [unavailable], total: 1, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const liveOverview = (await screen.findByText('最新上报')).closest('section')!;
|
||||
const liveOverview = (await screen.findByText('最新上报')).closest('.semi-card')!;
|
||||
for (const label of ['速度', 'SOC', '总里程', '当日里程']) {
|
||||
const metric = Array.from(liveOverview.querySelectorAll('.v2-live-grid > div')).find((item) => item.querySelector('small')?.textContent === label);
|
||||
expect(metric).toHaveTextContent('—');
|
||||
expect(metric).not.toHaveTextContent('88.8');
|
||||
expect(metric).not.toHaveTextContent(/^0/);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps protocol telemetry independent and explains mileage semantics', async () => {
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, sources: ['GB32960', 'JT808'] });
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [{ ...initialRealtime, protocols: ['GB32960', 'JT808'] }], total: 1, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({
|
||||
vin: initialRealtime.vin,
|
||||
categories: [{ key: 'vehicle', label: '整车数据', count: 2 }],
|
||||
values: [
|
||||
{
|
||||
key: 'total_mileage_km', sourceField: 'gb32960.vehicle.total_mileage_km', label: '仪表盘总里程', unit: 'km',
|
||||
category: 'vehicle', valueType: 'numeric', value: 1200, protocol: 'GB32960', frameId: 'gb', deviceTime: '', serverTime: '2026-07-16 10:00:00',
|
||||
quality: 'good', qualityReason: '正常', freshnessSeconds: 1
|
||||
},
|
||||
{
|
||||
key: 'total_mileage_km', sourceField: 'jt808.location.total_mileage_km', label: 'GPS 总里程', unit: 'km',
|
||||
category: 'vehicle', valueType: 'numeric', value: 1180, protocol: 'JT808', frameId: 'jt', deviceTime: '', serverTime: '2026-07-16 10:00:00',
|
||||
quality: 'good', qualityReason: '正常', freshnessSeconds: 1
|
||||
}
|
||||
],
|
||||
asOf: '2026-07-16T10:00:00+08:00', staleAfterSeconds: 300, scannedFrames: 2, evidence: 'test'
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('tab', { name: /GB\/T 32960/, selected: true })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.v2-telemetry-table [role="columnheader"]')).toHaveLength(5);
|
||||
expect(document.querySelector('.v2-telemetry-mobile-list')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-event-list.semi-list')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-event-empty.semi-empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('仪表盘总里程')).toBeInTheDocument();
|
||||
expect(screen.queryByText('GPS 总里程')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ }));
|
||||
expect(screen.getByRole('tab', { name: /JT\/T 808/, selected: true })).toBeInTheDocument();
|
||||
expect(screen.getByText('GPS 总里程')).toBeInTheDocument();
|
||||
expect(screen.queryByText('仪表盘总里程')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('uses compact Semi telemetry evidence cards on mobile', async () => {
|
||||
layout.mobile = true;
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({
|
||||
vin: initialRealtime.vin,
|
||||
categories: [{ key: 'vehicle', label: '整车数据', count: 1 }],
|
||||
values: [{
|
||||
key: 'speed_kmh', sourceField: 'jt808.location.speed_kmh', label: 'GPS 速度', unit: 'km/h',
|
||||
category: 'vehicle', valueType: 'numeric', value: 36, protocol: 'JT808', frameId: 'jt-mobile', deviceTime: '2026-07-16 10:00:00',
|
||||
serverTime: '2026-07-16 10:00:01', quality: 'good', qualityReason: '正常', freshnessSeconds: 1
|
||||
}],
|
||||
asOf: '2026-07-16T10:00:01+08:00', staleAfterSeconds: 300, scannedFrames: 1, evidence: 'test'
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('GPS 速度')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-mobile-list.semi-list')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-mobile-item.semi-list-item')).toHaveTextContent('36km/h正常');
|
||||
});
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
|
||||
IconMapPin, IconSearch, IconTickCircle
|
||||
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types';
|
||||
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, hasMenu } from '../auth/session';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import { isValidAMapCoordinate } from '../../integrations/amap';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
import { InlineError, PageLoading } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
|
||||
@@ -28,108 +33,157 @@ function durationHours(seconds?: number | null) { return seconds == null ? '—'
|
||||
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
|
||||
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
|
||||
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
|
||||
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
|
||||
|
||||
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
|
||||
const [sourceSystem, setSourceSystem] = useState('');
|
||||
const [sourceVersion, setSourceVersion] = useState('');
|
||||
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
|
||||
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
|
||||
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
|
||||
});
|
||||
const readFile = async (file?: File) => {
|
||||
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
|
||||
if (!file) return;
|
||||
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
|
||||
};
|
||||
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
|
||||
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
|
||||
const applied = sync.data && !sync.data.dryRun;
|
||||
return <section className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
|
||||
<header><div><strong>批量同步车辆主档</strong><p>CSV 最多 500 辆;先预演,再写入。默认保留人工档案和其他来源。</p></div><button type="button" onClick={onClose}>关闭</button></header>
|
||||
<div className="v2-profile-sync-fields">
|
||||
<label><span>来源系统标识</span><input value={sourceSystem} onChange={(event) => { setSourceSystem(event.target.value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
|
||||
<label><span>来源版本</span><input value={sourceVersion} onChange={(event) => { setSourceVersion(event.target.value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
|
||||
<label><span>冲突策略</span><select value={conflictPolicy} onChange={(event) => { setConflictPolicy(event.target.value as 'preserve' | 'overwrite'); sync.reset(); }}><option value="preserve">保护现有来源</option><option value="overwrite">显式覆盖现有来源</option></select></label>
|
||||
<label className="is-file"><span>CSV 文件</span><input type="file" accept=".csv,text/csv" onChange={(event) => { void readFile(event.target.files?.[0]); }} /></label>
|
||||
</div>
|
||||
<p className="v2-profile-sync-format">表头:<code>{vehicleProfileSyncCSVHeader}</code></p>
|
||||
{fileName ? <p className="v2-profile-sync-file">{fileName} · 已读取 {items.length} 辆</p> : null}
|
||||
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
|
||||
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
|
||||
{sync.data ? <div className="v2-profile-sync-result">
|
||||
<div><span>收到<strong>{sync.data.received}</strong></span><span>新增<strong>{sync.data.created}</strong></span><span>更新<strong>{sync.data.updated}</strong></span><span>未变化<strong>{sync.data.unchanged}</strong></span><span>冲突<strong>{sync.data.conflicted}</strong></span><span>身份缺失<strong>{sync.data.missing}</strong></span></div>
|
||||
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p>未发现来源冲突或身份缺失。</p>}
|
||||
</div> : null}
|
||||
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning">覆盖模式会接管人工或其他系统维护的补充主档,请先确认预演结果。</p> : null}
|
||||
<footer><button type="button" onClick={() => sync.mutate(true)} disabled={!ready}>{sync.isPending ? '处理中…' : '预演同步'}</button><button className="is-primary" type="button" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</button></footer>
|
||||
</section>;
|
||||
function resetWorkspaceScroll() {
|
||||
const content = document.querySelector<HTMLElement>('.v2-content');
|
||||
if (!content) return;
|
||||
content.scrollTop = 0;
|
||||
content.scrollLeft = 0;
|
||||
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
function VehicleSearch() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = usePlatformSession();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
const [candidatesOpen, setCandidatesOpen] = useState(false);
|
||||
const closeTimerRef = useRef<number>();
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const candidateParams = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: '8', offset: '0' });
|
||||
if (deferredKeyword) params.set('keyword', deferredKeyword);
|
||||
return params;
|
||||
}, [deferredKeyword]);
|
||||
const candidates = useQuery({
|
||||
queryKey: ['vehicle-search-options', candidateParams.toString()],
|
||||
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
|
||||
enabled: candidatesOpen,
|
||||
staleTime: 30_000,
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
|
||||
const openCandidates = () => {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
setCandidatesOpen(true);
|
||||
};
|
||||
const closeCandidates = () => {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = window.setTimeout(() => setCandidatesOpen(false), 140);
|
||||
};
|
||||
const openVehicle = (value: string) => {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return;
|
||||
setCandidatesOpen(false);
|
||||
resetWorkspaceScroll();
|
||||
navigate(`/vehicles/${encodeURIComponent(normalized)}`);
|
||||
window.queueMicrotask(resetWorkspaceScroll);
|
||||
window.requestAnimationFrame(resetWorkspaceScroll);
|
||||
window.setTimeout(resetWorkspaceScroll, 120);
|
||||
};
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = keyword.trim();
|
||||
if (value) navigate(`/vehicles/${encodeURIComponent(value)}`);
|
||||
openVehicle(keyword);
|
||||
};
|
||||
return <section className={`v2-vehicle-search-page ${syncOpen ? 'has-sync-panel' : ''}`}>
|
||||
<div className="v2-vehicle-search-card">
|
||||
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
|
||||
<h2>查询单车数字档案</h2>
|
||||
<p>通过车牌、VIN 或终端手机号定位车辆,并查看统一身份、实时状态和来源证据。</p>
|
||||
<form onSubmit={submit}>
|
||||
<IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="输入车牌 / VIN / 终端手机号" autoFocus />
|
||||
<button type="submit">查询车辆 <IconArrowRight /></button>
|
||||
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
|
||||
<Card className="v2-vehicle-search-card">
|
||||
<div className="v2-vehicle-search-intro">
|
||||
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
|
||||
<div>
|
||||
<Typography.Title heading={2}>查询单车数字档案</Typography.Title>
|
||||
<Typography.Text type="secondary">通过车牌、VIN 或终端手机号定位车辆,并查看统一身份、实时状态和来源证据。</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<form className="v2-vehicle-search-form" onSubmit={submit}>
|
||||
<div className={`v2-vehicle-search-picker${candidatesOpen ? ' is-open' : ''}`}>
|
||||
<Input
|
||||
aria-label="搜索车辆"
|
||||
aria-controls="v2-vehicle-search-options"
|
||||
aria-expanded={candidatesOpen}
|
||||
prefix={<IconSearch />}
|
||||
value={keyword}
|
||||
onChange={(value) => { setKeyword(value); setCandidatesOpen(true); }}
|
||||
onFocus={openCandidates}
|
||||
onBlur={closeCandidates}
|
||||
placeholder="输入车牌 / VIN / 终端手机号"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button theme="solid" htmlType="submit" icon={<IconArrowRight />} iconPosition="right">查询车辆</Button>
|
||||
{candidatesOpen ? <VehicleCandidateList
|
||||
id="v2-vehicle-search-options"
|
||||
className="v2-vehicle-search-options"
|
||||
items={options}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在搜索授权车辆"
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
emptyText="没有匹配的授权车辆"
|
||||
header="车辆候选"
|
||||
meta="车牌优先 · VIN 辅助"
|
||||
showProtocols
|
||||
layout={mobileLayout ? 'list' : 'grid'}
|
||||
onSelect={(vehicle) => openVehicle(vehicle.vin)}
|
||||
/> : null}
|
||||
</form>
|
||||
{canAdminister(session) ? <button className="v2-profile-sync-open" type="button" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</button> : null}
|
||||
</div>
|
||||
{syncOpen ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} /> : null}
|
||||
<div className="v2-vehicle-search-capabilities" aria-label="可查询的数据范围">
|
||||
<span><IconBox /><b>统一身份</b><small>车牌、VIN 与车辆主档</small></span>
|
||||
<span><IconClock /><b>实时状态</b><small>位置、里程与最新上报</small></span>
|
||||
<span><IconTickCircle /><b>来源证据</b><small>协议质量与字段明细</small></span>
|
||||
</div>
|
||||
{canAdminister(session) ? <Button className="v2-profile-sync-open" theme="borderless" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</Button> : null}
|
||||
</Card>
|
||||
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><span role="status"><span className="v2-spinner" />正在加载主档同步工具…</span></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
|
||||
const profile = detail.profile;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState({ modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const [draft, setDraft] = useState({ brandName: '', modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleProfile(detail.vin, {
|
||||
modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
brandName: draft.brandName.trim(), modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
|
||||
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
|
||||
}),
|
||||
onSuccess: () => { setEditing(false); onUpdated(); }
|
||||
});
|
||||
const startEditing = () => {
|
||||
setDraft({ modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
|
||||
setDraft({ brandName: profile?.brandName ?? '', modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
|
||||
save.reset(); setEditing(true);
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
|
||||
return <section className="v2-record-card v2-archive-card">
|
||||
<header><strong>车辆主档</strong><span className="v2-profile-heading">完整度 {profile?.completeness ?? 0}%{editable && !editing ? <button type="button" onClick={startEditing}>维护档案</button> : null}</span></header>
|
||||
return <Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆主档"
|
||||
description="身份、车型与运营属性"
|
||||
meta={`完整度 ${profile?.completeness ?? 0}%`}
|
||||
actions={editable && !editing ? <Button theme="borderless" size="small" onClick={startEditing}>维护档案</Button> : null}
|
||||
/>
|
||||
{editing ? <form className="v2-profile-form" onSubmit={submit}>
|
||||
<label><span>车型</span><input maxLength={128} value={draft.modelName} onChange={(event) => setDraft({ ...draft, modelName: event.target.value })} /></label>
|
||||
<label><span>车辆类型</span><input maxLength={64} value={draft.vehicleType} onChange={(event) => setDraft({ ...draft, vehicleType: event.target.value })} /></label>
|
||||
<label><span>所属企业</span><input maxLength={128} value={draft.companyName} onChange={(event) => setDraft({ ...draft, companyName: event.target.value })} /></label>
|
||||
<label><span>运营状态</span><select value={draft.operationStatus} onChange={(event) => setDraft({ ...draft, operationStatus: event.target.value })}>{Object.entries(operationStatusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
|
||||
<label><span>接入服务商</span><input maxLength={128} value={draft.accessProvider} onChange={(event) => setDraft({ ...draft, accessProvider: event.target.value })} /></label>
|
||||
<label><span>首次接入</span><input type="datetime-local" value={draft.firstAccessAt} onChange={(event) => setDraft({ ...draft, firstAccessAt: event.target.value })} /></label>
|
||||
<label><span>累计运行(小时)</span><input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(event) => setDraft({ ...draft, runtimeHours: event.target.value })} /></label>
|
||||
{save.isError ? <p>{save.error.message}</p> : null}<footer><button type="button" onClick={() => setEditing(false)}>取消</button><button className="is-primary" type="submit" disabled={save.isPending}>{save.isPending ? '保存中' : '保存档案'}</button></footer>
|
||||
</form> : <><dl className="v2-record-list">
|
||||
<div><dt>车型 / 类型</dt><dd>{[profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—'}</dd></div>
|
||||
<div><dt>所属企业</dt><dd>{fmt(profile?.companyName)}</dd></div>
|
||||
<div><dt>运营状态</dt><dd>{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</dd></div>
|
||||
<div><dt>接入服务商</dt><dd>{fmt(profile?.accessProvider)}</dd></div>
|
||||
<div><dt>首次接入</dt><dd>{fmt(profile?.firstAccessAt)}</dd></div>
|
||||
<div><dt>累计运行</dt><dd>{durationHours(profile?.runtimeSeconds)}</dd></div>
|
||||
</dl><p className="v2-record-note">身份字段来自网关;补充主档来源 {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
|
||||
</section>;
|
||||
<label><span>车辆品牌</span><Input maxLength={128} value={draft.brandName} onChange={(value) => setDraft({ ...draft, brandName: value })} /></label>
|
||||
<label><span>车型</span><Input maxLength={128} value={draft.modelName} onChange={(value) => setDraft({ ...draft, modelName: value })} /></label>
|
||||
<label><span>车辆类型</span><Input maxLength={64} value={draft.vehicleType} onChange={(value) => setDraft({ ...draft, vehicleType: value })} /></label>
|
||||
<label><span>所属企业</span><Input maxLength={128} value={draft.companyName} onChange={(value) => setDraft({ ...draft, companyName: value })} /></label>
|
||||
<label><span>运营状态</span><Select value={draft.operationStatus} onChange={(value) => setDraft({ ...draft, operationStatus: String(value) })} optionList={Object.entries(operationStatusLabels).map(([value, label]) => ({ value, label }))} /></label>
|
||||
<label><span>接入服务商</span><Input maxLength={128} value={draft.accessProvider} onChange={(value) => setDraft({ ...draft, accessProvider: value })} /></label>
|
||||
<label><span>首次接入</span><Input aria-label="首次接入" type="datetime-local" value={draft.firstAccessAt} onChange={(value) => setDraft({ ...draft, firstAccessAt: value })} /></label>
|
||||
<label><span>累计运行(小时)</span><Input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(value) => setDraft({ ...draft, runtimeHours: value })} /></label>
|
||||
{save.isError ? <p>{save.error.message}</p> : null}<footer><Button theme="light" onClick={() => setEditing(false)}>取消</Button><Button theme="solid" htmlType="submit" loading={save.isPending}>保存档案</Button></footer>
|
||||
</form> : <><Descriptions className="v2-record-descriptions" align="left" size="small" data={[
|
||||
{ key: '车辆品牌', value: fmt(profile?.brandName) },
|
||||
{ key: '车型 / 类型', value: [profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—' },
|
||||
{ key: '所属企业', value: fmt(profile?.companyName) },
|
||||
{ key: '运营状态', value: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
|
||||
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
|
||||
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
|
||||
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
|
||||
]} /><p className="v2-record-note">身份字段来自网关;补充主档来源 {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function Events({ detail }: { detail: VehicleDetail }) {
|
||||
@@ -137,42 +191,119 @@ function Events({ detail }: { detail: VehicleDetail }) {
|
||||
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
|
||||
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
|
||||
].slice(0, 5);
|
||||
return <section className="v2-record-card v2-events-card">
|
||||
<header><strong>最近事件</strong><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}>查看全部</Link></header>
|
||||
<div className="v2-event-list">{events.length ? events.map((event, index) => <div className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
|
||||
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
|
||||
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
|
||||
</div>) : <div className="v2-empty-compact">暂无可用事件证据</div>}</div>
|
||||
</section>;
|
||||
return <Card className="v2-record-card v2-events-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="最近事件"
|
||||
description="质量提醒与协议来源状态"
|
||||
meta={`${events.length} 条`}
|
||||
actions={<Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small">查看全部</Button></Link>}
|
||||
/>
|
||||
<List
|
||||
className="v2-event-list"
|
||||
dataSource={events}
|
||||
split={false}
|
||||
emptyContent={<Empty className="v2-event-empty" title="暂无可用事件证据" description="车辆产生质量提醒或来源状态变化后会显示在这里。" />}
|
||||
renderItem={(event, index) => <List.Item className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
|
||||
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
|
||||
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
|
||||
</List.Item>}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function TelemetryFieldCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-field"><strong title={item.description}>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></div>;
|
||||
}
|
||||
|
||||
function TelemetryValueCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-value"><strong>{formatTelemetryValue(item.value, item.displayValue)}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
|
||||
}
|
||||
|
||||
function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) {
|
||||
const color = item.quality === 'good' ? 'green' : item.quality === 'stale' ? 'orange' : 'red';
|
||||
return <div className="v2-telemetry-quality"><Tag color={color} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
|
||||
}
|
||||
|
||||
function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-time"><span><small>设备</small>{formatTelemetryTime(item.deviceTime)}</span><span><small>接收</small>{formatTelemetryTime(item.serverTime)}</span></div>;
|
||||
}
|
||||
|
||||
function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-source"><Tag color="blue" type="light" size="small">{item.protocol}</Tag><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
|
||||
}
|
||||
|
||||
function telemetryRowKey(item?: LatestTelemetryValue) {
|
||||
return `${item?.protocol ?? ''}-${item?.category ?? ''}-${item?.sourceField ?? ''}-${item?.frameId ?? ''}`;
|
||||
}
|
||||
|
||||
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [selectedProtocol, setSelectedProtocol] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('vehicle');
|
||||
const indexed = useMemo(() => {
|
||||
const valuesByCategory = new Map<string, LatestTelemetryResponse['values']>();
|
||||
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
|
||||
const sources = new Map<string, { protocol: string; endpoint?: string }>();
|
||||
const protocols: string[] = [];
|
||||
for (const value of data?.values ?? []) {
|
||||
const values = valuesByCategory.get(value.category);
|
||||
if (values) values.push(value); else valuesByCategory.set(value.category, [value]);
|
||||
if (!protocols.includes(value.protocol)) protocols.push(value.protocol);
|
||||
const categoryKey = `${value.protocol}\u0000${value.category}`;
|
||||
const values = valuesByProtocolCategory.get(categoryKey);
|
||||
if (values) values.push(value); else valuesByProtocolCategory.set(categoryKey, [value]);
|
||||
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
|
||||
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
|
||||
}
|
||||
return { valuesByCategory, sources: [...sources.values()] };
|
||||
const order = ['GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
protocols.sort((left, right) => order.indexOf(left) - order.indexOf(right));
|
||||
return { valuesByProtocolCategory, protocols, sources: [...sources.values()] };
|
||||
}, [data]);
|
||||
const categories = data?.categories ?? [];
|
||||
const activeCategory = indexed.valuesByCategory.has(selectedCategory) ? selectedCategory : categories[0]?.key ?? '';
|
||||
const visibleMetrics = indexed.valuesByCategory.get(activeCategory) ?? [];
|
||||
return <section className="v2-record-card v2-telemetry-card">
|
||||
<nav>{categories.map((item) => <button className={activeCategory === item.key ? 'is-active' : ''} onClick={() => setSelectedCategory(item.key)} type="button" key={item.key}>{item.label}<span>{item.count}</span></button>)}</nav>
|
||||
<div className="v2-telemetry-list">
|
||||
{pending ? <div className="v2-empty-compact">正在读取最新遥测…</div> : error ? <div className="v2-empty-compact is-error">最新遥测不可用:{error}</div> : visibleMetrics.length ? visibleMetrics.map((item) => <div key={item.key}>
|
||||
<span>{item.label}<small title={item.sourceField}>{item.sourceField} · {item.protocol}{item.sourceEndpoint ? ` · ${item.sourceEndpoint}` : ''}</small></span>
|
||||
<strong>{formatTelemetryValue(item.value)} <em>{item.unit}</em></strong>
|
||||
<time title={`设备时间 ${item.deviceTime || '缺失'};接收时间 ${item.serverTime || '缺失'};${item.qualityReason};帧 ${item.frameId}`}><i className={`is-${item.quality}`}>{telemetryQualityLabel(item.quality)}</i>{formatTelemetryTime(item.deviceTime || item.serverTime)}</time>
|
||||
</div>) : <div className="v2-empty-compact">最近 {data?.scannedFrames ?? 0} 帧没有可展示的标量遥测</div>}
|
||||
const activeProtocol = indexed.protocols.includes(selectedProtocol) ? selectedProtocol : indexed.protocols[0] ?? '';
|
||||
const categoryLabels = new Map((data?.categories ?? []).map((category) => [category.key, category.label]));
|
||||
const categories = [...new Set((data?.values ?? []).filter((value) => value.protocol === activeProtocol).map((value) => value.category))]
|
||||
.map((key) => ({ key, label: categoryLabels.get(key) ?? key, count: indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${key}`)?.length ?? 0 }));
|
||||
const activeCategory = indexed.valuesByProtocolCategory.has(`${activeProtocol}\u0000${selectedCategory}`) ? selectedCategory : categories[0]?.key ?? '';
|
||||
const visibleMetrics = indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${activeCategory}`) ?? [];
|
||||
const protocolLabel = (protocol: string) => protocol === 'GB32960' ? 'GB/T 32960' : protocol === 'JT808' ? 'JT/T 808' : protocol === 'YUTONG_MQTT' ? '宇通 MQTT' : protocol;
|
||||
const columns = [
|
||||
{ title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryFieldCell item={item} /> },
|
||||
{ title: '当前值', dataIndex: 'value', width: 150, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} /> },
|
||||
{ title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryQualityCell item={item} /> },
|
||||
{ title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryTimeCell item={item} /> },
|
||||
{ title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => <TelemetrySourceCell item={item} /> }
|
||||
];
|
||||
const state = pending
|
||||
? <div className="v2-telemetry-state" role="status"><strong>正在读取最新遥测</strong><span>按协议整理最新标量证据…</span></div>
|
||||
: error
|
||||
? <div className="v2-telemetry-state is-error" role="alert"><strong>最新遥测不可用</strong><span>{error}</span></div>
|
||||
: visibleMetrics.length === 0
|
||||
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
|
||||
: mobileLayout
|
||||
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
|
||||
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>
|
||||
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
|
||||
<TelemetrySourceCell item={item} />
|
||||
</List.Item>} />
|
||||
: <div className="v2-telemetry-table-wrap"><Table
|
||||
className="v2-telemetry-table"
|
||||
columns={columns}
|
||||
dataSource={visibleMetrics}
|
||||
rowKey={telemetryRowKey}
|
||||
pagination={false}
|
||||
scroll={{ x: 990 }}
|
||||
empty={null}
|
||||
/></div>;
|
||||
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="实时遥测"
|
||||
description="按协议与字段分类查看当前值、质量和来源"
|
||||
meta={`${data?.values.length ?? 0} 项`}
|
||||
/>
|
||||
<div className="v2-telemetry-tabs">
|
||||
<SegmentedTabs className="v2-telemetry-protocols" variant="filled" ariaLabel="数据协议" value={activeProtocol} onChange={(protocol) => { setSelectedProtocol(protocol); setSelectedCategory('vehicle'); }} items={indexed.protocols.map((protocol) => ({ key: protocol, label: protocolLabel(protocol), count: (data?.values ?? []).filter((value) => value.protocol === protocol).length }))} />
|
||||
<SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} />
|
||||
</div>
|
||||
{state}
|
||||
<footer><b>来源</b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small>扫描 {data?.scannedFrames ?? 0} 帧 · 截至 {formatTelemetryTime(data?.asOf)}</small></footer>
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtimeRow; fallback?: string }) {
|
||||
@@ -201,58 +332,107 @@ function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtim
|
||||
<span className="v2-current-address"><small>地理位置</small>{!coordinate
|
||||
? <strong>暂无有效实时坐标</strong>
|
||||
: !requestedKey
|
||||
? <button type="button" onClick={() => setRequestedKey(coordinateKey)}>解析当前位置</button>
|
||||
? <Button className="v2-current-address-action" theme="light" type="primary" size="small" aria-label="解析当前位置" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}>解析当前位置</Button>
|
||||
: address.isFetching && !address.data
|
||||
? <strong>地址解析中…</strong>
|
||||
: address.isError
|
||||
? <button type="button" className="is-error" onClick={() => void address.refetch()}>解析失败,重试</button>
|
||||
? <Button className="v2-current-address-action is-error" theme="light" type="danger" size="small" aria-label="地址解析失败,重试" onClick={() => void address.refetch()}>解析失败,重试</Button>
|
||||
: <strong title={address.data?.formattedAddress}>{address.data?.formattedAddress || fallback || '暂无地址结果'}</strong>}
|
||||
{moved ? <button type="button" onClick={() => setRequestedKey(coordinateKey)}>车辆已移动 · 更新地址</button> : null}
|
||||
{moved ? <Button className="v2-current-address-action" theme="borderless" type="primary" size="small" aria-label="车辆已移动,更新地址" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}>车辆已移动 · 更新地址</Button> : null}
|
||||
</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const vehicleSectionIDs = {
|
||||
location: 'vehicle-location-panel',
|
||||
events: 'vehicle-events-panel',
|
||||
telemetry: 'vehicle-telemetry-panel',
|
||||
archive: 'vehicle-archive-panel'
|
||||
} as const;
|
||||
|
||||
type VehicleSection = keyof typeof vehicleSectionIDs;
|
||||
|
||||
function VehicleRecordNavigation() {
|
||||
const jumpToSection = (section: VehicleSection) => {
|
||||
const target = document.getElementById(vehicleSectionIDs[section]);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.focus({ preventScroll: true });
|
||||
};
|
||||
return <Card className="v2-record-card v2-vehicle-record-nav" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="详情导航"
|
||||
description="快速定位当前车辆的数据区域"
|
||||
actions={<nav className="v2-vehicle-section-nav" aria-label="单车详情导航">
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconMapPin />} aria-label="跳转到实时位置" onClick={() => jumpToSection('location')}>实时位置</Button>
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconAlarm />} aria-label="跳转到最近事件" onClick={() => jumpToSection('events')}>最近事件</Button>
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconClock />} aria-label="跳转到实时遥测" onClick={() => jumpToSection('telemetry')}>实时遥测</Button>
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconBox />} aria-label="跳转到车辆主档" onClick={() => jumpToSection('archive')}>车辆主档</Button>
|
||||
</nav>}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; onUpdated: () => void }) {
|
||||
const { session } = usePlatformSession();
|
||||
const navigate = useNavigate();
|
||||
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
|
||||
const realtime = liveRealtime ?? detail.realtimeSummary;
|
||||
const identity = detail.identity;
|
||||
const hasLocation = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
|
||||
const mapVehicles = hasLocation && realtime ? [realtime] : [];
|
||||
const lastMileage = detail.mileage.items[0];
|
||||
const actions = [
|
||||
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: '/vehicles', type: 'primary' as const },
|
||||
...(hasMenu(session, 'tracks') ? [{ key: 'tracks', label: '轨迹回放', icon: <IconMapPin />, to: withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'history') ? [{ key: 'history', label: '历史数据', icon: <IconCalendar />, to: withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: <IconClock />, to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: <IconAlarm />, to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : [])
|
||||
];
|
||||
return <div className="v2-vehicle-record-page">
|
||||
<MonitorReturnBar />
|
||||
<section className="v2-identity-band">
|
||||
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
|
||||
<div className="v2-identity-meta"><div><small>统一车辆身份</small><strong>{fmt(identity?.oem || realtime?.oem)}</strong></div><div><small>可用数据来源</small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span>暂无来源</span>}</p></div></div>
|
||||
<div className="v2-identity-actions">
|
||||
{hasMenu(session, 'tracks') ? <Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconMapPin />轨迹回放</Link> : null}
|
||||
{hasMenu(session, 'history') ? <Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconCalendar />历史数据</Link> : null}
|
||||
{hasMenu(session, 'statistics') ? <Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconClock />里程查询</Link> : null}
|
||||
{hasMenu(session, 'alerts') ? <Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm />告警事件</Link> : null}
|
||||
<Card className="v2-identity-band" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><Tag className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`} color={realtime?.online ? 'green' : 'grey'} size="small">{realtime?.online ? '在线' : '离线'}</Tag><small>VIN</small><b>{detail.vin}</b><Button theme="borderless" aria-label="复制 VIN" title="复制 VIN" icon={<IconCopy />} onClick={() => navigator.clipboard?.writeText(detail.vin)} /></div>
|
||||
<div className="v2-identity-meta"><div><small>车辆品牌 / 车型</small><strong>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></div><div><small>可用数据来源</small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span>暂无来源</span>}</p></div></div>
|
||||
<div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
|
||||
{actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => navigate(action.to)}>{action.label}</Button>)}
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section className="v2-record-card v2-live-card v2-live-overview">
|
||||
<header><strong>最新上报</strong><span><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span></header>
|
||||
<Card className="v2-record-card v2-live-card v2-live-overview" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="最新上报"
|
||||
meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span>}
|
||||
/>
|
||||
<div className="v2-live-grid">
|
||||
<div><small>速度</small><strong>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em>km/h</em></strong></div>
|
||||
<div><small>SOC</small><strong>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em>%</em></strong></div>
|
||||
<div><small>总里程</small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></button></div>
|
||||
<div><small>当日里程</small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.todayMileageKm ?? lastMileage?.dailyMileageKm, realtime?.todayMileageAvailable)}<em>km</em></button></div>
|
||||
<div><small>总里程</small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看总里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></span><IconChevronRight /></Button></div>
|
||||
<div><small>当日里程</small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看当日里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em>km</em></span><IconChevronRight /></Button></div>
|
||||
<div><small>推荐来源</small><strong className="is-text">{fmt(realtime?.primaryProtocol)}<em>{realtime?.locationSource ? ` · ${realtime.locationSource}` : ''}</em></strong></div>
|
||||
<div><small>来源状态</small><strong>{realtime?.onlineSourceCount ?? 0}<em> 在线 / {detail.sourceStatus.length} 个</em></strong></div>
|
||||
</div>
|
||||
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
|
||||
<VehicleRecordNavigation />
|
||||
|
||||
<div className="v2-record-grid">
|
||||
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>更新时间:{fmt(realtime?.lastSeen)}</time></footer></section>
|
||||
<Events detail={detail} />
|
||||
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
|
||||
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
|
||||
<div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">
|
||||
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="实时位置"
|
||||
description="定位点按车辆上报周期平滑移动"
|
||||
meta={realtime?.online ? '实时跟随' : '保留最后位置'}
|
||||
/>
|
||||
<FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} />
|
||||
<footer><Button theme="borderless" type="tertiary" className="v2-map-source-link" title="展开全部位置来源" aria-label="查看全部位置来源" icon={<IconMapPin />} onClick={() => setSourceEvidenceOpen(true)}>{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</Button><time>更新时间:{fmt(realtime?.lastSeen)}</time></footer>
|
||||
</Card>
|
||||
</div>
|
||||
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
|
||||
<div id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
|
||||
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -273,9 +453,25 @@ export default function VehiclePage() {
|
||||
...LIVE_QUERY_POLICY
|
||||
});
|
||||
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
|
||||
useEffect(() => {
|
||||
resetWorkspaceScroll();
|
||||
let trailingFrame = 0;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
resetWorkspaceScroll();
|
||||
trailingFrame = window.requestAnimationFrame(resetWorkspaceScroll);
|
||||
});
|
||||
const shortTimer = window.setTimeout(resetWorkspaceScroll, 120);
|
||||
const settledTimer = window.setTimeout(resetWorkspaceScroll, 360);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.cancelAnimationFrame(trailingFrame);
|
||||
window.clearTimeout(shortTimer);
|
||||
window.clearTimeout(settledTimer);
|
||||
};
|
||||
}, [vin, resolvedVin]);
|
||||
if (!vin) return <VehicleSearch />;
|
||||
if (query.isPending) return <PageLoading />;
|
||||
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
|
||||
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2>未找到车辆</h2><p>没有匹配“{vin}”的车牌、VIN 或终端记录。</p><Link to="/vehicles">重新查询</Link></section>;
|
||||
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><Empty image={<IconSearch size="extra-large" />} title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}><Link to="/vehicles"><Button theme="solid" icon={<IconSearch />}>重新查询</Button></Link></Empty></Card>;
|
||||
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Select, Upload } from '@douyinfe/semi-ui';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
|
||||
export default function VehicleProfileSyncPanel({ onClose }: { onClose: () => void }) {
|
||||
const [sourceSystem, setSourceSystem] = useState('');
|
||||
const [sourceVersion, setSourceVersion] = useState('');
|
||||
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
|
||||
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
|
||||
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
|
||||
});
|
||||
const readFile = async (file?: File) => {
|
||||
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
|
||||
if (!file) return;
|
||||
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
|
||||
};
|
||||
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
|
||||
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
|
||||
const applied = sync.data && !sync.data.dryRun;
|
||||
|
||||
return <Card className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
|
||||
<header><div><strong>批量同步车辆主档</strong><p>CSV 最多 500 辆;先预演,再写入。默认保留人工档案和其他来源。</p></div><Button theme="borderless" type="tertiary" onClick={onClose}>关闭</Button></header>
|
||||
<div className="v2-profile-sync-fields">
|
||||
<label><span>来源系统标识</span><Input value={sourceSystem} onChange={(value) => { setSourceSystem(value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
|
||||
<label><span>来源版本</span><Input value={sourceVersion} onChange={(value) => { setSourceVersion(value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
|
||||
<label><span>冲突策略</span><Select value={conflictPolicy} onChange={(value) => { setConflictPolicy(String(value) as 'preserve' | 'overwrite'); sync.reset(); }} optionList={[{ value: 'preserve', label: '保护现有来源' }, { value: 'overwrite', label: '显式覆盖现有来源' }]} /></label>
|
||||
<label className="is-file"><span>CSV 文件</span><Upload action="" accept=".csv,text/csv" limit={1} uploadTrigger="custom" showUploadList={false} onFileChange={(files) => { void readFile(files[0]); }}><Button theme="light">选择 CSV 文件</Button></Upload></label>
|
||||
</div>
|
||||
<p className="v2-profile-sync-format">表头:<code>{vehicleProfileSyncCSVHeader}</code></p>
|
||||
{fileName ? <p className="v2-profile-sync-file">{fileName} · 已读取 {items.length} 辆</p> : null}
|
||||
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
|
||||
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
|
||||
{sync.data ? <div className="v2-profile-sync-result">
|
||||
<div><span>收到<strong>{sync.data.received}</strong></span><span>新增<strong>{sync.data.created}</strong></span><span>更新<strong>{sync.data.updated}</strong></span><span>未变化<strong>{sync.data.unchanged}</strong></span><span>冲突<strong>{sync.data.conflicted}</strong></span><span>身份缺失<strong>{sync.data.missing}</strong></span></div>
|
||||
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p>未发现来源冲突或身份缺失。</p>}
|
||||
</div> : null}
|
||||
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning">覆盖模式会接管人工或其他系统维护的补充主档,请先确认预演结果。</p> : null}
|
||||
<footer><Button theme="light" onClick={() => sync.mutate(true)} disabled={!ready} loading={sync.isPending}>预演同步</Button><Button theme="solid" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</Button></footer>
|
||||
</Card>;
|
||||
}
|
||||
@@ -6,11 +6,34 @@ import indexSource from '../../index.html?raw';
|
||||
import mainSource from '../main.tsx?raw';
|
||||
|
||||
const v2Styles = readFileSync(resolve(process.cwd(), 'src/v2/styles/v2.css'), 'utf8');
|
||||
const workspaceStyles = readFileSync(resolve(process.cwd(), 'src/v2/styles/workspace.css'), 'utf8');
|
||||
const corePageSources = Object.fromEntries(['MonitorPage', 'VehiclePage', 'TrackPage', 'HistoryPage', 'StatisticsPage', 'AlertsPage', 'AccessPage', 'UsersPage', 'OperationsPage'].map((name) => [
|
||||
name,
|
||||
readFileSync(resolve(process.cwd(), `src/v2/pages/${name}.tsx`), 'utf8')
|
||||
]));
|
||||
const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/ReconciliationCenter.tsx'), 'utf8');
|
||||
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
|
||||
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
|
||||
const mobileFilterToggleSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterToggle.tsx'), 'utf8');
|
||||
const segmentedTabsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/SegmentedTabs.tsx'), 'utf8');
|
||||
const metricActionSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MetricActionButton.tsx'), 'utf8');
|
||||
const tablePaginationSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/TablePagination.tsx'), 'utf8');
|
||||
const vehicleOptionErrorSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleOptionError.tsx'), 'utf8');
|
||||
const vehicleCandidateListSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleCandidateList.tsx'), 'utf8');
|
||||
const recoveryActionsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/RecoveryActions.tsx'), 'utf8');
|
||||
const detailTriggerRowSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/detailTriggerRow.ts'), 'utf8');
|
||||
const appShellSource = readFileSync(resolve(process.cwd(), 'src/v2/layout/AppShell.tsx'), 'utf8');
|
||||
const routeBoundarySource = readFileSync(resolve(process.cwd(), 'src/v2/routing/RouteBoundary.tsx'), 'utf8');
|
||||
const fleetMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/FleetMap.tsx'), 'utf8');
|
||||
const trackMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/TrackMap.tsx'), 'utf8');
|
||||
|
||||
describe('V2 production entry', () => {
|
||||
test('does not load the legacy Semi UI theme and global stylesheet', () => {
|
||||
test('loads the scoped production Semi theme without the retired global stylesheet', () => {
|
||||
expect(mainSource).not.toContain("./styles/global.css");
|
||||
expect(mainSource).toContain("./v2/styles/semi-theme.scss");
|
||||
expect(mainSource).toContain("./v2/styles/v2.css");
|
||||
expect(mainSource).toContain("./v2/styles/workspace.css");
|
||||
expect(mainSource).toContain("window.history.scrollRestoration = 'manual'");
|
||||
});
|
||||
|
||||
test('renders a static boot shell and recovers when the React entry never becomes ready', () => {
|
||||
@@ -26,6 +49,408 @@ describe('V2 production entry', () => {
|
||||
expect(appV2Source).toContain('window.dispatchEvent(new Event(PLATFORM_READY_EVENT))');
|
||||
});
|
||||
|
||||
test('keeps core business pages on shared Semi UI controls and page hierarchy', () => {
|
||||
for (const source of Object.values(corePageSources)) {
|
||||
expect(source).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(source).toContain('<Button');
|
||||
}
|
||||
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage', 'UsersPage', 'OperationsPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/PageHeader'");
|
||||
expect(corePageSources[name]).toContain('<PageHeader');
|
||||
}
|
||||
expect(corePageSources.StatisticsPage).not.toContain("from '../shared/PageHeader'");
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<PageHeader');
|
||||
expect(corePageSources.VehiclePage).toContain('<Select');
|
||||
expect(corePageSources.VehiclePage).toContain('<Card className="v2-record-card v2-live-card v2-live-overview"');
|
||||
expect(corePageSources.VehiclePage).toContain('<Card className="v2-single-map-card"');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-record-section-anchor v2-record-location-anchor"');
|
||||
expect(corePageSources.VehiclePage).toContain('<Card className="v2-identity-band"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-record-card');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-single-map-card');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-identity-band"');
|
||||
expect(corePageSources.VehiclePage).toContain('<SegmentedTabs');
|
||||
expect(corePageSources.VehiclePage).toContain('<Descriptions className="v2-record-descriptions"');
|
||||
expect(corePageSources.VehiclePage).toContain('<Empty image={<IconSearch size="extra-large" />} title="未找到车辆"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<dl className="v2-record-list"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-not-found"');
|
||||
expect(corePageSources.VehiclePage).toContain('v2-telemetry-protocols');
|
||||
expect(corePageSources.VehiclePage).toContain('<Table');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-table"');
|
||||
expect(corePageSources.VehiclePage).toContain('<List className="v2-telemetry-mobile-list"');
|
||||
expect(corePageSources.VehiclePage).toContain('<List.Item className="v2-telemetry-mobile-item"');
|
||||
expect(corePageSources.VehiclePage).toContain('<List');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-event-list"');
|
||||
expect(corePageSources.VehiclePage).toContain('useMobileLayout');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<div className="v2-telemetry-list"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<nav className="v2-telemetry-protocols"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<input');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<button');
|
||||
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleProfileSyncPanel'))");
|
||||
expect(corePageSources.VehiclePage).toContain('<Card className="v2-profile-sync-panel v2-profile-sync-loading"');
|
||||
expect(profileSyncPanelSource).toContain('<Upload');
|
||||
expect(profileSyncPanelSource).toContain('<Card className="v2-profile-sync-panel"');
|
||||
expect(profileSyncPanelSource).not.toContain('<input');
|
||||
expect(profileSyncPanelSource).not.toContain('<section className="v2-profile-sync-panel"');
|
||||
expect(corePageSources.VehiclePage).toContain('v2-profile-sync-panel');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-vehicle-detail"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-detail-peek"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-event-strip"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Tag className={`v2-status-text');
|
||||
expect(corePageSources.MonitorPage).toContain('<Tag className="v2-monitor-live-tag"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-vehicle-detail"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<aside className="v2-detail-peek"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-event-strip"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Table className="v2-monitor-table"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Empty className="v2-monitor-table-empty"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Spin size="small" tip="正在更新车辆实时数据…"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<table><thead><tr><th>车辆</th>');
|
||||
expect(sourceEvidenceSource).toContain("import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui'");
|
||||
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence');
|
||||
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence-card');
|
||||
expect(sourceEvidenceSource).toContain('<Tag className="is-recommended"');
|
||||
expect(sourceEvidenceSource).toContain('<Spin size="small"');
|
||||
expect(sourceEvidenceSource).toContain('<Empty className="v2-source-evidence-empty"');
|
||||
expect(sourceEvidenceSource).not.toContain('<article className={`v2-source-evidence-card');
|
||||
expect(sourceEvidenceSource).not.toContain('<section className={`v2-source-evidence');
|
||||
expect(corePageSources.HistoryPage).toContain('<Input');
|
||||
expect(corePageSources.HistoryPage).toContain('<Select');
|
||||
expect(corePageSources.HistoryPage).toContain('<Checkbox');
|
||||
expect(corePageSources.HistoryPage).toContain('<SideSheet');
|
||||
expect(corePageSources.HistoryPage).toContain('v2-history-column-sidesheet');
|
||||
expect(corePageSources.HistoryPage).toContain('<Card key={row.id} className={`v2-history-mobile-card');
|
||||
expect(corePageSources.HistoryPage).toContain('v2-history-trend');
|
||||
for (const surface of ['v2-export-jobs', 'v2-history-evidence', 'v2-history-metrics-card', 'v2-history-summary']) {
|
||||
expect(corePageSources.HistoryPage).toContain(`<Card className="${surface}"`);
|
||||
}
|
||||
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-history-filter-card');
|
||||
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-history-table-card');
|
||||
expect(corePageSources.HistoryPage).toContain('<Descriptions className="v2-history-evidence-descriptions"');
|
||||
expect(corePageSources.HistoryPage).toContain('<Descriptions className="v2-evidence-values"');
|
||||
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-export-job-card');
|
||||
expect(corePageSources.HistoryPage).toContain('<Progress className="v2-export-job-progress"');
|
||||
expect(corePageSources.HistoryPage).toContain('<Empty className="v2-history-empty"');
|
||||
expect(corePageSources.HistoryPage).toContain('<Spin size="middle" tip="正在加载当前筛选范围的历史数据…"');
|
||||
expect(corePageSources.HistoryPage).not.toContain('row ? <><dl>');
|
||||
expect(corePageSources.HistoryPage).not.toContain('job) => <article');
|
||||
expect(corePageSources.HistoryPage).not.toContain('<section className="v2-history-trend"');
|
||||
expect(corePageSources.HistoryPage).not.toContain('<section className={`v2-history-table-card');
|
||||
expect(corePageSources.HistoryPage).not.toContain('<input');
|
||||
expect(corePageSources.HistoryPage).not.toContain('<button');
|
||||
expect(corePageSources.HistoryPage).toContain('className="v2-history-data-table"');
|
||||
expect(corePageSources.HistoryPage).toContain('detailTriggerRow({');
|
||||
expect(corePageSources.HistoryPage).not.toContain('<table><thead><tr><th aria-label="选择行"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Input');
|
||||
expect(corePageSources.AlertsPage).toContain('<Select');
|
||||
expect(corePageSources.AlertsPage).toContain('<TextArea');
|
||||
expect(corePageSources.AlertsPage).toContain('<Radio');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card key={event.id} className={`v2-alert-mobile-card');
|
||||
for (const surface of ['v2-alert-kpis-card', 'v2-alert-table-card']) {
|
||||
expect(corePageSources.AlertsPage).toContain(`<Card className="${surface}"`);
|
||||
}
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-inspector');
|
||||
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-filter-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-detail-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-event-table"');
|
||||
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-filter-card');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<section className="v2-alert-table-card"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<aside className="v2-alert-inspector"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<select');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<textarea');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<input');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<button');
|
||||
expect(corePageSources.AlertsPage).toContain('<Descriptions className="v2-alert-descriptions"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Timeline className="v2-alert-timeline"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Empty className="v2-alert-inspector-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-detail-card"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-detail-card v2-alert-evidence-card"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<section><h3>事件信息</h3>');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<div className="v2-alert-timeline">');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-rule-list"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-rule-editor"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-notifications"');
|
||||
expect(corePageSources.AlertsPage).toContain('<CardGroup className="v2-alert-notification-cards"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-notification-card');
|
||||
expect(corePageSources.AlertsPage).toContain('<Descriptions className="v2-alert-channel-descriptions"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Empty className="v2-alert-notification-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Empty className="v2-alert-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Spin size="middle" tip="正在更新事件…"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<article className={item.read');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<section className="v2-alert-rule-list"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<form className="v2-alert-rule-editor"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<div className="v2-alert-notifications"');
|
||||
expect(corePageSources.AccessPage).toContain('<Input');
|
||||
expect(corePageSources.AccessPage).toContain('<Select');
|
||||
expect(corePageSources.AccessPage).toContain('<Card');
|
||||
for (const surface of ['v2-access-kpis-card-v3', 'v2-access-table-v3']) {
|
||||
expect(corePageSources.AccessPage).toContain(`<Card className="${surface}"`);
|
||||
}
|
||||
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-inspector-v3');
|
||||
expect(corePageSources.AccessPage).toContain('className="v2-access-detail-sidesheet"');
|
||||
expect(corePageSources.AccessPage).toContain('className="v2-access-governance-sidesheet"');
|
||||
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-filter-card-v3');
|
||||
expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-table-v3"');
|
||||
expect(corePageSources.AccessPage).not.toContain('<aside className="v2-access-inspector-v3"');
|
||||
expect(corePageSources.AccessPage).toContain('<Card key={row.vin} className={`v2-access-mobile-card');
|
||||
expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-inspector-summary"');
|
||||
expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-protocol-descriptions"');
|
||||
expect(corePageSources.AccessPage).toContain('<CardGroup className="v2-access-protocol-details"');
|
||||
expect(corePageSources.AccessPage).toContain('<Empty className="v2-access-empty"');
|
||||
expect(corePageSources.AccessPage).toContain('<Spin size="middle" tip="正在更新车辆接入状态…"');
|
||||
expect(corePageSources.AccessPage).toContain('className="v2-access-semi-table"');
|
||||
expect(corePageSources.AccessPage).toContain('detailTriggerRow({');
|
||||
expect(corePageSources.AccessPage).not.toContain('<table><thead><tr><th>车辆</th>');
|
||||
expect(corePageSources.AccessPage).not.toContain('<article className={`v2-access-protocol-detail');
|
||||
expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-inspector-summary"');
|
||||
expect(corePageSources.AccessPage).not.toContain('<dl>');
|
||||
expect(corePageSources.AccessPage).toContain('<Collapse');
|
||||
expect(corePageSources.AccessPage).not.toContain('<select');
|
||||
expect(corePageSources.AccessPage).not.toContain('<details');
|
||||
expect(corePageSources.AccessPage).not.toContain('<summary');
|
||||
expect(corePageSources.AccessPage).not.toContain('<button');
|
||||
expect(corePageSources.UsersPage).toContain('<Input');
|
||||
expect(corePageSources.UsersPage).not.toContain('<input');
|
||||
expect(corePageSources.UsersPage).toContain('<VehicleCandidateList');
|
||||
expect(corePageSources.UsersPage).toContain('<Avatar');
|
||||
expect(corePageSources.UsersPage).toContain('<Card');
|
||||
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-list"');
|
||||
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor"');
|
||||
expect(corePageSources.UsersPage).toContain('<List');
|
||||
expect(corePageSources.UsersPage).toContain('<List.Item');
|
||||
expect(corePageSources.UsersPage).toContain('v2-user-directory-metrics');
|
||||
expect(corePageSources.UsersPage).toContain('aria-expanded={selectedID === user.id && !creating}');
|
||||
expect(corePageSources.UsersPage).toContain('<Tabs className="v2-user-editor-tabs"');
|
||||
expect(corePageSources.UsersPage).toContain('<Tabs.TabPane');
|
||||
expect(corePageSources.UsersPage).toContain('aria-label="关闭账号详情"');
|
||||
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-identity-section"');
|
||||
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-menu-section"');
|
||||
expect(corePageSources.UsersPage).toContain('<Card className="v2-user-editor-section v2-user-vehicle-section"');
|
||||
expect(corePageSources.UsersPage).toContain('<Empty className="v2-user-list-empty"');
|
||||
expect(corePageSources.UsersPage).not.toContain('className="v2-user-editor-empty"');
|
||||
expect(corePageSources.UsersPage).toContain('<Empty className="v2-user-vehicle-empty"');
|
||||
expect(corePageSources.UsersPage).not.toContain('<section>');
|
||||
expect(corePageSources.UsersPage).not.toContain('<aside className="v2-user-list"');
|
||||
expect(corePageSources.UsersPage).not.toContain('<main className="v2-user-editor"');
|
||||
expect(corePageSources.UsersPage).toContain('className={`v2-user-list-row');
|
||||
expect(corePageSources.UsersPage).not.toContain('className={`v2-user-list-card');
|
||||
expect(corePageSources.UsersPage).toContain('<Collapse');
|
||||
expect(corePageSources.UsersPage).toContain('<Tag');
|
||||
expect(corePageSources.UsersPage).not.toContain('<details');
|
||||
expect(corePageSources.UsersPage).not.toContain('<summary');
|
||||
expect(corePageSources.UsersPage).not.toContain('<button');
|
||||
expect(corePageSources.OperationsPage).toContain('<Checkbox');
|
||||
expect(corePageSources.OperationsPage).toContain('<Input');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-diagnostic"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className="v2-ops-kpi-card"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className="v2-ops-panel v2-ops-links"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-ops-link-card');
|
||||
expect(corePageSources.OperationsPage).toContain('<Descriptions className="v2-ops-runtime-descriptions"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Empty className="v2-ops-capacity-empty"');
|
||||
expect(corePageSources.OperationsPage).toContain('<CardGroup className="v2-ops-source-list"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-ops-source-card');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<article key={item.name}');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<article key={source.protocol}');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<dl><div><dt>生产数据模式</dt>');
|
||||
expect(corePageSources.OperationsPage).toContain('<Empty className="v2-source-empty"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Spin size="large"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-summary-card"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-source-summary-card');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-recommendation"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-audit"');
|
||||
expect(corePageSources.OperationsPage).toContain('className="v2-source-table"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Table');
|
||||
expect(corePageSources.OperationsPage).toContain('<SideSheet');
|
||||
expect(corePageSources.OperationsPage).toContain('v2-source-policy-sidesheet');
|
||||
expect(corePageSources.OperationsPage).toContain('className={`v2-source-mobile-card');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<table className="v2-source-table"');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<section className="v2-source-diagnostic"');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<section className="v2-ops-links"');
|
||||
expect(corePageSources.OperationsPage).not.toContain('<article><small>车辆</small>');
|
||||
expect(reconciliationSource).toContain('<Table className="v2-reconcile-table"');
|
||||
expect(reconciliationSource).toContain('detailTriggerRow({');
|
||||
expect(reconciliationSource).toContain('<Card key={item.id} className={`v2-reconcile-mobile-card');
|
||||
expect(reconciliationSource).toContain('<Empty className="v2-reconcile-empty"');
|
||||
expect(reconciliationSource).toContain('<Spin size="middle" tip="正在读取差异队列…"');
|
||||
expect(reconciliationSource).not.toContain('<table className="v2-reconcile-table"');
|
||||
expect(detailTriggerRowSource).toContain("'aria-expanded': expanded");
|
||||
expect(detailTriggerRowSource).toContain("event.key !== 'Enter' && event.key !== ' '");
|
||||
expect(corePageSources.OperationsPage).not.toContain('<section className="v2-source-audit"');
|
||||
expect(corePageSources.OperationsPage).toContain('<VehicleCandidateList');
|
||||
expect(corePageSources.TrackPage).toContain('<VehicleCandidateList');
|
||||
expect(corePageSources.StatisticsPage).toContain('<VehicleCandidateList');
|
||||
expect(corePageSources.TrackPage).toContain('<Input');
|
||||
expect(corePageSources.TrackPage).toContain('<Select');
|
||||
expect(corePageSources.TrackPage).toContain('<Slider');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className={`v2-track-query-card${queryCollapsed');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-rail-result"');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-current-card"');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-empty-state"');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-playback-dock"');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-overview-card"');
|
||||
expect(corePageSources.TrackPage).toContain('<Descriptions className="v2-track-overview-descriptions"');
|
||||
expect(corePageSources.TrackPage).toContain('<Empty className="v2-track-rail-empty"');
|
||||
expect(corePageSources.TrackPage).toContain('<List className="v2-track-evidence-list v2-track-stop-list"');
|
||||
expect(corePageSources.TrackPage).toContain('<List className="v2-track-evidence-list v2-track-event-list"');
|
||||
expect(corePageSources.TrackPage).toContain('<List.Item className="v2-track-evidence-item"');
|
||||
expect(corePageSources.TrackPage).toContain('<Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放"');
|
||||
expect(corePageSources.TrackPage).not.toContain('<Card className="v2-track-overview-card"><header><strong>行程概览</strong><Tag color={track.sampled ? \'orange\' : \'green\'} type="light" size="small">{track.sampled ? \'地图已抽稀\' : \'完整点集\'}</Tag></header><dl>');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-overview-card v2-track-source-card"');
|
||||
expect(corePageSources.TrackPage).toContain('<List className="v2-track-source-list"');
|
||||
expect(corePageSources.TrackPage).toContain('<List.Item className="v2-track-source-item"');
|
||||
expect(corePageSources.TrackPage).not.toContain('<Card className="v2-track-source-item"');
|
||||
expect(corePageSources.TrackPage).toContain('<Card className={`v2-track-overview-card v2-track-quality-card');
|
||||
expect(corePageSources.TrackPage).not.toContain('<section className={`v2-track-quality-card');
|
||||
expect(corePageSources.TrackPage).not.toContain('<article key={source.protocol}');
|
||||
expect(corePageSources.TrackPage).not.toContain('<article className="v2-track-current-card"');
|
||||
expect(corePageSources.TrackPage).not.toContain('<footer className="v2-track-playback-dock"');
|
||||
expect(corePageSources.TrackPage).not.toContain('<select');
|
||||
expect(corePageSources.TrackPage).not.toContain('<input');
|
||||
expect(corePageSources.TrackPage).not.toContain('<button');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Input');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Card className="v2-mileage-query-panel"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Card className="v2-mileage-summary"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<CardGroup className="v2-mileage-summary-secondary"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Card className="v2-mileage-summary-card');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Card key={source.protocol} className={`v2-mileage-source-card');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<section className="v2-mileage-summary"');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<article key={source.protocol}');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Card className="v2-mileage-results"');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<section className="v2-mileage-query-panel"');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<section className="v2-mileage-results"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Tag');
|
||||
expect(corePageSources.StatisticsPage).toContain('<SideSheet');
|
||||
expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-sidesheet');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('v2-mileage-source-popover');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"');
|
||||
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-plate');
|
||||
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-total');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Spin size="middle" tip="正在查询里程"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Empty className="v2-mileage-empty"');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<table className="v2-mileage-table"');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('<input');
|
||||
expect(corePageSources.MonitorPage).toContain('<Input');
|
||||
expect(corePageSources.MonitorPage).toContain('<Select');
|
||||
expect(corePageSources.MonitorPage).toContain('<Modal');
|
||||
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-modal');
|
||||
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
|
||||
expect(corePageSources.MonitorPage).toContain('<TextArea');
|
||||
expect(corePageSources.MonitorPage).toContain('<ButtonGroup');
|
||||
expect(corePageSources.MonitorPage).toContain("aria-pressed={mode === 'map'}");
|
||||
expect(corePageSources.MonitorPage).toContain("aria-pressed={mode === 'list'}");
|
||||
expect(v2Styles).toContain('.v2-monitor-mode.semi-button-group > .semi-button-group-line { display: none; }');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<select');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<textarea');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<button');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-filterbar"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-kpis"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-monitor-table-panel"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-filterbar"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-kpis"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-monitor-table-panel"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-monitor-mobile-card"');
|
||||
expect(reconciliationSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(reconciliationSource).toContain('<RadioGroup');
|
||||
expect(reconciliationSource).toContain('<Select');
|
||||
expect(reconciliationSource).toContain('<Card className="v2-reconcile-center"');
|
||||
expect(reconciliationSource).toContain('<Card className="v2-reconcile-kpi-card');
|
||||
expect(reconciliationSource).toContain('v2-reconcile-trend');
|
||||
expect(reconciliationSource).toContain('<Card className={`v2-reconcile-detail');
|
||||
expect(reconciliationSource).toContain('<SideSheet');
|
||||
expect(reconciliationSource).toContain('className="v2-reconcile-detail-sidesheet"');
|
||||
expect(reconciliationSource).toContain('<Card className="v2-reconcile-evidence"');
|
||||
expect(reconciliationSource).toContain("from '../shared/WorkspacePanelHeader'");
|
||||
expect(reconciliationSource).toContain('<WorkspacePanelHeader');
|
||||
expect(reconciliationSource).toContain('className="v2-reconcile-heading"');
|
||||
expect(reconciliationSource).toContain('className="v2-reconcile-detail-heading"');
|
||||
expect(reconciliationSource).not.toContain('<header className="v2-reconcile-heading"');
|
||||
expect(reconciliationSource).not.toContain('<section className="v2-reconcile-center"');
|
||||
expect(reconciliationSource).not.toContain('<aside className="v2-reconcile-detail"');
|
||||
expect(reconciliationSource).toContain('<TextArea');
|
||||
expect(sourceEvidenceSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(sourceEvidenceSource).toContain('<Input');
|
||||
expect(sourceEvidenceSource).toContain('<Button');
|
||||
expect(mobileFilterToggleSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(mobileFilterToggleSource).toContain('<Button');
|
||||
expect(segmentedTabsSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(segmentedTabsSource).toContain('role="tablist"');
|
||||
expect(segmentedTabsSource).toContain('role="tab"');
|
||||
for (const name of ['VehiclePage', 'AlertsPage', 'TrackPage', 'OperationsPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/SegmentedTabs'");
|
||||
expect(corePageSources[name]).toContain('<SegmentedTabs');
|
||||
}
|
||||
expect(corePageSources.OperationsPage).toContain('className={`v2-ops-workspace is-${workspace}`}');
|
||||
expect(corePageSources.OperationsPage).toContain('role="tabpanel"');
|
||||
expect(metricActionSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(metricActionSource).toContain('aria-pressed={active}');
|
||||
expect(tablePaginationSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(tablePaginationSource).toContain('aria-label="上一页"');
|
||||
expect(tablePaginationSource).toContain('aria-label="下一页"');
|
||||
for (const name of ['MonitorPage', 'AlertsPage', 'HistoryPage', 'AccessPage', 'StatisticsPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/TablePagination'");
|
||||
expect(corePageSources[name]).toContain('<TablePagination');
|
||||
}
|
||||
expect(reconciliationSource).toContain("from '../shared/TablePagination'");
|
||||
expect(reconciliationSource).toContain('<TablePagination');
|
||||
expect(vehicleOptionErrorSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(vehicleOptionErrorSource).toContain('<Button');
|
||||
expect(vehicleCandidateListSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(vehicleCandidateListSource).toContain("from './VehicleOptionError'");
|
||||
expect(vehicleCandidateListSource).toContain('<VehicleOptionError');
|
||||
expect(vehicleCandidateListSource).toContain('role="listbox"');
|
||||
expect(vehicleCandidateListSource).toContain('role="option"');
|
||||
expect(recoveryActionsSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(recoveryActionsSource).toContain('<Button');
|
||||
expect(routeBoundarySource).toContain("from '../shared/RecoveryActions'");
|
||||
expect(routeBoundarySource).toContain('<RecoveryActions');
|
||||
expect(routeBoundarySource).not.toContain('<button');
|
||||
for (const source of [fleetMapSource, trackMapSource]) {
|
||||
expect(source).toContain("from '../shared/RecoveryActions'");
|
||||
expect(source).toContain('<MapRetryAction');
|
||||
expect(source).not.toContain('><IconRefresh />重新加载地图</button>');
|
||||
}
|
||||
for (const name of ['AlertsPage', 'AccessPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/MetricActionButton'");
|
||||
expect(corePageSources[name]).toContain('<MetricActionButton');
|
||||
}
|
||||
expect(corePageSources.HistoryPage).toContain('<Tag className="v2-history-metric-tag"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Tag className={`v2-alert-severity');
|
||||
expect(corePageSources.AlertsPage).toContain('<Tag className={`v2-alert-status');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-inspector-heading"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-rule-list-heading"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-rule-editor-heading"');
|
||||
expect(corePageSources.AlertsPage).toContain('variant="filled"');
|
||||
expect(corePageSources.AccessPage).toContain('<Tag className="v2-access-connection-tag"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Tag className={`v2-ops-health-tag');
|
||||
expect(reconciliationSource).toContain('<Tag className={`v2-reconcile-severity');
|
||||
expect(reconciliationSource).toContain('<Tag className={`v2-reconcile-status');
|
||||
expect(v2Styles).toContain('.v2-track-rail-expand { z-index: 21; top: auto; bottom: calc(132px + env(safe-area-inset-bottom));');
|
||||
expect(v2Styles).toContain('.v2-track-stage:has(.v2-track-current-card) .v2-track-rail-expand { bottom: calc(248px + env(safe-area-inset-bottom));');
|
||||
expect(v2Styles).toContain('grid-template-rows: minmax(0, 1fr);');
|
||||
expect(appShellSource).toContain("section === 'tracks' ? ' is-track-workspace' : ''");
|
||||
expect(corePageSources.TrackPage).toContain("monitorReturn ? ' has-monitor-return' : ''");
|
||||
expect(v2Styles).toContain('.v2-track-page.has-monitor-return { grid-template-rows: auto minmax(0, 1fr); }');
|
||||
expect(v2Styles).toContain('.v2-content.is-track-workspace { min-height: 0; overflow: hidden; }');
|
||||
expect(workspaceStyles).toContain('.v2-workspace-panel-header.is-inverted > .v2-workspace-panel-copy > h5.semi-typography');
|
||||
expect(workspaceStyles).toContain('color: #f8fbff;');
|
||||
expect(v2Styles).toContain('.v2-track-current-card > .semi-card-body > div strong { margin-top: 4px; color: var(--track-overlay-text);');
|
||||
expect(v2Styles).toContain('.v2-track-current-card.semi-card { top: auto; right: 8px; bottom: 126px; left: 8px; width: auto; height: auto;');
|
||||
for (const name of ['HistoryPage', 'StatisticsPage', 'AlertsPage', 'AccessPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/MobileFilterToggle'");
|
||||
expect(corePageSources[name]).toContain('<MobileFilterToggle');
|
||||
expect(corePageSources[name]).not.toContain('<button type="button" className="v2-mobile-filter-toggle"');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the shared desktop and mobile application shell on Semi UI navigation primitives', () => {
|
||||
expect(appShellSource).toContain('<Layout className="v2-shell">');
|
||||
expect(appShellSource).toContain('<Nav className="v2-navigation"');
|
||||
expect(appShellSource).toContain('<SideSheet className="v2-mobile-more-sidesheet"');
|
||||
expect(appShellSource).toContain('<Modal className="v2-password-modal"');
|
||||
expect(appShellSource).not.toContain('v2-mobile-more-backdrop');
|
||||
expect(appShellSource).not.toContain('v2-mobile-more-sheet');
|
||||
});
|
||||
|
||||
test('applies rendering containment to off-screen vehicle items instead of the visible scroller', () => {
|
||||
const ruleBody = (selector: string) => {
|
||||
const start = v2Styles.indexOf(`${selector} {`);
|
||||
@@ -35,8 +460,8 @@ describe('V2 production entry', () => {
|
||||
return v2Styles.slice(start, end);
|
||||
};
|
||||
const scrollRule = ruleBody('.v2-vehicle-scroll');
|
||||
const rowRule = ruleBody('.v2-vehicle-row');
|
||||
const mobileCardRule = ruleBody('.v2-monitor-mobile-cards > article');
|
||||
const rowRule = ruleBody('.v2-vehicle-row.semi-button');
|
||||
const mobileCardRule = ruleBody('.v2-monitor-mobile-cards > .v2-monitor-mobile-card.semi-card');
|
||||
|
||||
expect(scrollRule).not.toContain('content-visibility');
|
||||
expect(rowRule).toContain('content-visibility: auto');
|
||||
|
||||
@@ -19,8 +19,8 @@ describe('route recovery boundary', () => {
|
||||
render(<PlatformErrorBoundary><BrokenShell /></PlatformErrorBoundary>);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('平台外壳运行异常');
|
||||
expect(screen.getByRole('button', { name: /重新加载平台/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor');
|
||||
expect(screen.getByRole('button', { name: /重新加载平台/ })).toHaveClass('semi-button');
|
||||
expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button');
|
||||
});
|
||||
|
||||
it('recognizes deployment-related lazy chunk failures', () => {
|
||||
@@ -35,8 +35,8 @@ describe('route recovery boundary', () => {
|
||||
const BrokenPage = () => { throw new Error('render exploded'); };
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
|
||||
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor');
|
||||
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toHaveClass('semi-button');
|
||||
expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button');
|
||||
});
|
||||
|
||||
it('shows a deployment recovery page when an automatic chunk reload already ran', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { IconAlertTriangle, IconHome, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { PageLoading } from '../shared/AsyncState';
|
||||
import { RecoveryActions } from '../shared/RecoveryActions';
|
||||
|
||||
const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload';
|
||||
const CHUNK_RELOAD_COOLDOWN_MS = 2 * 60_000;
|
||||
@@ -64,11 +65,12 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
|
||||
<h2>{chunkTimeout ? '当前模块没有在预期时间内加载完成' : chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
|
||||
<p>{chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p>
|
||||
<code>{error.message || error.name}</code>
|
||||
<footer>
|
||||
<button type="button" onClick={this.props.onRetry}><IconRefresh />重试加载模块</button>
|
||||
<button type="button" onClick={() => window.location.reload()}><IconRefresh />刷新当前页面</button>
|
||||
<a href="/monitor"><IconHome />返回全局监控</a>
|
||||
</footer>
|
||||
<RecoveryActions
|
||||
primaryLabel="重试加载模块"
|
||||
onPrimary={this.props.onRetry}
|
||||
secondaryLabel="刷新当前页面"
|
||||
onSecondary={() => window.location.reload()}
|
||||
/>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -91,10 +93,7 @@ export class PlatformErrorBoundary extends Component<{ children: ReactNode }, {
|
||||
<h2>工作台暂时无法继续显示</h2>
|
||||
<p>系统已阻止异常扩散。请重新加载最新版本;服务端数据不会因页面恢复操作而改变。</p>
|
||||
<code>{error.message || error.name}</code>
|
||||
<footer>
|
||||
<button type="button" onClick={() => window.location.reload()}><IconRefresh />重新加载平台</button>
|
||||
<a href="/monitor"><IconHome />返回全局监控</a>
|
||||
</footer>
|
||||
<RecoveryActions primaryLabel="重新加载平台" onPrimary={() => window.location.reload()} />
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MonitorViewport } from '../hooks/useMonitorData';
|
||||
import { normalizeMonitorBounds } from '../domain/monitor';
|
||||
|
||||
export const MONITOR_RETURN_PARAM = 'monitorReturn';
|
||||
|
||||
@@ -38,19 +39,10 @@ function decimal(value: string | null, fallback: number, minimum: number, maximu
|
||||
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
|
||||
}
|
||||
|
||||
function validBounds(value: string | null) {
|
||||
if (!value) return '';
|
||||
const coordinates = value.split(',').map(Number);
|
||||
if (coordinates.length !== 4 || coordinates.some((coordinate) => !Number.isFinite(coordinate))) return '';
|
||||
const [west, south, east, north] = coordinates;
|
||||
if (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north) return '';
|
||||
return coordinates.map((coordinate) => coordinate.toFixed(6)).join(',');
|
||||
}
|
||||
|
||||
export function parseMonitorRouteContext(search: string | URLSearchParams): MonitorRouteContext {
|
||||
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
|
||||
const selectedVin = params.get('selectedVin')?.trim().toUpperCase() ?? '';
|
||||
const bounds = validBounds(params.get('bounds'));
|
||||
const bounds = normalizeMonitorBounds(params.get('bounds'));
|
||||
const hasViewport = params.has('zoom') || Boolean(bounds);
|
||||
return {
|
||||
mode: params.get('mode') === 'list' ? 'list' : 'map',
|
||||
@@ -80,7 +72,8 @@ export function buildMonitorPath(context: Omit<MonitorRouteContext, 'hasViewport
|
||||
params.set('detail', context.detailOpen ? 'open' : 'collapsed');
|
||||
}
|
||||
params.set('zoom', String(Math.round(context.viewport.zoom * 100) / 100));
|
||||
if (validBounds(context.viewport.bounds)) params.set('bounds', validBounds(context.viewport.bounds));
|
||||
const bounds = normalizeMonitorBounds(context.viewport.bounds);
|
||||
if (bounds) params.set('bounds', bounds);
|
||||
if (context.listOffset) params.set('listOffset', String(context.listOffset));
|
||||
if (context.listLimit !== 50) params.set('listLimit', String(context.listLimit));
|
||||
const query = params.toString();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { Button, Empty, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
|
||||
return <section className="v2-page-state" role="status" aria-live="polite">
|
||||
<header><span className="v2-spinner" /><div><strong>{label}</strong><small>导航与筛选状态已保留</small></div></header>
|
||||
<header><Spin size="large" /><div><Typography.Text strong>{label}</Typography.Text><Typography.Text type="tertiary" size="small">导航与筛选状态已保留</Typography.Text></div></header>
|
||||
<div className="v2-page-skeleton"><i /><i /><i /><i /></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -12,11 +13,11 @@ export function InlineError({ message, onRetry }: { message: string; onRetry?: (
|
||||
<div className="v2-inline-state is-error" role="alert">
|
||||
<IconAlertTriangle />
|
||||
<span>{message}</span>
|
||||
{onRetry ? <button type="button" onClick={onRetry}><IconRefresh />重试</button> : null}
|
||||
{onRetry ? <Button theme="light" icon={<IconRefresh />} onClick={onRetry}>重试</Button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无符合条件的车辆' }: { title?: string }) {
|
||||
return <div className="v2-inline-state"><span>{title}</span></div>;
|
||||
return <div className="v2-inline-state"><Empty title={title} /></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { MetricActionButton } from './MetricActionButton';
|
||||
|
||||
describe('MetricActionButton', () => {
|
||||
test('uses a pressed Semi button for the active metric filter', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<MetricActionButton label="未处理" value="12" tone="warning" active ariaLabel="筛选未处理告警,共 12 条" onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '筛选未处理告警,共 12 条', pressed: true });
|
||||
expect(button).toHaveClass('semi-button', 'v2-metric-action', 'is-active');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type MetricActionButtonProps = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
onClick: () => void;
|
||||
tone?: string;
|
||||
hint?: ReactNode;
|
||||
active?: boolean;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function MetricActionButton({
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
tone = 'default',
|
||||
hint,
|
||||
active = false,
|
||||
ariaLabel
|
||||
}: MetricActionButtonProps) {
|
||||
return <Button
|
||||
className={`v2-metric-action is-${tone}${active ? ' is-active' : ''}`}
|
||||
theme={active ? 'light' : 'borderless'}
|
||||
type="tertiary"
|
||||
htmlType="button"
|
||||
aria-pressed={active}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="v2-metric-action-content"><small>{label}</small><strong>{value}</strong>{hint ? <em>{hint}</em> : null}</span>
|
||||
</Button>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { MobileFilterToggle } from './MobileFilterToggle';
|
||||
|
||||
describe('MobileFilterToggle', () => {
|
||||
test('exposes the current filter summary and expanded state through a Semi button', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<MobileFilterToggle summary="全部车辆 · 7 天" expanded={false} onToggle={onToggle} collapsedLabel="修改" />);
|
||||
|
||||
const collapsed = screen.getByRole('button', { name: '修改筛选条件:全部车辆 · 7 天' });
|
||||
expect(collapsed).toHaveClass('semi-button');
|
||||
expect(collapsed).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(collapsed);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MobileFilterToggle summary="已选 2 辆 · 7 天" expanded onToggle={onToggle} collapsedLabel="修改" />);
|
||||
expect(screen.getByRole('button', { name: '收起筛选条件:已选 2 辆 · 7 天' })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IconChevronDown, IconChevronUp, IconFilter } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
type MobileFilterToggleProps = {
|
||||
title?: string;
|
||||
summary: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedLabel?: string;
|
||||
collapsedLabel?: string;
|
||||
};
|
||||
|
||||
export function MobileFilterToggle({
|
||||
title = '筛选条件',
|
||||
summary,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedLabel = '收起',
|
||||
collapsedLabel = '展开'
|
||||
}: MobileFilterToggleProps) {
|
||||
const actionLabel = expanded ? expandedLabel : collapsedLabel;
|
||||
return <Button
|
||||
className="v2-mobile-filter-toggle"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
block
|
||||
htmlType="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${actionLabel}${title}:${summary}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="v2-mobile-filter-toggle-content">
|
||||
<span className="v2-mobile-filter-toggle-icon" aria-hidden="true"><IconFilter /></span>
|
||||
<span className="v2-mobile-filter-toggle-copy"><b>{title}</b><small>{summary}</small></span>
|
||||
<span className="v2-mobile-filter-toggle-action">{actionLabel}{expanded ? <IconChevronUp /> : <IconChevronDown />}</span>
|
||||
</span>
|
||||
</Button>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusColor = 'blue',
|
||||
meta,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
statusColor?: React.ComponentProps<typeof Tag>['color'];
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="v2-page-heading">
|
||||
<div className="v2-page-heading-copy">
|
||||
<div>
|
||||
<Title heading={2}>{title}</Title>
|
||||
{status ? <Tag color={statusColor} size="small">{status}</Tag> : null}
|
||||
</div>
|
||||
<Text type="secondary">{description}</Text>
|
||||
</div>
|
||||
{meta || actions ? <div className="v2-page-heading-aside">{meta}{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { MapRetryAction, RecoveryActions } from './RecoveryActions';
|
||||
|
||||
describe('RecoveryActions', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders a compact Semi retry action for map errors', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<MapRetryAction onRetry={onRetry} />);
|
||||
const action = screen.getByRole('button', { name: '重新加载地图' });
|
||||
expect(action).toHaveClass('semi-button', 'v2-map-retry-action');
|
||||
fireEvent.click(action);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('renders primary and secondary recovery hierarchy with Semi buttons', () => {
|
||||
const onPrimary = vi.fn();
|
||||
const onSecondary = vi.fn();
|
||||
render(<RecoveryActions primaryLabel="重试加载模块" onPrimary={onPrimary} secondaryLabel="刷新当前页面" onSecondary={onSecondary} showHome={false} />);
|
||||
|
||||
const primary = screen.getByRole('button', { name: '重试加载模块' });
|
||||
const secondary = screen.getByRole('button', { name: '刷新当前页面' });
|
||||
expect(primary).toHaveClass('semi-button-primary');
|
||||
expect(secondary).toHaveClass('semi-button-light');
|
||||
fireEvent.click(primary);
|
||||
fireEvent.click(secondary);
|
||||
expect(onPrimary).toHaveBeenCalledOnce();
|
||||
expect(onSecondary).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { IconHome, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
export function MapRetryAction({ onRetry, label = '重新加载地图' }: { onRetry: () => void; label?: string }) {
|
||||
return <Button
|
||||
className="v2-map-retry-action"
|
||||
theme="light"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconRefresh />}
|
||||
aria-label={label}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{label}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function RecoveryActions({
|
||||
primaryLabel,
|
||||
onPrimary,
|
||||
secondaryLabel,
|
||||
onSecondary,
|
||||
showHome = true
|
||||
}: {
|
||||
primaryLabel: string;
|
||||
onPrimary: () => void;
|
||||
secondaryLabel?: string;
|
||||
onSecondary?: () => void;
|
||||
showHome?: boolean;
|
||||
}) {
|
||||
return <footer className="v2-recovery-actions">
|
||||
<Button theme="solid" type="primary" icon={<IconRefresh />} aria-label={primaryLabel} onClick={onPrimary}>{primaryLabel}</Button>
|
||||
{secondaryLabel && onSecondary ? <Button theme="light" icon={<IconRefresh />} aria-label={secondaryLabel} onClick={onSecondary}>{secondaryLabel}</Button> : null}
|
||||
{showHome ? <Button theme="borderless" type="tertiary" icon={<IconHome />} aria-label="返回全局监控" onClick={() => window.location.assign('/monitor')}>返回全局监控</Button> : null}
|
||||
</footer>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { SegmentedTabs } from './SegmentedTabs';
|
||||
|
||||
function Example() {
|
||||
const [value, setValue] = useState<'one' | 'two' | 'three'>('one');
|
||||
return <SegmentedTabs
|
||||
ariaLabel="示例页签"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
items={[
|
||||
{ key: 'one', label: '第一个', count: 3 },
|
||||
{ key: 'two', label: '第二个' },
|
||||
{ key: 'three', label: '第三个' }
|
||||
]}
|
||||
/>;
|
||||
}
|
||||
|
||||
describe('SegmentedTabs', () => {
|
||||
test('uses Semi buttons with tab semantics and keyboard navigation', () => {
|
||||
render(<Example />);
|
||||
const first = screen.getByRole('tab', { name: /第一个\s+3/, selected: true });
|
||||
expect(first).toHaveClass('semi-button');
|
||||
expect(first).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(first, { key: 'ArrowRight' });
|
||||
expect(screen.getByRole('tab', { name: '第二个', selected: true })).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '第二个' }), { key: 'End' });
|
||||
expect(screen.getByRole('tab', { name: '第三个', selected: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
|
||||
export type SegmentedTabItem<Key extends string> = {
|
||||
key: Key;
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
type SegmentedTabsProps<Key extends string> = {
|
||||
ariaLabel: string;
|
||||
value: Key;
|
||||
items: readonly SegmentedTabItem<Key>[];
|
||||
onChange: (key: Key) => void;
|
||||
className?: string;
|
||||
variant?: 'filled' | 'line';
|
||||
};
|
||||
|
||||
export function SegmentedTabs<Key extends string>({
|
||||
ariaLabel,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
className = '',
|
||||
variant = 'line'
|
||||
}: SegmentedTabsProps<Key>) {
|
||||
const selectFromKeyboard = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
let nextIndex = index;
|
||||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % items.length;
|
||||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + items.length) % items.length;
|
||||
else if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = items.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
onChange(items[nextIndex].key);
|
||||
const tabs = event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||
window.requestAnimationFrame(() => tabs?.[nextIndex]?.focus());
|
||||
};
|
||||
|
||||
return <div className={`v2-segmented-tabs is-${variant}${className ? ` ${className}` : ''}`} role="tablist" aria-label={ariaLabel}>
|
||||
{items.map((item, index) => {
|
||||
const selected = item.key === value;
|
||||
return <Button
|
||||
className={selected ? 'is-active' : ''}
|
||||
theme={variant === 'filled' && selected ? 'solid' : 'borderless'}
|
||||
type={selected ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onKeyDown={(event) => selectFromKeyboard(event, index)}
|
||||
onClick={() => onChange(item.key)}
|
||||
key={item.key}
|
||||
>
|
||||
<span className="v2-segmented-tab-label">{item.icon}<span>{item.label}</span>{item.count != null ? <em className="v2-segmented-tab-count">{item.count}</em> : null}</span>
|
||||
</Button>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { TablePagination } from './TablePagination';
|
||||
|
||||
describe('TablePagination', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders one shared Semi pagination pattern and changes pages', () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<footer><TablePagination page={2} totalPages={4} info="共 80 条" onPageChange={onPageChange} /></footer>);
|
||||
|
||||
expect(screen.getByText('共 80 条')).toHaveClass('v2-table-pagination-info');
|
||||
expect(screen.getByText('2').closest('.v2-table-pagination-current')).toHaveTextContent('2/4');
|
||||
const previous = screen.getByRole('button', { name: '上一页' });
|
||||
const next = screen.getByRole('button', { name: '下一页' });
|
||||
expect(previous).toHaveClass('semi-button');
|
||||
expect(next).toHaveClass('semi-button');
|
||||
fireEvent.click(previous);
|
||||
fireEvent.click(next);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
|
||||
});
|
||||
|
||||
test('clamps invalid pages and disables both boundaries for a single page', () => {
|
||||
render(<footer><TablePagination page={0} totalPages={0} info="暂无数据" onPageChange={vi.fn()} /></footer>);
|
||||
expect(screen.getByRole('button', { name: '上一页' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('1/1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons';
|
||||
import { Button, Select } from '@douyinfe/semi-ui';
|
||||
import { useId, type ReactNode } from 'react';
|
||||
|
||||
type PageSizeOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type TablePaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
info: ReactNode;
|
||||
onPageChange: (page: number) => void;
|
||||
disabled?: boolean;
|
||||
pageSize?: number;
|
||||
pageSizeOptions?: PageSizeOption[];
|
||||
pageSizeLabel?: string;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
};
|
||||
|
||||
export function TablePagination({
|
||||
page,
|
||||
totalPages,
|
||||
info,
|
||||
onPageChange,
|
||||
disabled = false,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
pageSizeLabel = '每页数量',
|
||||
onPageSizeChange
|
||||
}: TablePaginationProps) {
|
||||
const safeTotalPages = Math.max(1, totalPages);
|
||||
const safePage = Math.min(Math.max(1, page), safeTotalPages);
|
||||
const pageSizeID = `v2-page-size-${useId().replace(/:/g, '')}`;
|
||||
|
||||
return <>
|
||||
<span className="v2-table-pagination-info">{info}</span>
|
||||
<div className="v2-table-pagination-controls">
|
||||
<Button
|
||||
className="v2-table-pagination-button"
|
||||
theme="light"
|
||||
icon={<IconChevronLeft />}
|
||||
disabled={disabled || safePage <= 1}
|
||||
aria-label="上一页"
|
||||
onClick={() => onPageChange(safePage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="v2-table-pagination-current" aria-live="polite" aria-atomic="true">
|
||||
<b>{safePage}</b><i>/</i><span>{safeTotalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
className="v2-table-pagination-button"
|
||||
theme="light"
|
||||
icon={<IconChevronRight />}
|
||||
iconPosition="right"
|
||||
disabled={disabled || safePage >= safeTotalPages}
|
||||
aria-label="下一页"
|
||||
onClick={() => onPageChange(safePage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
{pageSize != null && pageSizeOptions?.length && onPageSizeChange ? <>
|
||||
<span className="v2-sr-only" id={pageSizeID}>{pageSizeLabel}</span>
|
||||
<Select
|
||||
className="v2-table-pagination-size"
|
||||
aria-labelledby={pageSizeID}
|
||||
disabled={disabled}
|
||||
value={pageSize}
|
||||
onChange={(value) => onPageSizeChange(Number(value))}
|
||||
optionList={pageSizeOptions}
|
||||
/>
|
||||
</> : null}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { VehicleCandidateList } from './VehicleCandidateList';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const vehicles = [
|
||||
{ vin: 'VIN001', plate: '粤A00001', protocols: ['GB32960', 'JT808'] },
|
||||
{ vin: 'VIN002', plate: '粤A00002', protocols: ['JT808'] }
|
||||
];
|
||||
|
||||
test('renders consistent Semi vehicle options and keeps plate first', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<VehicleCandidateList
|
||||
items={vehicles}
|
||||
selectedVins={new Set(['VIN002'])}
|
||||
selectedLabel="已分配"
|
||||
actionLabel="选择"
|
||||
showProtocols
|
||||
header="车辆候选"
|
||||
meta="1/20 已选"
|
||||
onSelect={onSelect}
|
||||
/>);
|
||||
|
||||
expect(screen.getByText('车辆候选')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/20 已选')).toBeInTheDocument();
|
||||
const option = screen.getByRole('option', { name: '粤A00001 VIN001 GB32960 JT808 选择' });
|
||||
expect(option).toHaveClass('semi-button', 'v2-vehicle-option');
|
||||
expect(option).toHaveTextContent('粤A00001VIN001GB32960JT808选择');
|
||||
expect(screen.getByRole('option', { name: '粤A00002 VIN002 JT808 已分配' })).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
fireEvent.click(option);
|
||||
expect(onSelect).toHaveBeenCalledWith(vehicles[0]);
|
||||
});
|
||||
|
||||
test('uses the shared retry and empty states', () => {
|
||||
const onRetry = vi.fn();
|
||||
const { rerender } = render(<VehicleCandidateList items={[]} error="车辆目录查询超时" onRetry={onRetry} onSelect={() => undefined} />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('车辆目录查询超时');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(<VehicleCandidateList items={[]} emptyText="没有已绑定车辆" onSelect={() => undefined} />);
|
||||
expect(screen.getByText('没有已绑定车辆')).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { VehicleOptionError } from './VehicleOptionError';
|
||||
|
||||
export interface VehicleCandidateItem {
|
||||
vin: string;
|
||||
plate?: string;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
interface VehicleCandidateListProps<T extends VehicleCandidateItem> {
|
||||
id?: string;
|
||||
items: T[];
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
error?: string;
|
||||
onRetry?: () => void;
|
||||
emptyText?: string;
|
||||
selectedVins?: ReadonlySet<string>;
|
||||
disableSelected?: boolean;
|
||||
actionLabel?: string;
|
||||
selectedLabel?: string;
|
||||
showProtocols?: boolean;
|
||||
header?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
layout?: 'list' | 'grid';
|
||||
onSelect: (vehicle: T) => void;
|
||||
}
|
||||
|
||||
export function VehicleCandidateList<T extends VehicleCandidateItem>({
|
||||
id,
|
||||
items,
|
||||
loading = false,
|
||||
loadingText = '正在搜索车辆…',
|
||||
error,
|
||||
onRetry,
|
||||
emptyText = '没有匹配车辆',
|
||||
selectedVins,
|
||||
disableSelected = false,
|
||||
actionLabel = '选择',
|
||||
selectedLabel = '已选择',
|
||||
showProtocols = false,
|
||||
header,
|
||||
meta,
|
||||
footer,
|
||||
className = '',
|
||||
layout = 'list',
|
||||
onSelect
|
||||
}: VehicleCandidateListProps<T>) {
|
||||
return <div id={id} className={`v2-vehicle-candidate-list is-${layout}${className ? ` ${className}` : ''}`} role="listbox" aria-busy={loading}>
|
||||
{header || meta ? <header><span>{header}</span>{meta ? <em>{meta}</em> : null}</header> : null}
|
||||
{loading ? <div className="v2-vehicle-candidate-state" role="status"><Spin size="small" /><span>{loadingText}</span></div> : null}
|
||||
{!loading && error && onRetry ? <VehicleOptionError message={error} onRetry={onRetry} /> : null}
|
||||
{!loading && !error ? items.map((vehicle) => {
|
||||
const selected = selectedVins?.has(vehicle.vin) ?? false;
|
||||
const label = selected ? selectedLabel : actionLabel;
|
||||
const protocols = showProtocols ? (vehicle.protocols ?? []).filter(Boolean) : [];
|
||||
return <Button
|
||||
key={vehicle.vin}
|
||||
className={`v2-vehicle-option${selected ? ' is-selected' : ''}`}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`${vehicle.plate || '未绑定车牌'} ${vehicle.vin} ${protocols.join(' ')} ${label}`.replace(/\s+/g, ' ').trim()}
|
||||
disabled={selected && disableSelected}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
>
|
||||
<span className="v2-vehicle-option-identity">
|
||||
<strong>{vehicle.plate || '未绑定车牌'}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
</span>
|
||||
{protocols.length ? <span className="v2-vehicle-option-protocols">{protocols.slice(0, 3).map((protocol) => <Tag key={protocol} color="grey" type="light" size="small">{protocol}</Tag>)}</span> : null}
|
||||
<Tag className="v2-vehicle-option-action" color={selected ? 'blue' : 'grey'} type="light" size="small">{label}</Tag>
|
||||
</Button>;
|
||||
}) : null}
|
||||
{!loading && !error && items.length === 0 ? <div className="v2-vehicle-candidate-state is-empty"><span>{emptyText}</span></div> : null}
|
||||
{footer ? <footer>{footer}</footer> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { VehicleOptionError } from './VehicleOptionError';
|
||||
|
||||
describe('VehicleOptionError', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders a shared Semi retry action without blurring the picker', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<VehicleOptionError message="车辆候选加载失败" onRetry={onRetry} />);
|
||||
|
||||
const retry = screen.getByRole('button', { name: '重试' });
|
||||
const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||
retry.dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
expect(retry).toHaveClass('semi-button');
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
type VehicleOptionErrorProps = {
|
||||
message: string;
|
||||
onRetry: () => void | Promise<unknown>;
|
||||
};
|
||||
|
||||
export function VehicleOptionError({ message, onRetry }: VehicleOptionErrorProps) {
|
||||
return <div className="v2-vehicle-option-error" role="alert">
|
||||
<span>{message}</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="light"
|
||||
type="danger"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void onRetry()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>;
|
||||
}
|
||||
@@ -46,12 +46,15 @@ test('loads all source evidence only after the user expands it', async () => {
|
||||
const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue(evidence);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><VehicleSourceEvidencePanel vin="VIN-001" /></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><VehicleSourceEvidencePanel vin="VIN-001" /></QueryClientProvider>);
|
||||
|
||||
expect(view.container.querySelector('.v2-source-evidence')).toHaveClass('semi-card');
|
||||
expect(sourceEvidence).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看全部来源' }));
|
||||
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('北斗平台')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-source-evidence-card.semi-card')).toHaveLength(3);
|
||||
expect(screen.getAllByText('当前推荐').every((item) => item.closest('.semi-tag'))).toBe(true);
|
||||
expect(screen.getAllByText('当前推荐').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('13307795425')).not.toBeInTheDocument();
|
||||
client.clear();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleLocationSourceEvidence, VehicleMileageSourceEvidence } from '../../api/types';
|
||||
@@ -28,16 +29,16 @@ function evidenceTone(source: Pick<VehicleLocationSourceEvidence, 'recommended'
|
||||
|
||||
function sourceBadges(source: Pick<VehicleLocationSourceEvidence, 'recommended' | 'selectedWithinProtocol' | 'enabled' | 'online' | 'qualityStatus'>) {
|
||||
return <>
|
||||
{source.recommended ? <b className="is-recommended">当前推荐</b> : null}
|
||||
{!source.recommended && source.selectedWithinProtocol ? <b>协议内选中</b> : null}
|
||||
{!source.enabled ? <b className="is-disabled">已禁用</b> : null}
|
||||
{source.enabled && !source.online ? <b className="is-offline">离线</b> : null}
|
||||
{source.qualityStatus !== 'OK' ? <b className="is-warning">{source.qualityStatus}</b> : null}
|
||||
{source.recommended ? <Tag className="is-recommended" color="blue" type="light" size="small">当前推荐</Tag> : null}
|
||||
{!source.recommended && source.selectedWithinProtocol ? <Tag color="cyan" type="light" size="small">协议内选中</Tag> : null}
|
||||
{!source.enabled ? <Tag className="is-disabled" color="grey" type="light" size="small">已禁用</Tag> : null}
|
||||
{source.enabled && !source.online ? <Tag className="is-offline" color="grey" type="light" size="small">离线</Tag> : null}
|
||||
{source.qualityStatus !== 'OK' ? <Tag className="is-warning" color="orange" type="light" size="small">{source.qualityStatus}</Tag> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) {
|
||||
return <article className={`v2-source-evidence-card is-${evidenceTone(source)}`}>
|
||||
return <Card className={`v2-source-evidence-card is-${evidenceTone(source)}`} bodyStyle={{ padding: 0 }}>
|
||||
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(source)}</aside></header>
|
||||
<dl>
|
||||
<div><dt>坐标</dt><dd>{coordinate(source)}</dd></div>
|
||||
@@ -48,12 +49,12 @@ function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence
|
||||
<div><dt>接收时间</dt><dd>{source.receivedAt || '—'}</dd></div>
|
||||
</dl>
|
||||
{source.qualityReason ? <p>质量说明:{source.qualityReason}</p> : null}
|
||||
</article>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) {
|
||||
const comparable = { ...source, online: true };
|
||||
return <article className={`v2-source-evidence-card is-${evidenceTone(comparable)}`}>
|
||||
return <Card className={`v2-source-evidence-card is-${evidenceTone(comparable)}`} bodyStyle={{ padding: 0 }}>
|
||||
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(comparable)}</aside></header>
|
||||
<dl>
|
||||
<div><dt>当日里程</dt><dd>{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}</dd></div>
|
||||
@@ -64,7 +65,7 @@ function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence })
|
||||
<div><dt>末条时间</dt><dd>{source.latestEventTime || '—'}</dd></div>
|
||||
</dl>
|
||||
{source.qualityReason ? <p>选举说明:{source.qualityReason}</p> : null}
|
||||
</article>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export function VehicleSourceEvidencePanel({
|
||||
@@ -100,18 +101,18 @@ export function VehicleSourceEvidencePanel({
|
||||
return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`;
|
||||
}, [query.data, sourceCount]);
|
||||
|
||||
return <section className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`}>
|
||||
return <Card className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<header className="v2-source-evidence-trigger">
|
||||
<div><strong>位置与里程来源</strong><span>{description}</span></div>
|
||||
<button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</button>
|
||||
<Button theme="light" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</Button>
|
||||
</header>
|
||||
{expanded ? <div className="v2-source-evidence-body">
|
||||
<div className="v2-source-evidence-toolbar">
|
||||
<label><span>里程日期</span><input type="date" value={date} max={today()} onChange={(event) => setDate(event.target.value)} /></label>
|
||||
<label><span>里程日期</span><Input aria-label="里程日期" type="date" value={date} max={today()} onChange={setDate} /></label>
|
||||
<small>终端标识已脱敏;推荐结果来自后台选举,展开不会改变原始证据。</small>
|
||||
</div>
|
||||
{query.isPending ? <div className="v2-source-evidence-state"><i />正在读取全部来源证据…</div> : null}
|
||||
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><button type="button" onClick={() => void query.refetch()}>重试</button></div> : null}
|
||||
{query.isPending ? <div className="v2-source-evidence-state" role="status"><Spin size="small" />正在读取全部来源证据…</div> : null}
|
||||
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><Button theme="borderless" type="danger" size="small" onClick={() => void query.refetch()}>重试</Button></div> : null}
|
||||
{query.data ? <>
|
||||
<div className="v2-source-evidence-summary">
|
||||
<div><small>推荐位置来源</small><strong>{query.data.recommendedLocationLabel || query.data.recommendedLocationProtocol || '—'}</strong></div>
|
||||
@@ -121,8 +122,8 @@ export function VehicleSourceEvidencePanel({
|
||||
</div>
|
||||
{query.data.locationSources.length ? <section className="v2-source-evidence-group"><header><strong>当前位置来源</strong><span>{query.data.locationConflict ? `后台检测到位置冲突${query.data.conflictDistanceM == null ? '' : ` · ${number(query.data.conflictDistanceM)} m`}` : '推荐来源与备用来源并列展示'}</span></header><div>{query.data.locationSources.map((source, index) => <LocationSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
|
||||
{query.data.mileageSources.length ? <section className="v2-source-evidence-group"><header><strong>{query.data.mileageDate} 里程来源</strong><span>日里程差 {number(query.data.comparison.dailyMileageDeltaKm)} km</span></header><div>{query.data.mileageSources.map((source, index) => <MileageSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
|
||||
{!query.data.locationSources.length && !query.data.mileageSources.length ? <div className="v2-source-evidence-state">该车辆当前没有可展示的来源证据</div> : null}
|
||||
{!query.data.locationSources.length && !query.data.mileageSources.length ? <Empty className="v2-source-evidence-empty" title="暂无来源证据" description="该车辆当前没有可展示的位置或里程来源。" /> : null}
|
||||
</> : null}
|
||||
</div> : null}
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { WorkspacePanelHeader } from './WorkspacePanelHeader';
|
||||
|
||||
describe('WorkspacePanelHeader', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders one shared Semi workspace heading with metadata and actions', () => {
|
||||
render(<WorkspacePanelHeader title="数据明细" description="最近一次查询结果" meta="共 20 条" actions={<Button>刷新</Button>} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '数据明细', level: 5 })).toHaveClass('semi-typography');
|
||||
expect(screen.getByRole('heading', { name: '数据明细', level: 5 }).closest('header')).toHaveClass('is-default');
|
||||
expect(screen.getByText('最近一次查询结果')).toHaveClass('semi-typography');
|
||||
expect(screen.getByText('共 20 条')).toHaveClass('v2-workspace-panel-meta');
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toHaveClass('semi-button');
|
||||
});
|
||||
|
||||
test('supports compact and inverted panel anatomy without changing semantics', () => {
|
||||
const { rerender } = render(<WorkspacePanelHeader variant="compact" title="行程概览" meta="完整点集" />);
|
||||
expect(screen.getByRole('heading', { name: '行程概览', level: 5 }).closest('header')).toHaveClass('is-compact');
|
||||
|
||||
rerender(<WorkspacePanelHeader variant="inverted" title="粤A12345" description="07/17 11:30:00" meta="50%" />);
|
||||
expect(screen.getByRole('heading', { name: '粤A12345', level: 5 }).closest('header')).toHaveClass('is-inverted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
export function WorkspacePanelHeader({
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
actions,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
actionsClassName = ''
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
variant?: 'default' | 'compact' | 'inverted';
|
||||
className?: string;
|
||||
actionsClassName?: string;
|
||||
}) {
|
||||
const headerClassName = ['v2-workspace-panel-header', `is-${variant}`, className].filter(Boolean).join(' ');
|
||||
const trailingClassName = ['v2-workspace-panel-actions', actionsClassName].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<header className={headerClassName}>
|
||||
<div className="v2-workspace-panel-copy">
|
||||
<Title heading={5}>{title}</Title>
|
||||
{description ? <Text type="tertiary">{description}</Text> : null}
|
||||
</div>
|
||||
{meta || actions ? <div className={trailingClassName}>
|
||||
{meta ? <span className="v2-workspace-panel-meta">{meta}</span> : null}
|
||||
{actions}
|
||||
</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { detailTriggerRow } from './detailTriggerRow';
|
||||
|
||||
describe('detailTriggerRow', () => {
|
||||
test('opens from pointer, Enter, and Space with consistent expanded semantics', () => {
|
||||
const onOpen = vi.fn();
|
||||
const props = detailTriggerRow({
|
||||
className: 'is-selected',
|
||||
expanded: true,
|
||||
label: '查看车辆详情',
|
||||
testId: 'vehicle-row',
|
||||
onOpen
|
||||
});
|
||||
|
||||
expect(props).toMatchObject({
|
||||
className: 'is-selected',
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-expanded': true,
|
||||
'aria-label': '查看车辆详情',
|
||||
'data-testid': 'vehicle-row'
|
||||
});
|
||||
|
||||
props.onClick?.({} as never);
|
||||
const enter = { key: 'Enter', preventDefault: vi.fn() };
|
||||
props.onKeyDown?.(enter as never);
|
||||
const space = { key: ' ', preventDefault: vi.fn() };
|
||||
props.onKeyDown?.(space as never);
|
||||
props.onKeyDown?.({ key: 'Escape', preventDefault: vi.fn() } as never);
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(3);
|
||||
expect(enter.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(space.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
type DetailTriggerRowOptions = {
|
||||
expanded: boolean;
|
||||
label: string;
|
||||
onOpen: () => void;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
type DetailTriggerRowProps = HTMLAttributes<HTMLElement> & {
|
||||
role: 'button';
|
||||
tabIndex: 0;
|
||||
'aria-expanded': boolean;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export function detailTriggerRow({
|
||||
expanded,
|
||||
label,
|
||||
onOpen,
|
||||
className = '',
|
||||
testId
|
||||
}: DetailTriggerRowOptions): DetailTriggerRowProps {
|
||||
return {
|
||||
className,
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-expanded': expanded,
|
||||
'aria-label': label,
|
||||
...(testId ? { 'data-testid': testId } : {}),
|
||||
onClick: onOpen,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { mergeVehicleCandidates } from './vehicleCandidates';
|
||||
|
||||
test('deduplicates candidates by normalized VIN and merges protocol evidence', () => {
|
||||
const result = mergeVehicleCandidates([
|
||||
{ vin: ' ltest000000000001 ', plate: '', protocol: 'JT808' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808', 'YUTONG_MQTT'] },
|
||||
{ vin: '', plate: '无效记录', protocol: 'JT808' }
|
||||
]);
|
||||
|
||||
expect(result).toEqual([{
|
||||
vin: 'LTEST000000000001',
|
||||
plate: '粤A12345',
|
||||
protocol: 'JT808',
|
||||
protocols: ['JT808', 'GB32960', 'YUTONG_MQTT']
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
export type VehicleCandidateSource = {
|
||||
vin: string;
|
||||
plate?: string;
|
||||
protocol?: string;
|
||||
protocols?: string[];
|
||||
};
|
||||
|
||||
export type MergedVehicleCandidate<T extends VehicleCandidateSource> = T & {
|
||||
protocols: string[];
|
||||
};
|
||||
|
||||
export function mergeVehicleCandidates<T extends VehicleCandidateSource>(
|
||||
vehicles: readonly T[]
|
||||
): Array<MergedVehicleCandidate<T>> {
|
||||
const byVIN = new Map<string, MergedVehicleCandidate<T>>();
|
||||
|
||||
for (const vehicle of vehicles) {
|
||||
const vin = vehicle.vin.trim().toUpperCase();
|
||||
if (!vin) continue;
|
||||
const incomingProtocols = new Set([
|
||||
...(vehicle.protocols ?? []),
|
||||
vehicle.protocol ?? ''
|
||||
].map((protocol) => protocol.trim()).filter(Boolean));
|
||||
const current = byVIN.get(vin);
|
||||
|
||||
if (!current) {
|
||||
byVIN.set(vin, {
|
||||
...vehicle,
|
||||
vin,
|
||||
protocols: [...incomingProtocols]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocols = new Set([...current.protocols, ...incomingProtocols]);
|
||||
byVIN.set(vin, {
|
||||
...current,
|
||||
...(!current.plate && vehicle.plate ? { plate: vehicle.plate } : {}),
|
||||
protocols: [...protocols]
|
||||
});
|
||||
}
|
||||
|
||||
return [...byVIN.values()];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import '@douyinfe/semi-theme-default/scss/index.scss';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,19 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://115.29.187.205:20300';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: resolve(__dirname, 'index.html'),
|
||||
semiPrototype: resolve(__dirname, 'semi-prototype.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Keep the actual vehicle brand in the OneOS-owned supplemental master data.
|
||||
-- vehicle_identity_binding.oem may describe a TBox/GPS platform and remains
|
||||
-- untouched for gateway compatibility and historical audit.
|
||||
ALTER TABLE vehicle_profile
|
||||
ADD COLUMN brand_name VARCHAR(128) NOT NULL DEFAULT '' AFTER vin;
|
||||
|
||||
CREATE INDEX idx_vehicle_profile_brand ON vehicle_profile(brand_name);
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Expand the single protocol-field catalog used by realtime telemetry,
|
||||
-- history selection/export and alert rules. Vehicle mileage semantics remain
|
||||
-- protocol-specific in the UI: JT808 is GPS mileage; GB32960/Yutong is meter mileage.
|
||||
UPDATE vehicle_metric_definition
|
||||
SET description='车辆累计行驶里程;JT808 为 GPS 里程,其余为仪表盘里程'
|
||||
WHERE metric_key='total_mileage_km';
|
||||
|
||||
UPDATE vehicle_metric_protocol_mapping
|
||||
SET source_field='yutong_mqtt.data.total_mileage_km'
|
||||
WHERE metric_key='total_mileage_km' AND protocol='YUTONG_MQTT';
|
||||
|
||||
INSERT INTO vehicle_metric_definition
|
||||
(metric_key,label,description,unit,category,value_type,protocols_json,searchable,chartable,alertable,enabled,sort_order) VALUES
|
||||
('vehicle_status','车辆状态','GB/T 32960 整车状态码','','vehicle','numeric','["GB32960"]',1,0,1,1,90),
|
||||
('charge_status','充电状态','GB/T 32960 充电状态码','','energy','numeric','["GB32960"]',1,0,1,1,100),
|
||||
('total_voltage_v','总电压','动力电池总电压','V','energy','numeric','["GB32960"]',1,1,1,1,110),
|
||||
('total_current_a','总电流','动力电池总电流','A','energy','numeric','["GB32960"]',1,1,1,1,120),
|
||||
('insulation_kohm','绝缘电阻','整车绝缘电阻','kΩ','safety','numeric','["GB32960"]',1,1,1,1,130),
|
||||
('fuel_cell_voltage_v','燃料电池电压','燃料电池系统输出电压','V','fuel-cell','numeric','["GB32960"]',1,1,1,1,140),
|
||||
('fuel_cell_current_a','燃料电池电流','燃料电池系统输出电流','A','fuel-cell','numeric','["GB32960"]',1,1,1,1,150),
|
||||
('hydrogen_consumption_kg_per_100km','百公里氢耗','燃料电池系统百公里氢气消耗量','kg/100km','fuel-cell','numeric','["GB32960"]',1,1,1,1,160),
|
||||
('hydrogen_concentration_ppm','最高氢气浓度','燃料电池系统氢气浓度探针的最高读数','ppm','fuel-cell','numeric','["GB32960"]',1,1,1,1,170),
|
||||
('hydrogen_pressure_mpa','最高氢气压力','燃料电池系统氢气压力探针的最高读数','MPa','fuel-cell','numeric','["GB32960"]',1,1,1,1,180),
|
||||
('hydrogen_temperature_c','最高氢气温度','燃料电池系统氢气温度探针的最高读数','℃','fuel-cell','numeric','["GB32960"]',1,1,1,1,190),
|
||||
('engine_speed_rpm','发动机转速','发动机曲轴转速','rpm','vehicle','numeric','["GB32960"]',1,1,1,1,200),
|
||||
('max_cell_voltage_v','单体最高电压','动力电池单体电压最高值','V','energy','numeric','["GB32960"]',1,1,1,1,210),
|
||||
('min_cell_voltage_v','单体最低电压','动力电池单体电压最低值','V','energy','numeric','["GB32960"]',1,1,1,1,220),
|
||||
('max_battery_temperature_c','电池最高温度','动力电池温度探针最高值','℃','energy','numeric','["GB32960"]',1,1,1,1,230),
|
||||
('min_battery_temperature_c','电池最低温度','动力电池温度探针最低值','℃','energy','numeric','["GB32960"]',1,1,1,1,240),
|
||||
('gnss_satellite_count','GNSS 卫星数','JT/T 808 定位终端参与定位的卫星数量','颗','location','numeric','["JT808"]',1,1,1,1,250),
|
||||
('network_signal_strength','通信信号强度','JT/T 808 终端无线通信信号强度','','quality','numeric','["JT808"]',1,1,1,1,260),
|
||||
('fuel_l','油量','JT/T 808 终端上报的车辆油量','L','vehicle','numeric','["JT808"]',1,1,1,1,270)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
label=VALUES(label),description=VALUES(description),unit=VALUES(unit),category=VALUES(category),
|
||||
value_type=VALUES(value_type),protocols_json=VALUES(protocols_json),searchable=VALUES(searchable),
|
||||
chartable=VALUES(chartable),alertable=VALUES(alertable),enabled=VALUES(enabled),sort_order=VALUES(sort_order);
|
||||
|
||||
INSERT INTO vehicle_metric_protocol_mapping(metric_key,protocol,source_field) VALUES
|
||||
('vehicle_status','GB32960','gb32960.vehicle.vehicle_status'),
|
||||
('charge_status','GB32960','gb32960.vehicle.charge_status'),
|
||||
('total_voltage_v','GB32960','gb32960.vehicle.total_voltage_v'),
|
||||
('total_current_a','GB32960','gb32960.vehicle.total_current_a'),
|
||||
('insulation_kohm','GB32960','gb32960.vehicle.insulation_kohm'),
|
||||
('fuel_cell_voltage_v','GB32960','gb32960.fuel_cell.fuel_cell_voltage_v'),
|
||||
('fuel_cell_current_a','GB32960','gb32960.fuel_cell.fuel_cell_current_a'),
|
||||
('hydrogen_consumption_kg_per_100km','GB32960','gb32960.fuel_cell.hydrogen_consumption_kg_per_100km'),
|
||||
('hydrogen_concentration_ppm','GB32960','gb32960.fuel_cell.max_hydrogen_concentration_ppm'),
|
||||
('hydrogen_pressure_mpa','GB32960','gb32960.fuel_cell.max_hydrogen_pressure_mpa'),
|
||||
('hydrogen_temperature_c','GB32960','gb32960.fuel_cell.max_hydrogen_temperature_c'),
|
||||
('engine_speed_rpm','GB32960','gb32960.engine.crank_speed_rpm'),
|
||||
('max_cell_voltage_v','GB32960','gb32960.extreme.max_voltage_v'),
|
||||
('min_cell_voltage_v','GB32960','gb32960.extreme.min_voltage_v'),
|
||||
('max_battery_temperature_c','GB32960','gb32960.extreme.max_temp_c'),
|
||||
('min_battery_temperature_c','GB32960','gb32960.extreme.min_temp_c'),
|
||||
('gnss_satellite_count','JT808','jt808.location.additional.gnss_satellite_count'),
|
||||
('network_signal_strength','JT808','jt808.location.additional.network_signal_strength'),
|
||||
('fuel_l','JT808','jt808.location.additional.fuel_l')
|
||||
ON DUPLICATE KEY UPDATE source_field=VALUES(source_field);
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Align GB/T 32960 business terminology and concentration units with the
|
||||
-- authoritative protocol-field reference. RAW ppm remains available as
|
||||
-- evidence, while catalog, charts and new alert rules use percent.
|
||||
INSERT INTO vehicle_metric_definition
|
||||
(metric_key,label,description,unit,category,value_type,protocols_json,searchable,chartable,alertable,enabled,sort_order) VALUES
|
||||
('hydrogen_concentration_percent','最高氢浓度','氢气浓度最高值','%','fuel-cell','numeric','["GB32960"]',1,1,1,1,170)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
label=VALUES(label),description=VALUES(description),unit=VALUES(unit),category=VALUES(category),
|
||||
value_type=VALUES(value_type),protocols_json=VALUES(protocols_json),searchable=VALUES(searchable),
|
||||
chartable=VALUES(chartable),alertable=VALUES(alertable),enabled=VALUES(enabled),sort_order=VALUES(sort_order);
|
||||
|
||||
INSERT INTO vehicle_metric_protocol_mapping(metric_key,protocol,source_field) VALUES
|
||||
('hydrogen_concentration_percent','GB32960','gb32960.fuel_cell.max_hydrogen_concentration_percent')
|
||||
ON DUPLICATE KEY UPDATE source_field=VALUES(source_field);
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET label='最高氢浓度原始值', description='协议原始浓度计数,仅用于历史证据兼容;业务展示使用百分比', enabled=0
|
||||
WHERE metric_key='hydrogen_concentration_ppm';
|
||||
|
||||
DELETE FROM vehicle_alert_candidate
|
||||
WHERE rule_id IN (SELECT id FROM vehicle_alert_rule WHERE metric='hydrogen_concentration_ppm');
|
||||
|
||||
DELETE FROM vehicle_alert_rule_state
|
||||
WHERE rule_id IN (SELECT id FROM vehicle_alert_rule WHERE metric='hydrogen_concentration_ppm');
|
||||
|
||||
UPDATE vehicle_alert_rule
|
||||
SET metric='hydrogen_concentration_percent',
|
||||
threshold_value=threshold_value / 10000,
|
||||
threshold_high=threshold_high / 10000,
|
||||
recovery_threshold=recovery_threshold / 10000,
|
||||
description=REPLACE(description, 'ppm', '%'),
|
||||
version=version + 1
|
||||
WHERE metric='hydrogen_concentration_ppm';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET label='车辆状态', description='车辆运行、停止等状态编码'
|
||||
WHERE metric_key='vehicle_status';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET label='充电状态', description='车辆充电状态编码'
|
||||
WHERE metric_key='charge_status';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET description='动力系统总电压'
|
||||
WHERE metric_key='total_voltage_v';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET description='动力系统总电流'
|
||||
WHERE metric_key='total_current_a';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET description='高压系统绝缘电阻'
|
||||
WHERE metric_key='insulation_kohm';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET label='氢耗', description='燃料电池氢气消耗率'
|
||||
WHERE metric_key='hydrogen_consumption_kg_per_100km';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET label='最高氢压力', description='氢系统最高压力'
|
||||
WHERE metric_key='hydrogen_pressure_mpa';
|
||||
|
||||
UPDATE vehicle_metric_definition
|
||||
SET label='氢系统最高温度', description='氢系统最高温度'
|
||||
WHERE metric_key='hydrogen_temperature_c';
|
||||
@@ -206,10 +206,13 @@ test -n "$MYSQL_DSN"
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/016_business_scope_dimensions.sql \
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/017_reconciliation_center.sql \
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/018_vehicle_oem_audit.sql \
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/019_vehicle_source_provider.sql
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/019_vehicle_source_provider.sql \
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/020_vehicle_profile_brand.sql \
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/021_protocol_metric_catalog.sql \
|
||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/022_gb32960_reference_catalog.sql
|
||||
```
|
||||
|
||||
The API guards the access-threshold tables for compatibility, while alert and reconciliation APIs deliberately require their migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection. Migration `017` creates the reconciliation run, issue and immutable action tables; the unique fingerprint index is the database-level duplicate-work-item guard. Migration `018` records every OneOS-sourced brand fill before the platform identity OEM field changes; it does not grant access to OneOS or run a cross-database sync. Migration `019` stores administrator-maintained provider names by exact vehicle/protocol/source key and writes an immutable versioned audit. The browser still receives only the SHA-256 `sourceRef`; raw source keys remain server-side.
|
||||
The API guards the access-threshold tables for compatibility, while alert and reconciliation APIs deliberately require their migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection. Migration `017` creates the reconciliation run, issue and immutable action tables; the unique fingerprint index is the database-level duplicate-work-item guard. Migration `018` records every OneOS-sourced brand fill before the platform identity OEM field changes; it does not grant access to OneOS or run a cross-database sync. Migration `019` stores administrator-maintained provider names by exact vehicle/protocol/source key and writes an immutable versioned audit. The browser still receives only the SHA-256 `sourceRef`; raw source keys remain server-side. Migration `020` separates the actual vehicle brand in `vehicle_profile.brand_name` from T-Box/GPS provider identity. Migration `021` adds the cross-protocol core metric catalog and correct source paths for JT808 GPS mileage, GB32960/YUTONG instrument mileage and GB32960 fuel-cell/engine telemetry. Migration `022` applies the authoritative GB/T 32960 Chinese terminology, changes business hydrogen-concentration metrics and alert thresholds from ppm to percent, and leaves the ppm field only as disabled historical evidence.
|
||||
|
||||
`docs/oneos-brand-backfill.sql` is an explicit one-time calibration, not a service dependency. It reads OneOS vehicle/model master data, inserts immutable source evidence, and fills only empty `vehicle_identity_binding.oem` values. Review the pre-run count, apply migration `018`, run the script once through the migration runner or a transaction-capable MySQL client, and verify its final audit/missing counts. Never overwrite a non-empty platform brand and never schedule this script; future updates belong in the formal OneOS API.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user