ai-coding
2026-06-29 662d425dc6d79bda88ed3ef9795fea83f648d8f9
refactor(commandCenter): 车辆信息显示

Co-Authored-By: ai-coding <ai-coding@devgenie.local>
10个文件已修改
279 ■■■■■ 已修改文件
src/composables/useCommandCenterSocket.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/commandCenter.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/command-center.constants.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/command-center.types.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/Header.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/OperatingDataSection.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/RouteSection.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/index.vue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/map/VehicleLayer.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/composables/useCommandCenterSocket.ts
@@ -2,7 +2,7 @@
import { useCommandCenterStore } from '@/store/commandCenter'
import { createCommandCenterSocket, destroyCommandCenterSocket } from '@/utils/socket'
import { ONLINE_TIMEOUT_MS, SOCKET_EMIT_EVENT, SOCKET_ON_EVENT } from '@/views/businessOperations/commandCenter/command-center.constants'
import type { CommandCenterKpiVo, PassengerCallVo, VehicleMonitorVo } from '@/views/businessOperations/commandCenter/command-center.types'
import type { CommandCenterKpiVo, PassengerCallVo, VehicleExtraVo, VehicleMonitorVo } from '@/views/businessOperations/commandCenter/command-center.types'
/**
 * 指挥中心 WebSocket 连接管理 composable
@@ -53,6 +53,12 @@
      store.socketStatus.lastPushAt = Date.now()
    })
    socket.on(SOCKET_ON_EVENT.VEHICLE_EXTRA, (list: VehicleExtraVo[]) => {
      console.log('[CC-Socket] ⬇ vehicle_extra :', list)
      store.vehicleExtras = list
      store.socketStatus.lastPushAt = Date.now()
    })
    socket.on(SOCKET_ON_EVENT.KPI, (data: CommandCenterKpiVo) => {
      console.log('[CC-Socket] ⬇ kpi', data)
      store.kpi = data
src/store/commandCenter.ts
@@ -7,6 +7,7 @@
  SocketStatus,
  StationVo,
  VehicleDetailVo,
  VehicleExtraVo,
  VehicleMonitorVo,
  XpOpenCity,
  XpPointVo,
@@ -26,6 +27,7 @@
  districtCode: string
  regions: XpOpenCity[]
  vehicles: VehicleMonitorVo[]
  vehicleExtras: VehicleExtraVo[]
  kpi: CommandCenterKpiVo | null
  passengerCalls: PassengerCallVo[]
  fences: FenceVo[]
@@ -48,6 +50,7 @@
    districtCode: DEFAULT_DISTRICT_CODE,
    regions: [],
    vehicles: [],
    vehicleExtras: [],
    kpi: null,
    passengerCalls: [],
    fences: [],
src/views/businessOperations/commandCenter/command-center.constants.ts
@@ -42,6 +42,7 @@
  AUTH_SUCCESS: 'auth_success',
  AUTH_FAILED: 'auth_failed',
  VEHICLES: 'vehicles',
  VEHICLE_EXTRA: 'vehicle_extra',
  KPI: 'kpi',
  PASSENGER_CALLS: 'passenger_calls',
} as const
src/views/businessOperations/commandCenter/command-center.types.ts
@@ -27,6 +27,12 @@
  positionUpdateTime: number
}
export interface VehicleExtraVo {
  vehicleId: number
  todayCompletedOrders: number
  routeInfo: CommandCenterRouteInfoVo | null
}
export interface CommandCenterKpiVo {
  cruisingCount: number
  hasOrderCount: number
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/Header.vue
@@ -4,20 +4,20 @@
      class="vehicle-detail-header__dot"
      :style="{ background: colorConfig.color }"
    />
    <span class="vehicle-detail-header__plate">{{ detail.plateNumber || detail.vin }}</span>
    <span class="vehicle-detail-header__plate">{{ vehicle.plateNumber || vehicle.vin }}</span>
    <NTag size="small" :color="{ color: colorConfig.color + '20', textColor: colorConfig.color, borderColor: colorConfig.color }">
      {{ detail.monitorStatus }}
      {{ vehicle.monitorStatus }}
    </NTag>
  </div>
</template>
<script setup lang="ts">
import type { VehicleDetailVo } from '../../command-center.types'
import type { VehicleMonitorVo } from '../../command-center.types'
import { MONITOR_COLOR_MAP } from '../../command-center.constants'
const props = defineProps<{ detail: VehicleDetailVo }>()
const props = defineProps<{ vehicle: VehicleMonitorVo }>()
const colorConfig = computed(() => MONITOR_COLOR_MAP[props.detail.monitorGroup] || MONITOR_COLOR_MAP.ABNORMAL)
const colorConfig = computed(() => MONITOR_COLOR_MAP[props.vehicle.monitorGroup] || MONITOR_COLOR_MAP.ABNORMAL)
</script>
<style scoped lang="scss">
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/OperatingDataSection.vue
@@ -1,24 +1,3 @@
<template>
  <div class="operating-data">
    <div class="operating-data__title">运营数据</div>
    <div class="operating-data__item">
      <span class="operating-data__label">电量</span>
      <NProgress
        type="line"
        :percentage="electricity"
        :color="electricityColor"
        :rail-color="'rgba(0,0,0,0.06)'"
        style="flex: 1;"
      />
      <span class="operating-data__value">{{ electricity }}%</span>
    </div>
    <div class="operating-data__item">
      <span class="operating-data__label">今日完单</span>
      <span class="operating-data__value">{{ todayOrders }}</span>
    </div>
  </div>
</template>
<script setup lang="ts">
const props = defineProps<{
  electricity: number
@@ -34,6 +13,34 @@
})
</script>
<template>
  <div class="operating-data">
    <div class="operating-data__title">
      运营数据
    </div>
    <div class="operating-data__cards">
      <div class="operating-data__card">
        <span class="operating-data__label">电量</span>
        <div class="operating-data__electricity">
          <span class="operating-data__value" :style="{ color: electricityColor }">{{ electricity }}%</span>
          <NProgress
            type="line"
            :percentage="electricity"
            :color="electricityColor"
            rail-color="rgba(0,0,0,0.06)"
            :show-indicator="false"
            style="flex: 1;"
          />
        </div>
      </div>
      <div class="operating-data__card">
        <span class="operating-data__label">今日完单</span>
        <span class="operating-data__value">{{ todayOrders }}</span>
      </div>
    </div>
  </div>
</template>
<style scoped lang="scss">
.operating-data {
  padding: 12px 16px;
@@ -45,25 +52,36 @@
    margin-bottom: 12px;
  }
  &__item {
  &__cards {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 10px;
  }
  &__card {
    flex: 1;
    background: #f5f8fc;
    border-radius: 8px;
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  &__label {
    font-size: 12px;
    color: rgba(0, 0, 0, 0.65);
    min-width: 60px;
    color: rgba(0, 0, 0, 0.45);
  }
  &__electricity {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  &__value {
    font-size: 14px;
    font-size: 20px;
    font-weight: 600;
    color: rgba(0, 0, 0, 0.85);
    font-weight: 500;
    min-width: 40px;
    text-align: right;
  }
}
</style>
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/RouteSection.vue
@@ -2,39 +2,34 @@
  <div class="route-section">
    <div class="route-section__title">路线信息</div>
    <div class="route-section__content">
      <template v-if="detail.routeInfo">
        <NTimeline>
          <NTimelineItem color="#52c41a">
            <template #header>
              <span class="route-section__label">上车点</span>
            </template>
            <span class="route-section__address">📍 {{ detail.routeInfo.startAddress || '暂无位置' }}</span>
          </NTimelineItem>
          <NTimelineItem color="#1890ff">
            <template #header>
              <span class="route-section__label">车辆位置</span>
            </template>
            <span class="route-section__address">暂无位置</span>
          </NTimelineItem>
          <NTimelineItem color="#ff4d4f">
            <template #header>
              <span class="route-section__label">目的地</span>
            </template>
            <span class="route-section__address">🏁 {{ detail.routeInfo.endAddress || '暂无位置' }}</span>
          </NTimelineItem>
        </NTimeline>
      </template>
      <template v-else>
        <p>{{ detail.monitorStatus }}</p>
      </template>
      <NTimeline>
        <NTimelineItem color="#52c41a">
          <template #header>
            <span class="route-section__label">上车点</span>
          </template>
          <span class="route-section__address">📍 {{ routeInfo.startAddress || '暂无位置' }}</span>
        </NTimelineItem>
        <NTimelineItem color="#ff4d4f">
          <template #header>
            <span class="route-section__label">目的地</span>
          </template>
          <span class="route-section__address">🏁 {{ routeInfo.endAddress || '暂无位置' }}</span>
        </NTimelineItem>
      </NTimeline>
      <div v-if="routeInfo.eta" class="route-section__eta">
        预计到达:{{ Math.ceil(routeInfo.eta / 60) }} 分钟
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import type { VehicleDetailVo } from '../../command-center.types'
import type { CommandCenterRouteInfoVo } from '../../command-center.types'
defineProps<{ detail: VehicleDetailVo }>()
defineProps<{
  routeInfo: CommandCenterRouteInfoVo
  monitorStatus: string
}>()
</script>
<style scoped lang="scss">
src/views/businessOperations/commandCenter/components/VehicleDetailDrawer/index.vue
@@ -1,3 +1,28 @@
<script setup lang="ts">
import { useCommandCenterStore } from '@/store/commandCenter'
import Header from './Header.vue'
import OperatingDataSection from './OperatingDataSection.vue'
const store = useCommandCenterStore()
const vehicle = computed(() => {
  if (!store.selectedVehicleId)
    return null
  return store.vehicles.find(v => v.vehicleId === store.selectedVehicleId) ?? null
})
const extra = computed(() => {
  if (!store.selectedVehicleId)
    return null
  return store.vehicleExtras.find(e => e.vehicleId === store.selectedVehicleId) ?? null
})
function handleClose(show: boolean) {
  if (!show)
    store.closeVehicleDetail()
}
</script>
<template>
  <NDrawer
    :show="!!store.selectedVehicleId"
@@ -11,53 +36,13 @@
      :body-content-style="{ padding: 0, background: '#ffffff' }"
      :header-style="{ display: 'none' }"
    >
      <template v-if="store.vehicleDetail">
        <Header :detail="store.vehicleDetail" />
        <RouteSection :detail="store.vehicleDetail" />
      <template v-if="vehicle">
        <Header :vehicle="vehicle" />
        <OperatingDataSection
          :electricity="currentElectricity"
          :today-orders="currentTodayOrders"
          :electricity="vehicle.currentElectricity"
          :today-orders="extra?.todayCompletedOrders ?? 0"
        />
      </template>
      <div v-else class="drawer-loading">
        加载中...
      </div>
    </NDrawerContent>
  </NDrawer>
</template>
<script setup lang="ts">
import { useCommandCenterStore } from '@/store/commandCenter'
import Header from './Header.vue'
import RouteSection from './RouteSection.vue'
import OperatingDataSection from './OperatingDataSection.vue'
const store = useCommandCenterStore()
const currentElectricity = computed(() => {
  if (store.vehicleDetail)
    return store.vehicleDetail.currentElectricity
  const v = store.vehicles.find(v => v.vehicleId === store.selectedVehicleId)
  return v?.currentElectricity ?? 0
})
const currentTodayOrders = computed(() => {
  return store.vehicleDetail?.todayCompletedOrders ?? 0
})
function handleClose(show: boolean) {
  if (!show)
    store.closeVehicleDetail()
}
</script>
<style scoped lang="scss">
.drawer-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 200px;
  color: rgba(0, 0, 0, 0.45);
  font-size: 14px;
}
</style>
src/views/businessOperations/commandCenter/index.vue
@@ -12,6 +12,7 @@
import LayerControl from './components/LayerControl.vue'
import MapLegend from './components/MapLegend.vue'
import BackToOverview from './components/BackToOverview.vue'
import VehicleDetailDrawer from './components/VehicleDetailDrawer/index.vue'
defineOptions({ name: 'CommandCenter' })
@@ -101,6 +102,15 @@
  passengerCallLayer = new PassengerCallLayer(map)
  pointLayer = new PointLayer(map)
  vehicleLayer.onClick((vehicleId) => {
    store.openVehicleDetail(vehicleId)
    vehicleLayer?.setHighlight(vehicleId)
    const vehicle = store.vehicles.find(v => v.vehicleId === vehicleId)
    if (vehicle) {
      mapCanvasRef.value?.mapInstance?.setZoomAndCenter(12, [vehicle.lon, vehicle.lat])
    }
  })
  fenceLayer.update(store.fences)
  pointLayer.update(store.points)
}
@@ -111,6 +121,13 @@
watch(() => store.vehicles, (list) => {
  vehicleLayer?.update(list)
})
watch(() => store.selectedVehicleId, (id) => {
  vehicleLayer?.setHighlight(id)
  if (!id) {
    mapCanvasRef.value?.fitToCity(store.cityCode)
  }
})
watch(() => store.passengerCalls, (list) => {
@@ -257,6 +274,7 @@
      <MapControls @reset="handleMapReset" @zoom-in="handleZoomIn" @zoom-out="handleZoomOut" />
      <BackToOverview />
    </div>
    <VehicleDetailDrawer />
  </div>
</template>
src/views/businessOperations/commandCenter/map/VehicleLayer.ts
@@ -11,6 +11,7 @@
  private visible = true
  private detailWindow: any = null
  private closeHandler: (() => void) | null = null
  private onClickCallback: ((vehicleId: number) => void) | null = null
  /**
   * @param map - 高德地图实例
@@ -65,13 +66,9 @@
        })
        marker._vehicleData = vehicle
        marker.on('mouseover', (e: any) => {
        marker.on('click', (e: any) => {
          const data = e.target._vehicleData as VehicleMonitorVo
          this.showVehicleDetail(data, e.target.getPosition())
        })
        marker.on('mouseout', () => {
          this.detailWindow?.close()
          this.onClickCallback?.(data.vehicleId)
        })
        marker.setMap(this.visible ? this.map : null)
@@ -114,17 +111,17 @@
   */
  setHighlight(selectedId: number | null) {
    this.markers.forEach((marker, id) => {
      const dom = marker.getContentDom?.()
      if (!dom)
        return
      if (selectedId === null) {
        marker.setExtData({ ...marker.getExtData(), opacity: 1 })
        const dom = marker.getContentDom?.()
        if (dom)
          dom.style.opacity = '1'
        dom.style.opacity = '1'
        dom.style.transform = 'scale(1)'
      }
      else {
        const opacity = id === selectedId ? 1 : 0.3
        const dom = marker.getContentDom?.()
        if (dom)
          dom.style.opacity = String(opacity)
        const isSelected = id === selectedId
        dom.style.opacity = isSelected ? '1' : '0.3'
        dom.style.transform = isSelected ? 'scale(1.5)' : 'scale(1)'
      }
    })
  }
@@ -164,6 +161,13 @@
    this.markers.clear()
  }
  /**
   * 设置车辆点击回调
   */
  onClick(cb: (vehicleId: number) => void) {
    this.onClickCallback = cb
  }
  /** 销毁图层,释放资源 */
  destroy() {
    this.clear()
@@ -171,6 +175,7 @@
      document.removeEventListener('closeVehicleInfoWindow', this.closeHandler)
      this.closeHandler = null
    }
    this.onClickCallback = null
    this.detailWindow = null
  }
}