refactor(commandCenter): 车辆信息显示
Co-Authored-By: ai-coding <ai-coding@devgenie.local>
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | SocketStatus, |
| | | StationVo, |
| | | VehicleDetailVo, |
| | | VehicleExtraVo, |
| | | VehicleMonitorVo, |
| | | XpOpenCity, |
| | | XpPointVo, |
| | |
| | | districtCode: string |
| | | regions: XpOpenCity[] |
| | | vehicles: VehicleMonitorVo[] |
| | | vehicleExtras: VehicleExtraVo[] |
| | | kpi: CommandCenterKpiVo | null |
| | | passengerCalls: PassengerCallVo[] |
| | | fences: FenceVo[] |
| | |
| | | districtCode: DEFAULT_DISTRICT_CODE, |
| | | regions: [], |
| | | vehicles: [], |
| | | vehicleExtras: [], |
| | | kpi: null, |
| | | passengerCalls: [], |
| | | fences: [], |
| | |
| | | AUTH_SUCCESS: 'auth_success', |
| | | AUTH_FAILED: 'auth_failed', |
| | | VEHICLES: 'vehicles', |
| | | VEHICLE_EXTRA: 'vehicle_extra', |
| | | KPI: 'kpi', |
| | | PASSENGER_CALLS: 'passenger_calls', |
| | | } as const |
| | |
| | | positionUpdateTime: number |
| | | } |
| | | |
| | | export interface VehicleExtraVo { |
| | | vehicleId: number |
| | | todayCompletedOrders: number |
| | | routeInfo: CommandCenterRouteInfoVo | null |
| | | } |
| | | |
| | | export interface CommandCenterKpiVo { |
| | | cruisingCount: number |
| | | hasOrderCount: number |
| | |
| | | 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"> |
| | |
| | | <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 |
| | |
| | | }) |
| | | </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; |
| | |
| | | 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> |
| | |
| | | <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"> |
| | |
| | | <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" |
| | |
| | | :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> |
| | |
| | | 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' }) |
| | | |
| | |
| | | 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) |
| | | } |
| | |
| | | |
| | | 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) => { |
| | |
| | | <MapControls @reset="handleMapReset" @zoom-in="handleZoomIn" @zoom-out="handleZoomOut" /> |
| | | <BackToOverview /> |
| | | </div> |
| | | <VehicleDetailDrawer /> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | private visible = true |
| | | private detailWindow: any = null |
| | | private closeHandler: (() => void) | null = null |
| | | private onClickCallback: ((vehicleId: number) => void) | null = null |
| | | |
| | | /** |
| | | * @param map - 高德地图实例 |
| | |
| | | }) |
| | | 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) |
| | |
| | | */ |
| | | 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)' |
| | | } |
| | | }) |
| | | } |
| | |
| | | this.markers.clear() |
| | | } |
| | | |
| | | /** |
| | | * 设置车辆点击回调 |
| | | */ |
| | | onClick(cb: (vehicleId: number) => void) { |
| | | this.onClickCallback = cb |
| | | } |
| | | |
| | | /** 销毁图层,释放资源 */ |
| | | destroy() { |
| | | this.clear() |
| | |
| | | document.removeEventListener('closeVehicleInfoWindow', this.closeHandler) |
| | | this.closeHandler = null |
| | | } |
| | | this.onClickCallback = null |
| | | this.detailWindow = null |
| | | } |
| | | } |