ai-coding
2026-06-27 5e2464b4482074022c2b06a0d13f21d54a04d5f6
feat(command):运营点位

Co-Authored-By: ai-coding <ai-coding@devgenie.local>
1个文件已添加
7个文件已修改
254 ■■■■■ 已修改文件
src/service/api/commandCenter.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/commandCenter.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/command-center.constants.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/command-center.types.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/components/LayerControl.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/components/MapCanvas.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/index.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/commandCenter/map/PointLayer.ts 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/commandCenter.ts
@@ -1,5 +1,5 @@
import { request } from '@/service/http'
import type { FenceVo, StationVo, VehicleDetailVo, XpOpenCity } from '@/views/businessOperations/commandCenter/command-center.types'
import type { FenceVo, StationVo, VehicleDetailVo, XpOpenCity, XpPointVo } from '@/views/businessOperations/commandCenter/command-center.types'
import { API_PREFIX } from '@/views/businessOperations/commandCenter/command-center.constants'
/**
@@ -26,6 +26,14 @@
}
/**
 * 获取指定城市的运营点位列表
 * @param cityCode - 城市编码
 */
export const fetchCommandCenterPoints = (cityCode: string) => {
  return request.Get<Service.ResponseResult<XpPointVo[]>>(`${API_PREFIX}/points`, { params: { cityCode } })
}
/**
 * 获取车辆详情
 * @param vehicleId - 车辆 ID
 */
src/store/commandCenter.ts
@@ -9,8 +9,9 @@
  VehicleDetailVo,
  VehicleMonitorVo,
  XpOpenCity,
  XpPointVo,
} from '@/views/businessOperations/commandCenter/command-center.types'
import { fetchCommandCenterFences, fetchCommandCenterRegions, fetchCommandCenterStations, fetchVehicleDetail } from '@/service/api/commandCenter'
import { fetchCommandCenterFences, fetchCommandCenterPoints, fetchCommandCenterRegions, fetchCommandCenterStations, fetchVehicleDetail } from '@/service/api/commandCenter'
/** 站点缓存条目 */
interface StationsCacheEntry {
@@ -30,6 +31,7 @@
  fences: FenceVo[]
  stations: StationVo[]
  stationsCache: Record<string, StationsCacheEntry>
  points: XpPointVo[]
  selectedVehicleId: number | null
  vehicleDetail: VehicleDetailVo | null
  layerVisibility: LayerVisibility
@@ -51,12 +53,14 @@
    fences: [],
    stations: [],
    stationsCache: {},
    points: [],
    selectedVehicleId: null,
    vehicleDetail: null,
    layerVisibility: {
      vehicle: true,
      passengerCall: true,
      fence: true,
      point: true,
    },
    socketStatus: {
      connected: false,
@@ -107,6 +111,21 @@
    },
    /**
     * 加载运营点位列表
     * @param cityCode - 城市编码
     */
    async fetchPoints(cityCode: string) {
      try {
        const { data } = await fetchCommandCenterPoints(cityCode)
        if (data)
          this.points = data
      }
      catch (e) {
        console.error('[CommandCenter] fetchPoints failed:', e)
      }
    },
    /**
     * 加载站点列表(带本地缓存)
     * @param cityCode - 城市编码
     */
src/views/businessOperations/commandCenter/command-center.constants.ts
@@ -71,6 +71,7 @@
export const LAYER_Z_INDEX = {
  FENCE: 10,
  STATION: 20,
  POINT: 30,
  PASSENGER_CALL: 40,
  VEHICLE: 50,
  ROUTE: 60,
src/views/businessOperations/commandCenter/command-center.types.ts
@@ -121,10 +121,49 @@
  currentLat: number
}
export interface XpPointVo {
  id: string
  electronicAreaId: number | null
  fpElectronicAreaId: number | null
  pointName: string
  pointPoi: string
  pointNumber: string
  operationStatus: number
  pointType: number
  verificationStatus: number
  parkingTimeLimit: number | null
  operationType: number
  effectivePeriod: number
  effectiveTimePeriod: string | null
  pointAddress: string
  remarks: string | null
  pointPictures: string | null
  pointLon: number
  pointLat: number
  chargePoint: number
  parkingPoint: number
  provinceName: string
  provinceCode: string
  cityName: string
  cityCode: string
  districtName: string
  districtCode: string
  stationId: number | null
  stationSortNo: number | null
  pointOnOff: number
  electronicName: string | null
  stationName: string | null
  createBy: string | null
  updateBy: string | null
  createTime: string | null
  updateTime: string | null
}
export interface LayerVisibility {
  vehicle: boolean
  passengerCall: boolean
  fence: boolean
  point: boolean
}
export type SocketStatus = {
src/views/businessOperations/commandCenter/components/LayerControl.vue
@@ -3,10 +3,6 @@
const store = useCommandCenterStore()
const localLayers = reactive({
  point: true,
})
const layerItems = computed(() => [
  {
    key: 'vehicle',
@@ -25,8 +21,8 @@
  {
    key: 'point',
    label: '运营点位',
    active: localLayers.point,
    toggle: () => { localLayers.point = !localLayers.point },
    active: store.layerVisibility.point,
    toggle: () => store.toggleLayer('point', !store.layerVisibility.point),
    iconHtml: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>',
  },
  {
src/views/businessOperations/commandCenter/components/MapCanvas.vue
@@ -68,9 +68,27 @@
  }
})
function fitToDistrict(districtName: string) {
  if (!mapInstance.value)
    return
  const AMap = (window as any).AMap
  if (!AMap)
    return
  AMap.plugin('AMap.DistrictSearch', () => {
    const districtSearch = new AMap.DistrictSearch({ level: 'district', subdistrict: 0 })
    districtSearch.search(districtName, (status: string, result: any) => {
      if (status === 'complete' && result?.districtList?.length) {
        const center = result.districtList[0].center
        mapInstance.value?.setZoomAndCenter(13, [center.lng, center.lat])
      }
    })
  })
}
defineExpose({
  mapInstance,
  fitToCity,
  fitToDistrict,
})
</script>
src/views/businessOperations/commandCenter/index.vue
@@ -4,6 +4,7 @@
import { FenceLayer } from './map/FenceLayer'
import { VehicleLayer } from './map/VehicleLayer'
import { PassengerCallLayer } from './map/PassengerCallLayer'
import { PointLayer } from './map/PointLayer'
import StatusHeader from './components/StatusHeader/index.vue'
import MapCanvas from './components/MapCanvas.vue'
import LayerControl from './components/LayerControl.vue'
@@ -19,6 +20,7 @@
let fenceLayer: FenceLayer | null = null
let vehicleLayer: VehicleLayer | null = null
let passengerCallLayer: PassengerCallLayer | null = null
let pointLayer: PointLayer | null = null
const provinceCode = computed({
  get: () => store.provinceCode,
@@ -52,7 +54,7 @@
const provinceOptions = computed(() => {
  const map = new Map<string, string>()
  store.regions.forEach((r) => {
    if (!map.has(r.provinceCode))
    if (r.provinceCode && r.provinceName && !map.has(r.provinceCode))
      map.set(r.provinceCode, r.provinceName)
  })
  return Array.from(map, ([value, label]) => ({ label, value }))
@@ -63,7 +65,7 @@
  store.regions
    .filter(r => r.provinceCode === store.provinceCode)
    .forEach((r) => {
      if (!map.has(r.cityCode))
      if (r.cityCode && r.cityName && !map.has(r.cityCode))
        map.set(r.cityCode, r.cityName)
    })
  return Array.from(map, ([value, label]) => ({ label, value }))
@@ -71,7 +73,7 @@
const districtOptions = computed(() => {
  return store.regions
    .filter(r => r.cityCode === store.cityCode)
    .filter(r => r.cityCode === store.cityCode && r.districtCode && r.districtName)
    .map(r => ({ label: r.districtName, value: r.districtCode }))
})
@@ -79,6 +81,7 @@
  await Promise.all([
    store.fetchRegions(),
    store.fetchFences(store.cityCode),
    store.fetchPoints(store.cityCode),
  ])
  initLayers()
}
@@ -91,8 +94,10 @@
  fenceLayer = new FenceLayer(map)
  vehicleLayer = new VehicleLayer(map)
  passengerCallLayer = new PassengerCallLayer(map)
  pointLayer = new PointLayer(map)
  fenceLayer.update(store.fences)
  pointLayer.update(store.points)
}
watch(() => store.fences, (list) => {
@@ -107,6 +112,10 @@
  passengerCallLayer?.update(list)
})
watch(() => store.points, (list) => {
  pointLayer?.update(list)
})
watch(() => store.layerVisibility.vehicle, (v) => {
  vehicleLayer?.setVisible(v)
})
@@ -119,16 +128,31 @@
  fenceLayer?.setVisible(v)
})
watch(() => store.layerVisibility.point, (v) => {
  pointLayer?.setVisible(v)
})
watch(cityCode, async (curr, prev) => {
  if (prev)
    unsubscribeCity(prev)
  vehicleLayer?.clear()
  passengerCallLayer?.clear()
  pointLayer?.clear()
  await Promise.all([
    store.fetchFences(curr),
    store.fetchPoints(curr),
  ])
  mapCanvasRef.value?.fitToCity(curr)
  subscribeCity(curr)
})
watch(districtCode, (curr) => {
  if (!curr)
    return
  const region = store.regions.find(r => r.districtCode === curr)
  if (region?.districtName) {
    mapCanvasRef.value?.fitToDistrict(region.districtName)
  }
})
onMounted(async () => {
@@ -141,6 +165,7 @@
  vehicleLayer?.destroy()
  passengerCallLayer?.destroy()
  fenceLayer?.destroy()
  pointLayer?.destroy()
  disconnect()
})
</script>
src/views/businessOperations/commandCenter/map/PointLayer.ts
New file
@@ -0,0 +1,126 @@
import type { XpPointVo } from '../command-center.types'
import { LAYER_Z_INDEX } from '../command-center.constants'
/**
 * 运营点位图层 — 管理地图上运营点位标注
 */
export class PointLayer {
  private map: any
  private markers: any[] = []
  private visible = true
  private currentInfoWindow: any = null
  /**
   * @param map - 高德地图实例
   */
  constructor(map: any) {
    this.map = map
  }
  /**
   * 全量更新点位列表(先清除再重绘)
   * @param points - 点位数据列表
   */
  update(points: XpPointVo[]) {
    this.clear()
    const AMap = (window as any).AMap
    if (!AMap)
      return
    points.forEach((point) => {
      if (!point.pointLon || !point.pointLat)
        return
      const marker = new AMap.Marker({
        position: [point.pointLon, point.pointLat],
        content: createPointMarkerContent(point),
        offset: new AMap.Pixel(-10, -10),
        zIndex: LAYER_Z_INDEX.POINT,
      })
      marker.on('click', (e: any) => {
        if (this.currentInfoWindow) {
          this.currentInfoWindow.close()
        }
        const infoWindow = new AMap.InfoWindow({
          content: createPointInfoContent(point),
          offset: new AMap.Pixel(0, -20),
        })
        infoWindow.open(this.map, e.target.getPosition())
        this.currentInfoWindow = infoWindow
      })
      marker.setMap(this.visible ? this.map : null)
      this.markers.push(marker)
    })
  }
  /**
   * 设置图层可见性
   * @param v - 是否可见
   */
  setVisible(v: boolean) {
    this.visible = v
    this.markers.forEach((m) => {
      m.setMap(v ? this.map : null)
    })
  }
  /** 清除所有点位标注 */
  clear() {
    if (this.currentInfoWindow) {
      this.currentInfoWindow.close()
      this.currentInfoWindow = null
    }
    this.markers.forEach((m) => {
      m.setMap(null)
    })
    this.markers = []
  }
  /** 销毁图层 */
  destroy() {
    this.clear()
  }
}
/** 点位上下车类型标签 */
function getPointOnOffLabel(pointOnOff: number): string {
  switch (pointOnOff) {
    case 1: return '上车点'
    case 2: return '下车点'
    case 3: return '上车点'
    default: return ''
  }
}
/** 根据点位上下车类型获取标记颜色 */
function getPointMarkerColor(pointOnOff: number): string {
  switch (pointOnOff) {
    case 1: return '#0891b2' // 上车点
    case 2: return '#FF4D4F' // 下车点 - 红色
    case 3: return '#0891b2' // 上车点
    default: return '#8C8C8C'
  }
}
/** 生成点位 Marker DOM */
function createPointMarkerContent(point: XpPointVo): string {
  const color = getPointMarkerColor(point.pointOnOff)
  return `<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;">
    <svg viewBox="0 0 24 24" width="20" height="20" fill="${color}">
      <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
    </svg>
  </div>`
}
/** 生成点位信息窗口内容 */
function createPointInfoContent(point: XpPointVo): string {
  const typeLabel = getPointOnOffLabel(point.pointOnOff)
  return `<div style="padding:10px 14px;font-size:13px;line-height:1.8;min-width:200px;">
    <div style="font-weight:600;font-size:14px;margin-bottom:6px;border-bottom:1px solid #f0f0f0;padding-bottom:6px;">${typeLabel}</div>
    <div style="display:flex;justify-content:space-between;gap:24px;"><span style="color:#64748b;white-space:nowrap;">点位ID</span><span style="color:#1e293b;font-weight:500;">${point.pointNumber || ''}</span></div>
    <div style="display:flex;justify-content:space-between;gap:24px;"><span style="color:#64748b;white-space:nowrap;">名称</span><span style="color:#1e293b;font-weight:500;">${point.pointName || ''}</span></div>
  </div>`
}