feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
RUN pip install --no-cache-dir \
--index-url https://mirrors.aliyun.com/pypi/simple \
ddddocr==1.6.1
WORKDIR /app
COPY ocr.py /app/ocr.py
USER nobody
ENTRYPOINT ["python", "/app/ocr.py"]

22
deploy/feichi-ocr/ocr.py Normal file
View File

@@ -0,0 +1,22 @@
import re
import sys
import ddddocr
def main() -> int:
image = sys.stdin.buffer.read()
if not image:
print("captcha image is empty", file=sys.stderr)
return 2
result = ddddocr.DdddOcr(show_ad=False).classification(image)
code = re.sub(r"[^0-9A-Za-z]", "", result).upper()
if not re.fullmatch(r"[0-9A-Z]{4}", code):
print(f"invalid OCR result: {code!r}", file=sys.stderr)
return 3
print(code)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,15 @@
FEICHI_BASE_URL=http://mob.fsfeichi.com.cn:8000
FEICHI_AUTH_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-auth.json
FEICHI_TARGET_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-target.json
FEICHI_TARGET_ADDR=127.0.0.1:32960
FEICHI_STATE_FILE=/var/lib/lingniu-feichi-bridge/state.json
FEICHI_OCR_IMAGE=lingniu/feichi-captcha-ocr:1.0.0
FEICHI_LOGIN_MAX_ATTEMPTS=20
FEICHI_LOGIN_RETRY_SECONDS=1
FEICHI_POLL_INTERVAL_SECONDS=10
FEICHI_DISCOVERY_INTERVAL_SECONDS=300
FEICHI_FETCH_CONCURRENCY=4
FEICHI_SOURCE_STALE_SECONDS=120
FEICHI_STALE_REISSUE_ENABLED=true
FEICHI_BACKFILL_ENABLED=false
HEALTH_ADDR=127.0.0.1:20219

View File

@@ -0,0 +1,28 @@
[Unit]
Description=Lingniu Feichi HTTP to GB/T 32960 Bridge
Documentation=file:/opt/lingniu-go-native/current/docs/ops/feichi-bridge.md
After=network-online.target docker.service lingniu-go-gateway.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
WorkingDirectory=/opt/lingniu-go-native/current
EnvironmentFile=/opt/lingniu-go-native/env/base.env
EnvironmentFile=/opt/lingniu-go-native/env/feichi-bridge.env
Environment=GOMEMLIMIT=256MiB
ExecStart=/opt/lingniu-go-native/current/feichi-bridge
Restart=always
RestartSec=5
LimitNOFILE=65536
MemoryLimit=384M
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWriteDirectories=/var/lib/lingniu-feichi-bridge
[Install]
WantedBy=multi-user.target

160
docs/ops/feichi-bridge.md Normal file
View File

@@ -0,0 +1,160 @@
# 飞驰 HTTP → GB/T 32960 桥接服务
## 目标
将飞驰车辆数据平台中的 20 辆 `GB_T32960` 车辆转换为 GB/T 32960.3-2016 标准 TCP 报文,并发送到岭牛内网车辆网关的 32960 端口。
服务必须满足:
- 每 10 秒发现实时增量,不重复转发平台持续返回的最后一帧;
- 使用 `0x05` 完成平台登录,实时数据使用 `0x02`,历史补传使用 `0x03`
- 每帧收到内网网关成功 ACK 后才推进持久化游标;
- 自动完成 CSRF 初始化、验证码识别、RSA 密码登录和 `R_SESS` 会话维护;
- 源会话返回 401/403 时自动重新登录,并重放一次原 API 请求;
- TCP 断线后重新登录,并用相同原始帧重试一次;
- 重启后从本地游标继续,默认补齐最近 1 小时、每次查询 20 分钟;
- 保留源接口调用、目标 ACK、错误和活跃车辆等 Prometheus 指标;
- 不转发离线车辆反复返回的旧快照。
- 离线车辆最后快照按源时间仅补发一次 `0x03`,不伪装成实时 `0x02`
## 数据路径
```text
飞驰 HTTP API
├─ 车辆发现 vehicleRealStatuss
├─ 实时明细 vehicleRealStatussByVId
└─ 历史明细 hisdataQuerys
feichi-bridge
字段映射 → 去重 → 32960 编码 → ACK 后提交游标
内网 vehicle-gateway :32960
```
桥接服务当前编码 V2016 `##` 报文。源平台确认的主要字段如下:
| 32960 数据 | 飞驰字段 |
|---|---|
| 数据时间 | `2000`(兼容 `9999` |
| 车速、里程、挡位 | `2201``2202``2203` |
| 加速/制动踏板 | `2208``2209` |
| 运行模式、DCDC | `2213``2214` |
| 充电状态、车辆状态 | `2301``3201` |
| 总电压、总电流、绝缘电阻 | `2613``2614``2617` |
| SOC | `7615` |
| 原始经纬度 | `2502``2503` |
| 单体电压、温度 | `2003``2103` |
| 极值 | `2601``2612` |
| 燃料电池主要数据 | `2110``2121` |
| 驱动电机复合数据 | `2307``2308` |
定位必须使用 `2502/2503` 原始坐标;`.gd` 字段是页面地图转换坐标,不能写进国标数据。
## 自动登录与认证文件
服务按照网页端协议执行 `first-login → randCode → login`:读取 `x-api-csrf`/`CSRF`,验证码图片交给 ECS 本机、禁网运行的专用 OCR 容器,密码使用网页端同一 RSA 公钥做 PKCS#1 v1.5 加密,登录成功后保存响应中的 `R_SESS`。OCR 结果不会被直接信任登录接口是最终校验错误时会换一张验证码重试。密码、Cookie、验证码图片和响应正文均不写日志。
`/opt/lingniu-go-native/secrets/feichi-auth.json`
```json
{
"username": "replace-me",
"password": "replace-me"
}
```
文件权限必须为 `0600`。飞驰若提供机器账号、长期 Token 或正式接口鉴权,仍应优先切换到供应商承诺的方式。
`/opt/lingniu-go-native/secrets/feichi-target.json`
```json
{
"platformId": "FEICHIBRIDGE00001",
"username": "bridge",
"password": "replace-me"
}
```
`platformId` 必须正好 17 字节。目标网关启用 `GB32960_AUTH_MODE=enforce` 时,需在网关凭据文件中登记同一平台账号和密码。
## 环境配置
`/opt/lingniu-go-native/env/feichi-bridge.env` 最小配置:
```dotenv
FEICHI_BASE_URL=http://mob.fsfeichi.com.cn:8000
FEICHI_AUTH_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-auth.json
FEICHI_TARGET_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-target.json
FEICHI_TARGET_ADDR=127.0.0.1:32960
FEICHI_STATE_FILE=/var/lib/lingniu-feichi-bridge/state.json
HEALTH_ADDR=127.0.0.1:20219
```
可调参数及默认值:
| 参数 | 默认值 | 说明 |
|---|---:|---|
| `FEICHI_POLL_INTERVAL_SECONDS` | 10 | 实时轮询间隔 |
| `FEICHI_DISCOVERY_INTERVAL_SECONDS` | 300 | 车辆清单刷新间隔 |
| `FEICHI_FETCH_CONCURRENCY` | 4 | 明细接口并发数 |
| `FEICHI_SOURCE_STALE_SECONDS` | 120 | 超过该时间不作为实时帧转发 |
| `FEICHI_STALE_REISSUE_ENABLED` | true | 将离线旧快照去重后仅补发一次为 `0x03` |
| `FEICHI_HTTP_TIMEOUT_SECONDS` | 15 | HTTP 超时 |
| `FEICHI_OCR_IMAGE` | `lingniu/feichi-captcha-ocr:1.0.0` | 本机验证码 OCR 镜像 |
| `FEICHI_OCR_TIMEOUT_SECONDS` | 20 | 单次验证码识别超时 |
| `FEICHI_LOGIN_MAX_ATTEMPTS` | 20 | 单轮自动登录最多验证码次数 |
| `FEICHI_LOGIN_RETRY_SECONDS` | 1 | 验证码/登录失败重试间隔 |
| `FEICHI_TARGET_TIMEOUT_SECONDS` | 10 | TCP 写入和 ACK 超时 |
| `FEICHI_BACKFILL_ENABLED` | true | 是否启用历史补传 |
| `FEICHI_BACKFILL_LOOKBACK_SECONDS` | 3600 | 无游标时回看范围 |
| `FEICHI_BACKFILL_WINDOW_SECONDS` | 1200 | 单次历史查询窗口 |
| `FEICHI_BACKFILL_SAFETY_SECONDS` | 30 | 历史与实时之间的安全延迟 |
| `FEICHI_BACKFILL_INTERVAL_SECONDS` | 3600 | 补传巡检周期 |
飞驰地址当前是明文 HTTP。部署时应限制服务的出口目的地址并避免认证文件、Cookie、响应原文进入代理访问日志。
## 构建与启动
```bash
cd go/vehicle-gateway
go test ./internal/feichibridge ./cmd/feichi-bridge
CGO_ENABLED=0 go build -trimpath -o feichi-bridge ./cmd/feichi-bridge
docker build -t lingniu/feichi-captcha-ocr:1.0.0 ../../deploy/feichi-ocr
install -d -m 0750 /var/lib/lingniu-feichi-bridge
systemctl daemon-reload
systemctl enable --now lingniu-go-feichi-bridge
```
检查:
```bash
curl -fsS http://127.0.0.1:20219/healthz
curl -fsS http://127.0.0.1:20219/readyz
curl -fsS http://127.0.0.1:20219/metrics | grep vehicle_feichi_bridge
journalctl -u lingniu-go-feichi-bridge -f
jq '.vehicles | to_entries[] | select(.value.last_realtime_ack_at or .value.last_backfill_ack_at) |
{vin: .key, realtime_ack: .value.last_realtime_ack_at, backfill_ack: .value.last_backfill_ack_at}' \
/var/lib/lingniu-feichi-bridge/state.json
```
## 上线验收
先选 1 辆在线车灰度 30 分钟,再开放全部 20 辆。验收需要同时满足:
1. 网关收到一次 `0x05` 登录,后续实时帧为 `0x02`
2. 同一源时间和同一内容只落一条,重启后无持续重复;
3. 断开目标 TCP 后能重连、重新登录并继续收到 ACK
4. 使源会话过期后能自动重登;连续登录失败时 `/readyz` 变为 503游标不前移
5. 两辆离线车不会把历史最后一帧当实时数据周期发送;
6. 原始经纬度、总压、总流、SOC、单体电压和温度与平台页面抽样一致
7. 20 辆车连续运行 24 小时后,源/目标计数和平台记录数差异可解释。
## 已知边界
- 页面接口不是飞驰承诺的稳定开放 API接口结构升级可能要求同步调整客户端
- 验证码识别依赖本机 OCR 镜像和飞驰当前验证码样式;样式变化会触发登录失败告警,不会导致未 ACK 数据推进游标;
- 源接口只提供离散快照,无法恢复平台未保存或查询窗口之外的原始上行帧;
- 映射结果是标准 32960 语义报文,不是车辆原始二进制报文的逐字节复制。

View File

@@ -49,6 +49,8 @@ Gateway 在 NATS 模式下设置 `FIELDS_DERIVE_FROM_RAW_ENABLED=true`,每帧
stat-writer 默认 `STATS_WORKERS=3`,每个 worker 是同一 consumer group 的独立 Kafka reader。Kafka 按车辆 key 固定分区,因此单车累计里程仍按分区顺序执行,不同分区并行。调整 worker 后同时核对 `vehicle_stat_config{setting="workers"}`、每个 `vehicle_stat_worker_active{worker}`、MySQL 连接数、write p99 和 stat lagworker 不应超过 fields topic 的有效分区并行度。
stat-writer 对死锁、锁等待和网络中断等瞬时 MySQL 故障按 `STATS_RETRY_ATTEMPTS` 有限重试,重试耗尽后保持失败关闭并等待存储恢复。只有 `NULL`、数值越界、类型转换、字段过长和 CHECK 约束这类明确的单消息数据错误,才会在同样的有限重试耗尽后写入 `STATS_QUARANTINE_DIR` 并提交该 Kafka 偏移量,避免一条坏消息阻塞整个分区;表结构、权限和未知程序错误不会被隔离。隔离文件包含原 topic、partition、offset、key、value 和错误,默认权限为 `0600`,处理前不得删除。
history-writer 和 realtime-writer 同样默认分别使用 `HISTORY_WORKERS=3``REALTIME_WORKERS=3`。每个 worker 只顺序处理 Kafka 分配给自己的分区TDengine 表缓存、MySQL 实时节流缓存和车牌缓存均支持并发访问。调整后必须同时核对对应的 `*_config{setting="workers"}``*_worker_active`、pending/retry、后端连接数和 Kafka lag。
history-writer 启动会对既有 `vehicle_locations` stable 幂等补齐 `soc_percent DOUBLE`。发布后除 `/readyz` 外,必须抽查一台支持 SOC 的车辆在 TDengine 历史位置中 `soc_percent IS NOT NULL`;如果迁移报错且不是明确的重复列错误,服务应保持未就绪,先处理 TDengine DDL/权限,不能跳过迁移后强启。
@@ -174,6 +176,8 @@ curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_kafka_lag
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_samples_total
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_sources_total
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_last_
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_quarantine_total
find /var/lib/lingniu-go-native/stat-writer-quarantine -maxdepth 1 -type f -printf '%f %s bytes\n'
curl -fsS http://127.0.0.1:20216/metrics | grep vehicle_realtime_kafka_lag
curl -fsS http://127.0.0.1:20217/metrics | grep vehicle_identity_writer_kafka_lag
for port in 20212 20213 20216 20217 20200; do

View File

@@ -6,6 +6,7 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/gateway \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/feichi-bridge ./cmd/feichi-bridge \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/history-writer ./cmd/history-writer \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/stat-writer ./cmd/stat-writer \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/realtime-api ./cmd/realtime-api
@@ -18,6 +19,7 @@ RUN apt-get update \
WORKDIR /app
COPY --from=build /out/gateway /app/gateway
COPY --from=build /out/feichi-bridge /app/feichi-bridge
COPY --from=build /out/history-writer /app/history-writer
COPY --from=build /out/stat-writer /app/stat-writer
COPY --from=build /out/realtime-api /app/realtime-api

View File

@@ -0,0 +1,211 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/feichibridge"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
)
type authSecret struct {
Username string `json:"username"`
Password string `json:"password"`
}
type targetSecret struct {
PlatformID string `json:"platformId"`
Username string `json:"username"`
Password string `json:"password"`
}
type config struct {
BaseURL string
AuthSecretFile string
TargetSecret string
TargetAddress string
StateFile string
HealthAddress string
HTTPTimeout time.Duration
TargetTimeout time.Duration
OCRImage string
OCRTimeout time.Duration
LoginAttempts int
LoginRetryDelay time.Duration
Service feichibridge.ServiceConfig
}
func main() {
logger := observability.NewLogger("feichi-bridge")
cfg, err := loadConfig()
if err != nil {
logger.Error("load configuration failed", "error", err)
os.Exit(1)
}
auth, err := readJSONSecret[authSecret](cfg.AuthSecretFile)
if err != nil {
logger.Error("read Feichi API secret failed", "error", err)
os.Exit(1)
}
credentials, err := readJSONSecret[targetSecret](cfg.TargetSecret)
if err != nil {
logger.Error("read GB/T 32960 target secret failed", "error", err)
os.Exit(1)
}
source, err := feichibridge.NewAuthenticatedAPIClient(
cfg.BaseURL,
feichibridge.LoginCredentials{
Username: auth.Username, Password: auth.Password,
MaxAttempts: cfg.LoginAttempts, RetryDelay: cfg.LoginRetryDelay,
},
feichibridge.DockerCaptchaSolver{Image: cfg.OCRImage, Timeout: cfg.OCRTimeout},
cfg.HTTPTimeout,
)
if err != nil {
logger.Error("build Feichi API client failed", "error", err)
os.Exit(1)
}
state, err := feichibridge.OpenStateStore(cfg.StateFile)
if err != nil {
logger.Error("open bridge state failed", "error", err)
os.Exit(1)
}
target, err := feichibridge.NewTarget(feichibridge.TargetConfig{
Address: cfg.TargetAddress, PlatformID: credentials.PlatformID,
Username: credentials.Username, Password: credentials.Password,
Timeout: cfg.TargetTimeout,
}, state)
if err != nil {
logger.Error("build GB/T 32960 target failed", "error", err)
os.Exit(1)
}
registry := metrics.NewRegistry()
service, err := feichibridge.NewService(cfg.Service, source, target, state, logger, registry)
if err != nil {
logger.Error("build bridge service failed", "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
health.Start(ctx, logger, health.NewServer(cfg.HealthAddress, "feichi-bridge", []health.Check{
{Name: "bridge", Check: service.Ready},
}, registry))
logger.Info("Feichi bridge starting",
"base_url", cfg.BaseURL,
"target_address", cfg.TargetAddress,
"poll_interval", cfg.Service.PollInterval,
"backfill_enabled", cfg.Service.BackfillEnabled,
)
if strings.HasPrefix(strings.ToLower(cfg.BaseURL), "http://") {
logger.Warn("Feichi source uses clear-text HTTP; deploy only through a controlled egress path")
}
if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Error("Feichi bridge stopped unexpectedly", "error", err)
os.Exit(1)
}
logger.Info("Feichi bridge stopped")
}
func loadConfig() (config, error) {
cfg := config{
BaseURL: strings.TrimSpace(os.Getenv("FEICHI_BASE_URL")),
AuthSecretFile: env("FEICHI_AUTH_SECRET_FILE", strings.TrimSpace(os.Getenv("FEICHI_AUTH_HEADERS_FILE"))),
TargetSecret: strings.TrimSpace(os.Getenv("FEICHI_TARGET_SECRET_FILE")),
TargetAddress: env("FEICHI_TARGET_ADDR", "127.0.0.1:32960"),
StateFile: env("FEICHI_STATE_FILE", "/var/lib/lingniu-feichi-bridge/state.json"),
HealthAddress: env("HEALTH_ADDR", "127.0.0.1:20219"),
HTTPTimeout: seconds("FEICHI_HTTP_TIMEOUT_SECONDS", 15),
TargetTimeout: seconds("FEICHI_TARGET_TIMEOUT_SECONDS", 10),
OCRImage: env("FEICHI_OCR_IMAGE", "lingniu/feichi-captcha-ocr:1.0.0"),
OCRTimeout: seconds("FEICHI_OCR_TIMEOUT_SECONDS", 20),
LoginAttempts: envInt("FEICHI_LOGIN_MAX_ATTEMPTS", 20),
LoginRetryDelay: seconds("FEICHI_LOGIN_RETRY_SECONDS", 1),
Service: feichibridge.ServiceConfig{
PollInterval: seconds("FEICHI_POLL_INTERVAL_SECONDS", 10),
DiscoveryInterval: seconds("FEICHI_DISCOVERY_INTERVAL_SECONDS", 300),
BackfillInterval: seconds("FEICHI_BACKFILL_INTERVAL_SECONDS", 3600),
BackfillLookback: seconds("FEICHI_BACKFILL_LOOKBACK_SECONDS", 3600),
BackfillWindow: seconds("FEICHI_BACKFILL_WINDOW_SECONDS", 1200),
BackfillSafetyLag: seconds("FEICHI_BACKFILL_SAFETY_SECONDS", 30),
SourceStaleAfter: seconds("FEICHI_SOURCE_STALE_SECONDS", 120),
FetchConcurrency: envInt("FEICHI_FETCH_CONCURRENCY", 4),
BackfillEnabled: envBool("FEICHI_BACKFILL_ENABLED", true),
StaleReissueEnabled: envBool("FEICHI_STALE_REISSUE_ENABLED", true),
},
}
var missing []string
if cfg.BaseURL == "" {
missing = append(missing, "FEICHI_BASE_URL")
}
if cfg.AuthSecretFile == "" {
missing = append(missing, "FEICHI_AUTH_SECRET_FILE")
}
if cfg.TargetSecret == "" {
missing = append(missing, "FEICHI_TARGET_SECRET_FILE")
}
if len(missing) > 0 {
return config{}, fmt.Errorf("required configuration missing: %s", strings.Join(missing, ", "))
}
if cfg.Service.BackfillWindow > 24*time.Hour {
return config{}, errors.New("FEICHI_BACKFILL_WINDOW_SECONDS must not exceed 86400")
}
return cfg, nil
}
func readJSONSecret[T any](path string) (T, error) {
var value T
encoded, err := os.ReadFile(path)
if err != nil {
return value, err
}
if err := json.Unmarshal(encoded, &value); err != nil {
return value, fmt.Errorf("decode %s: %w", path, err)
}
return value, nil
}
func env(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func envInt(name string, fallback int) int {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envBool(name string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func seconds(name string, fallback int) time.Duration {
return time.Duration(envInt(name, fallback)) * time.Second
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -54,6 +55,7 @@ func main() {
registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds())
registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds())
registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds())
registry.SetGauge("vehicle_stat_baseline_hit_ttl_seconds", nil, cfg.BaselineHitTTL.Seconds())
registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
{Name: "mysql", Check: db.PingContext},
@@ -65,6 +67,7 @@ func main() {
writer.SetCacheRetention(cfg.CacheRetention)
writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval)
writer.SetBaselineMissTTL(cfg.BaselineMissTTL)
writer.SetBaselineHitTTL(cfg.BaselineHitTTL)
writer.SetMaxCacheEntries(cfg.CacheMaxEntries)
if cfg.EnsureSchema {
if err := writer.EnsureSchema(ctx); err != nil {
@@ -89,14 +92,15 @@ func main() {
delay: cfg.RetryDelay,
registry: registry,
}
quarantiner := fileStatMessageQuarantiner{dir: cfg.QuarantineDir}
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds())
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "baseline_hit_ttl_seconds", cfg.BaselineHitTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds(), "quarantine_dir", cfg.QuarantineDir)
var workers sync.WaitGroup
for workerID := 1; workerID <= cfg.Workers; workerID++ {
workers.Add(1)
go func(id int) {
defer workers.Done()
runStatConsumer(ctx, logger, registry, appender, cfg, id)
runStatConsumer(ctx, logger, registry, appender, quarantiner, cfg, id)
}(workerID)
}
workers.Wait()
@@ -105,7 +109,7 @@ func main() {
func runStatConsumer(ctx context.Context, logger interface {
Error(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, cfg config, workerID int) {
}, registry *metrics.Registry, appender statAppender, quarantiner statMessageQuarantiner, cfg config, workerID int) {
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: cfg.KafkaBrokers,
GroupID: cfg.KafkaGroup,
@@ -129,7 +133,7 @@ func runStatConsumer(ctx context.Context, logger interface {
continue
}
batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels)
processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, quarantiner, batch, cfg.RetryDelay, workerLabels)
}
}
@@ -155,6 +159,10 @@ type kafkaMessageFetcher interface {
FetchMessage(context.Context) (kafka.Message, error)
}
type statMessageQuarantiner interface {
Quarantine(context.Context, kafka.Message, error) error
}
type statBatchItem struct {
message kafka.Message
processed bool
@@ -163,6 +171,8 @@ type statBatchItem struct {
type statBatchOutcome struct {
commitMessages []kafka.Message
retryMessages []kafka.Message
failedMessage *kafka.Message
writeErr error
}
var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
@@ -251,7 +261,12 @@ func processStatBatchForWorker(ctx context.Context, logger interface {
addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)}
failedMessage := message
outcome := statBatchOutcome{
retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed),
failedMessage: &failedMessage,
writeErr: err,
}
if commitErr != nil {
outcome.commitMessages = committed
}
@@ -279,13 +294,13 @@ func processStatBatchReliably(ctx context.Context, logger interface {
Error(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil)
processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, nil, messages, retryDelay, nil)
}
func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
Error(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, quarantiner statMessageQuarantiner, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0)
pending := messages
for len(pending) > 0 {
@@ -297,6 +312,19 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
}
}
pending = outcome.retryMessages
if outcome.writeErr != nil && outcome.failedMessage != nil && isQuarantinableStatError(outcome.writeErr) && quarantiner != nil {
failed := *outcome.failedMessage
if err := quarantiner.Quarantine(ctx, failed, outcome.writeErr); err != nil {
logger.Error("stat message quarantine failed", "topic", failed.Topic, "partition", failed.Partition, "offset", failed.Offset, "error", err)
registry.IncCounter("vehicle_stat_quarantine_total", metrics.Labels{"status": "error", "topic": failed.Topic})
} else {
registry.IncCounter("vehicle_stat_quarantine_total", metrics.Labels{"status": "ok", "topic": failed.Topic})
logger.Warn("quarantined permanent stat write failure", "topic", failed.Topic, "partition", failed.Partition, "offset", failed.Offset, "error", outcome.writeErr)
if retryStatCommit(ctx, logger, registry, committer, []kafka.Message{failed}, retryDelay) {
pending = messagesExceptStatMessage(pending, failed)
}
}
}
registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending)))
if len(pending) == 0 {
return
@@ -308,6 +336,105 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
}
}
type quarantinedStatMessage struct {
QuarantinedAt string `json:"quarantined_at"`
Topic string `json:"topic"`
Partition int `json:"partition"`
Offset int64 `json:"offset"`
Key []byte `json:"key,omitempty"`
Value []byte `json:"value"`
Error string `json:"error"`
}
type fileStatMessageQuarantiner struct {
dir string
}
func (q fileStatMessageQuarantiner) Quarantine(ctx context.Context, message kafka.Message, cause error) error {
if err := ctx.Err(); err != nil {
return err
}
dir := strings.TrimSpace(q.dir)
if dir == "" {
return errors.New("stat quarantine directory is empty")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create quarantine directory: %w", err)
}
record := quarantinedStatMessage{
QuarantinedAt: time.Now().UTC().Format(time.RFC3339Nano),
Topic: message.Topic,
Partition: message.Partition,
Offset: message.Offset,
Key: message.Key,
Value: message.Value,
Error: cause.Error(),
}
payload, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal quarantine record: %w", err)
}
name := fmt.Sprintf("%s-%d-%d.json", sanitizeStatQuarantineName(message.Topic), message.Partition, message.Offset)
temporary, err := os.CreateTemp(dir, "."+name+"-*")
if err != nil {
return fmt.Errorf("create quarantine record: %w", err)
}
temporaryName := temporary.Name()
removeTemporary := true
defer func() {
_ = temporary.Close()
if removeTemporary {
_ = os.Remove(temporaryName)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return fmt.Errorf("secure quarantine record: %w", err)
}
if _, err := temporary.Write(payload); err != nil {
return fmt.Errorf("write quarantine record: %w", err)
}
if err := temporary.Sync(); err != nil {
return fmt.Errorf("sync quarantine record: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close quarantine record: %w", err)
}
if err := os.Rename(temporaryName, filepath.Join(dir, name)); err != nil {
return fmt.Errorf("publish quarantine record: %w", err)
}
removeTemporary = false
return nil
}
func sanitizeStatQuarantineName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown-topic"
}
var out strings.Builder
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
out.WriteRune(r)
} else {
out.WriteByte('_')
}
}
return out.String()
}
func messagesExceptStatMessage(messages []kafka.Message, excluded kafka.Message) []kafka.Message {
out := make([]kafka.Message, 0, len(messages))
removed := false
for _, message := range messages {
if !removed && message.Topic == excluded.Topic && message.Partition == excluded.Partition && message.Offset == excluded.Offset {
removed = true
continue
}
out = append(out, message)
}
return out
}
func retryStatCommit(ctx context.Context, logger interface {
Error(string, ...any)
Warn(string, ...any)
@@ -447,7 +574,7 @@ func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.Fr
var err error
for attempt := 1; attempt <= attempts; attempt++ {
result, err = appendStatEnvelope(ctx, a.delegate, env)
if err == nil || !isTransientMySQLStatError(err) {
if err == nil || (!isTransientMySQLStatError(err) && !isQuarantinableStatError(err)) {
return result, err
}
if attempt == attempts {
@@ -512,6 +639,32 @@ func isTransientMySQLStatError(err error) bool {
strings.Contains(text, "no route to host")
}
func isQuarantinableStatError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
// Only deterministic row-data failures may advance past a Kafka message
// after the configured finite retries. Schema, permission and unknown
// application errors remain failure-closed so a systemic outage cannot
// silently quarantine an entire stream.
return strings.Contains(text, "error 1048") ||
strings.Contains(text, "cannot be null") ||
strings.Contains(text, "error 1264") ||
strings.Contains(text, "out of range value") ||
strings.Contains(text, "error 1265") ||
strings.Contains(text, "data truncated") ||
strings.Contains(text, "error 1292") ||
strings.Contains(text, "incorrect datetime value") ||
strings.Contains(text, "error 1366") ||
strings.Contains(text, "incorrect decimal value") ||
strings.Contains(text, "incorrect integer value") ||
strings.Contains(text, "error 1406") ||
strings.Contains(text, "data too long for column") ||
strings.Contains(text, "error 3819") ||
strings.Contains(text, "check constraint")
}
func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
if registry == nil {
return
@@ -686,6 +839,7 @@ type config struct {
CacheRetention time.Duration
CacheCleanupInterval time.Duration
BaselineMissTTL time.Duration
BaselineHitTTL time.Duration
CacheMaxEntries int
Workers int
BatchSize int
@@ -694,6 +848,7 @@ type config struct {
RetryDelay time.Duration
NormalizePlatformSourcesOnStart bool
NormalizePlatformSourcesTimeout time.Duration
QuarantineDir string
}
func (c config) Validate() error {
@@ -728,6 +883,7 @@ func loadConfig() config {
CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour,
CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second,
BaselineMissTTL: time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second,
BaselineHitTTL: time.Duration(envInt("STATS_BASELINE_HIT_TTL_SECONDS", 300)) * time.Second,
CacheMaxEntries: envInt("STATS_CACHE_MAX_ENTRIES", 1000000),
Workers: envInt("STATS_WORKERS", 3),
BatchSize: envInt("STATS_BATCH_SIZE", 200),
@@ -736,6 +892,7 @@ func loadConfig() config {
RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond,
NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false",
NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second,
QuarantineDir: env("STATS_QUARANTINE_DIR", "/var/lib/lingniu-go-native/stat-writer-quarantine"),
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -664,6 +666,72 @@ func TestProcessStatBatchReliablyRetriesFailedSuffix(t *testing.T) {
}
}
func TestProcessStatBatchReliablyQuarantinesPermanentFailureAndContinues(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
payload := marshalStatTestFieldsEnvelope(t, env)
appender := &contextCheckingStatAppender{failOnCount: 1, err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")}
committer := &contextCheckingStatCommitter{}
quarantiner := &recordingStatQuarantiner{}
registry := metrics.NewRegistry()
messages := []kafka.Message{
{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload},
{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload},
}
processStatBatchReliablyForWorker(
context.Background(),
discardStatLogger{},
registry,
appender,
committer,
quarantiner,
messages,
time.Nanosecond,
nil,
)
if appender.count != 2 {
t.Fatalf("append attempts = %d, want failed message once and following message once", appender.count)
}
if quarantiner.count != 1 || quarantiner.message.Offset != 10 {
t.Fatalf("quarantine = %d/%d, want one message at offset 10", quarantiner.count, quarantiner.message.Offset)
}
if got := committedOffsets(committer.messages); len(got) != 2 || got[0] != 10 || got[1] != 11 {
t.Fatalf("committed offsets = %#v, want [10 11]", got)
}
text := registry.Render()
if !strings.Contains(text, `vehicle_stat_quarantine_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`) {
t.Fatalf("quarantine success metric missing:\n%s", text)
}
}
func TestFileStatMessageQuarantinerPersistsOriginalKafkaMessage(t *testing.T) {
dir := t.TempDir()
message := kafka.Message{
Topic: "vehicle.fields.go.jt808.v1",
Partition: 2,
Offset: 5094052,
Key: []byte("vehicle-key"),
Value: []byte(`{"event_id":"poison:fields"}`),
}
wantErr := errors.New("permanent mysql error")
if err := (fileStatMessageQuarantiner{dir: dir}).Quarantine(context.Background(), message, wantErr); err != nil {
t.Fatalf("Quarantine() error = %v", err)
}
payload, err := os.ReadFile(filepath.Join(dir, "vehicle.fields.go.jt808.v1-2-5094052.json"))
if err != nil {
t.Fatalf("read quarantine record: %v", err)
}
var record quarantinedStatMessage
if err := json.Unmarshal(payload, &record); err != nil {
t.Fatalf("unmarshal quarantine record: %v", err)
}
if record.Topic != message.Topic || record.Partition != message.Partition || record.Offset != message.Offset || string(record.Key) != string(message.Key) || string(record.Value) != string(message.Value) || record.Error != wantErr.Error() {
t.Fatalf("quarantine record = %#v, want original message and error", record)
}
}
func TestProcessStatBatchReliablyRetriesCommitWithoutReappending(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
payload := marshalStatTestFieldsEnvelope(t, env)
@@ -826,6 +894,30 @@ func TestRetryStatAppenderRecordsExhaustedTransientError(t *testing.T) {
}
}
func TestRetryStatAppenderRetriesQuarantinableDataErrorBeforeIsolation(t *testing.T) {
registry := metrics.NewRegistry()
delegate := &contextCheckingStatAppender{err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")}
appender := retryStatAppender{delegate: delegate, attempts: 3, registry: registry}
_, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808})
if err == nil {
t.Fatal("AppendWithResult() error = nil, want exhausted data error")
}
if delegate.count != 3 {
t.Fatalf("append attempts = %d, want configured finite retries", delegate.count)
}
text := registry.Render()
for _, want := range []string{
`vehicle_stat_write_retries_total{operation="single",status="retry"} 2`,
`vehicle_stat_write_retries_total{operation="single",status="exhausted"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("retry metric missing %s:\n%s", want, text)
}
}
}
func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) {
wantErr := errors.New("validation failed")
delegate := &contextCheckingStatAppender{err: wantErr}
@@ -841,6 +933,34 @@ func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) {
}
}
func TestIsQuarantinableStatErrorOnlyAcceptsDeterministicRowDataFailures(t *testing.T) {
for _, err := range []error{
errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null"),
errors.New("Error 1264: Out of range value for column 'daily_mileage_km'"),
errors.New("Error 1265: Data truncated for column 'daily_mileage_km'"),
errors.New("Error 1292: Incorrect datetime value"),
errors.New("Error 1366: Incorrect decimal value"),
errors.New("Error 1406: Data too long for column"),
errors.New("Error 3819: Check constraint is violated"),
} {
if !isQuarantinableStatError(err) {
t.Fatalf("isQuarantinableStatError(%q) = false, want true", err.Error())
}
}
for _, err := range []error{
errors.New("Error 1054: Unknown column 'daily_mileage_km'"),
errors.New("Error 1142: INSERT command denied"),
errors.New("validation failed"),
context.Canceled,
context.DeadlineExceeded,
nil,
} {
if isQuarantinableStatError(err) {
t.Fatalf("isQuarantinableStatError(%v) = true, want false", err)
}
}
}
func TestIsTransientMySQLStatError(t *testing.T) {
for _, err := range []error{
errors.New("dial tcp 127.0.0.1:3306: connection refused"),
@@ -903,6 +1023,9 @@ func TestLoadConfigDefaultsToGoFieldsTopics(t *testing.T) {
if cfg.BaselineMissTTL != time.Minute {
t.Fatalf("BaselineMissTTL = %s, want 1m", cfg.BaselineMissTTL)
}
if cfg.BaselineHitTTL != 5*time.Minute {
t.Fatalf("BaselineHitTTL = %s, want 5m", cfg.BaselineHitTTL)
}
if cfg.RetryAttempts != 3 {
t.Fatalf("RetryAttempts = %d, want 3", cfg.RetryAttempts)
}
@@ -965,6 +1088,7 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) {
t.Setenv("STATS_CACHE_RETENTION_HOURS", "48")
t.Setenv("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", "300")
t.Setenv("STATS_BASELINE_MISS_TTL_SECONDS", "45")
t.Setenv("STATS_BASELINE_HIT_TTL_SECONDS", "180")
t.Setenv("STATS_CACHE_MAX_ENTRIES", "12345")
cfg := loadConfig()
@@ -984,6 +1108,9 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) {
if cfg.BaselineMissTTL != 45*time.Second {
t.Fatalf("BaselineMissTTL = %s, want 45s", cfg.BaselineMissTTL)
}
if cfg.BaselineHitTTL != 3*time.Minute {
t.Fatalf("BaselineHitTTL = %s, want 3m", cfg.BaselineHitTTL)
}
if cfg.CacheMaxEntries != 12345 {
t.Fatalf("CacheMaxEntries = %d, want 12345", cfg.CacheMaxEntries)
}
@@ -997,6 +1124,7 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) {
t.Setenv("STATS_RETRY_DELAY_MS", "33")
t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "false")
t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", "17")
t.Setenv("STATS_QUARANTINE_DIR", "/tmp/stat-writer-quarantine-test")
cfg := loadConfig()
@@ -1021,6 +1149,9 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) {
if cfg.NormalizePlatformSourcesTimeout != 17*time.Second {
t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 17s", cfg.NormalizePlatformSourcesTimeout)
}
if cfg.QuarantineDir != "/tmp/stat-writer-quarantine-test" {
t.Fatalf("QuarantineDir = %q, want env override", cfg.QuarantineDir)
}
}
type contextCheckingStatAppender struct {
@@ -1070,6 +1201,18 @@ type contextCheckingStatCommitter struct {
failOnCount int
}
type recordingStatQuarantiner struct {
count int
message kafka.Message
err error
}
func (q *recordingStatQuarantiner) Quarantine(_ context.Context, message kafka.Message, _ error) error {
q.count++
q.message = message
return q.err
}
func (c *contextCheckingStatCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error {
c.ctxErr = ctx.Err()
c.count++

View File

@@ -36,6 +36,7 @@ type config struct {
Debug bool
EventTimeFullScan bool
ProgressEvery int64
BaselineLookback int
Location *time.Location
}
@@ -50,6 +51,7 @@ type rawFrameRow struct {
EventTimeMS int64
ReceivedAtMS int64
ParsedJSON string
RawText string
}
type metricAgg struct {
@@ -188,6 +190,9 @@ func main() {
}
}
fields := fieldsForStats(row.Protocol, row.VIN, text)
if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT {
fields = fieldsForStats(row.Protocol, row.VIN, row.RawText)
}
if cfg.Debug && scanned <= 5 {
slog.Info("debug raw frame",
"protocol", row.Protocol,
@@ -427,7 +432,7 @@ func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB,
return aggregates, nil
}
func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) {
func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, _ *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) {
if aggregates == nil {
return 0, fmt.Errorf("aggregates map is nil")
}
@@ -441,11 +446,6 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
continue
}
latestHistory := map[sourceHistoryID]dailySourceLast{}
preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0])
if err != nil {
return added, err
}
rememberLatestSourceRows(latestHistory, preWindow)
aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol)
for _, date := range targetDates {
current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date)
@@ -656,7 +656,9 @@ func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, proto
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
where = append(where, predicate)
}
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), LAST(event_time), LAST(parsed_json), COUNT(*)
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint,
FIRST(event_time), FIRST(parsed_json), FIRST(raw_text),
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*)
FROM %s.raw_frames
WHERE %s
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
@@ -668,7 +670,7 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr
// LAST aggregates the complete pre-window history once per source so empty
// calendar days do not make a backfill fall back to the current day's first
// sample.
where := backfillBeforePredicates(date)
where := backfillBeforePredicates(cfg, date)
where = append(where,
"parse_status = 'OK'",
"vin IS NOT NULL",
@@ -679,17 +681,31 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
where = append(where, predicate)
}
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(event_time), LAST(parsed_json), COUNT(*)
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint,
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*)
FROM %s.raw_frames
WHERE %s
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
return querySourceRows(ctx, db, cfg, protocol, sqlText, false)
}
func backfillBeforePredicates(eventDateExclusive string) []string {
return []string{
func backfillBeforePredicates(cfg config, eventDateExclusive string) []string {
lookbackDays := cfg.BaselineLookback
if lookbackDays <= 0 {
lookbackDays = 7
}
eventDateFrom := shiftDate(eventDateExclusive, -lookbackDays)
where := []string{
fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)),
fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)),
}
if !cfg.EventTimeFullScan {
where = append([]string{
fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))),
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateExclusive))),
}, where...)
}
return where
}
func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
@@ -775,27 +791,30 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
var sourceEndpoint string
var firstTS time.Time
var firstParsedJSON string
var firstRawText string
var ts time.Time
var parsedJSON string
var rawText string
var rawSampleCount int64
if includeFirst {
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &ts, &parsedJSON, &rawSampleCount); err != nil {
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &firstRawText, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil {
return nil, err
}
} else {
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawSampleCount); err != nil {
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil {
return nil, err
}
firstTS = ts
firstParsedJSON = parsedJSON
firstRawText = rawText
}
latestTotalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, ts, cfg.Location)
latestTotalKM, ok := mileageFromEvidence(protocol, vin, parsedJSON, rawText, ts, cfg.Location)
if !ok {
continue
}
firstTotalKM := latestTotalKM
if includeFirst {
if parsedFirst, ok := mileageFromParsed(protocol, vin, firstParsedJSON, firstTS, cfg.Location); ok {
if parsedFirst, ok := mileageFromEvidence(protocol, vin, firstParsedJSON, firstRawText, firstTS, cfg.Location); ok {
firstTotalKM = parsedFirst
}
}
@@ -852,6 +871,16 @@ func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string
return samples[0].TotalMileageKM, true
}
func mileageFromEvidence(protocol envelope.Protocol, vin string, parsedJSON string, rawText string, eventTime time.Time, loc *time.Location) (float64, bool) {
if totalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, eventTime, loc); ok {
return totalKM, true
}
if protocol != envelope.ProtocolYutongMQTT || strings.TrimSpace(rawText) == "" {
return 0, false
}
return mileageFromParsed(protocol, vin, rawText, eventTime, loc)
}
func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string {
return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "")
}
@@ -951,6 +980,7 @@ func loadConfig() (config, error) {
Debug: envBool("BACKFILL_DEBUG", false),
EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false),
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7),
Location: loc,
}, nil
}
@@ -1000,7 +1030,7 @@ func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, err
where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")")
}
where = append(where, realtimeMileageFramePredicate())
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json, raw_text
FROM %s.raw_frames
WHERE %s
ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
@@ -1044,6 +1074,9 @@ func mileageBearingFramePredicate(protocol envelope.Protocol) string {
conditions := make([]string, 0, len(tokens))
for _, token := range tokens {
conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token)))
if protocol == envelope.ProtocolYutongMQTT {
conditions = append(conditions, fmt.Sprintf("raw_text LIKE '%%%s%%'", quote(token)))
}
}
return "(" + strings.Join(conditions, " OR ") + ")"
}
@@ -1071,10 +1104,10 @@ func mileageSearchTokens(protocol envelope.Protocol) []string {
}
func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string
var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed, rawText string
var messageID int64
var eventTime, receivedAt time.Time
if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed); err != nil {
if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed, &rawText); err != nil {
return rawFrameRow{}, err
}
return rawFrameRow{
@@ -1088,6 +1121,7 @@ func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
EventTimeMS: eventTime.UnixMilli(),
ReceivedAtMS: receivedAt.UnixMilli(),
ParsedJSON: parsed,
RawText: rawText,
}, nil
}
@@ -1267,6 +1301,14 @@ func previousDate(date string) string {
return parsed.AddDate(0, 0, -1).Format("2006-01-02")
}
func shiftDate(date string, days int) string {
parsed, err := time.Parse("2006-01-02", date)
if err != nil {
return date
}
return parsed.AddDate(0, 0, days).Format("2006-01-02")
}
func dateRangeWithPrevious(from string, to string) ([]string, error) {
dates, err := dateRange(from, to)
if err != nil {

View File

@@ -233,23 +233,23 @@ func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
currentRows := func() *sqlmock.Rows {
return sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
})
}
previousRows := func() *sqlmock.Rows {
return sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
})
}
mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(previousRows())
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, int64(4)))
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4)))
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(currentRows())
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, int64(6)))
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6)))
aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
TDengineDatabase: "lingniu_vehicle_ts",
@@ -342,18 +342,10 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0))
tdMock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-12 00:00:00'.*protocol = 'YUTONG_MQTT'.*TOTAL_MILEAGE").
WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
}).AddRow(
"LMRKH9AC0R1004086",
"",
"LMRKH9AC0R1004086",
"mqtt://yutong/ytforward/shln/4",
previousTS,
`{"data":{"TOTAL_MILEAGE":11500000}}`,
int64(12),
))
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source").
WithArgs("LMRKH9AC0R1004086", "2026-07-12", "YUTONG_MQTT", sourceKey).
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(11500.0, previousTS))
aggregates := map[string]*metricAgg{}
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
@@ -373,7 +365,7 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.
if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
}
if agg.SourceKey != stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") {
if agg.SourceKey != sourceKey {
t.Fatalf("source key = %q", agg.SourceKey)
}
if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" {
@@ -410,10 +402,6 @@ func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *tes
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
tdMock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
}))
for _, date := range []string{"2026-07-09", "2026-07-10"} {
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
WithArgs("YUTONG_MQTT", date, date).
@@ -483,16 +471,18 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}).AddRow(
"LMRKH9AC6R1004108",
"",
"LMRKH9AC6R1004108",
"mqtt://yutong/ytforward/shln/3",
firstTS,
`{"yutong_mqtt.data.total_mileage":"65422000"}`,
`{"yutong_mqtt.data.latitude":"30.0"}`,
`{"data":{"TOTAL_MILEAGE":65422000}}`,
lastTS,
`{"yutong_mqtt.data.total_mileage":"65423000"}`,
`{"yutong_mqtt.data.latitude":"30.1"}`,
`{"data":{"TOTAL_MILEAGE":65423000}}`,
int64(42),
))
@@ -526,14 +516,15 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}).AddRow(
"LMRKH9AC6R1004108",
"",
"LMRKH9AC6R1004108",
"mqtt://yutong/ytforward/shln/3",
lastTS,
`{"yutong_mqtt.data.total_mileage":"65377000"}`,
`{"yutong_mqtt.data.latitude":"30.0"}`,
`{"data":{"TOTAL_MILEAGE":65377000}}`,
int64(31),
))
@@ -556,13 +547,19 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
}
}
func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) {
where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ")
func TestBackfillBeforePredicatesBoundsHistoryScan(t *testing.T) {
where := strings.Join(backfillBeforePredicates(config{BaselineLookback: 7}, "2026-07-08"), " AND ")
if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
}
if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") {
t.Fatalf("pre-window predicate must not stop at the previous day: %s", where)
for _, predicate := range []string{
"event_time >= '2026-07-01 00:00:00'",
"ts >= '2026-06-30 00:00:00'",
"ts < '2026-07-09 00:00:00'",
} {
if !strings.Contains(where, predicate) {
t.Fatalf("pre-window predicate missing %q: %s", predicate, where)
}
}
}
@@ -577,7 +574,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone
utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}).AddRow(
"LMRKH9AC7R1004098",
"",
@@ -585,6 +582,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone
"mqtt://yutong/ytforward/shln/4",
utcInstant,
`{"yutong_mqtt.data.total_mileage":"41249000"}`,
"",
int64(1),
))

View File

@@ -0,0 +1,57 @@
package feichibridge
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
)
var captchaPattern = regexp.MustCompile(`^[0-9A-Z]{4}$`)
type CaptchaSolver interface {
Solve(context.Context, []byte) (string, error)
}
type DockerCaptchaSolver struct {
Image string
Timeout time.Duration
}
func (s DockerCaptchaSolver) Solve(ctx context.Context, image []byte) (string, error) {
if len(image) == 0 {
return "", errors.New("captcha image is empty")
}
if strings.TrimSpace(s.Image) == "" {
return "", errors.New("captcha OCR image is required")
}
timeout := s.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
solveCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := exec.CommandContext(
solveCtx,
"docker", "run", "--rm", "--network=none", "-i", strings.TrimSpace(s.Image),
)
command.Stdin = bytes.NewReader(image)
var stderr bytes.Buffer
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
if solveCtx.Err() != nil {
return "", fmt.Errorf("captcha OCR timed out: %w", solveCtx.Err())
}
return "", fmt.Errorf("captcha OCR failed: %w: %s", err, strings.TrimSpace(stderr.String()))
}
code := strings.ToUpper(strings.TrimSpace(string(output)))
if !captchaPattern.MatchString(code) {
return "", fmt.Errorf("captcha OCR returned invalid result %q", code)
}
return code, nil
}

View File

@@ -0,0 +1,211 @@
package feichibridge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var ErrUnauthorized = errors.New("feichi API session unauthorized")
type APIClient struct {
baseURL *url.URL
headers http.Header
client *http.Client
login *loginSession
}
func NewAPIClient(baseURL string, headers map[string]string, timeout time.Duration) (*APIClient, error) {
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/")
if err != nil {
return nil, fmt.Errorf("parse FEICHI_BASE_URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("FEICHI_BASE_URL must use http or https")
}
if timeout <= 0 {
timeout = 15 * time.Second
}
out := &APIClient{
baseURL: parsed,
headers: make(http.Header),
client: &http.Client{Timeout: timeout},
}
for name, value := range headers {
if strings.TrimSpace(name) != "" && strings.TrimSpace(value) != "" {
out.headers.Set(name, value)
}
}
return out, nil
}
func NewAuthenticatedAPIClient(baseURL string, credentials LoginCredentials, solver CaptchaSolver, timeout time.Duration) (*APIClient, error) {
client, err := NewAPIClient(baseURL, nil, timeout)
if err != nil {
return nil, err
}
login, err := newLoginSession(credentials, solver)
if err != nil {
return nil, err
}
client.login = login
return client, nil
}
func (c *APIClient) Vehicles(ctx context.Context) ([]Vehicle, error) {
payload := map[string]any{
"conditions": []map[string]string{
{"name": "iccid", "value": ""},
{"name": "powerMode", "value": ""},
},
"sort": []map[string]string{{"name": "updateTime", "order": "desc"}},
"start": 0,
"limit": 100,
}
var envelope collectionEnvelope[Vehicle]
if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/vehicleRealStatuss", payload, &envelope); err != nil {
return nil, err
}
if !apiCodeOK(envelope.Code) {
return nil, apiCodeError(envelope.Code)
}
return envelope.Data, nil
}
func (c *APIClient) Snapshot(ctx context.Context, vehicleID string) (Snapshot, error) {
path := "api/v1/sys/vehicleRealStatussByVId/" + url.PathEscape(vehicleID) + "/-1"
var envelope objectEnvelope[Snapshot]
if err := c.doJSON(ctx, http.MethodGet, path, nil, &envelope); err != nil {
return Snapshot{}, err
}
if !apiCodeOK(envelope.Code) {
return Snapshot{}, apiCodeError(envelope.Code)
}
return envelope.Data, nil
}
func (c *APIClient) History(ctx context.Context, vin string, begin, end time.Time) ([]Record, error) {
payload := map[string]any{
"conditions": []map[string]string{
{"name": "queryType", "value": "0"},
{"name": "queryContent", "value": vin},
{"name": "beginTime", "value": begin.In(shanghai).Format("2006-01-02 15:04:05")},
{"name": "endTime", "value": end.In(shanghai).Format("2006-01-02 15:04:05")},
{"name": "hisdataType", "value": "0"},
},
"start": 0,
"limit": 100,
}
var envelope collectionEnvelope[historySegment]
if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/hisdataQuerys", payload, &envelope); err != nil {
return nil, err
}
if !apiCodeOK(envelope.Code) {
return nil, apiCodeError(envelope.Code)
}
var records []Record
for _, segment := range envelope.Data {
records = append(records, segment.Subdata...)
}
return records, nil
}
func (c *APIClient) doJSON(ctx context.Context, method, path string, body any, output any) error {
if c.login != nil {
if err := c.login.ensure(ctx, c); err != nil {
return err
}
}
for attempt := 0; attempt < 2; attempt++ {
_, err := c.rawJSON(ctx, method, path, body, output)
if !errors.Is(err, ErrUnauthorized) || c.login == nil || attempt == 1 {
return err
}
c.login.invalidate()
if err := c.login.ensure(ctx, c); err != nil {
return err
}
}
return ErrUnauthorized
}
func (c *APIClient) rawJSON(ctx context.Context, method, path string, body any, output any) (http.Header, error) {
endpoint, err := c.baseURL.Parse(path)
if err != nil {
return nil, err
}
var reader io.Reader
if body != nil {
encoded, marshalErr := json.Marshal(body)
if marshalErr != nil {
return nil, marshalErr
}
reader = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
for name, values := range c.headers {
for _, value := range values {
request.Header.Add(name, value)
}
}
if c.login != nil {
if cookie := c.login.cookieHeader(); cookie != "" {
request.Header.Set("Cookie", cookie)
}
if csrf := c.login.csrfHeader(); csrf != "" {
request.Header.Set("x-api-csrf", csrf)
}
}
response, err := c.client.Do(request)
if err != nil {
return nil, fmt.Errorf("feichi %s %s: %w", method, path, err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return response.Header, ErrUnauthorized
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
return response.Header, fmt.Errorf("feichi %s %s: HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(snippet)))
}
encoded, err := io.ReadAll(io.LimitReader(response.Body, 16<<20))
if err != nil {
return response.Header, fmt.Errorf("read feichi %s: %w", path, err)
}
var status struct {
Code any `json:"code"`
}
if err := json.Unmarshal(encoded, &status); err != nil {
return response.Header, fmt.Errorf("decode feichi %s: %w", path, err)
}
if fmt.Sprint(status.Code) == "401" || fmt.Sprint(status.Code) == "403" {
return response.Header, ErrUnauthorized
}
if output != nil {
if err := json.Unmarshal(encoded, output); err != nil {
return response.Header, fmt.Errorf("decode feichi %s: %w", path, err)
}
}
return response.Header, nil
}
func apiCodeError(code any) error {
if fmt.Sprint(code) == "401" || fmt.Sprint(code) == "403" {
return ErrUnauthorized
}
return fmt.Errorf("feichi API returned code %v", code)
}

View File

@@ -0,0 +1,154 @@
package feichibridge
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type fixedCaptchaSolver struct {
code string
calls int
}
func (s *fixedCaptchaSolver) Solve(_ context.Context, image []byte) (string, error) {
s.calls++
if string(image) != "captcha-image" {
return "", fmt.Errorf("unexpected captcha image")
}
return s.code, nil
}
func TestAPIClientUsesSessionHeadersAndExpectedEndpoints(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls++
if request.Header.Get("Cookie") != "R_SESS=test" || request.Header.Get("x-api-csrf") != "csrf" {
t.Errorf("missing session headers: %#v", request.Header)
}
response.Header().Set("Content-Type", "application/json")
switch {
case request.URL.Path == "/api/v1/sys/vehicleRealStatuss":
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200,
"data": []map[string]string{{"vehicleId": "id-1", "vin": "LTEST32960VIN0001"}},
})
case strings.Contains(request.URL.Path, "vehicleRealStatussByVId"):
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200,
"data": map[string]any{"vehicleId": "id-1", "vin": "LTEST32960VIN0001", "dataItems": map[string]string{"2000": "2026-07-17 12:00:00"}},
})
case request.URL.Path == "/api/v1/sys/hisdataQuerys":
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200,
"data": []map[string]any{{"subdata": []map[string]string{{"2000": "2026-07-17 11:59:50"}}}},
})
default:
http.NotFound(response, request)
}
}))
defer server.Close()
client, err := NewAPIClient(server.URL, map[string]string{"Cookie": "R_SESS=test", "x-api-csrf": "csrf"}, time.Second)
if err != nil {
t.Fatal(err)
}
vehicles, err := client.Vehicles(context.Background())
if err != nil || len(vehicles) != 1 {
t.Fatalf("vehicles = %#v, err = %v", vehicles, err)
}
if _, err := client.Snapshot(context.Background(), "id-1"); err != nil {
t.Fatal(err)
}
records, err := client.History(context.Background(), vehicles[0].VIN, time.Now().Add(-time.Hour), time.Now())
if err != nil || len(records) != 1 {
t.Fatalf("history = %#v, err = %v", records, err)
}
if calls != 3 {
t.Fatalf("calls = %d", calls)
}
}
func TestAPIClientMapsUnauthorizedEnvelope(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"code":401,"data":[]}`))
}))
defer server.Close()
client, err := NewAPIClient(server.URL, nil, time.Second)
if err != nil {
t.Fatal(err)
}
if _, err := client.Vehicles(context.Background()); err != ErrUnauthorized {
t.Fatalf("error = %v", err)
}
}
func TestAuthenticatedAPIClientLogsInAndRetriesExpiredSession(t *testing.T) {
var loginCalls int
var vehicleCalls int
solver := &fixedCaptchaSolver{code: "12WN"}
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
switch request.URL.Path {
case "/api/v1/first-login":
response.Header().Set("x-api-csrf", "csrf-value")
response.Header().Add("Set-Cookie", "CSRF=csrf-value; Path=/")
_, _ = response.Write([]byte(`{"code":200}`))
case "/api/v1/login/randCode":
if request.Header.Get("x-api-csrf") != "csrf-value" {
t.Errorf("captcha CSRF = %q", request.Header.Get("x-api-csrf"))
}
src := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("captcha-image"))
_ = json.NewEncoder(response).Encode(map[string]any{"code": 200, "data": map[string]string{"src": src}})
case "/api/v1/login":
loginCalls++
var payload map[string]string
_ = json.NewDecoder(request.Body).Decode(&payload)
if payload["username"] != "JXQN0003" || payload["validCode"] != "12WN" {
t.Errorf("login payload = %#v", payload)
}
encrypted, err := base64.StdEncoding.DecodeString(payload["password"])
if err != nil || len(encrypted) != 128 || payload["password"] == "JXQN0003-" {
t.Errorf("password was not RSA encrypted: len=%d err=%v", len(encrypted), err)
}
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200, "data": map[string]string{"token": fmt.Sprintf("token-%d", loginCalls)},
})
case "/api/v1/sys/vehicleRealStatuss":
vehicleCalls++
if vehicleCalls == 1 {
_, _ = response.Write([]byte(`{"code":401,"data":[]}`))
return
}
if request.Header.Get("Cookie") != "CSRF=csrf-value; R_SESS=token-2" {
t.Errorf("session cookie = %q", request.Header.Get("Cookie"))
}
_, _ = response.Write([]byte(`{"code":200,"data":[]}`))
default:
http.NotFound(response, request)
}
}))
defer server.Close()
client, err := NewAuthenticatedAPIClient(
server.URL,
LoginCredentials{Username: "JXQN0003", Password: "JXQN0003-", MaxAttempts: 2},
solver,
time.Second,
)
if err != nil {
t.Fatal(err)
}
if _, err := client.Vehicles(context.Background()); err != nil {
t.Fatal(err)
}
if loginCalls != 2 || solver.calls != 2 || vehicleCalls != 2 {
t.Fatalf("login=%d solver=%d vehicles=%d", loginCalls, solver.calls, vehicleCalls)
}
}

View File

@@ -0,0 +1,481 @@
package feichibridge
import (
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
const (
CommandRealtime = byte(0x02)
CommandReissue = byte(0x03)
CommandLogin = byte(0x05)
)
type Encoder struct{}
func (Encoder) DataFrame(command byte, vin string, at time.Time, record Record) ([]byte, error) {
if command != CommandRealtime && command != CommandReissue {
return nil, fmt.Errorf("unsupported data command 0x%02X", command)
}
body := encodeTime(at)
units := 0
if unit, ok := wholeVehicleUnit(record); ok {
body = append(body, 0x01)
body = append(body, unit...)
units++
}
if unit, ok := motorUnit(record); ok {
body = append(body, 0x02)
body = append(body, unit...)
units++
}
if unit, ok := fuelCellUnit(record); ok {
body = append(body, 0x03)
body = append(body, unit...)
units++
}
if unit, ok := positionUnit(record); ok {
body = append(body, 0x05)
body = append(body, unit...)
units++
}
if unit, ok := extremeUnit(record); ok {
body = append(body, 0x06)
body = append(body, unit...)
units++
}
if unit, ok := alarmUnit(record); ok {
body = append(body, 0x07)
body = append(body, unit...)
units++
}
for _, unit := range voltageUnits(record) {
body = append(body, 0x08)
body = append(body, unit...)
units++
}
for _, unit := range temperatureUnits(record) {
body = append(body, 0x09)
body = append(body, unit...)
units++
}
if units == 0 {
return nil, errors.New("source record has no mappable GB/T 32960 units")
}
return buildFrame('#', command, 0xFE, vin, body)
}
func LoginFrame(platformID, username, password string, serial uint16, now time.Time) ([]byte, error) {
if len(username) > 12 {
return nil, errors.New("GB/T 32960 platform username exceeds 12 bytes")
}
if len(password) > 20 {
return nil, errors.New("GB/T 32960 platform password exceeds 20 bytes")
}
body := encodeTime(now)
body = binary.BigEndian.AppendUint16(body, serial)
body = appendPaddedASCII(body, username, 12)
body = appendPaddedASCII(body, password, 20)
body = append(body, 0x01)
return buildFrame('#', CommandLogin, 0xFE, platformID, body)
}
func buildFrame(start, command, response byte, vin string, body []byte) ([]byte, error) {
if len(vin) != 17 {
return nil, fmt.Errorf("GB/T 32960 identifier must be exactly 17 bytes, got %d", len(vin))
}
if len(body) > math.MaxUint16 {
return nil, errors.New("GB/T 32960 body exceeds 65535 bytes")
}
frame := make([]byte, 24, 25+len(body))
frame[0], frame[1] = start, start
frame[2], frame[3] = command, response
copy(frame[4:21], vin)
frame[21] = 0x01
binary.BigEndian.PutUint16(frame[22:24], uint16(len(body)))
frame = append(frame, body...)
frame = append(frame, bcc(frame[2:]))
return frame, nil
}
func wholeVehicleUnit(record Record) ([]byte, bool) {
if !hasAny(record, "2201", "2202", "3201", "7615") {
return nil, false
}
out := make([]byte, 20)
out[0] = enum(recordValue(record, "3201"), map[string]byte{
"启动": 1, "启动状态": 1, "行驶": 1, "熄火": 2, "熄火状态": 2, "其他": 3,
}, 0xFE)
out[1] = enum(recordValue(record, "2301"), map[string]byte{
"停车充电": 1, "行驶充电": 2, "未充电": 3, "未充电状态": 3, "充电完成": 4,
}, 0xFE)
out[2] = enum(recordValue(record, "2213"), map[string]byte{
"纯电": 1, "纯电动": 1, "混动": 2, "混合动力": 2, "燃油": 3,
}, 0xFE)
putScaledU16(out[3:5], recordValue(record, "2201"), 10, 0)
putScaledU32(out[5:9], recordValue(record, "2202"), 10)
putScaledU16(out[9:11], recordValue(record, "2613"), 10, 0)
putScaledU16(out[11:13], recordValue(record, "2614"), 10, 1000)
out[13] = byteValue(recordValue(record, "7615"))
out[14] = enum(recordValue(record, "2214"), map[string]byte{"工作": 1, "断开": 2}, 0xFE)
out[15] = gearValue(firstField(recordValue(record, "2203")))
putU16(out[16:18], recordValue(record, "2617"))
out[18] = byteValue(recordValue(record, "2208"))
out[19] = byteValue(recordValue(record, "2209"))
return out, true
}
func motorUnit(record Record) ([]byte, bool) {
// The source carries every motor as a key:value composite separated by "|".
// Fields are the GB/T 32960 sequence numbers exposed by the platform metadata.
composite := recordValue(record, "2308")
if composite == "" {
return nil, false
}
groups := parseCompositeGroups(composite)
if len(groups) == 0 || len(groups) > 253 {
return nil, false
}
out := []byte{byte(len(groups))}
for index, group := range groups {
out = append(out, byteValue(firstNonempty(group["2302"], strconv.Itoa(index+1))))
out = append(out, enum(group["2303"], map[string]byte{
"耗电": 1, "发电": 2, "关闭": 3, "准备": 4, "准备状态": 4,
}, 0xFE))
out = append(out, tempByte(group["2304"]))
out = binary.BigEndian.AppendUint16(out, offsetU16(group["2305"], 1, 20000))
out = binary.BigEndian.AppendUint16(out, offsetU16(group["2306"], 10, 2000))
out = append(out, tempByte(group["2309"]))
out = binary.BigEndian.AppendUint16(out, scaledU16(group["2311"], 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(group["2312"], 10, 1000))
}
return out, true
}
func fuelCellUnit(record Record) ([]byte, bool) {
if !hasAny(record, "2110", "2111", "2112", "2117", "2119") {
return nil, false
}
temperatureGroups := parseSeriesGroups(recordValue(record, "2103"))
var temperatures []string
for _, group := range temperatureGroups {
temperatures = append(temperatures, group.values...)
}
if len(temperatures) > math.MaxUint16 {
temperatures = temperatures[:math.MaxUint16]
}
out := binary.BigEndian.AppendUint16(nil, scaledU16(recordValue(record, "2110"), 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2111"), 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2112"), 100, 0))
out = binary.BigEndian.AppendUint16(out, uint16(len(temperatures)))
for _, value := range temperatures {
out = append(out, tempByte(value))
}
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2115"), 10, 40))
out = append(out, byteValue(recordValue(record, "2116")))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2117"), 1, 0))
out = append(out, byteValue(recordValue(record, "2118")))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2119"), 10, 0))
out = append(out, byteValue(recordValue(record, "2120")))
out = append(out, enum(recordValue(record, "2121"), map[string]byte{"工作": 1, "断开": 2}, 0xFE))
return out, true
}
func positionUnit(record Record) ([]byte, bool) {
longitude, lonOK := floatValue(recordValue(record, "2502"))
latitude, latOK := floatValue(recordValue(record, "2503"))
if !lonOK || !latOK || longitude < 0 || latitude < 0 {
return nil, false
}
status := byte(0)
text := strings.TrimSpace(recordValue(record, "2501"))
if text != "" && (strings.Contains(text, "无效") || text == "1") {
status = 1
}
out := []byte{status}
out = binary.BigEndian.AppendUint32(out, uint32(math.Round(longitude*1_000_000)))
out = binary.BigEndian.AppendUint32(out, uint32(math.Round(latitude*1_000_000)))
return out, true
}
func extremeUnit(record Record) ([]byte, bool) {
if !hasAny(record, "2601", "2602", "2603", "2604", "2605", "2606", "2607", "2608", "2609", "2610", "2611", "2612") {
return nil, false
}
out := []byte{
byteValue(recordValue(record, "2601")),
byteValue(recordValue(record, "2602")),
}
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2603"), 1000, 0))
out = append(out, byteValue(recordValue(record, "2604")), byteValue(recordValue(record, "2605")))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2606"), 1000, 0))
out = append(out,
byteValue(recordValue(record, "2607")),
byteValue(recordValue(record, "2608")),
tempByte(recordValue(record, "2609")),
byteValue(recordValue(record, "2610")),
byteValue(recordValue(record, "2611")),
tempByte(recordValue(record, "2612")),
)
return out, true
}
func alarmUnit(record Record) ([]byte, bool) {
levelRaw := recordValue(record, "2900", "2901")
var general uint32
for bit := 0; bit < 32; bit++ {
value := recordValue(record, strconv.Itoa(2901+bit))
if truthy(value) {
general |= 1 << bit
}
}
if strings.TrimSpace(levelRaw) == "" && general == 0 {
return nil, false
}
out := []byte{byteValue(levelRaw)}
out = binary.BigEndian.AppendUint32(out, general)
out = append(out, 0, 0, 0, 0)
return out, true
}
func voltageUnits(record Record) [][]byte {
raw := recordValue(record, "2003", "batteryVoltages")
groups := parseSeriesGroups(raw)
var units [][]byte
for _, group := range groups {
for start := 0; start < len(group.values); start += 255 {
end := start + 255
if end > len(group.values) {
end = len(group.values)
}
out := []byte{1, byte(group.id)}
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2613"), 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2614"), 10, 1000))
out = binary.BigEndian.AppendUint16(out, uint16(len(group.values)))
out = binary.BigEndian.AppendUint16(out, uint16(start+1))
out = append(out, byte(end-start))
for _, value := range group.values[start:end] {
out = binary.BigEndian.AppendUint16(out, scaledU16(value, 1000, 0))
}
units = append(units, out)
}
}
return units
}
func temperatureUnits(record Record) [][]byte {
groups := parseSeriesGroups(recordValue(record, "2103", "batteryTemperatures"))
var units [][]byte
for _, group := range groups {
for start := 0; start < len(group.values); start += math.MaxUint16 {
end := start + math.MaxUint16
if end > len(group.values) {
end = len(group.values)
}
out := []byte{1, byte(group.id)}
out = binary.BigEndian.AppendUint16(out, uint16(end-start))
for _, value := range group.values[start:end] {
out = append(out, tempByte(value))
}
units = append(units, out)
}
}
return units
}
func parseCompositeGroups(raw string) []map[string]string {
var groups []map[string]string
for _, encoded := range strings.Split(raw, "|") {
group := map[string]string{}
for _, field := range strings.Split(encoded, ",") {
key, value, found := strings.Cut(field, ":")
if found {
group[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
if len(group) > 0 {
groups = append(groups, group)
}
}
return groups
}
type seriesGroup struct {
id int
values []string
}
func parseSeriesGroups(raw string) []seriesGroup {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var groups []seriesGroup
for index, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ';' || r == '|' }) {
group := seriesGroup{id: index + 1}
if before, after, found := strings.Cut(part, ":"); found {
if parsed, err := strconv.Atoi(strings.TrimSpace(before)); err == nil && parsed > 0 && parsed <= 255 {
group.id = parsed
}
part = after
}
for _, value := range strings.FieldsFunc(part, func(r rune) bool {
return r == '_' || r == ',' || r == ' ' || r == '[' || r == ']'
}) {
if strings.TrimSpace(value) != "" {
group.values = append(group.values, strings.TrimSpace(value))
}
}
if len(group.values) > 0 {
groups = append(groups, group)
}
}
return groups
}
func recordValue(record Record, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(record[key]); value != "" && value != "--" && value != "null" {
return value
}
}
return ""
}
func firstField(value string) string {
fields := strings.Fields(value)
if len(fields) == 0 {
return ""
}
return fields[0]
}
func firstNonempty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func hasAny(record Record, keys ...string) bool { return recordValue(record, keys...) != "" }
func floatValue(raw string) (float64, bool) {
raw = strings.TrimSpace(strings.TrimSuffix(raw, "%"))
if raw == "" {
return 0, false
}
value, err := strconv.ParseFloat(raw, 64)
return value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0)
}
func byteValue(raw string) byte {
value, ok := floatValue(raw)
if !ok || value < 0 || value > 253 {
return 0xFE
}
return byte(math.Round(value))
}
func tempByte(raw string) byte {
value, ok := floatValue(raw)
if !ok || value < -40 || value > 210 {
return 0xFE
}
return byte(math.Round(value + 40))
}
func putU16(out []byte, raw string) { binary.BigEndian.PutUint16(out, scaledU16(raw, 1, 0)) }
func putScaledU16(out []byte, raw string, scale, offset float64) {
binary.BigEndian.PutUint16(out, scaledU16(raw, scale, offset))
}
func putScaledU32(out []byte, raw string, scale float64) {
value, ok := floatValue(raw)
if !ok || value < 0 || value*scale > math.MaxUint32-2 {
binary.BigEndian.PutUint32(out, 0xFFFFFFFE)
return
}
binary.BigEndian.PutUint32(out, uint32(math.Round(value*scale)))
}
func scaledU16(raw string, scale, offset float64) uint16 {
value, ok := floatValue(raw)
encoded := (value + offset) * scale
if !ok || encoded < 0 || encoded > math.MaxUint16-2 {
return 0xFFFE
}
return uint16(math.Round(encoded))
}
func offsetU16(raw string, scale, offset float64) uint16 {
return scaledU16(raw, scale, offset)
}
func enum(raw string, values map[string]byte, missing byte) byte {
raw = strings.TrimSpace(raw)
if raw == "" {
return missing
}
if numeric, err := strconv.ParseUint(raw, 10, 8); err == nil {
return byte(numeric)
}
for label, value := range values {
if raw == label || strings.Contains(raw, label) {
return value
}
}
return missing
}
func gearValue(raw string) byte {
raw = strings.ToUpper(strings.TrimSpace(raw))
switch raw {
case "P", "P档":
return 15
case "R", "R档":
return 13
case "N", "N档":
return 0
case "D", "D档":
return 14
}
raw = strings.TrimSuffix(raw, "档")
raw = strings.TrimPrefix(raw, "D")
return byteValue(raw)
}
func truthy(raw string) bool {
raw = strings.ToLower(strings.TrimSpace(raw))
return raw != "" && raw != "0" && raw != "false" && raw != "无" && raw != "正常"
}
func appendPaddedASCII(out []byte, value string, width int) []byte {
start := len(out)
out = append(out, make([]byte, width)...)
copy(out[start:start+width], value)
return out
}
func encodeTime(value time.Time) []byte {
local := value.In(shanghai)
return []byte{
byte(local.Year() - 2000), byte(local.Month()), byte(local.Day()),
byte(local.Hour()), byte(local.Minute()), byte(local.Second()),
}
}
func bcc(value []byte) byte {
var result byte
for _, current := range value {
result ^= current
}
return result
}

View File

@@ -0,0 +1,93 @@
package feichibridge
import (
"encoding/binary"
"fmt"
"strings"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
)
func TestDataFrameMapsFeichiFields(t *testing.T) {
at := time.Date(2026, 7, 17, 14, 5, 6, 0, shanghai)
record := Record{
"2000": "2026-07-17 14:05:06",
"2201": "42.3", "2202": "12345.6", "2203": "D 0 0",
"2208": "35", "2209": "0", "2213": "纯电",
"2214": "工作", "2301": "未充电状态", "3201": "启动状态",
"2613": "550.2", "2614": "-20.5", "2617": "2200", "7615": "78",
"2501": "有效定位", "2502": "113.2644", "2503": "23.1291",
"2601": "1", "2602": "8", "2603": "3.955",
"2604": "1", "2605": "26", "2606": "3.821",
"2607": "1", "2608": "3", "2609": "48",
"2610": "1", "2611": "9", "2612": "37",
"2003": "1:3.900_3.901_3.902",
"2103": "1:35_36_37",
}
frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", at, record)
if err != nil {
t.Fatal(err)
}
env, err := gb32960.ParseFrame(frame, at.UnixMilli(), "test")
if err != nil {
t.Fatal(err)
}
if env.MessageID != "0x02" || env.VIN != "LTEST32960VIN0001" {
t.Fatalf("unexpected envelope: %#v", env)
}
if got := env.Fields["speed_kmh"]; got != 42.3 {
t.Fatalf("speed = %#v", got)
}
if got := env.Fields["total_mileage_km"]; got != 12345.6 {
t.Fatalf("mileage = %#v", got)
}
if got := env.Fields["soc_percent"]; got != 78 {
t.Fatalf("soc = %#v", got)
}
if got := env.Fields["longitude"]; got != 113.2644 {
t.Fatalf("longitude = %#v", got)
}
}
func TestDataFrameSplitsMoreThan255CellVoltages(t *testing.T) {
values := make([]string, 288)
for index := range values {
values[index] = fmt.Sprintf("%.3f", 3.5+float64(index)/1000)
}
frame, err := (Encoder{}).DataFrame(CommandReissue, "LTEST32960VIN0002", time.Now(), Record{
"2003": "1:" + strings.Join(values, "_"),
"2613": "530",
"2614": "10",
})
if err != nil {
t.Fatal(err)
}
body := frame[24 : len(frame)-1]
if got := strings.Count(string(body), string([]byte{0x08})); got < 2 {
t.Fatalf("expected at least two voltage units, got %d", got)
}
if binary.BigEndian.Uint16(frame[22:24]) != uint16(len(body)) {
t.Fatal("body length mismatch")
}
if _, err := gb32960.ParseFrame(frame, time.Now().UnixMilli(), "test"); err != nil {
t.Fatal(err)
}
}
func TestLoginFrameLayout(t *testing.T) {
frame, err := LoginFrame("FEICHIBRIDGE00001", "bridge", "secret", 7, time.Date(2026, 7, 17, 1, 2, 3, 0, shanghai))
if err != nil {
t.Fatal(err)
}
if frame[2] != CommandLogin || frame[3] != 0xFE {
t.Fatalf("unexpected login header: %x", frame[:4])
}
if got := binary.BigEndian.Uint16(frame[30:32]); got != 7 {
t.Fatalf("serial = %d", got)
}
if got, want := frame[len(frame)-1], bcc(frame[2:len(frame)-1]); got != want {
t.Fatalf("BCC = %x, want %x", got, want)
}
}

View File

@@ -0,0 +1,209 @@
package feichibridge
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
const feichiPublicKey = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOwmFtEk1oJxDU0NI4kVO0Jx0X
nt+Abx+JKGUzcRzPwjUkd5z9Ice5rh87CCmj0XjZ5pPac6TtA3f0v5FqiK/kjQY5
XMLti4weJ4dcp1/q1O7PCYxRX8WgetGxwsjxGn+uoZOZkclN1PFS4wRnKEso6+G/
e60QGB29cZoo4jZnZwIDAQAB
-----END PUBLIC KEY-----`
type LoginCredentials struct {
Username string
Password string
MaxAttempts int
RetryDelay time.Duration
}
type loginSession struct {
credentials LoginCredentials
solver CaptchaSolver
mu sync.Mutex
ready atomic.Bool
csrf atomic.Value
token atomic.Value
}
type firstLoginEnvelope struct {
Code any `json:"code"`
}
type captchaPayload struct {
Src string `json:"src"`
}
type loginPayload struct {
Token string `json:"token"`
}
func newLoginSession(credentials LoginCredentials, solver CaptchaSolver) (*loginSession, error) {
credentials.Username = strings.TrimSpace(credentials.Username)
if credentials.Username == "" || credentials.Password == "" {
return nil, errors.New("Feichi username and password are required")
}
if solver == nil {
return nil, errors.New("captcha solver is required")
}
if credentials.MaxAttempts <= 0 {
credentials.MaxAttempts = 20
}
if credentials.RetryDelay <= 0 {
credentials.RetryDelay = time.Second
}
session := &loginSession{credentials: credentials, solver: solver}
session.csrf.Store("")
session.token.Store("")
return session, nil
}
func (s *loginSession) invalidate() {
s.ready.Store(false)
}
func (s *loginSession) cookieHeader() string {
var cookies []string
if csrf := s.csrf.Load().(string); csrf != "" {
cookies = append(cookies, "CSRF="+csrf)
}
if token := s.token.Load().(string); token != "" {
cookies = append(cookies, "R_SESS="+token)
}
return strings.Join(cookies, "; ")
}
func (s *loginSession) csrfHeader() string {
return s.csrf.Load().(string)
}
func (s *loginSession) ensure(ctx context.Context, client *APIClient) error {
if s.ready.Load() {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.ready.Load() {
return nil
}
var lastErr error
for attempt := 1; attempt <= s.credentials.MaxAttempts; attempt++ {
if err := s.loginAttempt(ctx, client); err == nil {
s.ready.Store(true)
return nil
} else {
lastErr = err
}
if attempt < s.credentials.MaxAttempts {
timer := time.NewTimer(s.credentials.RetryDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
return fmt.Errorf("Feichi automatic login failed after %d attempts: %w", s.credentials.MaxAttempts, lastErr)
}
func (s *loginSession) loginAttempt(ctx context.Context, client *APIClient) error {
s.ready.Store(false)
s.token.Store("")
var first firstLoginEnvelope
headers, err := client.rawJSON(ctx, http.MethodGet, "api/v1/first-login", nil, &first)
if err != nil {
return fmt.Errorf("initialize login session: %w", err)
}
if !apiCodeOK(first.Code) {
return apiCodeError(first.Code)
}
csrf := strings.TrimSpace(headers.Get("x-api-csrf"))
if csrf == "" {
for _, rawCookie := range headers.Values("Set-Cookie") {
cookie, parseErr := http.ParseSetCookie(rawCookie)
if parseErr == nil && cookie.Name == "CSRF" {
csrf = cookie.Value
break
}
}
}
if csrf == "" {
return errors.New("first-login response did not provide CSRF")
}
s.csrf.Store(csrf)
var captcha objectEnvelope[captchaPayload]
if _, err := client.rawJSON(ctx, http.MethodGet, "api/v1/login/randCode", nil, &captcha); err != nil {
return fmt.Errorf("request captcha: %w", err)
}
if !apiCodeOK(captcha.Code) {
return apiCodeError(captcha.Code)
}
encodedImage := captcha.Data.Src
if comma := strings.IndexByte(encodedImage, ','); comma >= 0 {
encodedImage = encodedImage[comma+1:]
}
image, err := base64.StdEncoding.DecodeString(encodedImage)
if err != nil {
return fmt.Errorf("decode captcha image: %w", err)
}
validCode, err := s.solver.Solve(ctx, image)
if err != nil {
return err
}
encrypted, err := encryptFeichiPassword(s.credentials.Password)
if err != nil {
return err
}
var login objectEnvelope[loginPayload]
if _, err := client.rawJSON(ctx, http.MethodPost, "api/v1/login", map[string]string{
"username": s.credentials.Username,
"password": encrypted,
"validCode": validCode,
}, &login); err != nil {
return fmt.Errorf("submit login: %w", err)
}
if !apiCodeOK(login.Code) {
return apiCodeError(login.Code)
}
token := strings.TrimSpace(login.Data.Token)
if token == "" {
return errors.New("login succeeded without R_SESS token")
}
s.token.Store(token)
return nil
}
func encryptFeichiPassword(password string) (string, error) {
block, _ := pem.Decode([]byte(feichiPublicKey))
if block == nil {
return "", errors.New("decode Feichi RSA public key")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse Feichi RSA public key: %w", err)
}
publicKey, ok := parsed.(*rsa.PublicKey)
if !ok {
return "", errors.New("Feichi public key is not RSA")
}
encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, []byte(password))
if err != nil {
return "", fmt.Errorf("encrypt Feichi password: %w", err)
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}

View File

@@ -0,0 +1,94 @@
package feichibridge
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
type Vehicle struct {
VehicleID string `json:"vehicleId"`
VIN string `json:"vin"`
OnlineStatus any `json:"onlineStatus"`
UpdateTime string `json:"updateTime"`
RuleTypeName string `json:"ruleTypeName"`
}
type Snapshot struct {
VehicleID string `json:"vehicleId"`
VIN string `json:"vin"`
DataItems map[string]string `json:"dataItems"`
}
type Record map[string]string
type collectionEnvelope[T any] struct {
Code any `json:"code"`
Data []T `json:"data"`
}
type objectEnvelope[T any] struct {
Code any `json:"code"`
Data T `json:"data"`
}
type historySegment struct {
Subdata []Record `json:"subdata"`
}
func apiCodeOK(code any) bool {
switch value := code.(type) {
case nil:
return true
case float64:
return value == 0 || value == 200
case string:
return value == "" || value == "0" || value == "200"
default:
return false
}
}
func recordTime(record Record) (time.Time, error) {
for _, key := range []string{"2000", "9999", "dataTime", "updateTime", "time"} {
if raw := strings.TrimSpace(record[key]); raw != "" {
return parseSourceTime(raw)
}
}
return time.Time{}, fmt.Errorf("source record has no timestamp")
}
func parseSourceTime(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
for _, layout := range []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
time.RFC3339Nano,
} {
if layout == time.RFC3339Nano {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed, nil
}
continue
}
if parsed, err := time.ParseInLocation(layout, raw, shanghai); err == nil {
return parsed, nil
}
}
if unixMS, err := strconv.ParseInt(raw, 10, 64); err == nil {
if unixMS < 10_000_000_000 {
return time.Unix(unixMS, 0).In(shanghai), nil
}
return time.UnixMilli(unixMS).In(shanghai), nil
}
return time.Time{}, fmt.Errorf("unsupported source timestamp %q", raw)
}
func canonicalHash(record Record) string {
encoded, _ := json.Marshal(record)
return hashBytes(encoded)
}

View File

@@ -0,0 +1,469 @@
package feichibridge
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type Source interface {
Vehicles(context.Context) ([]Vehicle, error)
Snapshot(context.Context, string) (Snapshot, error)
History(context.Context, string, time.Time, time.Time) ([]Record, error)
}
type FrameTarget interface {
Connect(context.Context) error
Send(context.Context, []byte) error
Close() error
LastACK() time.Time
}
type ServiceConfig struct {
PollInterval time.Duration
DiscoveryInterval time.Duration
BackfillInterval time.Duration
BackfillLookback time.Duration
BackfillWindow time.Duration
BackfillSafetyLag time.Duration
SourceStaleAfter time.Duration
FetchConcurrency int
BackfillEnabled bool
StaleReissueEnabled bool
}
type Service struct {
config ServiceConfig
source Source
target FrameTarget
state *StateStore
encoder Encoder
logger *slog.Logger
metrics *metrics.Registry
mu sync.RWMutex
vehicles []Vehicle
lastSourceSuccess time.Time
lastError error
}
func NewService(config ServiceConfig, source Source, target FrameTarget, state *StateStore, logger *slog.Logger, registry *metrics.Registry) (*Service, error) {
if source == nil || target == nil || state == nil {
return nil, errors.New("source, target, and state are required")
}
if config.PollInterval <= 0 {
config.PollInterval = 10 * time.Second
}
if config.DiscoveryInterval <= 0 {
config.DiscoveryInterval = 5 * time.Minute
}
if config.BackfillInterval <= 0 {
config.BackfillInterval = time.Hour
}
if config.BackfillLookback <= 0 {
config.BackfillLookback = time.Hour
}
if config.BackfillWindow <= 0 {
config.BackfillWindow = 20 * time.Minute
}
if config.BackfillSafetyLag <= 0 {
config.BackfillSafetyLag = 30 * time.Second
}
if config.SourceStaleAfter <= 0 {
config.SourceStaleAfter = 2 * time.Minute
}
if config.FetchConcurrency <= 0 {
config.FetchConcurrency = 4
}
if logger == nil {
logger = slog.Default()
}
return &Service{
config: config, source: source, target: target, state: state,
logger: logger, metrics: registry,
}, nil
}
func (s *Service) Run(ctx context.Context) error {
if err := s.target.Connect(ctx); err != nil {
s.setError(err)
return fmt.Errorf("connect GB/T 32960 target: %w", err)
}
if err := s.discover(ctx); err != nil {
s.setError(err)
return fmt.Errorf("initial vehicle discovery: %w", err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
s.discoveryLoop(ctx)
}()
go func() {
defer wg.Done()
s.realtimeLoop(ctx)
}()
if s.config.BackfillEnabled {
wg.Add(1)
go func() {
defer wg.Done()
s.backfillLoop(ctx)
}()
}
<-ctx.Done()
_ = s.target.Close()
wg.Wait()
return nil
}
func (s *Service) Ready(context.Context) error {
s.mu.RLock()
lastSourceSuccess := s.lastSourceSuccess
lastErr := s.lastError
s.mu.RUnlock()
maxSourceAge := max(3*s.config.PollInterval, time.Minute)
if lastSourceSuccess.IsZero() || time.Since(lastSourceSuccess) > maxSourceAge {
if lastErr != nil {
return fmt.Errorf("source unavailable: %w", lastErr)
}
return errors.New("source has not completed a successful request")
}
lastACK := s.target.LastACK()
if lastACK.IsZero() || time.Since(lastACK) > max(3*s.config.DiscoveryInterval, 10*time.Minute) {
return errors.New("GB/T 32960 target has no recent ACK")
}
return nil
}
func (s *Service) discoveryLoop(ctx context.Context) {
ticker := time.NewTicker(s.config.DiscoveryInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.discover(ctx); err != nil {
s.recordFailure("discover", err)
}
}
}
}
func (s *Service) realtimeLoop(ctx context.Context) {
s.pollRealtime(ctx)
ticker := time.NewTicker(s.config.PollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.pollRealtime(ctx)
}
}
}
func (s *Service) backfillLoop(ctx context.Context) {
s.runBackfill(ctx)
ticker := time.NewTicker(s.config.BackfillInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runBackfill(ctx)
}
}
}
func (s *Service) discover(ctx context.Context) error {
start := time.Now()
vehicles, err := s.source.Vehicles(ctx)
s.observeAPI("vehicles", start, err)
if err != nil {
return err
}
filtered := make([]Vehicle, 0, len(vehicles))
for _, vehicle := range vehicles {
vehicle.VIN = strings.TrimSpace(vehicle.VIN)
vehicle.VehicleID = strings.TrimSpace(vehicle.VehicleID)
if len(vehicle.VIN) != 17 || vehicle.VehicleID == "" {
continue
}
if vehicle.RuleTypeName != "" && !strings.Contains(strings.ToUpper(vehicle.RuleTypeName), "32960") {
continue
}
filtered = append(filtered, vehicle)
}
sort.Slice(filtered, func(i, j int) bool { return filtered[i].VIN < filtered[j].VIN })
s.mu.Lock()
s.vehicles = filtered
s.lastSourceSuccess = time.Now()
s.lastError = nil
s.mu.Unlock()
if s.metrics != nil {
s.metrics.SetGauge("vehicle_feichi_bridge_vehicles", nil, float64(len(filtered)))
}
s.logger.Info("feichi vehicles discovered", "count", len(filtered))
return nil
}
type fetchedSnapshot struct {
vehicle Vehicle
record Record
at time.Time
hash string
err error
duration time.Duration
}
func (s *Service) pollRealtime(ctx context.Context) {
vehicles := s.vehicleSnapshot()
jobs := make(chan Vehicle)
results := make(chan fetchedSnapshot, len(vehicles))
var wg sync.WaitGroup
for worker := 0; worker < min(s.config.FetchConcurrency, len(vehicles)); worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for vehicle := range jobs {
start := time.Now()
snapshot, err := s.source.Snapshot(ctx, vehicle.VehicleID)
result := fetchedSnapshot{vehicle: vehicle, duration: time.Since(start), err: err}
if err == nil {
result.record = Record(snapshot.DataItems)
result.at, result.err = recordTime(result.record)
result.hash = canonicalHash(result.record)
}
results <- result
}
}()
}
for _, vehicle := range vehicles {
jobs <- vehicle
}
close(jobs)
wg.Wait()
close(results)
var snapshots []fetchedSnapshot
for result := range results {
s.observeAPIWithDuration("snapshot", result.duration, result.err)
if result.err != nil {
s.recordFailure("snapshot", fmt.Errorf("VIN %s: %w", result.vehicle.VIN, result.err))
continue
}
snapshots = append(snapshots, result)
s.markSourceSuccess()
}
sort.Slice(snapshots, func(i, j int) bool {
if snapshots[i].at.Equal(snapshots[j].at) {
return snapshots[i].vehicle.VIN < snapshots[j].vehicle.VIN
}
return snapshots[i].at.Before(snapshots[j].at)
})
for _, snapshot := range snapshots {
if time.Since(snapshot.at) > s.config.SourceStaleAfter {
if s.config.StaleReissueEnabled {
s.reissueStaleSnapshot(ctx, snapshot)
}
continue
}
current := s.state.Vehicle(snapshot.vehicle.VIN)
if snapshot.at.Before(current.LastRealtimeTime) ||
(snapshot.at.Equal(current.LastRealtimeTime) && snapshot.hash == current.LastRealtimeHash) {
continue
}
frame, err := s.encoder.DataFrame(CommandRealtime, snapshot.vehicle.VIN, snapshot.at, snapshot.record)
if err != nil {
s.recordFailure("encode", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
continue
}
if err := s.target.Send(ctx, frame); err != nil {
s.recordFrame(CommandRealtime, "error", snapshot.vehicle.VIN, snapshot.at)
s.recordFailure("send", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
continue
}
if err := s.state.CommitRealtime(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil {
s.recordFailure("state", err)
continue
}
s.recordFrame(CommandRealtime, "acked", snapshot.vehicle.VIN, snapshot.at)
}
}
func (s *Service) reissueStaleSnapshot(ctx context.Context, snapshot fetchedSnapshot) {
current := s.state.Vehicle(snapshot.vehicle.VIN)
if snapshot.at.Before(current.LastSnapshotReissueTime) ||
(snapshot.at.Equal(current.LastSnapshotReissueTime) && snapshot.hash == current.LastSnapshotReissueHash) {
return
}
frame, err := s.encoder.DataFrame(CommandReissue, snapshot.vehicle.VIN, snapshot.at, snapshot.record)
if err != nil {
s.recordFailure("encode_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
return
}
if err := s.target.Send(ctx, frame); err != nil {
s.recordFrame(CommandReissue, "error", snapshot.vehicle.VIN, snapshot.at)
s.recordFailure("send_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
return
}
if err := s.state.CommitSnapshotReissue(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil {
s.recordFailure("state", err)
return
}
s.recordFrame(CommandReissue, "acked", snapshot.vehicle.VIN, snapshot.at)
}
func (s *Service) runBackfill(ctx context.Context) {
for _, vehicle := range s.vehicleSnapshot() {
if ctx.Err() != nil {
return
}
if err := s.backfillVehicle(ctx, vehicle); err != nil {
s.recordFailure("backfill", fmt.Errorf("VIN %s: %w", vehicle.VIN, err))
}
}
}
func (s *Service) backfillVehicle(ctx context.Context, vehicle Vehicle) error {
end := time.Now().Add(-s.config.BackfillSafetyLag)
cursor := s.state.Vehicle(vehicle.VIN).BackfillCursor
if cursor.IsZero() {
cursor = end.Add(-s.config.BackfillLookback)
}
for cursor.Before(end) {
windowEnd := cursor.Add(s.config.BackfillWindow)
if windowEnd.After(end) {
windowEnd = end
}
start := time.Now()
records, err := s.source.History(ctx, vehicle.VIN, cursor, windowEnd)
s.observeAPI("history", start, err)
if err != nil {
return err
}
sort.Slice(records, func(i, j int) bool {
left, _ := recordTime(records[i])
right, _ := recordTime(records[j])
return left.Before(right)
})
committed := cursor
for _, record := range records {
at, err := recordTime(record)
if err != nil || !at.After(cursor) || at.After(windowEnd) {
continue
}
frame, err := s.encoder.DataFrame(CommandReissue, vehicle.VIN, at, record)
if err != nil {
return err
}
if err := s.target.Send(ctx, frame); err != nil {
s.recordFrame(CommandReissue, "error", vehicle.VIN, at)
return err
}
if err := s.state.CommitBackfill(vehicle.VIN, at); err != nil {
return err
}
s.recordFrame(CommandReissue, "acked", vehicle.VIN, at)
committed = at
}
if !committed.After(cursor) || committed.Before(windowEnd) {
if err := s.state.AdvanceBackfillCursor(vehicle.VIN, windowEnd); err != nil {
return err
}
}
cursor = windowEnd
s.markSourceSuccess()
}
return nil
}
func (s *Service) vehicleSnapshot() []Vehicle {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]Vehicle(nil), s.vehicles...)
}
func (s *Service) markSourceSuccess() {
s.mu.Lock()
s.lastSourceSuccess = time.Now()
s.lastError = nil
s.mu.Unlock()
if s.metrics != nil {
metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_source_last_success_unix_seconds", nil)
}
}
func (s *Service) setError(err error) {
s.mu.Lock()
s.lastError = err
s.mu.Unlock()
}
func (s *Service) recordFailure(operation string, err error) {
if err == nil {
return
}
s.setError(err)
if s.metrics != nil {
s.metrics.IncCounter("vehicle_feichi_bridge_errors_total", metrics.Labels{"operation": operation})
}
s.logger.Error("feichi bridge operation failed", "operation", operation, "error", err)
}
func (s *Service) observeAPI(operation string, start time.Time, err error) {
s.observeAPIWithDuration(operation, time.Since(start), err)
}
func (s *Service) observeAPIWithDuration(operation string, duration time.Duration, err error) {
if s.metrics == nil {
return
}
status := "ok"
if err != nil {
status = "error"
}
s.metrics.IncCounter("vehicle_feichi_bridge_api_requests_total", metrics.Labels{"operation": operation, "status": status})
s.metrics.ObserveHistogram(
"vehicle_feichi_bridge_api_request_duration_seconds",
metrics.Labels{"operation": operation},
[]float64{0.1, 0.25, 0.5, 1, 2, 5},
duration.Seconds(),
)
}
func (s *Service) recordFrame(command byte, status, vin string, sourceTime time.Time) {
if s.metrics != nil {
s.metrics.IncCounter("vehicle_feichi_bridge_frames_total", metrics.Labels{
"command": fmt.Sprintf("0x%02X", command),
"status": status,
})
}
if status == "acked" {
if s.metrics != nil {
metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_target_last_ack_unix_seconds", nil)
s.metrics.SetGauge(
"vehicle_feichi_bridge_vehicle_last_ack_unix_seconds",
metrics.Labels{"vin": vin, "command": fmt.Sprintf("0x%02X", command)},
float64(time.Now().Unix()),
)
}
s.logger.Info(
"GB/T 32960 vehicle frame acknowledged",
"vin", vin,
"command", fmt.Sprintf("0x%02X", command),
"source_time", sourceTime,
)
}
}

View File

@@ -0,0 +1,123 @@
package feichibridge
import (
"context"
"errors"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
)
type fakeSource struct {
vehicles []Vehicle
record Record
}
func (f *fakeSource) Vehicles(context.Context) ([]Vehicle, error) {
return append([]Vehicle(nil), f.vehicles...), nil
}
func (f *fakeSource) Snapshot(context.Context, string) (Snapshot, error) {
return Snapshot{DataItems: map[string]string(f.record)}, nil
}
func (f *fakeSource) History(context.Context, string, time.Time, time.Time) ([]Record, error) {
return nil, nil
}
type fakeTarget struct {
mu sync.Mutex
frames [][]byte
sendErr error
lastACK time.Time
}
func (f *fakeTarget) Connect(context.Context) error { return nil }
func (f *fakeTarget) Close() error { return nil }
func (f *fakeTarget) LastACK() time.Time { return f.lastACK }
func (f *fakeTarget) Send(_ context.Context, frame []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.sendErr != nil {
return f.sendErr
}
f.frames = append(f.frames, append([]byte(nil), frame...))
f.lastACK = time.Now()
return nil
}
func TestRealtimeCommitsOnlyAfterACKAndDeduplicates(t *testing.T) {
now := time.Now().In(shanghai).Truncate(time.Second)
source := &fakeSource{
vehicles: []Vehicle{{
VehicleID: "id-1", VIN: "LTEST32960VIN0001", RuleTypeName: "GB_T32960",
}},
record: Record{"2000": now.Format("2006-01-02 15:04:05"), "2201": "10"},
}
target := &fakeTarget{}
store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
service, err := NewService(ServiceConfig{
SourceStaleAfter: time.Minute,
FetchConcurrency: 1,
}, source, target, store, slog.Default(), nil)
if err != nil {
t.Fatal(err)
}
if err := service.discover(context.Background()); err != nil {
t.Fatal(err)
}
service.pollRealtime(context.Background())
service.pollRealtime(context.Background())
if len(target.frames) != 1 {
t.Fatalf("frames = %d, want 1", len(target.frames))
}
if got := store.Vehicle("LTEST32960VIN0001"); !got.LastRealtimeTime.Equal(now) {
t.Fatalf("committed state = %#v", got)
}
source.record = Record{"2000": now.Add(time.Second).Format("2006-01-02 15:04:05"), "2201": "11"}
target.sendErr = errors.New("target down")
service.pollRealtime(context.Background())
if got := store.Vehicle("LTEST32960VIN0001"); !got.LastRealtimeTime.Equal(now) {
t.Fatalf("cursor advanced without ACK: %#v", got)
}
}
func TestStaleSnapshotIsReissuedOnlyOnce(t *testing.T) {
at := time.Now().In(shanghai).Add(-24 * time.Hour).Truncate(time.Second)
vin := "LTEST32960VIN0002"
source := &fakeSource{
vehicles: []Vehicle{{VehicleID: "id-2", VIN: vin, RuleTypeName: "GB_T32960"}},
record: Record{"2000": at.Format("2006-01-02 15:04:05"), "2201": "0"},
}
target := &fakeTarget{}
store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
service, err := NewService(ServiceConfig{
SourceStaleAfter: time.Minute,
FetchConcurrency: 1,
StaleReissueEnabled: true,
}, source, target, store, slog.Default(), nil)
if err != nil {
t.Fatal(err)
}
if err := service.discover(context.Background()); err != nil {
t.Fatal(err)
}
service.pollRealtime(context.Background())
service.pollRealtime(context.Background())
if len(target.frames) != 1 || target.frames[0][2] != CommandReissue {
t.Fatalf("frames = %d command = %#v", len(target.frames), target.frames)
}
state := store.Vehicle(vin)
if !state.LastSnapshotReissueTime.Equal(at) || state.LastSnapshotReissueACKAt.IsZero() {
t.Fatalf("reissue state = %#v", state)
}
}

View File

@@ -0,0 +1,160 @@
package feichibridge
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type VehicleState struct {
LastRealtimeTime time.Time `json:"last_realtime_time,omitempty"`
LastRealtimeHash string `json:"last_realtime_hash,omitempty"`
LastRealtimeACKAt time.Time `json:"last_realtime_ack_at,omitempty"`
LastSnapshotReissueTime time.Time `json:"last_snapshot_reissue_time,omitempty"`
LastSnapshotReissueHash string `json:"last_snapshot_reissue_hash,omitempty"`
LastSnapshotReissueACKAt time.Time `json:"last_snapshot_reissue_ack_at,omitempty"`
BackfillCursor time.Time `json:"backfill_cursor,omitempty"`
LastBackfillACKAt time.Time `json:"last_backfill_ack_at,omitempty"`
}
func (s *StateStore) CommitSnapshotReissue(vin string, at time.Time, hash string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.LastSnapshotReissueTime = at
current.LastSnapshotReissueHash = hash
current.LastSnapshotReissueACKAt = time.Now()
s.state.Vehicles[vin] = current
return s.saveLocked()
}
type persistentState struct {
Version int `json:"version"`
PlatformSerial uint16 `json:"platform_serial"`
Vehicles map[string]VehicleState `json:"vehicles"`
}
type StateStore struct {
mu sync.Mutex
path string
state persistentState
}
func OpenStateStore(path string) (*StateStore, error) {
store := &StateStore{
path: path,
state: persistentState{
Version: 1,
Vehicles: map[string]VehicleState{},
},
}
encoded, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return store, nil
}
if err != nil {
return nil, fmt.Errorf("read bridge state: %w", err)
}
if err := json.Unmarshal(encoded, &store.state); err != nil {
return nil, fmt.Errorf("decode bridge state: %w", err)
}
if store.state.Vehicles == nil {
store.state.Vehicles = map[string]VehicleState{}
}
return store, nil
}
func (s *StateStore) Vehicle(vin string) VehicleState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state.Vehicles[vin]
}
func (s *StateStore) CommitRealtime(vin string, at time.Time, hash string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.LastRealtimeTime = at
current.LastRealtimeHash = hash
current.LastRealtimeACKAt = time.Now()
s.state.Vehicles[vin] = current
return s.saveLocked()
}
func (s *StateStore) CommitBackfill(vin string, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.BackfillCursor = at
current.LastBackfillACKAt = time.Now()
s.state.Vehicles[vin] = current
return s.saveLocked()
}
func (s *StateStore) AdvanceBackfillCursor(vin string, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.BackfillCursor = at
s.state.Vehicles[vin] = current
return s.saveLocked()
}
func (s *StateStore) NextPlatformSerial() (uint16, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.state.PlatformSerial++
if s.state.PlatformSerial == 0 {
s.state.PlatformSerial = 1
}
if err := s.saveLocked(); err != nil {
return 0, err
}
return s.state.PlatformSerial, nil
}
func (s *StateStore) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
return fmt.Errorf("create bridge state directory: %w", err)
}
encoded, err := json.MarshalIndent(s.state, "", " ")
if err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json")
if err != nil {
return err
}
tempName := temp.Name()
defer os.Remove(tempName)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(encoded); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempName, s.path); err != nil {
return fmt.Errorf("replace bridge state: %w", err)
}
return nil
}
func hashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,38 @@
package feichibridge
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestStateStorePersistsAcknowledgedCursor(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "state.json")
store, err := OpenStateStore(path)
if err != nil {
t.Fatal(err)
}
at := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
if err := store.CommitRealtime("LTEST32960VIN0001", at, "hash"); err != nil {
t.Fatal(err)
}
if err := store.CommitBackfill("LTEST32960VIN0001", at.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
reopened, err := OpenStateStore(path)
if err != nil {
t.Fatal(err)
}
got := reopened.Vehicle("LTEST32960VIN0001")
if !got.LastRealtimeTime.Equal(at) || got.LastRealtimeHash != "hash" {
t.Fatalf("state = %#v", got)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("state mode = %o", info.Mode().Perm())
}
}

View File

@@ -0,0 +1,193 @@
package feichibridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
)
type TargetConfig struct {
Address string
PlatformID string
Username string
Password string
Timeout time.Duration
}
type Target struct {
mu sync.Mutex
config TargetConfig
state *StateStore
dialer net.Dialer
conn net.Conn
lastACK time.Time
}
func NewTarget(config TargetConfig, state *StateStore) (*Target, error) {
if strings.TrimSpace(config.Address) == "" {
return nil, errors.New("GB/T 32960 target address is required")
}
if len(config.PlatformID) != 17 {
return nil, fmt.Errorf("target platform ID must be exactly 17 bytes")
}
if state == nil {
return nil, errors.New("state store is required")
}
if config.Timeout <= 0 {
config.Timeout = 10 * time.Second
}
return &Target{
config: config,
state: state,
dialer: net.Dialer{Timeout: config.Timeout, KeepAlive: 30 * time.Second},
}, nil
}
func (t *Target) Send(ctx context.Context, frame []byte) error {
t.mu.Lock()
defer t.mu.Unlock()
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if err := t.ensureConnected(ctx); err != nil {
lastErr = err
t.closeLocked()
continue
}
if err := t.sendAndACK(ctx, frame); err != nil {
lastErr = err
t.closeLocked()
continue
}
t.lastACK = time.Now()
return nil
}
return fmt.Errorf("send GB/T 32960 frame after reconnect: %w", lastErr)
}
func (t *Target) Connect(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if err := t.ensureConnected(ctx); err != nil {
t.closeLocked()
return err
}
return nil
}
func (t *Target) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return nil
}
err := t.conn.Close()
t.conn = nil
return err
}
func (t *Target) LastACK() time.Time {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastACK
}
func (t *Target) ensureConnected(ctx context.Context) error {
if t.conn != nil {
return nil
}
conn, err := t.dialer.DialContext(ctx, "tcp", t.config.Address)
if err != nil {
return fmt.Errorf("dial target %s: %w", t.config.Address, err)
}
t.conn = conn
serial, err := t.state.NextPlatformSerial()
if err != nil {
return fmt.Errorf("allocate platform login serial: %w", err)
}
login, err := LoginFrame(t.config.PlatformID, t.config.Username, t.config.Password, serial, time.Now())
if err != nil {
return err
}
if err := t.sendAndACK(ctx, login); err != nil {
return fmt.Errorf("GB/T 32960 platform login: %w", err)
}
t.lastACK = time.Now()
return nil
}
func (t *Target) sendAndACK(ctx context.Context, frame []byte) error {
if t.conn == nil {
return errors.New("target connection is closed")
}
deadline := time.Now().Add(t.config.Timeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
deadline = contextDeadline
}
if err := t.conn.SetDeadline(deadline); err != nil {
return err
}
if err := writeFull(t.conn, frame); err != nil {
return fmt.Errorf("write target frame: %w", err)
}
response, err := readFrame(t.conn)
if err != nil {
return fmt.Errorf("read target ACK: %w", err)
}
if response[2] != frame[2] {
return fmt.Errorf("target ACK command mismatch: got 0x%02X want 0x%02X", response[2], frame[2])
}
if response[3] != 0x01 {
return fmt.Errorf("target rejected command 0x%02X with response 0x%02X", response[2], response[3])
}
if string(response[4:21]) != string(frame[4:21]) {
return errors.New("target ACK identifier mismatch")
}
return nil
}
func (t *Target) closeLocked() {
if t.conn != nil {
_ = t.conn.Close()
t.conn = nil
}
}
func readFrame(reader io.Reader) ([]byte, error) {
header := make([]byte, 24)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, err
}
if (header[0] != '#' || header[1] != '#') && (header[0] != '$' || header[1] != '$') {
return nil, fmt.Errorf("bad GB/T 32960 ACK start %q", header[:2])
}
bodyLength := int(binary.BigEndian.Uint16(header[22:24]))
tail := make([]byte, bodyLength+1)
if _, err := io.ReadFull(reader, tail); err != nil {
return nil, err
}
frame := append(header, tail...)
if got, want := bcc(frame[2:len(frame)-1]), frame[len(frame)-1]; got != want {
return nil, fmt.Errorf("bad GB/T 32960 ACK BCC: got 0x%02X want 0x%02X", got, want)
}
return frame, nil
}
func writeFull(writer io.Writer, value []byte) error {
for len(value) > 0 {
written, err := writer.Write(value)
if err != nil {
return err
}
if written == 0 {
return io.ErrShortWrite
}
value = value[written:]
}
return nil
}

View File

@@ -0,0 +1,83 @@
package feichibridge
import (
"context"
"net"
"path/filepath"
"sync"
"testing"
"time"
)
func TestTargetLogsInAndWaitsForACK(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
var commands []byte
var mu sync.Mutex
done := make(chan error, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
done <- acceptErr
return
}
defer conn.Close()
for count := 0; count < 2; count++ {
frame, readErr := readFrame(conn)
if readErr != nil {
done <- readErr
return
}
mu.Lock()
commands = append(commands, frame[2])
mu.Unlock()
body := []byte(nil)
if len(frame) >= 31 {
body = append(body, frame[24:30]...)
}
ack, buildErr := buildFrame('#', frame[2], 0x01, string(frame[4:21]), body)
if buildErr != nil {
done <- buildErr
return
}
if writeErr := writeFull(conn, ack); writeErr != nil {
done <- writeErr
return
}
}
done <- nil
}()
store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
target, err := NewTarget(TargetConfig{
Address: listener.Addr().String(), PlatformID: "FEICHIBRIDGE00001",
Username: "bridge", Password: "secret", Timeout: time.Second,
}, store)
if err != nil {
t.Fatal(err)
}
frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", time.Now(), Record{"2201": "1"})
if err != nil {
t.Fatal(err)
}
if err := target.Send(context.Background(), frame); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(commands) != 2 || commands[0] != CommandLogin || commands[1] != CommandRealtime {
t.Fatalf("commands = %x", commands)
}
if target.LastACK().IsZero() {
t.Fatal("last ACK was not recorded")
}
}

View File

@@ -151,16 +151,23 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
cursor += size
case 0x05:
if len(body[cursor:]) < 9 {
size := 9
if version == "V2025" {
size = 10
}
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x05", "error": "truncated"})
return eventTime, units
}
unit := parsePositionData(body[cursor : cursor+9])
unit := parsePositionData(version, body[cursor:cursor+size])
units = append(units, map[string]any{"type": "0x05", "name": "position", "value": unit})
fields["position_status"] = unit["position_status"]
if coordinateSystem, ok := unit["coordinate_system"]; ok {
fields["coordinate_system"] = coordinateSystem
}
fields[envelope.FieldLongitude] = unit["longitude"]
fields[envelope.FieldLatitude] = unit["latitude"]
cursor += 9
cursor += size
case 0x02:
if len(body[cursor:]) < 1 {
units = append(units, map[string]any{"type": "0x02", "error": "truncated"})
@@ -364,21 +371,27 @@ func parsePlatformLogin(body []byte) map[string]any {
}
func parseVehicleData(data []byte) map[string]any {
return map[string]any{
out := map[string]any{
"vehicle_status": int(data[0]),
"charge_status": int(data[1]),
"running_mode": int(data[2]),
"speed_kmh": scaledU16(data[3:5], 10),
"total_mileage_km": scaledU32(data[5:9], 10),
"total_voltage_v": scaledU16(data[9:11], 10),
"total_current_a": scaledU16WithOffset(data[11:13], 10, -1000),
"soc_percent": int(data[13]),
"speed_kmh": nullableScaledU16(data[3:5], 10),
"total_mileage_km": nullableScaledU32(data[5:9], 10),
"total_voltage_v": nullableScaledU16(data[9:11], 10),
"total_current_a": nullableScaledU16WithOffset(data[11:13], 10, -1000),
"soc_percent": nullableByte(data[13]),
"dc_dc_status": int(data[14]),
"gear": int(data[15]),
"insulation_kohm": binary.BigEndian.Uint16(data[16:18]),
"accelerator_pct": int(data[18]),
"brake_pct": int(data[19]),
"insulation_kohm": nullableU16(data[16:18]),
}
// GB/T 32960.3-2025 removes the 2016 accelerator and brake bytes from
// the 0x01 vehicle unit. Keep them only when the frame actually carries
// the 20-byte 2016 payload.
if len(data) >= 20 {
out["accelerator_pct"] = nullableByte(data[18])
out["brake_pct"] = nullableByte(data[19])
}
return out
}
func parseDriveMotorData(data []byte) map[string]any {
@@ -389,12 +402,12 @@ func parseDriveMotorData(data []byte) map[string]any {
motors = append(motors, map[string]any{
"serial_no": int(data[cursor]),
"state": int(data[cursor+1]),
"controller_temperature_c": int(data[cursor+2]) - 40,
"speed_rpm": int(binary.BigEndian.Uint16(data[cursor+3:cursor+5])) - 20000,
"torque_nm": scaledIntWithOffset(int64(binary.BigEndian.Uint16(data[cursor+5:cursor+7])), 10, -20000),
"motor_temperature_c": int(data[cursor+7]) - 40,
"controller_voltage_v": scaledU16(data[cursor+8:cursor+10], 10),
"controller_current_a": scaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
"controller_temperature_c": nullableTempOffset40(data[cursor+2]),
"speed_rpm": nullableU16WithOffset(data[cursor+3:cursor+5], -20000),
"torque_nm": nullableScaledU16WithOffset(data[cursor+5:cursor+7], 10, -20000),
"motor_temperature_c": nullableTempOffset40(data[cursor+7]),
"controller_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 10),
"controller_current_a": nullableScaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
})
cursor += 12
}
@@ -406,34 +419,45 @@ func parseDriveMotorData(data []byte) map[string]any {
func parseFuelCellData(data []byte) map[string]any {
probeCount := int(binary.BigEndian.Uint16(data[6:8]))
probes := make([]int, 0, probeCount)
probes := make([]any, 0, probeCount)
cursor := 8
for i := 0; i < probeCount; i++ {
probes = append(probes, int(data[cursor])-40)
probes = append(probes, nullableTempOffset40(data[cursor]))
cursor++
}
out := map[string]any{
"fuel_cell_voltage_v": scaledU16(data[0:2], 10),
"fuel_cell_current_a": scaledU16(data[2:4], 10),
"hydrogen_consumption_kg_per_100km": scaledU16(data[4:6], 100),
"fuel_cell_voltage_v": nullableScaledU16(data[0:2], 10),
"fuel_cell_current_a": nullableScaledU16(data[2:4], 10),
"hydrogen_consumption_kg_per_100km": nullableScaledU16(data[4:6], 100),
"temperature_probe_count": probeCount,
"temperature_probe_values_c": probes,
"max_hydrogen_temperature_c": scaledU16WithOffset(data[cursor:cursor+2], 10, -40),
"max_hydrogen_temperature_c": nullableScaledU16WithOffset(data[cursor:cursor+2], 10, -40),
"max_hydrogen_temperature_probe_id": int(data[cursor+2]),
"max_hydrogen_concentration_fraction": scaledU16(data[cursor+3:cursor+5], 1_000_000),
"max_hydrogen_concentration_probe_id": int(data[cursor+5]),
"max_hydrogen_pressure_mpa": scaledU16(data[cursor+6:cursor+8], 10),
"max_hydrogen_pressure_mpa": nullableScaledU16(data[cursor+6:cursor+8], 10),
"max_hydrogen_pressure_probe_id": int(data[cursor+8]),
"dc_dc_status": int(data[cursor+9]),
}
if concentration := nullableU16(data[cursor+3 : cursor+5]); concentration != nil {
raw := concentration.(int)
out["max_hydrogen_concentration_fraction"] = float64(raw) / 1_000_000
out["max_hydrogen_concentration_ppm"] = raw
out["max_hydrogen_concentration_percent"] = float64(raw) / 10_000
} else {
out["max_hydrogen_concentration_fraction"] = nil
out["max_hydrogen_concentration_ppm"] = nil
out["max_hydrogen_concentration_percent"] = nil
}
return out
}
func parseEngineData(data []byte) map[string]any {
return map[string]any{
"engine_status": int(data[0]),
"crank_speed_rpm": binary.BigEndian.Uint16(data[1:3]),
"fuel_rate": binary.BigEndian.Uint16(data[3:5]),
"crank_speed_rpm": nullableU16(data[1:3]),
// The supplied authoritative reference does not define this field's
// business unit or scale, so preserve the decoded protocol integer.
"fuel_rate": nullableU16(data[3:5]),
}
}
@@ -632,11 +656,13 @@ func parseGdFCStackData(data []byte) map[string]any {
"frame_cell_count": frameCellCount,
}
cursor += 22
frameVoltages := make([]float64, 0, frameCellCount)
maxCell := 0.0
minCell := 0.0
seen := false
for c := 0; c < frameCellCount; c++ {
voltage := scaledU16(data[cursor:cursor+2], 1000)
frameVoltages = append(frameVoltages, voltage)
if !seen || voltage > maxCell {
maxCell = voltage
}
@@ -650,6 +676,7 @@ func parseGdFCStackData(data []byte) map[string]any {
summary["frame_max_cell_voltage_v"] = maxCell
summary["frame_min_cell_voltage_v"] = minCell
}
summary["frame_cell_voltages_v"] = frameVoltages
summaries = append(summaries, summary)
}
return map[string]any{
@@ -760,6 +787,13 @@ func nullableU16WithOffset(data []byte, offset int) any {
return int(value) + offset
}
func nullableByte(value byte) any {
if value == 0xfe || value == 0xff {
return nil
}
return int(value)
}
func scaledU16(data []byte, divisor int64) float64 {
return scaledInt(int64(binary.BigEndian.Uint16(data)), divisor)
}
@@ -791,6 +825,14 @@ func nullableScaledU16(data []byte, divisor int64) any {
return scaledInt(int64(value), divisor)
}
func nullableScaledU32(data []byte, divisor int64) any {
value := binary.BigEndian.Uint32(data)
if value == 0xfffffffe || value == 0xffffffff {
return nil
}
return scaledInt(int64(value), divisor)
}
func nullableScaledU16WithOffset(data []byte, divisor int64, offset int64) any {
value := binary.BigEndian.Uint16(data)
if value == 0xfffe || value == 0xffff {
@@ -806,12 +848,16 @@ func nullableTempOffset40(value byte) any {
return int(value) - 40
}
func parsePositionData(data []byte) map[string]any {
return map[string]any{
"position_status": int(data[0]),
"longitude": float64(binary.BigEndian.Uint32(data[1:5])) / 1_000_000,
"latitude": float64(binary.BigEndian.Uint32(data[5:9])) / 1_000_000,
func parsePositionData(version string, data []byte) map[string]any {
offset := 1
out := map[string]any{"position_status": int(data[0])}
if version == "V2025" {
out["coordinate_system"] = int(data[1])
offset = 2
}
out["longitude"] = float64(binary.BigEndian.Uint32(data[offset:offset+4])) / 1_000_000
out["latitude"] = float64(binary.BigEndian.Uint32(data[offset+4:offset+8])) / 1_000_000
return out
}
func indexStart(data []byte) int {

View File

@@ -222,6 +222,13 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
if units[2]["name"] != "fuel_cell" {
t.Fatalf("unexpected fuel cell unit: %#v", units[2])
}
fuelCell, ok := units[2]["value"].(map[string]any)
if !ok {
t.Fatalf("fuel cell payload missing: %#v", units[2])
}
if _, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok {
t.Fatalf("hydrogen concentration ppm missing: %#v", fuelCell["max_hydrogen_concentration_ppm"])
}
if units[9]["name"] != "gd_fc_stack" {
t.Fatalf("unexpected gd stack unit: %#v", units[9])
}
@@ -237,6 +244,69 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
}
}
func TestParseFuelCellDataExposesHydrogenConcentrationInReferencePercent(t *testing.T) {
data := make([]byte, 18)
data[11] = 0x01
data[12] = 0xf4
fuelCell := parseFuelCellData(data)
if concentration, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok || concentration != 500 {
t.Fatalf("hydrogen concentration ppm = %#v, want int(500)", fuelCell["max_hydrogen_concentration_ppm"])
}
if fraction, ok := fuelCell["max_hydrogen_concentration_fraction"].(float64); !ok || math.Abs(fraction-0.0005) > 0.0000001 {
t.Fatalf("hydrogen concentration fraction = %#v, want 0.0005", fuelCell["max_hydrogen_concentration_fraction"])
}
if percent, ok := fuelCell["max_hydrogen_concentration_percent"].(float64); !ok || math.Abs(percent-0.05) > 0.0000001 {
t.Fatalf("hydrogen concentration percent = %#v, want 0.05", fuelCell["max_hydrogen_concentration_percent"])
}
}
func TestParseFuelCellDataTreatsProtocolInvalidMarkersAsMissing(t *testing.T) {
data := make([]byte, 18)
for index := range data {
data[index] = 0xff
}
data[6], data[7] = 0, 0
fuelCell := parseFuelCellData(data)
for _, key := range []string{"fuel_cell_voltage_v", "fuel_cell_current_a", "hydrogen_consumption_kg_per_100km", "max_hydrogen_temperature_c", "max_hydrogen_concentration_percent", "max_hydrogen_pressure_mpa"} {
if fuelCell[key] != nil {
t.Fatalf("%s should be nil for protocol invalid marker: %#v", key, fuelCell[key])
}
}
}
func TestParseV2025VehicleAndPositionUsesCoordinateSystemByte(t *testing.T) {
body := []byte{0x1a, 0x07, 0x11, 0x0a, 0x00, 0x00, 0x01}
body = append(body,
0x01, 0x03, 0x01,
0x00, 0x64,
0x00, 0x00, 0x03, 0xe8,
0x13, 0x88,
0x27, 0x10,
80, 0x01, 0x00,
0x03, 0xe8)
body = append(body,
0x05,
0x00, 0x02,
0x07, 0x36, 0x50, 0x40,
0x01, 0xd2, 0x4f, 0x00)
fields := map[string]any{}
_, units := parseDataBody("V2025", body, fields)
if len(units) != 2 {
t.Fatalf("units = %#v", units)
}
vehicle := units[0]["value"].(map[string]any)
if _, exists := vehicle["accelerator_pct"]; exists {
t.Fatalf("2025 vehicle unit must not invent removed pedal fields: %#v", vehicle)
}
position := units[1]["value"].(map[string]any)
if position["coordinate_system"] != 2 {
t.Fatalf("coordinate system = %#v", position["coordinate_system"])
}
if longitude := position["longitude"].(float64); math.Abs(longitude-121) > 0.000001 {
t.Fatalf("longitude = %v", longitude)
}
}
func TestScaledDecimalFieldsDoNotLeakBinaryFloatTails(t *testing.T) {
unit := parseDriveMotorData([]byte{
0x01,

View File

@@ -6,6 +6,8 @@ import (
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) {
@@ -157,6 +159,37 @@ func TestBuildFieldsEnvelopeCopiesSourceMetadata(t *testing.T) {
}
}
func TestBuildFieldsEnvelopeKeepsYutongJSONNumberMileage(t *testing.T) {
env, err := yutongmqtt.ParseMessage(
"yutong",
"/ytforward/shln/1",
[]byte(`{"device":"LMRKH9AC2R1004106","time":"2026-07-17 11:50:13.021","data":{"LATITUDE":30.466027,"LONGITUDE":103.96096,"METER_SPEED":0,"TOTAL_MILEAGE":77081000}}`),
1784260213051,
)
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit Yutong fields")
}
for _, field := range []string{
"yutong_mqtt.data.total_mileage",
"yutong_mqtt.root.data.total_mileage",
} {
if got := fieldsEnv.Fields[field]; got != "77081000" {
t.Fatalf("%s = %#v, want 77081000", field, got)
}
}
mileageKM, found := telemetry.TotalMileageKM(fieldsEnv.Protocol, fieldsEnv.Fields)
if !found || mileageKM != 77081 {
t.Fatalf("TotalMileageKM() = %.3f, %v; want 77081, true", mileageKM, found)
}
}
func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,

View File

@@ -825,12 +825,27 @@ func positiveNumber(value any) bool {
return typed > 0
case int:
return typed > 0
case int8:
return typed > 0
case int16:
return typed > 0
case int32:
return typed > 0
case int64:
return typed > 0
case uint:
return typed > 0
case uint8:
return typed > 0
case uint16:
return typed > 0
case uint32:
return typed > 0
case uint64:
return typed > 0
case json.Number:
parsed, err := typed.Float64()
return err == nil && parsed > 0
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return err == nil && parsed > 0

View File

@@ -18,6 +18,7 @@ const (
defaultCacheRetention = 72 * time.Hour
defaultCacheCleanupInterval = 10 * time.Minute
defaultBaselineMissTTL = time.Minute
defaultBaselineHitTTL = 5 * time.Minute
defaultMaxCacheEntries = 1000000
)
@@ -34,6 +35,7 @@ type Writer struct {
cacheRetention time.Duration
cacheCleanupInterval time.Duration
baselineMissTTL time.Duration
baselineHitTTL time.Duration
maxCacheEntries int
lastCacheCleanup time.Time
lastCacheCleanupStats cacheCleanupStats
@@ -122,6 +124,7 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
cacheRetention: defaultCacheRetention,
cacheCleanupInterval: defaultCacheCleanupInterval,
baselineMissTTL: defaultBaselineMissTTL,
baselineHitTTL: defaultBaselineHitTTL,
maxCacheEntries: defaultMaxCacheEntries,
lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{},
@@ -182,6 +185,15 @@ func (w *Writer) SetBaselineMissTTL(ttl time.Duration) {
w.baselineMissTTL = ttl
}
func (w *Writer) SetBaselineHitTTL(ttl time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if ttl < 0 {
ttl = 0
}
w.baselineHitTTL = ttl
}
func (w *Writer) SetMaxCacheEntries(maxEntries int) {
w.mu.Lock()
defer w.mu.Unlock()
@@ -517,8 +529,12 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
now := time.Now()
w.mu.Lock()
if cached, ok := w.baselineCache[cacheKey]; ok {
missExpired := !cached.found && (w.baselineMissTTL == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= w.baselineMissTTL)
if !missExpired {
ttl := w.baselineMissTTL
if cached.found {
ttl = w.baselineHitTTL
}
expired := ttl == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= ttl
if !expired {
w.mu.Unlock()
return cached.baseline, cached.found, nil
}
@@ -570,6 +586,16 @@ func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, e
return
}
w.mu.Lock()
if previous, ok := w.baselineCache[cacheKey]; ok &&
previous.found && entry.found &&
previous.baseline.LatestTotalKM == entry.baseline.LatestTotalKM &&
previous.baseline.LatestEventTime.Equal(entry.baseline.LatestEventTime) &&
!previous.cachedAt.IsZero() {
// markBaselineWritten runs for every accepted sample. Preserve the
// original cache age when the durable day-boundary baseline has not
// changed, otherwise an active vehicle would keep stale history forever.
entry.cachedAt = previous.cachedAt
}
if entry.cachedAt.IsZero() {
entry.cachedAt = time.Now()
}

View File

@@ -1091,6 +1091,91 @@ func TestWriterRetriesExpiredMissingPreviousDayBaseline(t *testing.T) {
}
}
func TestWriterRefreshesExpiredFoundPreviousDayBaseline(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
writer.SetBaselineHitTTL(time.Minute)
candidate := SourceMileageSample{
VIN: "LMRKH9AC6R1004111",
StatDate: "2026-07-17",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong",
}
cacheKey := mileageCacheKey(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
})
staleTime := time.Date(2026, 7, 12, 4, 12, 20, 0, loc)
writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{
baseline: sourceBaseline{LatestTotalKM: 90778, LatestEventTime: staleTime},
found: true,
cachedAt: time.Now().Add(-2 * time.Minute),
}
refreshedTime := time.Date(2026, 7, 16, 23, 59, 59, 0, loc)
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs(candidate.VIN, candidate.StatDate, "YUTONG_MQTT", candidate.SourceKey).
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
AddRow(91302.0, refreshedTime))
baseline, found, err := writer.previousBaseline(context.Background(), candidate)
if err != nil || !found {
t.Fatalf("previousBaseline() found=%v error=%v, want refreshed baseline", found, err)
}
if baseline.LatestTotalKM != 91302 || !baseline.LatestEventTime.Equal(refreshedTime) {
t.Fatalf("baseline = %+v, want nearest refreshed baseline", baseline)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterMarkBaselineKeepsCacheAgeForUnchangedBoundary(t *testing.T) {
db, _, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
candidate := SourceMileageSample{
VIN: "LMRKH9AC6R1004111",
StatDate: "2026-07-17",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong",
FirstTotalKM: 91302,
FirstEventTime: time.Date(2026, 7, 16, 23, 59, 59, 0, loc),
}
sample := MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
}
cacheKey := mileageCacheKey(sample)
cachedAt := time.Now().Add(-4 * time.Minute)
writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{
baseline: sourceBaseline{LatestTotalKM: candidate.FirstTotalKM, LatestEventTime: candidate.FirstEventTime},
found: true,
cachedAt: cachedAt,
}
writer.markBaselineWritten(sample, candidate)
if got := writer.baselineCache[cacheKey].cachedAt; !got.Equal(cachedAt) {
t.Fatalf("cachedAt = %s, want unchanged %s", got, cachedAt)
}
}
func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {

View File

@@ -337,10 +337,30 @@ func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, s
return err
}
const upsertSourceStatDateStartSQL = `CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)`
const upsertSourcePreferIncomingFirstSQL = `(
first_event_time IS NULL
OR (
first_event_time < ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) >= first_event_time
)
OR (
first_event_time >= ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + `
)
OR (
first_event_time >= ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) >= ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) <= first_event_time
)
)`
const upsertSourceMergedFirstTotalSQL = `CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
WHEN ` + upsertSourcePreferIncomingFirstSQL + `
THEN VALUES(first_total_mileage_km)
ELSE first_total_mileage_km
END`
@@ -354,7 +374,7 @@ const upsertSourceMergedLatestTotalSQL = `CASE
END`
const upsertSourceMergedFirstEventSQL = `CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
WHEN ` + upsertSourcePreferIncomingFirstSQL + `
THEN VALUES(first_event_time)
ELSE first_event_time
END`
@@ -490,6 +510,8 @@ WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND s.first_total_mileage_km IS NOT NULL
AND s.latest_total_mileage_km IS NOT NULL
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
@@ -558,6 +580,8 @@ WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND s.first_total_mileage_km IS NOT NULL
AND s.latest_total_mileage_km IS NOT NULL
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))

View File

@@ -187,7 +187,9 @@ func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
for _, want := range []string{
"first_total_mileage_km = CASE",
"VALUES(first_event_time) >= first_event_time",
"VALUES(first_event_time) <= first_event_time",
"VALUES(first_event_time) < CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)",
"latest_total_mileage_km = CASE",
"VALUES(latest_event_time) >= latest_event_time",
"daily_mileage_km = CASE",
@@ -334,6 +336,22 @@ func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
}
}
func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing.T) {
for name, query := range map[string]string{
"insert": normalizePlatformSourceMileageInsertSQL,
"delete": normalizePlatformSourceMileageDeleteSQL,
} {
for _, predicate := range []string{
"s.first_total_mileage_km IS NOT NULL",
"s.latest_total_mileage_km IS NOT NULL",
} {
if !strings.Contains(query, predicate) {
t.Fatalf("%s query missing incomplete-total guard %q:\n%s", name, predicate, query)
}
}
}
}
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {

View File

@@ -5,6 +5,7 @@ HOST=""
REMOTE_ROOT="/opt/lingniu-go-native"
DAYS_BACK=1
WINDOW_DAYS=3
BASELINE_LOOKBACK_DAYS=7
PROTOCOLS="GB32960,JT808,YUTONG_MQTT"
ON_CALENDAR="*-*-* 01:30:00"
DRY_RUN="false"
@@ -19,6 +20,9 @@ Options:
--remote-root DIR Remote install root. Defaults to /opt/lingniu-go-native.
--days-back N Target end date relative to local day. Defaults to 1, meaning yesterday.
--window-days N Number of days to recalculate ending at days-back. Defaults to 3.
--baseline-lookback-days N
Maximum days searched before the target window for a mileage baseline.
Defaults to 7.
--protocols LIST Comma-separated protocols. Defaults to GB32960,JT808,YUTONG_MQTT.
--on-calendar SPEC systemd OnCalendar value. Defaults to "*-*-* 01:30:00".
--dry-run Install timer in dry-run mode.
@@ -44,6 +48,10 @@ while [[ $# -gt 0 ]]; do
WINDOW_DAYS="${2:-}"
shift 2
;;
--baseline-lookback-days)
BASELINE_LOOKBACK_DAYS="${2:-}"
shift 2
;;
--protocols)
PROTOCOLS="${2:-}"
shift 2
@@ -86,11 +94,17 @@ case "$WINDOW_DAYS" in
exit 2
;;
esac
case "$BASELINE_LOOKBACK_DAYS" in
''|*[!0-9]*|0)
echo "--baseline-lookback-days must be a positive integer" >&2
exit 2
;;
esac
remote_cmd=""
printf -v remote_cmd \
"REMOTE_ROOT=%q DAYS_BACK=%q WINDOW_DAYS=%q PROTOCOLS=%q ON_CALENDAR=%q DRY_RUN=%q bash -s" \
"$REMOTE_ROOT" "$DAYS_BACK" "$WINDOW_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN"
"REMOTE_ROOT=%q DAYS_BACK=%q WINDOW_DAYS=%q BASELINE_LOOKBACK_DAYS=%q PROTOCOLS=%q ON_CALENDAR=%q DRY_RUN=%q bash -s" \
"$REMOTE_ROOT" "$DAYS_BACK" "$WINDOW_DAYS" "$BASELINE_LOOKBACK_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN"
ssh "$HOST" "$remote_cmd" <<'REMOTE'
set -euo pipefail
@@ -110,6 +124,7 @@ Environment=BACKFILL_METHOD=last_diff
Environment=BACKFILL_DRY_RUN=${DRY_RUN}
Environment=BACKFILL_DAYS_BACK=${DAYS_BACK}
Environment=BACKFILL_WINDOW_DAYS=${WINDOW_DAYS}
Environment=BACKFILL_BASELINE_LOOKBACK_DAYS=${BASELINE_LOOKBACK_DAYS}
Environment=BACKFILL_PROTOCOLS=${PROTOCOLS}
ExecStart=${REMOTE_ROOT}/current/stats-backfill
TimeoutStartSec=45min

View File

@@ -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":

View File

@@ -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"},

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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())
}
}

View File

@@ -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,

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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`);

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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; }
}

View File

@@ -53,6 +53,7 @@ Object.defineProperty(navigator, 'clipboard', {
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string) => ({
matches: false,
media: query,

View File

@@ -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>;

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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('');
});
});

View File

@@ -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(',');
}

View File

@@ -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(/引号结束/);
});
});

View File

@@ -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
};

View File

@@ -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', () => {

View File

@@ -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);
}

View File

@@ -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', () => {

View File

@@ -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 };
}

View File

@@ -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]);
}

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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]);

View File

@@ -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="车辆状态图例">

View File

@@ -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();

View File

@@ -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>;
}

View File

@@ -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);
});

View File

@@ -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>;
}

View File

@@ -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();
});

View File

@@ -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>;
}

View File

@@ -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'));

View File

@@ -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>;
}

View File

@@ -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));

View File

@@ -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>
);

View File

@@ -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();

View File

@@ -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>;
}

View File

@@ -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>
</>;
}

View File

@@ -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'));
});

View File

@@ -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>;
}

View File

@@ -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' } });

View File

@@ -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>;
}

View File

@@ -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('第 110 条,共 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('第 11 条,共 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({

View File

@@ -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>;
}

View File

@@ -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正常');
});

View File

@@ -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(); }} />;
}

View File

@@ -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>;
}

View File

@@ -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');

View File

@@ -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', () => {

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