feat(command):运营点位
Co-Authored-By: ai-coding <ai-coding@devgenie.local>
| | |
| | | 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' |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取指定城市的运营点位列表 |
| | | * @param cityCode - 城市编码 |
| | | */ |
| | | export const fetchCommandCenterPoints = (cityCode: string) => { |
| | | return request.Get<Service.ResponseResult<XpPointVo[]>>(`${API_PREFIX}/points`, { params: { cityCode } }) |
| | | } |
| | | |
| | | /** |
| | | * 获取车辆详情 |
| | | * @param vehicleId - 车辆 ID |
| | | */ |
| | |
| | | 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 { |
| | |
| | | fences: FenceVo[] |
| | | stations: StationVo[] |
| | | stationsCache: Record<string, StationsCacheEntry> |
| | | points: XpPointVo[] |
| | | selectedVehicleId: number | null |
| | | vehicleDetail: VehicleDetailVo | null |
| | | layerVisibility: LayerVisibility |
| | |
| | | fences: [], |
| | | stations: [], |
| | | stationsCache: {}, |
| | | points: [], |
| | | selectedVehicleId: null, |
| | | vehicleDetail: null, |
| | | layerVisibility: { |
| | | vehicle: true, |
| | | passengerCall: true, |
| | | fence: true, |
| | | point: true, |
| | | }, |
| | | socketStatus: { |
| | | connected: false, |
| | |
| | | }, |
| | | |
| | | /** |
| | | * 加载运营点位列表 |
| | | * @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 - 城市编码 |
| | | */ |
| | |
| | | export const LAYER_Z_INDEX = { |
| | | FENCE: 10, |
| | | STATION: 20, |
| | | POINT: 30, |
| | | PASSENGER_CALL: 40, |
| | | VEHICLE: 50, |
| | | ROUTE: 60, |
| | |
| | | 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 = { |
| | |
| | | |
| | | const store = useCommandCenterStore() |
| | | |
| | | const localLayers = reactive({ |
| | | point: true, |
| | | }) |
| | | |
| | | const layerItems = computed(() => [ |
| | | { |
| | | key: 'vehicle', |
| | |
| | | { |
| | | 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>', |
| | | }, |
| | | { |
| | |
| | | } |
| | | }) |
| | | |
| | | 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> |
| | | |
| | |
| | | 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' |
| | |
| | | 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, |
| | |
| | | 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 })) |
| | |
| | | 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 })) |
| | |
| | | |
| | | 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 })) |
| | | }) |
| | | |
| | |
| | | await Promise.all([ |
| | | store.fetchRegions(), |
| | | store.fetchFences(store.cityCode), |
| | | store.fetchPoints(store.cityCode), |
| | | ]) |
| | | initLayers() |
| | | } |
| | |
| | | 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) => { |
| | |
| | | passengerCallLayer?.update(list) |
| | | }) |
| | | |
| | | watch(() => store.points, (list) => { |
| | | pointLayer?.update(list) |
| | | }) |
| | | |
| | | watch(() => store.layerVisibility.vehicle, (v) => { |
| | | vehicleLayer?.setVisible(v) |
| | | }) |
| | |
| | | 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 () => { |
| | |
| | | vehicleLayer?.destroy() |
| | | passengerCallLayer?.destroy() |
| | | fenceLayer?.destroy() |
| | | pointLayer?.destroy() |
| | | disconnect() |
| | | }) |
| | | </script> |
| New file |
| | |
| | | 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>` |
| | | } |