feat(operations): add source diagnosis workspace

This commit is contained in:
lingniu
2026-07-16 17:35:10 +08:00
parent df7d9799b3
commit 96cad7eef7
18 changed files with 881 additions and 10 deletions

View File

@@ -232,6 +232,12 @@ func requiredMenu(r *http.Request) string {
}
func requiredRole(r *http.Request) string {
if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return "operator"
}
return "admin"
}
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
return "viewer"
}

View File

@@ -94,6 +94,18 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorProfileSync.Code != http.StatusForbidden {
t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code)
}
viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken)
if viewerSourceDiagnostic.Code != http.StatusForbidden {
t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code)
}
operatorSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", operatorToken)
if operatorSourceDiagnostic.Code != http.StatusNoContent {
t.Fatalf("operator source diagnostic status=%d", operatorSourceDiagnostic.Code)
}
operatorSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", operatorToken)
if operatorSourcePolicy.Code != http.StatusForbidden {
t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code)
}
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))
@@ -106,6 +118,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if adminThreshold.Code != http.StatusNoContent {
t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String())
}
adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken)
if adminSourcePolicy.Code != http.StatusNoContent {
t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String())
}
}
func TestAPIAuthSessionAndDisabledMode(t *testing.T) {

View File

@@ -61,6 +61,8 @@ func (h *Handler) routes() {
h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/source-evidence", h.handleVehicleSourceEvidence)
h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic)
h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy)
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
@@ -111,6 +113,22 @@ func (h *Handler) handleVehicleSourceEvidence(w http.ResponseWriter, r *http.Req
h.write(w, r, data, err)
}
func (h *Handler) handleVehicleSourceDiagnostic(w http.ResponseWriter, r *http.Request) {
data, err := h.service.VehicleSourceDiagnostic(r.Context(), r.PathValue("vin"))
h.write(w, r, data, err)
}
func (h *Handler) handleUpdateVehicleSourcePolicy(w http.ResponseWriter, r *http.Request) {
var update VehicleSourcePolicyUpdate
if !decodeJSONBody(w, r, &update) {
return
}
update.SourceRef = r.PathValue("sourceRef")
update.Actor = ActorFromContext(r.Context())
data, err := h.service.UpdateVehicleSourcePolicy(r.Context(), r.PathValue("vin"), update)
h.write(w, r, data, err)
}
func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) {
var input VehicleProfileInput
if !decodeJSONBody(w, r, &input) {

View File

@@ -2,7 +2,9 @@ package platform
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -420,6 +422,31 @@ func TestHandlerVehicleSourceEvidenceRejectsInvalidDate(t *testing.T) {
}
}
func TestHandlerVehicleSourceDiagnosticAndPolicyUpdate(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
ctx := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources", nil).WithContext(ctx)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("diagnostic status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data VehicleSourceDiagnostic `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
source := body.Data.Evidence.LocationSources[1]
payload := fmt.Sprintf(`{"version":%d,"enabled":false,"priority":%d,"remark":"人工核验"}`, body.Data.Policy.Version, source.Priority)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources/"+source.SourceRef, strings.NewReader(payload)).WithContext(ctx)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"version":2`) {
t.Fatalf("policy update status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -15,6 +15,8 @@ type MockStore struct {
locations []RealtimeLocationRow
accessMu sync.RWMutex
accessThresholds AccessThresholdConfig
sourcePolicyMu sync.RWMutex
sourcePolicies map[string]VehicleSourcePolicyConfig
profileMu sync.RWMutex
profiles map[string]VehicleProfile
alertMu sync.RWMutex
@@ -36,6 +38,7 @@ func NewMockStore() *MockStore {
store := &MockStore{
vehicles: vehicles,
accessThresholds: defaultAccessThresholds(time.Now()),
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
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"},
},
@@ -51,6 +54,54 @@ func NewMockStore() *MockStore {
func int64Pointer(value int64) *int64 { return &value }
func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string) ([]vehicleLocationSourceHistory, error) {
if vin != "LB9A32A24R0LS1426" {
return []vehicleLocationSourceHistory{}, nil
}
return []vehicleLocationSourceHistory{
{Protocol: "GB32960", sourceKey: "GB32960:canonical", FirstSeenAt: "2026-03-01 08:00:00", LastSeenAt: "2026-07-16 16:20:08", ReportSampleCount: 108000},
{Protocol: "JT808", sourceKey: "jt808:g7", FirstSeenAt: "2026-04-01 09:00:00", LastSeenAt: "2026-07-16 16:20:06", ReportSampleCount: 432000},
{Protocol: "JT808", sourceKey: "jt808:beidou", FirstSeenAt: "2026-05-01 09:00:00", LastSeenAt: "2026-07-16 16:19:58", ReportSampleCount: 216000},
}, nil
}
func (m *MockStore) VehicleSourcePolicy(_ context.Context, vin string) (VehicleSourcePolicyConfig, error) {
m.sourcePolicyMu.RLock()
defer m.sourcePolicyMu.RUnlock()
config, exists := m.sourcePolicies[vin]
if !exists {
return VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}, nil
}
config.Audit = append([]VehicleSourcePolicyAudit(nil), config.Audit...)
return config, nil
}
func (m *MockStore) SaveVehicleSourcePolicy(_ context.Context, update vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) {
m.sourcePolicyMu.Lock()
defer m.sourcePolicyMu.Unlock()
current, exists := m.sourcePolicies[update.VIN]
if !exists {
current = VehicleSourcePolicyConfig{VIN: update.VIN, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}
}
if current.Version != update.Version {
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"}
}
if update.CurrentEnabled == update.Enabled && update.CurrentPriority == update.Priority && update.CurrentRemark == update.Remark {
return current, nil
}
now := time.Now().Format(time.RFC3339)
current.Version++
current.UpdatedBy = update.Actor
current.UpdatedAt = now
current.Audit = append([]VehicleSourcePolicyAudit{{
Version: current.Version, Protocol: update.Protocol, SourceRef: update.SourceRef,
SourceLabel: update.SourceLabel, Actor: update.Actor, ChangedAt: now,
Summary: update.SourceLabel + "" + enabledLabel(update.CurrentEnabled) + "→" + enabledLabel(update.Enabled),
}}, current.Audit...)
m.sourcePolicies[update.VIN] = current
return current, nil
}
func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfile, bool, error) {
m.profileMu.RLock()
defer m.profileMu.RUnlock()

View File

@@ -944,6 +944,7 @@ type VehicleLocationSourceEvidence struct {
SourceLabel string `json:"sourceLabel"`
TerminalLabel string `json:"terminalLabel"`
SourceKind string `json:"sourceKind"`
SourceRef string `json:"sourceRef,omitempty"`
SelectedWithinProtocol bool `json:"selectedWithinProtocol"`
Recommended bool `json:"recommended"`
Enabled bool `json:"enabled"`
@@ -958,6 +959,10 @@ type VehicleLocationSourceEvidence struct {
SOCPercent *float64 `json:"socPercent,omitempty"`
EventTime string `json:"eventTime"`
ReceivedAt string `json:"receivedAt"`
FirstSeenAt string `json:"firstSeenAt,omitempty"`
ReportIntervalSec *int `json:"reportIntervalSec,omitempty"`
ReportSampleCount int64 `json:"reportSampleCount,omitempty"`
SelectionReason string `json:"selectionReason,omitempty"`
sourceKey string
}
@@ -988,6 +993,60 @@ type VehicleSourceEvidenceComparison struct {
ReportTimeDeltaSeconds float64 `json:"reportTimeDeltaSeconds"`
}
type VehicleSourceDiagnostic struct {
Evidence VehicleSourceEvidence `json:"evidence"`
Access *AccessVehicleRow `json:"access,omitempty"`
Policy VehicleSourcePolicyConfig `json:"policy"`
RecommendationReason string `json:"recommendationReason"`
RefreshHint string `json:"refreshHint"`
}
type VehicleSourcePolicyConfig struct {
VIN string `json:"vin"`
Version int `json:"version"`
UpdatedBy string `json:"updatedBy"`
UpdatedAt string `json:"updatedAt"`
Audit []VehicleSourcePolicyAudit `json:"audit"`
}
type VehicleSourcePolicyAudit struct {
Version int `json:"version"`
Protocol string `json:"protocol"`
SourceRef string `json:"sourceRef"`
SourceLabel string `json:"sourceLabel"`
Actor string `json:"actor"`
ChangedAt string `json:"changedAt"`
Summary string `json:"summary"`
}
type VehicleSourcePolicyUpdate struct {
Version int `json:"version"`
SourceRef string `json:"sourceRef"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
Remark string `json:"remark"`
Actor string `json:"actor"`
}
type vehicleSourcePolicyStoreUpdate struct {
VehicleSourcePolicyUpdate
VIN string
Protocol string
SourceKey string
SourceLabel string
CurrentEnabled bool
CurrentPriority int
CurrentRemark string
}
type vehicleLocationSourceHistory struct {
Protocol string
sourceKey string
FirstSeenAt string
LastSeenAt string
ReportSampleCount int64
}
type VehicleServiceOverview struct {
VIN string `json:"vin"`
Plate string `json:"plate"`

View File

@@ -216,6 +216,59 @@ func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) {
}
}
func TestVehicleSourceDiagnosticExposesOpaqueReferencesAndElectionReasons(t *testing.T) {
service := NewService(NewMockStore())
ctx := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
diagnostic, err := service.VehicleSourceDiagnostic(ctx, "LB9A32A24R0LS1426")
if err != nil {
t.Fatalf("VehicleSourceDiagnostic returned error: %v", err)
}
if diagnostic.Policy.Version != 1 || diagnostic.Access == nil || diagnostic.Evidence.RecommendedLocationProtocol != "JT808" {
t.Fatalf("diagnostic lost access or policy evidence: %+v", diagnostic)
}
if diagnostic.RecommendationReason == "" || diagnostic.RefreshHint == "" {
t.Fatalf("diagnostic must explain recommendation and refresh boundary: %+v", diagnostic)
}
for _, source := range diagnostic.Evidence.LocationSources {
if len(source.SourceRef) != 64 || source.SelectionReason == "" || source.FirstSeenAt == "" {
t.Fatalf("source diagnostic is incomplete: %+v", source)
}
if strings.Contains(source.SourceRef, "jt808") || strings.Contains(source.SourceRef, "g7") {
t.Fatalf("source reference must stay opaque: %+v", source)
}
}
}
func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testing.T) {
service := NewService(NewMockStore())
operator := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
diagnostic, err := service.VehicleSourceDiagnostic(operator, "LB9A32A24R0LS1426")
if err != nil {
t.Fatal(err)
}
source := diagnostic.Evidence.LocationSources[1]
if _, err := service.UpdateVehicleSourcePolicy(operator, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验",
}); err == nil {
t.Fatal("operator must not modify source policy")
}
admin := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"})
updated, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验",
})
if err != nil {
t.Fatalf("admin source policy update failed: %v", err)
}
if updated.Policy.Version != 2 || len(updated.Policy.Audit) != 1 || updated.Policy.Audit[0].Actor != "平台管理员" {
t.Fatalf("source policy update must version and audit: %+v", updated.Policy)
}
if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority,
}); err == nil {
t.Fatal("stale source policy update must conflict")
}
}
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++
return s.MockStore.VehicleServiceOverviews(ctx, query)

View File

@@ -0,0 +1,208 @@
package platform
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
)
type vehicleSourcePolicyStore interface {
VehicleSourcePolicy(context.Context, string) (VehicleSourcePolicyConfig, error)
SaveVehicleSourcePolicy(context.Context, vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error)
VehicleLocationSourceHistory(context.Context, string) ([]vehicleLocationSourceHistory, error)
}
func sourceReference(vin, protocol, sourceKey string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(vin) + "\x00" + strings.TrimSpace(protocol) + "\x00" + sourceKey))
return hex.EncodeToString(sum[:])
}
func (s *Service) VehicleSourceDiagnostic(ctx context.Context, vin string) (VehicleSourceDiagnostic, error) {
if err := authorizeInternalOperations(ctx, false); err != nil {
return VehicleSourceDiagnostic{}, err
}
evidence, err := s.VehicleSourceEvidence(ctx, vin, "")
if err != nil {
return VehicleSourceDiagnostic{}, err
}
store, ok := s.store.(vehicleSourcePolicyStore)
if !ok {
return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured")
}
history, err := store.VehicleLocationSourceHistory(ctx, evidence.VIN)
if err != nil {
return VehicleSourceDiagnostic{}, err
}
historyBySource := make(map[string]vehicleLocationSourceHistory, len(history))
for _, item := range history {
historyBySource[item.Protocol+"\x00"+item.sourceKey] = item
}
var access *AccessVehicleRow
page, accessErr := s.AccessVehicles(ctx, AccessQuery{Keyword: evidence.VIN, Limit: 10})
if accessErr == nil {
for index := range page.Items {
if page.Items[index].VIN == evidence.VIN {
row := page.Items[index]
access = &row
break
}
}
}
protocolStatus := map[string]AccessProtocolStatus{}
if access != nil {
for _, status := range access.ProtocolStatuses {
protocolStatus[status.Protocol] = status
}
}
for index := range evidence.LocationSources {
source := &evidence.LocationSources[index]
if !strings.EqualFold(source.SourceKind, "CANONICAL") {
source.SourceRef = sourceReference(evidence.VIN, source.Protocol, source.sourceKey)
}
if item, exists := historyBySource[source.Protocol+"\x00"+source.sourceKey]; exists {
source.FirstSeenAt = item.FirstSeenAt
source.ReportSampleCount = item.ReportSampleCount
}
if status, exists := protocolStatus[source.Protocol]; exists {
source.ReportIntervalSec = status.ReportIntervalSec
if source.FirstSeenAt == "" {
source.FirstSeenAt = status.FirstSeenAt
}
}
source.SelectionReason = sourceSelectionReason(*source)
}
policy, err := store.VehicleSourcePolicy(ctx, evidence.VIN)
if err != nil {
return VehicleSourceDiagnostic{}, err
}
return VehicleSourceDiagnostic{
Evidence: evidence,
Access: access,
Policy: policy,
RecommendationReason: recommendationReason(evidence),
RefreshHint: "策略保存后由车辆下一次有效上报触发网关重新选举;页面不会伪造即时切换结果。",
}, nil
}
func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, update VehicleSourcePolicyUpdate) (VehicleSourceDiagnostic, error) {
if err := authorizeInternalOperations(ctx, true); err != nil {
return VehicleSourceDiagnostic{}, err
}
vin = strings.ToUpper(strings.TrimSpace(vin))
update.SourceRef = strings.ToLower(strings.TrimSpace(update.SourceRef))
update.Remark = strings.TrimSpace(update.Remark)
if vin == "" {
return VehicleSourceDiagnostic{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"}
}
if update.Version < 1 {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_POLICY_VERSION_INVALID", Message: "来源策略版本无效,请刷新后重试"}
}
if len(update.SourceRef) != sha256.Size*2 {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REF_INVALID", Message: "来源引用无效"}
}
if _, err := hex.DecodeString(update.SourceRef); err != nil {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REF_INVALID", Message: "来源引用无效"}
}
if update.Priority < 1 || update.Priority > 1000 {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PRIORITY_INVALID", Message: "来源优先级必须在 1 到 1000 之间"}
}
if len([]rune(update.Remark)) > 200 {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REMARK_TOO_LONG", Message: "策略备注不能超过 200 个字符"}
}
update.Actor = strings.TrimSpace(update.Actor)
if update.Actor == "" {
update.Actor = ActorFromContext(ctx)
}
evidenceStore, ok := s.store.(VehicleSourceEvidenceStore)
if !ok {
return VehicleSourceDiagnostic{}, errors.New("vehicle source evidence store is not configured")
}
evidence, err := evidenceStore.VehicleSourceEvidence(ctx, vin, "")
if err != nil {
return VehicleSourceDiagnostic{}, err
}
var selected *VehicleLocationSourceEvidence
for index := range evidence.LocationSources {
source := &evidence.LocationSources[index]
if !strings.EqualFold(source.SourceKind, "CANONICAL") && sourceReference(vin, source.Protocol, source.sourceKey) == update.SourceRef {
selected = source
break
}
}
if selected == nil || selected.sourceKey == "" {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"}
}
store, ok := s.store.(vehicleSourcePolicyStore)
if !ok {
return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured")
}
if _, err := store.SaveVehicleSourcePolicy(ctx, vehicleSourcePolicyStoreUpdate{
VehicleSourcePolicyUpdate: update,
VIN: vin,
Protocol: selected.Protocol,
SourceKey: selected.sourceKey,
SourceLabel: selected.SourceLabel,
CurrentEnabled: selected.Enabled,
CurrentPriority: selected.Priority,
}); err != nil {
return VehicleSourceDiagnostic{}, err
}
return s.VehicleSourceDiagnostic(ctx, vin)
}
func authorizeInternalOperations(ctx context.Context, adminOnly bool) error {
principal, ok := PrincipalFromContext(ctx)
if !ok {
return nil
}
if principal.UserType == "customer" || principal.Role == "customer" {
return clientError{Code: "PERMISSION_DENIED", Message: "客户账号无权访问内部来源策略"}
}
if adminOnly && principal.Role != "admin" {
return clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以修改来源策略"}
}
return nil
}
func sourceSelectionReason(source VehicleLocationSourceEvidence) string {
if strings.EqualFold(source.SourceKind, "CANONICAL") {
return "这是协议融合结果快照,不是独立终端候选;需要展开实际来源后调整策略。"
}
if !source.Enabled {
return "已由运维策略停用,不参与协议内选举。"
}
if source.QualityStatus != "" && !strings.EqualFold(source.QualityStatus, "OK") {
return fmt.Sprintf("质量状态为 %s当前不应作为首选%s", source.QualityStatus, firstNonEmpty(source.QualityReason, "请核对原始证据"))
}
if source.Recommended {
return fmt.Sprintf("当前融合推荐;协议内已选中,策略优先级 %d并通过在线与质量检查。", source.Priority)
}
if source.SelectedWithinProtocol {
return fmt.Sprintf("当前为 %s 协议内候选,策略优先级 %d跨协议融合当前推荐其他来源。", source.Protocol, source.Priority)
}
if !source.Online {
return fmt.Sprintf("来源当前离线,策略优先级 %d在线来源会优先参与选举。", source.Priority)
}
return fmt.Sprintf("来源可用,策略优先级 %d当前候选在新鲜度、连续有效样本或稳定性排序中未胜出。", source.Priority)
}
func recommendationReason(evidence VehicleSourceEvidence) string {
for _, source := range evidence.LocationSources {
if source.Recommended {
if strings.EqualFold(source.SourceKind, "CANONICAL") {
return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得可配置的独立终端候选,不能从该快照反推人工优先级。", source.Protocol)
}
return fmt.Sprintf(
"当前推荐 %s / %s来源已启用、质量为 %s、协议内选中策略优先级为 %d。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。",
source.Protocol, source.SourceLabel, firstNonEmpty(source.QualityStatus, "未知"), source.Priority,
)
}
}
if len(evidence.LocationSources) == 0 {
return "当前车辆没有可用于位置选举的来源证据。"
}
return "当前没有明确推荐来源;请检查来源启用状态、质量、新鲜度和网关下一次有效上报。"
}

View File

@@ -0,0 +1,162 @@
package platform
import (
"context"
"database/sql"
"fmt"
"strings"
)
func (s *ProductionStore) VehicleLocationSourceHistory(ctx context.Context, vin string) ([]vehicleLocationSourceHistory, error) {
rows, err := s.db.QueryContext(ctx, `SELECT protocol, source_key,
COALESCE(DATE_FORMAT(MIN(first_event_time), '%Y-%m-%d %H:%i:%s'), ''),
COALESCE(DATE_FORMAT(MAX(latest_event_time), '%Y-%m-%d %H:%i:%s'), ''),
COALESCE(SUM(sample_count), 0)
FROM vehicle_daily_mileage_source
WHERE vin = ?
GROUP BY protocol, source_key
ORDER BY protocol, source_key`, vin)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]vehicleLocationSourceHistory, 0, 8)
for rows.Next() {
var item vehicleLocationSourceHistory
if err := rows.Scan(&item.Protocol, &item.sourceKey, &item.FirstSeenAt, &item.LastSeenAt, &item.ReportSampleCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *ProductionStore) VehicleSourcePolicy(ctx context.Context, vin string) (VehicleSourcePolicyConfig, error) {
config := VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system"}
var updatedAt sql.NullString
err := s.db.QueryRowContext(ctx, `SELECT version, updated_by,
DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s')
FROM platform_vehicle_source_policy_version WHERE vin = ?`, vin).Scan(&config.Version, &config.UpdatedBy, &updatedAt)
if err != nil && err != sql.ErrNoRows {
return VehicleSourcePolicyConfig{}, err
}
if updatedAt.Valid {
config.UpdatedAt = normalizeAccessTime(updatedAt.String)
}
rows, err := s.db.QueryContext(ctx, `SELECT version, protocol, source_ref, source_label, actor,
DATE_FORMAT(changed_at, '%Y-%m-%d %H:%i:%s'), summary
FROM platform_vehicle_source_policy_audit
WHERE vin = ?
ORDER BY changed_at DESC, id DESC
LIMIT 20`, vin)
if err != nil {
return VehicleSourcePolicyConfig{}, err
}
defer rows.Close()
for rows.Next() {
var item VehicleSourcePolicyAudit
if err := rows.Scan(&item.Version, &item.Protocol, &item.SourceRef, &item.SourceLabel, &item.Actor, &item.ChangedAt, &item.Summary); err != nil {
return VehicleSourcePolicyConfig{}, err
}
item.ChangedAt = normalizeAccessTime(item.ChangedAt)
config.Audit = append(config.Audit, item)
}
return config, rows.Err()
}
func (s *ProductionStore) SaveVehicleSourcePolicy(ctx context.Context, update vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return VehicleSourcePolicyConfig{}, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO platform_vehicle_source_policy_version
(vin, version, updated_by) VALUES (?, 1, 'system')`, update.VIN); err != nil {
return VehicleSourcePolicyConfig{}, err
}
var currentVersion int
if err := tx.QueryRowContext(ctx, `SELECT version FROM platform_vehicle_source_policy_version
WHERE vin = ? FOR UPDATE`, update.VIN).Scan(&currentVersion); err != nil {
return VehicleSourcePolicyConfig{}, err
}
if currentVersion != update.Version {
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"}
}
currentEnabled := update.CurrentEnabled
currentPriority := update.CurrentPriority
currentRemark := update.CurrentRemark
var enabled int
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
FROM vehicle_location_source_policy
WHERE vin = ? AND protocol = ? AND source_key = ?`,
update.VIN, update.Protocol, update.SourceKey,
).Scan(&enabled, &currentPriority, &currentRemark)
if err == nil {
currentEnabled = enabled == 1
} else if err != sql.ErrNoRows {
return VehicleSourcePolicyConfig{}, err
}
if currentEnabled == update.Enabled && currentPriority == update.Priority && strings.TrimSpace(currentRemark) == update.Remark {
if err := tx.Rollback(); err != nil {
return VehicleSourcePolicyConfig{}, err
}
return s.VehicleSourcePolicy(ctx, update.VIN)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_location_source_policy
(vin, protocol, source_key, enabled, priority, remark)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority),
remark = VALUES(remark), updated_at = CURRENT_TIMESTAMP`,
update.VIN, update.Protocol, update.SourceKey, boolInt(update.Enabled), update.Priority, update.Remark,
); err != nil {
return VehicleSourcePolicyConfig{}, err
}
nextVersion := currentVersion + 1
result, err := tx.ExecContext(ctx, `UPDATE platform_vehicle_source_policy_version
SET version = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE vin = ? AND version = ?`, nextVersion, update.Actor, update.VIN, currentVersion)
if err != nil {
return VehicleSourcePolicyConfig{}, err
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略更新冲突,请刷新后重试"}
}
summary := fmt.Sprintf(
"%s%s→%s优先级 %d→%d",
update.SourceLabel,
enabledLabel(currentEnabled), enabledLabel(update.Enabled),
currentPriority, update.Priority,
)
if update.Remark != "" {
summary += "" + update.Remark
}
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_policy_audit
(vin, version, protocol, source_ref, source_key, source_label,
old_enabled, new_enabled, old_priority, new_priority, old_remark, new_remark,
actor, summary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey, update.SourceLabel,
boolInt(currentEnabled), boolInt(update.Enabled), currentPriority, update.Priority, currentRemark, update.Remark,
update.Actor, summary,
); err != nil {
return VehicleSourcePolicyConfig{}, err
}
if err := tx.Commit(); err != nil {
return VehicleSourcePolicyConfig{}, err
}
return s.VehicleSourcePolicy(ctx, update.VIN)
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func enabledLabel(value bool) string {
if value {
return "启用"
}
return "停用"
}

View File

@@ -56,7 +56,9 @@ import type {
VehicleIdentityResolution,
VehicleServiceOverview,
VehicleServiceSummary,
VehicleSourceDiagnostic,
VehicleSourceEvidence,
VehicleSourcePolicyUpdate,
VehicleRow
} from './types';
import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session';
@@ -273,6 +275,23 @@ export const api = {
const suffix = params.toString() ? `?${params.toString()}` : '';
return request<VehicleSourceEvidence>(`/api/v2/vehicles/${encodeURIComponent(vin)}/source-evidence${suffix}`, withSignal(undefined, signal));
},
vehicleSourceDiagnostic: (vin: string, signal?: AbortSignal) => request<VehicleSourceDiagnostic>(
`/api/v2/operations/vehicles/${encodeURIComponent(vin)}/sources`,
withSignal(undefined, signal)
),
updateVehicleSourcePolicy: (vin: string, update: VehicleSourcePolicyUpdate) => request<VehicleSourceDiagnostic>(
`/api/v2/operations/vehicles/${encodeURIComponent(vin)}/sources/${encodeURIComponent(update.sourceRef)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
version: update.version,
enabled: update.enabled,
priority: update.priority,
remark: update.remark
})
}
),
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),

View File

@@ -521,6 +521,7 @@ export interface VehicleLocationSourceEvidence {
sourceLabel: string;
terminalLabel: string;
sourceKind: string;
sourceRef?: string;
selectedWithinProtocol: boolean;
recommended: boolean;
enabled: boolean;
@@ -535,6 +536,10 @@ export interface VehicleLocationSourceEvidence {
socPercent?: number;
eventTime: string;
receivedAt: string;
firstSeenAt?: string;
reportIntervalSec?: number;
reportSampleCount?: number;
selectionReason?: string;
}
export interface VehicleMileageSourceEvidence {
@@ -563,6 +568,40 @@ export interface VehicleSourceEvidenceComparison {
reportTimeDeltaSeconds: number;
}
export interface VehicleSourcePolicyAudit {
version: number;
protocol: string;
sourceRef: string;
sourceLabel: string;
actor: string;
changedAt: string;
summary: string;
}
export interface VehicleSourcePolicyConfig {
vin: string;
version: number;
updatedBy: string;
updatedAt: string;
audit: VehicleSourcePolicyAudit[];
}
export interface VehicleSourceDiagnostic {
evidence: VehicleSourceEvidence;
access?: AccessVehicleRow;
policy: VehicleSourcePolicyConfig;
recommendationReason: string;
refreshHint: string;
}
export interface VehicleSourcePolicyUpdate {
version: number;
sourceRef: string;
enabled: boolean;
priority: number;
remark: string;
}
export interface VehicleServiceOverview {
vin: string;
plate: string;

View File

@@ -1,14 +1,22 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import OperationsPage from './OperationsPage';
const mocks = vi.hoisted(() => ({ opsHealth: vi.fn(), sourceReadiness: vi.fn() }));
const mocks = vi.hoisted(() => ({
opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(),
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn()
}));
vi.mock('../../api/client', () => ({ api: mocks }));
afterEach(() => { cleanup(); mocks.opsHealth.mockReset(); mocks.sourceReadiness.mockReset(); });
afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); });
function seedSession() {
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
}
test('reconciles service identities with bound and identity-required vehicles', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
@@ -34,6 +42,7 @@ test('reconciles service identities with bound and identity-required vehicles',
});
test('keeps health evidence visible when source readiness fails and retries that section', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
@@ -54,3 +63,39 @@ test('keeps health evidence visible when source readiness fails and retries that
expect(screen.queryByText('来源就绪度暂时不可用')).not.toBeInTheDocument();
expect(mocks.sourceReadiness).toHaveBeenCalledTimes(2);
});
test('fuzzy searches a vehicle and renders all source diagnosis evidence', async () => {
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: [] });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN001', plate: '粤A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 2, onlineSourceCount: 2, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 });
mocks.vehicleSourceDiagnostic.mockResolvedValue({
evidence: {
vin: 'VIN001', plate: '粤A00001', mileageDate: '2026-07-16', recommendedLocationProtocol: 'JT808',
recommendedLocationLabel: 'G7', locationConflict: true, conflictDistanceM: 233,
locationSources: [{
protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****0001', sourceKind: 'PLATFORM', sourceRef: 'a'.repeat(64),
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true, qualityStatus: 'OK', qualityReason: '',
longitude: 113.1, latitude: 23.1, speedKmh: 30, totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00',
receivedAt: '2026-07-16 10:00:01', firstSeenAt: '2026-03-01 08:00:00', reportIntervalSec: 10, reportSampleCount: 1000,
selectionReason: '当前融合推荐'
}],
mileageSources: [], comparison: { locationMaxDistanceM: 233, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: ''
},
policy: { vin: 'VIN001', version: 1, updatedBy: 'system', updatedAt: '', audit: [] },
recommendationReason: '当前推荐 G7', refreshHint: '下一次有效上报后重新选举'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
expect(await screen.findByText('粤A00001')).toBeInTheDocument();
fireEvent.click(screen.getByText('粤A00001'));
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument();
await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal)));
});

View File

@@ -1,6 +1,8 @@
import { IconRefresh } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
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 { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
@@ -8,13 +10,139 @@ function statusLabel(status: string) {
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
}
function fmt(value?: string) {
if (!value) return '—';
const parsed = new Date(value.replace(' ', 'T'));
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
}
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 }: {
vin: string;
source: VehicleLocationSourceEvidence;
diagnostic: VehicleSourceDiagnostic;
editable: boolean;
onSaved: (next: VehicleSourceDiagnostic) => void;
}) {
const [enabled, setEnabled] = useState(source.enabled);
const [priority, setPriority] = useState(source.priority);
const [remark, setRemark] = useState('');
useEffect(() => {
setEnabled(source.enabled);
setPriority(source.priority);
setRemark('');
}, [source.enabled, source.priority, source.sourceRef]);
const save = useMutation({
mutationFn: () => api.updateVehicleSourcePolicy(vin, {
version: diagnostic.policy.version,
sourceRef: source.sourceRef ?? '',
enabled,
priority,
remark
}),
onSuccess: onSaved
});
const changed = enabled !== source.enabled || priority !== source.priority || remark.trim() !== '';
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 || save.isPending} onChange={(event) => setEnabled(event.target.checked)} /></label>
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
<input aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="调整原因" />
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
</td>
</tr>;
}
function SourceDiagnosticWorkspace() {
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [selected, setSelected] = useState<VehicleCoverageRow>();
const [candidateOffset, setCandidateOffset] = useState(0);
useEffect(() => setCandidateOffset(0), [deferredKeyword]);
const candidates = useQuery({
queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset],
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredKeyword, bindingStatus: 'bound', limit: '20', offset: String(candidateOffset) }), signal),
enabled: deferredKeyword.length > 0,
staleTime: 15_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const session = useQuery({ queryKey: ['ops-session'], queryFn: ({ signal }) => api.session(signal), staleTime: 60_000 });
const diagnostic = useQuery({
queryKey: ['ops-source-diagnostic', selected?.vin],
queryFn: ({ signal }) => api.vehicleSourceDiagnostic(selected!.vin, signal),
enabled: Boolean(selected?.vin),
staleTime: 5_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const choose = (vehicle: VehicleCoverageRow) => {
setSelected(vehicle);
setKeyword(vehicle.plate || vehicle.vin);
};
const submit = (event: FormEvent) => {
event.preventDefault();
const first = candidates.data?.items[0];
if (first) choose(first);
};
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>
<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}
</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}
{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>
</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)} />)}
</tbody></table></div>
<section className="v2-source-audit"><header><strong></strong><span> source_key </span></header>
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p></p>}
</section>
</> : null}
</section>;
}
export default function OperationsPage() {
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>
<header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
<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">

View File

@@ -391,6 +391,10 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; }
.v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; }
.v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; }
.v2-source-diagnostic { position: relative; border: 1px solid #cfdbea; border-radius: 12px; background: #fff; box-shadow: 0 8px 28px rgba(31,53,80,.08); overflow: hidden; }.v2-source-diagnostic > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf3; padding: 9px 14px; background: linear-gradient(100deg,#f8fbff,#f4f8ff); }.v2-source-diagnostic > header div { display: grid; gap: 2px; }.v2-source-diagnostic > header small { color: #547297; font-size: 9px; }.v2-source-diagnostic > header strong { font-size: 15px; }.v2-source-diagnostic > header span { color: var(--v2-muted); font-size: 9px; }.v2-source-diagnostic > header button, .v2-source-search > button, .v2-source-policy-cell button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 12px; color: #355272; cursor: pointer; font-size: 10px; }.v2-source-diagnostic button:disabled { cursor: not-allowed; opacity: .5; }
.v2-source-search { position: relative; display: flex; gap: 8px; padding: 12px 14px; }.v2-source-search > label { display: flex; min-width: 280px; flex: 1; height: 36px; align-items: center; gap: 8px; border: 1px solid #cbd8e8; border-radius: 8px; padding: 0 11px; color: #6c7c91; }.v2-source-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; font-size: 11px; }.v2-source-search > button { height: 36px; border-color: #2f6fe4; background: #2f6fe4; color: #fff; }.v2-source-candidates { position: absolute; z-index: 12; top: 51px; right: 114px; left: 14px; max-height: 310px; overflow: auto; border: 1px solid #d6e0ec; border-radius: 9px; background: #fff; box-shadow: 0 12px 32px rgba(31,53,80,.18); }.v2-source-candidates > button { display: grid; width: 100%; grid-template-columns: 150px minmax(180px,1fr) auto; gap: 10px; border: 0; border-bottom: 1px solid #edf1f6; background: #fff; padding: 10px 12px; text-align: left; cursor: pointer; }.v2-source-candidates > button:hover { background: #f5f9ff; }.v2-source-candidates strong { font-size: 11px; }.v2-source-candidates span, .v2-source-candidates em, .v2-source-candidates p { color: var(--v2-muted); font-size: 9px; font-style: normal; }.v2-source-candidates p { margin: 0; padding: 14px; }.v2-source-candidates footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #e5ebf3; background: #fff; padding: 7px 10px; }.v2-source-candidates footer div { display: flex; gap: 5px; }.v2-source-candidates footer button { border: 1px solid #d2dce8; border-radius: 5px; background: #fff; padding: 4px 8px; color: #52657d; font-size: 8px; }
.v2-source-empty { display: grid; min-height: 110px; place-content: center; gap: 6px; color: var(--v2-muted); text-align: center; }.v2-source-empty strong { color: #42556e; font-size: 13px; }.v2-source-empty span { font-size: 10px; }.v2-source-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-block: 1px solid #e7edf4; background: #fafcff; }.v2-source-summary article { min-width: 0; padding: 11px 14px; }.v2-source-summary article + article { border-left: 1px solid #e7edf4; }.v2-source-summary small, .v2-source-summary span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-summary strong { display: block; margin: 5px 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-recommendation { margin: 12px 14px 8px; border-left: 3px solid #2f6fe4; border-radius: 6px; background: #f3f7ff; padding: 9px 11px; }.v2-source-recommendation strong { font-size: 10px; }.v2-source-recommendation p { margin: 4px 0; color: #405774; font-size: 9px; line-height: 1.5; }.v2-source-recommendation span, .v2-source-readonly { color: #728299; font-size: 8px; }.v2-source-readonly { margin: 0 14px 8px; border-radius: 6px; background: #fff8e8; padding: 7px 9px; color: #966500; }
.v2-source-table-wrap { margin: 0 14px 12px; overflow: auto; border: 1px solid #dfe7f0; border-radius: 8px; }.v2-source-table { width: 100%; min-width: 1320px; border-collapse: collapse; table-layout: fixed; }.v2-source-table th { height: 34px; background: #f6f8fb; color: #63748a; font-size: 8px; text-align: left; }.v2-source-table th, .v2-source-table td { border-bottom: 1px solid #e8edf3; padding: 7px 9px; vertical-align: top; }.v2-source-table tbody tr:last-child td { border-bottom: 0; }.v2-source-table tbody tr.is-recommended { background: #f4fbf8; }.v2-source-table td { color: #3e5067; font-size: 9px; line-height: 1.45; }.v2-source-table td > strong, .v2-source-table td > span { display: block; }.v2-source-table td > span { margin-top: 3px; color: var(--v2-muted); }.v2-source-table td > i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: #9aa7b8; }.v2-source-table td > i.is-online { background: var(--v2-green); }.v2-source-table td > i.is-offline { background: #94a3b8; }.v2-source-reason span { max-width: 230px; white-space: normal !important; }.v2-source-policy-cell { display: grid; grid-template-columns: auto 64px; gap: 5px; }.v2-source-policy-cell label { display: flex; align-items: center; gap: 4px; }.v2-source-policy-cell input[type=number], .v2-source-policy-cell input[type=text], .v2-source-policy-cell > input:not([type]) { min-width: 0; height: 26px; border: 1px solid #ccd7e5; border-radius: 5px; padding: 0 6px; font-size: 9px; }.v2-source-policy-cell > input:last-of-type { grid-column: 1 / -1; }.v2-source-policy-cell button { grid-column: 1 / -1; height: 27px; }.v2-source-policy-cell em { grid-column: 1 / -1; color: var(--v2-red); font-size: 8px; font-style: normal; }.v2-source-audit { margin: 0 14px 14px; border: 1px solid #e0e7f0; border-radius: 8px; }.v2-source-audit > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7edf4; padding: 0 10px; }.v2-source-audit > header strong { font-size: 10px; }.v2-source-audit > header span, .v2-source-audit > p { color: var(--v2-muted); font-size: 8px; }.v2-source-audit > p { margin: 0; padding: 12px; }.v2-source-audit ol { max-height: 170px; margin: 0; overflow: auto; padding: 0; list-style: none; }.v2-source-audit li { display: grid; min-height: 34px; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f6; padding: 5px 10px; font-size: 9px; }.v2-source-audit li em { color: var(--v2-muted); font-size: 8px; font-style: normal; }
.v2-vehicle-search-page, .v2-not-found { display: grid; min-height: 100%; place-items: center; padding: 28px; }
.v2-vehicle-search-card { width: min(660px, 100%); border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 54px; text-align: center; box-shadow: var(--v2-shadow); }
@@ -1121,6 +1125,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rules { display: flex; flex-direction: column; }
.v2-alert-rule-list { max-height: 300px; flex: none; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
.v2-source-summary { grid-template-columns: repeat(2,1fr); }.v2-source-summary article:nth-child(3) { border-left: 0; }.v2-source-summary article:nth-child(n+3) { border-top: 1px solid #e7edf4; }
}
.v2-mileage-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 18px 20px 20px; }
@@ -1435,6 +1440,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
.v2-source-diagnostic > header { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-source-diagnostic > header button { width: 100%; }.v2-source-search { flex-direction: column; }.v2-source-search > label { width: auto; min-width: 0; height: 42px; }.v2-source-search > button { width: 100%; }.v2-source-candidates { top: 61px; right: 14px; }.v2-source-candidates > button { grid-template-columns: 1fr; gap: 3px; }.v2-source-summary { grid-template-columns: 1fr; }.v2-source-summary article + article, .v2-source-summary article:nth-child(3) { border-top: 1px solid #e7edf4; border-left: 0; }.v2-source-audit > header { height: auto; align-items: flex-start; flex-direction: column; gap: 3px; padding-block: 7px; }.v2-source-audit li { grid-template-columns: 35px minmax(0,1fr); }.v2-source-audit li em { grid-column: 2; }
.v2-mileage-page { gap: 10px; padding: 12px 8px 18px; }
.v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; }
.v2-mileage-heading h2 { font-size: 18px; }