Merge origin/release-0630 into develop
[ai-coding] [祝美莲]
| | |
| | | { |
| | | "name": "xp-robotaxi-management", |
| | | "type": "module", |
| | | "version": "1.1.26", |
| | | "version": "1.1.27", |
| | | "private": true, |
| | | "description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.", |
| | | "license": "MIT", |
| | |
| | | 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, CommandCenterVehicleDetailVo, PassengerCallVo, VehicleMonitorVo } from '@/views/businessOperations/commandCenter/command-center.types' |
| | | |
| | | /** |
| | | * 指挥中心 WebSocket 连接管理 composable |
| | | * @returns isOnline - 是否在线;connect/disconnect - 连接/断开方法;subscribeCity/unsubscribeCity - 订阅/取消订阅城市 |
| | | */ |
| | | export const useCommandCenterSocket = () => { |
| | | export function useCommandCenterSocket() { |
| | | const store = useCommandCenterStore() |
| | | const socket = createCommandCenterSocket() |
| | | |
| | |
| | | store.socketStatus.lastPushAt = Date.now() |
| | | }) |
| | | |
| | | socket.on(SOCKET_ON_EVENT.VEHICLE_DETAIL, (data: CommandCenterVehicleDetailVo) => { |
| | | console.log('[CC-Socket] ⬇ vehicle_detail :', data) |
| | | if (data.vehicleId === store.selectedVehicleId) { |
| | | store.vehicleDetail = data |
| | | } |
| | | store.socketStatus.lastPushAt = Date.now() |
| | | }) |
| | | |
| | | socket.on(SOCKET_ON_EVENT.KPI, (data: CommandCenterKpiVo) => { |
| | | console.log('[CC-Socket] ⬇ kpi', data) |
| | | store.kpi = data |
| | |
| | | socket.emit(SOCKET_EMIT_EVENT.UNSUBSCRIBE, { cityCode }) |
| | | } |
| | | |
| | | const subscribeVehicleDetail = (vehicleId: number) => { |
| | | console.log('[CC-Socket] ⬆ emit subscribe_vehicle_detail, vehicleId:', vehicleId) |
| | | socket.emit(SOCKET_EMIT_EVENT.SUBSCRIBE_VEHICLE_DETAIL, { vehicleId }) |
| | | } |
| | | |
| | | const unsubscribeVehicleDetail = (vehicleId?: number) => { |
| | | const payload = vehicleId ? { vehicleId } : {} |
| | | console.log('[CC-Socket] ⬆ emit unsubscribe_vehicle_detail', payload) |
| | | socket.emit(SOCKET_EMIT_EVENT.UNSUBSCRIBE_VEHICLE_DETAIL, payload) |
| | | } |
| | | |
| | | return { |
| | | isOnline, |
| | | connect, |
| | | disconnect, |
| | | subscribeCity, |
| | | unsubscribeCity, |
| | | subscribeVehicleDetail, |
| | | unsubscribeVehicleDetail, |
| | | } |
| | | } |
| | |
| | | { |
| | | name: 'testDemoOperationWorkbenchMap', |
| | | path: '/testDemo/operationWorkbench/map', |
| | | title: '测试运营工作台', |
| | | title: '测试工作台', |
| | | requiresAuth: true, |
| | | icon: 'icon-park-outline:alarm', |
| | | menuType: 'page', |
| | |
| | | import { DEFAULT_CITY_CODE, DEFAULT_DISTRICT_CODE, DEFAULT_PROVINCE_CODE, STATIONS_CACHE_TTL } from '@/views/businessOperations/commandCenter/command-center.constants' |
| | | import type { |
| | | CommandCenterKpiVo, |
| | | CommandCenterVehicleDetailVo, |
| | | FenceVo, |
| | | LayerVisibility, |
| | | PassengerCallVo, |
| | | SocketStatus, |
| | | StationVo, |
| | | VehicleDetailVo, |
| | | VehicleMonitorVo, |
| | | XpOpenCity, |
| | | XpPointVo, |
| | |
| | | stationsCache: Record<string, StationsCacheEntry> |
| | | points: XpPointVo[] |
| | | selectedVehicleId: number | null |
| | | vehicleDetail: VehicleDetailVo | null |
| | | vehicleDetail: CommandCenterVehicleDetailVo | null |
| | | layerVisibility: LayerVisibility |
| | | socketStatus: SocketStatus |
| | | } |
| | |
| | | AUTH: 'auth', |
| | | SUBSCRIBE: 'subscribe', |
| | | UNSUBSCRIBE: 'unsubscribe', |
| | | SUBSCRIBE_VEHICLE_DETAIL: 'subscribe_vehicle_detail', |
| | | UNSUBSCRIBE_VEHICLE_DETAIL: 'unsubscribe_vehicle_detail', |
| | | } as const |
| | | |
| | | /** Socket.IO 服务端 → 客户端事件名 */ |
| | |
| | | AUTH_SUCCESS: 'auth_success', |
| | | AUTH_FAILED: 'auth_failed', |
| | | VEHICLES: 'vehicles', |
| | | VEHICLE_DETAIL: 'vehicle_detail', |
| | | KPI: 'kpi', |
| | | PASSENGER_CALLS: 'passenger_calls', |
| | | } as const |
| | |
| | | positionUpdateTime: number |
| | | } |
| | | |
| | | export interface CommandCenterVehicleDetailVo { |
| | | vehicleId: number |
| | | vin: string |
| | | plateNumber: string |
| | | monitorGroup: MonitorGroup |
| | | monitorStatus: string |
| | | stateCode: VehicleMonitorStateCode |
| | | currentElectricity: number |
| | | todayCompletedOrders: number |
| | | routeInfo: CommandCenterRouteInfoVo | null |
| | | currentLon: number |
| | | currentLat: number |
| | | } |
| | | |
| | | export interface CommandCenterKpiVo { |
| | | cruisingCount: number |
| | | hasOrderCount: number |
| | |
| | | export interface PassengerCallVo { |
| | | orderId: number |
| | | orderNumber: string |
| | | appUserId: string |
| | | startLon: number |
| | | startLat: number |
| | | startAddress: string |
| | |
| | | label: '车辆位置', |
| | | active: store.layerVisibility.vehicle, |
| | | toggle: () => store.toggleLayer('vehicle', !store.layerVisibility.vehicle), |
| | | iconHtml: '', |
| | | iconHtml: '<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="16" height="16"><path d="M941.4 467.2l-117.6-3.2-56-155.2c-16-44.8-59.2-75.2-107.2-75.2H362.2c-48 0-91.2 30.4-107.2 75.2l-56 155.2-117.6 3.2C56.6 468.8 35 491.2 35 518.4v148.8c0 27.2 22.4 49.6 49.6 49.6h44.8c8 51.2 52.8 89.6 105.6 89.6s97.6-38.4 105.6-89.6h342.4c8 51.2 52.8 89.6 105.6 89.6s97.6-38.4 105.6-89.6h44.8c27.2 0 49.6-22.4 49.6-49.6V518.4c0-27.2-22.4-51.2-46.6-51.2zM235 758.4c-27.2 0-49.6-22.4-49.6-49.6s22.4-49.6 49.6-49.6 49.6 22.4 49.6 49.6-22.4 49.6-49.6 49.6zm553.6 0c-27.2 0-49.6-22.4-49.6-49.6s22.4-49.6 49.6-49.6 49.6 22.4 49.6 49.6-22.4 49.6-49.6 49.6zM253.4 462.4l44.8-131.2c8-24 30.4-40 56-40h315.2c25.6 0 48 16 56 40l44.8 131.2H253.4z"></path><path d="M512 296a25.6 25.6 0 0 1-25.6-25.6V134.4a25.6 25.6 0 0 1 51.2 0v136c0 14.1-11.5 25.6-25.6 25.6z"></path></svg>', |
| | | }, |
| | | { |
| | | key: 'passengerCall', |
| | |
| | | <script setup lang="ts"> |
| | | import type { CommandCenterVehicleDetailVo } from '../../command-center.types' |
| | | import { MONITOR_COLOR_MAP } from '../../command-center.constants' |
| | | |
| | | const props = defineProps<{ vehicle: CommandCenterVehicleDetailVo }>() |
| | | |
| | | const colorConfig = computed(() => MONITOR_COLOR_MAP[props.vehicle.monitorGroup] || MONITOR_COLOR_MAP.ABNORMAL) |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="vehicle-detail-header"> |
| | | <span |
| | | class="vehicle-detail-header__dot" |
| | | :style="{ background: colorConfig.color }" |
| | | /> |
| | | <span class="vehicle-detail-header__plate">{{ detail.plateNumber || detail.vin }}</span> |
| | | <NTag size="small" :color="{ color: colorConfig.color + '20', textColor: colorConfig.color, borderColor: colorConfig.color }"> |
| | | {{ detail.monitorStatus }} |
| | | <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 }"> |
| | | {{ vehicle.monitorStatus }} |
| | | </NTag> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import type { VehicleDetailVo } from '../../command-center.types' |
| | | import { MONITOR_COLOR_MAP } from '../../command-center.constants' |
| | | |
| | | const props = defineProps<{ detail: VehicleDetailVo }>() |
| | | |
| | | const colorConfig = computed(() => MONITOR_COLOR_MAP[props.detail.monitorGroup] || MONITOR_COLOR_MAP.ABNORMAL) |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .vehicle-detail-header { |
| | |
| | | <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 detail = computed(() => store.vehicleDetail) |
| | | |
| | | 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="detail"> |
| | | <Header :vehicle="detail" /> |
| | | <OperatingDataSection |
| | | :electricity="currentElectricity" |
| | | :today-orders="currentTodayOrders" |
| | | :electricity="detail.currentElectricity" |
| | | :today-orders="detail.todayCompletedOrders" |
| | | /> |
| | | </template> |
| | | <div v-else class="drawer-loading"> |
| | |
| | | </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 { |
| | |
| | | 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' }) |
| | | |
| | | const store = useCommandCenterStore() |
| | | const { isOnline, connect, disconnect, subscribeCity, unsubscribeCity } = useCommandCenterSocket() |
| | | const { isOnline, connect, disconnect, subscribeCity, unsubscribeCity, subscribeVehicleDetail, unsubscribeVehicleDetail } = useCommandCenterSocket() |
| | | |
| | | const mapCanvasRef = ref<InstanceType<typeof MapCanvas> | null>(null) |
| | | let fenceLayer: FenceLayer | null = null |
| | |
| | | passengerCallLayer = new PassengerCallLayer(map) |
| | | pointLayer = new PointLayer(map) |
| | | |
| | | vehicleLayer.onClick((vehicleId) => { |
| | | if (store.selectedVehicleId) { |
| | | unsubscribeVehicleDetail(store.selectedVehicleId) |
| | | } |
| | | store.openVehicleDetail(vehicleId) |
| | | vehicleLayer?.setHighlight(vehicleId) |
| | | subscribeVehicleDetail(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, oldId) => { |
| | | vehicleLayer?.setHighlight(id) |
| | | if (!id) { |
| | | if (oldId) { |
| | | unsubscribeVehicleDetail(oldId) |
| | | } |
| | | mapCanvasRef.value?.fitToCity(store.cityCode) |
| | | } |
| | | }) |
| | | |
| | | watch(() => store.passengerCalls, (list) => { |
| | |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | if (store.selectedVehicleId) { |
| | | unsubscribeVehicleDetail(store.selectedVehicleId) |
| | | store.closeVehicleDetail() |
| | | } |
| | | vehicleLayer?.destroy() |
| | | passengerCallLayer?.destroy() |
| | | fenceLayer?.destroy() |
| | |
| | | <MapControls @reset="handleMapReset" @zoom-in="handleZoomIn" @zoom-out="handleZoomOut" /> |
| | | <BackToOverview /> |
| | | </div> |
| | | <VehicleDetailDrawer /> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | <div class="vehicle-info-card__body"> |
| | | <div class="vehicle-info-card__row"> |
| | | <span class="vehicle-info-card__label">用户ID</span> |
| | | <span class="vehicle-info-card__value">${call.orderNumber}</span> |
| | | <span class="vehicle-info-card__value">${call.appUserId}</span> |
| | | </div> |
| | | <div class="vehicle-info-card__row"> |
| | | <span class="vehicle-info-card__label">等待时长</span> |
| | |
| | | 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 |
| | | } |
| | | } |
| | |
| | | transform: scale(1.4); |
| | | } |
| | | |
| | | &__dot { |
| | | width: 16px; |
| | | height: 16px; |
| | | border-radius: 50%; |
| | | border: 2px solid rgba(255, 255, 255, 0.9); |
| | | &__icon { |
| | | width: 20px; |
| | | height: 20px; |
| | | } |
| | | |
| | | &__charging { |
| | |
| | | |
| | | return ` |
| | | <div class="vehicle-marker ${isPulse ? 'vehicle-marker--pulse' : ''}"> |
| | | <div class="vehicle-marker__dot" style="background: ${colorConfig.color};"></div> |
| | | <svg class="vehicle-marker__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="${colorConfig.color}"> |
| | | <path d="M941.4 467.2l-117.6-3.2-56-155.2c-16-44.8-59.2-75.2-107.2-75.2H362.2c-48 0-91.2 30.4-107.2 75.2l-56 155.2-117.6 3.2C56.6 468.8 35 491.2 35 518.4v148.8c0 27.2 22.4 49.6 49.6 49.6h44.8c8 51.2 52.8 89.6 105.6 89.6s97.6-38.4 105.6-89.6h342.4c8 51.2 52.8 89.6 105.6 89.6s97.6-38.4 105.6-89.6h44.8c27.2 0 49.6-22.4 49.6-49.6V518.4c0-27.2-22.4-51.2-46.6-51.2zM235 758.4c-27.2 0-49.6-22.4-49.6-49.6s22.4-49.6 49.6-49.6 49.6 22.4 49.6 49.6-22.4 49.6-49.6 49.6zm553.6 0c-27.2 0-49.6-22.4-49.6-49.6s22.4-49.6 49.6-49.6 49.6 22.4 49.6 49.6-22.4 49.6-49.6 49.6zM253.4 462.4l44.8-131.2c8-24 30.4-40 56-40h315.2c25.6 0 48 16 56 40l44.8 131.2H253.4z"></path> |
| | | <path d="M512 296a25.6 25.6 0 0 1-25.6-25.6V134.4a25.6 25.6 0 0 1 51.2 0v136c0 14.1-11.5 25.6-25.6 25.6z"></path> |
| | | </svg> |
| | | ${isCharging ? '<span class="vehicle-marker__charging">⚡</span>' : ''} |
| | | </div> |
| | | ` |
| | |
| | | getFenceStatusTagType(Number(fenceDetailData?.status || 0)) |
| | | " |
| | | :bordered="false" |
| | | class="h-24px mx-4px" |
| | | class="h-24px mx-4px flex-shrink-0" |
| | | > |
| | | {{ getFenceStatusText(Number(fenceDetailData?.status || 0)) }} |
| | | </NTag> |
| | |
| | | ] |
| | | |
| | | const fenceStatusOptions = [ |
| | | { label: '草稿', value: 0 }, |
| | | { label: '待生效', value: 1 }, |
| | | // { label: '草稿', value: 0 }, |
| | | // { label: '待生效', value: 1 }, |
| | | { label: '启用中', value: 2 }, |
| | | { label: '已停用', value: 3 }, |
| | | ] |
| | |
| | | color: #e02716; |
| | | } |
| | | |
| | | .fence-hover-tooltip { |
| | | position: absolute; |
| | | z-index: 5; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | min-width: 88px; |
| | | max-width: 160px; |
| | | padding: 7px 10px; |
| | | border-radius: 6px; |
| | | color: #fff; |
| | | font-size: 13px; |
| | | line-height: 18px; |
| | | white-space: nowrap; |
| | | pointer-events: none; |
| | | transform: translate(-50%, -100%); |
| | | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); |
| | | |
| | | &.is-enabled { |
| | | background: #19b144; |
| | | } |
| | | |
| | | &.is-disabled { |
| | | background: #8f959e; |
| | | } |
| | | |
| | | &.is-testing { |
| | | background: #171f26; |
| | | } |
| | | } |
| | | |
| | | .fence-hover-tooltip__text { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .fence-hover-tooltip__arrow { |
| | | position: absolute; |
| | | bottom: -5px; |
| | | left: 50%; |
| | | width: 10px; |
| | | height: 10px; |
| | | background: inherit; |
| | | transform: translateX(-50%) rotate(45deg); |
| | | } |
| | | |
| | | .map-tips { |
| | | position: absolute; |
| | | top: 46px; |
| | |
| | | 'white-space: normal', |
| | | 'word-break: break-word', |
| | | ].join(';') |
| | | text.innerHTML = '拖动蓝色实心点增加顶点<br />点击白色实心点删除顶点' |
| | | text.innerHTML = '拖动绿色实心点增加顶点<br />点击白色实心点删除顶点' |
| | | const arrow = document.createElement('div') |
| | | arrow.style.cssText = [ |
| | | 'position: absolute', |
| | |
| | | const stationPolygons = shallowRef<any[]>([]) |
| | | const fencePolygons = shallowRef<any[]>([]) |
| | | const currentCityBoundaryPaths = ref<LngLatTuple[][]>([]) |
| | | const fenceHoverTooltipVisible = ref(false) |
| | | const fenceHoverTooltipText = ref('') |
| | | const fenceHoverTooltipType = ref<'enabled' | 'disabled' | 'testing'>('testing') |
| | | const fenceCreateOutsideCurrentCity = ref(false) |
| | | const hoveredFencePolygon = shallowRef<any | null>(null) |
| | | const hoveredFenceBaseStrokeWeight = ref<number | null>(null) |
| | | const fenceHoverTooltipPosition = reactive({ |
| | | x: 0, |
| | | y: 0, |
| | | }) |
| | | let fenceHoverTooltipTimer: ReturnType<typeof setTimeout> | null = null |
| | | let pendingFenceHoverKey = '' |
| | | let pendingFenceHoverPixel: { x: number, y: number } | null = null |
| | | // 围栏/场站点选后会有“立即聚焦 + 异步详情回来的二次聚焦”两条链路。 |
| | | // 这里记录同一轮选中的自动聚焦请求,避免重复 fitView 抢走用户正在手动调整的视野。 |
| | | const lastMapWheelViewportInteractionAt = ref(0) |
| | |
| | | |
| | | function handleCityBoundaryMouseOut() { |
| | | fenceCreateOutsideCurrentCity.value = false |
| | | hideFenceHoverTooltip() |
| | | resetFenceEditGuideHoverState() |
| | | resetRestrictedEditGuideHoverState() |
| | | resetStationEditGuideHoverState() |
| | | } |
| | | |
| | | function clearFenceHoverTooltipTimer() { |
| | | if (fenceHoverTooltipTimer) { |
| | | clearTimeout(fenceHoverTooltipTimer) |
| | | fenceHoverTooltipTimer = null |
| | | } |
| | | } |
| | | |
| | | function resetFenceHoverPolygonStyle() { |
| | | if (hoveredFencePolygon.value && hoveredFenceBaseStrokeWeight.value !== null) { |
| | | hoveredFencePolygon.value.setOptions({ |
| | | strokeWeight: hoveredFenceBaseStrokeWeight.value, |
| | | }) |
| | | } |
| | | hoveredFencePolygon.value = null |
| | | hoveredFenceBaseStrokeWeight.value = null |
| | | } |
| | | |
| | | function highlightFenceHoverPolygon(polygon: any, item: any) { |
| | | if (Number(item?.areaType) !== 1) |
| | | return |
| | | |
| | | if (hoveredFencePolygon.value === polygon) |
| | | return |
| | | |
| | | resetFenceHoverPolygonStyle() |
| | | |
| | | const currentStrokeWeight = Number(polygon?.getExtData?.()?.baseStrokeWeight ?? 2) |
| | | hoveredFencePolygon.value = polygon |
| | | hoveredFenceBaseStrokeWeight.value = currentStrokeWeight |
| | | polygon.setOptions({ |
| | | strokeWeight: currentStrokeWeight + 1.5, |
| | | }) |
| | | } |
| | | |
| | | function hideFenceHoverTooltip() { |
| | | clearFenceHoverTooltipTimer() |
| | | pendingFenceHoverKey = '' |
| | | pendingFenceHoverPixel = null |
| | | resetFenceHoverPolygonStyle() |
| | | fenceHoverTooltipVisible.value = false |
| | | fenceHoverTooltipText.value = '' |
| | | fenceHoverTooltipType.value = 'testing' |
| | | } |
| | | |
| | | function getFenceHoverKey(item: any) { |
| | | return `${String(item?.electronicId || '')}-${String(item?.id || '')}` |
| | | } |
| | | |
| | | function resolveFenceOverlayType(item: any) { |
| | | if (Number(item?.areaStatus || 1) === 2 || Number(item?.status) === 3) |
| | | return 'disabled' as const |
| | | if (Number(item?.status) === 2 || enabledFenceIdSet.value.has(String(item?.electronicId || ''))) |
| | | return 'enabled' as const |
| | | return 'testing' as const |
| | | } |
| | | |
| | | function getFenceHoverTooltipText(type: 'enabled' | 'disabled' | 'testing') { |
| | | if (type === 'enabled') |
| | | return '启用中围栏' |
| | | if (type === 'disabled') |
| | | return '已停用围栏' |
| | | return '测试围栏' |
| | | } |
| | | |
| | | function parseFencePriorityTime(value: unknown) { |
| | | const timestamp = dayjs(String(value || '')).valueOf() |
| | | return Number.isFinite(timestamp) ? timestamp : 0 |
| | | } |
| | | |
| | | function getFenceOverlayPriority(item: any) { |
| | | const type = resolveFenceOverlayType(item) |
| | | return { |
| | | type, |
| | | typePriority: type === 'testing' ? 2 : type === 'enabled' ? 1 : 0, |
| | | updateTimePriority: parseFencePriorityTime(item?.updateTime), |
| | | fallbackPriority: Number(item?.electronicId ?? item?.id ?? 0) || 0, |
| | | } |
| | | } |
| | | |
| | | function updateFenceHoverTooltipPositionByPixel(pixel?: { x: number, y: number } | null) { |
| | | if (!map.value || !pixel) |
| | | return false |
| | | |
| | | const container = map.value.getContainer?.() |
| | | if (!container) |
| | | return false |
| | | |
| | | const offsetX = 1 |
| | | const offsetY = 7 |
| | | const rawX = Number(pixel.x) + offsetX |
| | | const rawY = Number(pixel.y) - offsetY |
| | | const clampedX = Math.max(12, Math.min(rawX, container.clientWidth - 12)) |
| | | const clampedY = Math.max(12, Math.min(rawY, container.clientHeight - 12)) |
| | | |
| | | fenceHoverTooltipPosition.x = clampedX |
| | | fenceHoverTooltipPosition.y = clampedY |
| | | return true |
| | | } |
| | | |
| | | function scheduleFenceHoverTooltip(item: any, pixel?: { x: number, y: number } | null) { |
| | | if (Number(item?.areaType) !== 1 || isFenceFormMode.value || isStationFormMode.value) |
| | | return |
| | | |
| | | const hoverKey = getFenceHoverKey(item) |
| | | pendingFenceHoverKey = hoverKey |
| | | pendingFenceHoverPixel = pixel |
| | | updateFenceHoverTooltipPositionByPixel(pixel) |
| | | clearFenceHoverTooltipTimer() |
| | | fenceHoverTooltipTimer = window.setTimeout(() => { |
| | | if (pendingFenceHoverKey !== hoverKey) |
| | | return |
| | | |
| | | const nextType = resolveFenceOverlayType(item) |
| | | if (!updateFenceHoverTooltipPositionByPixel(pendingFenceHoverPixel)) |
| | | return |
| | | |
| | | fenceHoverTooltipType.value = nextType |
| | | fenceHoverTooltipText.value = getFenceHoverTooltipText(nextType) |
| | | fenceHoverTooltipVisible.value = true |
| | | fenceHoverTooltipTimer = null |
| | | }, 300) |
| | | } |
| | | |
| | | function handleNativeMouseMove(event: MouseEvent) { |
| | |
| | | } |
| | | if (fenceDraftPoints.value.length < 3) |
| | | return '请在地图上【非运营区域】绘制多边形区域,双击闭合' |
| | | return '【蓝色运营区】可调整区域点位,请勿与其他运营区重叠' |
| | | return '【绿色运营区】可调整区域点位,请勿与其他运营区重叠' |
| | | }) |
| | | const stationMapTipText = computed(() => { |
| | | if (!isStationFormMode.value) |
| | |
| | | function clearMapEntities() { |
| | | if (!map.value) |
| | | return |
| | | hideFenceHoverTooltip() |
| | | if (pointMarkers.value.length) |
| | | map.value.remove(pointMarkers.value) |
| | | if (cityBoundaryPolygons.value.length) |
| | |
| | | } |
| | | return false |
| | | }) |
| | | .sort((prev: any, next: any) => { |
| | | const prevPriority = getFenceOverlayPriority(prev) |
| | | const nextPriority = getFenceOverlayPriority(next) |
| | | if (prevPriority.typePriority !== nextPriority.typePriority) |
| | | return prevPriority.typePriority - nextPriority.typePriority |
| | | if ( |
| | | prevPriority.type === 'testing' |
| | | && nextPriority.type === 'testing' |
| | | && prevPriority.updateTimePriority !== nextPriority.updateTimePriority |
| | | ) { |
| | | return prevPriority.updateTimePriority - nextPriority.updateTimePriority |
| | | } |
| | | if (prevPriority.updateTimePriority !== nextPriority.updateTimePriority) |
| | | return prevPriority.updateTimePriority - nextPriority.updateTimePriority |
| | | return prevPriority.fallbackPriority - nextPriority.fallbackPriority |
| | | }) |
| | | .map((item) => { |
| | | const areaType = Number(item.areaType) |
| | | const isDisabledFenceArea = Number(item.areaStatus || 1) === 2 |
| | |
| | | = areaType === 1 && isDisabledFenceArea && !isFenceSelectedDetail |
| | | const isFenceReadonlyDimmed |
| | | = areaType === 1 && !isActive && !isDisabledFenceArea |
| | | const isFenceEnabled |
| | | = areaType === 1 && resolveFenceOverlayType(item) === 'enabled' |
| | | const isFenceDetailActive = isFenceSelectedDetail |
| | | const isFenceHighlighted = isFenceDetailActive || isFenceEnabled |
| | | // 停用运营区现已需要支持从地图点选进入详情并继续编辑; |
| | | // 这里仅保留“停用禁行区不可点”,不再沿用旧逻辑把停用运营区一并禁点。 |
| | | const shouldBlockAreaClick |
| | | = areaType === 2 && isDisabledFenceArea |
| | | const fencePriority = getFenceOverlayPriority(item) |
| | | const baseStrokeWeight = areaType === 2 |
| | | ? 0 |
| | | : isFenceHighlighted |
| | | ? 2 |
| | | : isActive |
| | | ? 3 |
| | | : 2 |
| | | const polygon = new (window as any).AMap.Polygon({ |
| | | path: points, |
| | | fillColor: |
| | |
| | | ? isReadonlyRestrictedActive |
| | | ? '#F14637' |
| | | : '#EF938B' |
| | | : isFenceDetailActive |
| | | : isFenceHighlighted |
| | | ? '#19B144' |
| | | : isFenceReadonlyDimmed |
| | | ? '#171F26' |
| | |
| | | ? isReadonlyRestrictedActive |
| | | ? '#F14637' |
| | | : '#EF938B' |
| | | : isFenceDetailActive |
| | | : isFenceHighlighted |
| | | ? '#19B144' |
| | | : isFenceReadonlyDimmed |
| | | ? '#AEB0B2' |
| | | : '#AEB0B2', |
| | | strokeWeight: areaType === 2 |
| | | ? 0 |
| | | : isFenceDetailActive |
| | | ? 2 |
| | | : isActive |
| | | ? 3 |
| | | : 2, |
| | | strokeWeight: baseStrokeWeight, |
| | | strokeOpacity: |
| | | isFenceDisabledReadonly |
| | | ? 0.3 |
| | |
| | | ? isReadonlyRestrictedActive |
| | | ? 1 |
| | | : 1 |
| | | : isFenceDetailActive |
| | | : isFenceHighlighted |
| | | ? 0.1 |
| | | : isFenceReadonlyDimmed |
| | | ? 0.08 |
| | |
| | | && !isPointMapPickActive.value |
| | | && !isFenceFormMode.value |
| | | && !isStationFormMode.value, |
| | | zIndex: isReadonlyRestrictedActive ? 230 : isActive ? 220 : 205, |
| | | zIndex: areaType === 2 |
| | | ? (isReadonlyRestrictedActive ? 230 : 205) |
| | | : 240 + fencePriority.typePriority * 40 + (isActive ? 8 : 0), |
| | | }) |
| | | polygon.on('click', () => { |
| | | hideFenceHoverTooltip() |
| | | if ( |
| | | shouldBlockAreaClick |
| | | || isPointMapPickActive.value |
| | |
| | | } |
| | | handleMapEntityClick('fence', currentFenceId) |
| | | }) |
| | | polygon.on('mouseover', (event: any) => { |
| | | highlightFenceHoverPolygon(polygon, item) |
| | | scheduleFenceHoverTooltip(item, event?.pixel || null) |
| | | }) |
| | | polygon.on('mousemove', (event: any) => { |
| | | pendingFenceHoverPixel = event?.pixel || null |
| | | updateFenceHoverTooltipPositionByPixel(pendingFenceHoverPixel) |
| | | }) |
| | | polygon.on('mouseout', () => { |
| | | if (pendingFenceHoverKey === getFenceHoverKey(item)) |
| | | pendingFenceHoverKey = '' |
| | | hideFenceHoverTooltip() |
| | | }) |
| | | polygon.setExtData({ |
| | | id: String(item.electronicId), |
| | | type: 'fence', |
| | | areaType, |
| | | areaId: String(item.id || ''), |
| | | baseStrokeWeight, |
| | | }) |
| | | return polygon |
| | | }) |
| | |
| | | class="map-content" |
| | | :class="{ 'map-content--warning-cursor': fenceCreateOutsideCurrentCity }" |
| | | /> |
| | | <div |
| | | v-if="fenceHoverTooltipVisible" |
| | | class="fence-hover-tooltip" |
| | | :class="`is-${fenceHoverTooltipType}`" |
| | | :style="{ |
| | | left: `${fenceHoverTooltipPosition.x}px`, |
| | | top: `${fenceHoverTooltipPosition.y}px`, |
| | | }" |
| | | > |
| | | <span class="fence-hover-tooltip__text">{{ fenceHoverTooltipText }}</span> |
| | | <span class="fence-hover-tooltip__arrow" /> |
| | | </div> |
| | | <MapInfoPopupCard |
| | | v-if="showFenceEditMapInfoPopup" |
| | | :style="mapInfoPopupStyle" |
| | |
| | | class="operate-item create-tool-trigger" |
| | | :class="{ |
| | | 'active': showCreateMenu, |
| | | 'disabled': !!createToolDisabledReason, |
| | | // 'disabled': !!createToolDisabledReason, |
| | | 'has-selection': |
| | | !showCreateMenu && !!selectedCreateToolOption, |
| | | }" |
| | |
| | | // 三类对象在左侧筛选区共用的状态筛选项。 |
| | | export const statusOptionsMap: Record<WorkbenchTab, OptionItem[]> = { |
| | | fence: [ |
| | | { label: "草稿", value: "draft" }, |
| | | { label: "待生效", value: "pending" }, |
| | | // { label: "草稿", value: "draft" }, |
| | | // { label: "待生效", value: "pending" }, |
| | | { label: "启用中", value: "enabled" }, |
| | | { label: "已停用", value: "disabled" }, |
| | | ], |
| | |
| | | |
| | | <style scoped lang="scss"> |
| | | .footer-btn { |
| | | padding: 7px 29px; |
| | | height: auto; |
| | | font-size: 13px; |
| | | line-height: 18px; |
| | | width: 84px; |
| | | height: 32px; |
| | | } |
| | | |
| | | :deep(.robotaxi-table-row--selected td) { |
| | |
| | | useDialog, |
| | | useMessage, |
| | | } from 'naive-ui' |
| | | import { onActivated, onMounted, reactive, ref, watch } from 'vue' |
| | | import { nextTick, onActivated, onMounted, reactive, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { |
| | | addRole, |
| | |
| | | }) |
| | | const saveButtonDisabled = computed(() => isReadOnlyMode.value || !formModel.roleName.trim() || loading.value) |
| | | const batchActionDisabled = computed(() => isReadOnlyMode.value || selectedPermissionSections.value.length === 0) |
| | | const permissionDetailPanelRef = ref<HTMLElement | null>(null) |
| | | |
| | | const rules: FormRules = { |
| | | roleName: [ |
| | |
| | | |
| | | const filteredPermissions = computed(() => filterPermissionTree(currentPermissions.value, formModel.permissionKeyword)) |
| | | |
| | | function isSpecialActionNode(parentNode: PermissionNode, node: PermissionNode) { |
| | | // 运营管理下“系统操作日志”的“导出”接口返回的是 C 类型, |
| | | // 但业务上需要按右侧按钮权限展示,不能作为左侧子模块显示。 |
| | | return parentNode.routerName === 'adminOperationLog:list' |
| | | && node.routerName === 'adminOperationLog:export' |
| | | } |
| | | |
| | | function getChildModuleNodes(node: PermissionNode) { |
| | | // 模块节点按菜单类型区分,C/M 都属于页面模块,F 才是按钮权限。 |
| | | // 不能再用“是否有 children”判断,否则没有按钮权限的 C 节点会被过滤掉。 |
| | | return (node.children || []).filter(item => item.menuType !== 'F') |
| | | return (node.children || []).filter(item => item.menuType !== 'F' && !isSpecialActionNode(node, item)) |
| | | } |
| | | |
| | | function getActionNodes(node: PermissionNode) { |
| | | return (node.children || []).filter(item => item.menuType === 'F') |
| | | return (node.children || []).filter(item => item.menuType === 'F' || isSpecialActionNode(node, item)) |
| | | } |
| | | |
| | | function isModuleExpanded(key: string) { |
| | |
| | | currentModuleKey.value = item.key |
| | | if (item.expandable && !formModel.permissionKeyword.trim()) |
| | | toggleModuleExpanded(item.key) |
| | | scrollToPermissionSection(item.key) |
| | | } |
| | | |
| | | watch([currentAppTab, visibleModuleItems], () => { |
| | |
| | | const hasChildModules = childModules.length > 0 |
| | | const actions = getActionNodes(currentNode) |
| | | |
| | | // 右侧只展示“有按钮权限”的叶子模块;没有子级菜单/按钮的模块不渲染。 |
| | | if (isNodeChecked(currentNode) && !hasChildModules && actions.length > 0) { |
| | | // 右侧展示所有已选中的叶子模块。 |
| | | // 如果叶子模块没有按钮权限,也需要展示分组,但不显示“全选”。 |
| | | if (isNodeChecked(currentNode) && !hasChildModules) { |
| | | sections.push({ |
| | | key: currentNode.key, |
| | | node: currentNode, |
| | |
| | | |
| | | // 勾选父节点时默认勾选全部子权限;取消父节点时同步清空子权限 |
| | | function handleTogglePermission(node: PermissionNode, checked: boolean) { |
| | | currentModuleKey.value = node.key |
| | | const nextSet = new Set(checkedPermissionKeys.value) |
| | | const hasChildren = !!node.children?.length |
| | | const checkedKeys = hasChildren ? getModuleDescendantKeys(node) : [node.key] |
| | |
| | | } |
| | | |
| | | checkedPermissionKeys.value = Array.from(nextSet) |
| | | if (checked) |
| | | scrollToPermissionSection(node.key) |
| | | } |
| | | |
| | | function isSectionMatchedModule(sectionNode: PermissionNode, moduleKey: string) { |
| | | let currentKey: string | null | undefined = sectionNode.key |
| | | while (currentKey) { |
| | | if (currentKey === moduleKey) |
| | | return true |
| | | currentKey = parentMap.get(currentKey) |
| | | } |
| | | return false |
| | | } |
| | | |
| | | function scrollToPermissionSection(moduleKey: string) { |
| | | nextTick(() => { |
| | | const panel = permissionDetailPanelRef.value |
| | | if (!panel) |
| | | return |
| | | |
| | | const matchedSection = selectedPermissionSections.value.find(section => |
| | | isSectionMatchedModule(section.node, moduleKey)) |
| | | |
| | | if (!matchedSection) { |
| | | panel.scrollTo({ top: 0, behavior: 'smooth' }) |
| | | return |
| | | } |
| | | |
| | | const sectionElements = Array.from(panel.querySelectorAll<HTMLElement>('.permission-section-row')) |
| | | const targetElement = sectionElements.find(element => element.dataset.sectionKey === matchedSection.key) |
| | | targetElement?.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
| | | }) |
| | | } |
| | | |
| | | function getActionStatus(node: PermissionNode): 'checked' | 'partial' | 'unchecked' { |
| | |
| | | const displayedActionNodes = computed(() => |
| | | selectedPermissionSections.value.flatMap(section => section.actions)) |
| | | |
| | | function handleBatchSelectActions(matchers: RegExp[], successText: string) { |
| | | function areMatchedActionsAllChecked(matchers: RegExp[]) { |
| | | const matchedActions = displayedActionNodes.value.filter(action => |
| | | matchers.some(pattern => pattern.test(action.menuName))) |
| | | |
| | | if (!matchedActions.length) |
| | | return false |
| | | |
| | | const checkedKeySet = new Set(checkedPermissionKeys.value) |
| | | return matchedActions.every(action => checkedKeySet.has(action.key)) |
| | | } |
| | | |
| | | function getBatchActionLabel(matchers: RegExp[], actionText: string) { |
| | | return `${areMatchedActionsAllChecked(matchers) ? '批量不选中' : '批量选中'}${actionText}` |
| | | } |
| | | |
| | | function handleBatchToggleActions(matchers: RegExp[], successText: string, cancelText: string) { |
| | | if (!displayedActionNodes.value.length) |
| | | return |
| | | |
| | |
| | | } |
| | | |
| | | const nextSet = new Set(checkedPermissionKeys.value) |
| | | const allMatchedChecked = matchedActions.every(action => nextSet.has(action.key)) |
| | | |
| | | matchedActions.forEach((action) => { |
| | | if (allMatchedChecked) { |
| | | nextSet.delete(action.key) |
| | | delete permissionConfigMap[action.key] |
| | | return |
| | | } |
| | | |
| | | nextSet.add(action.key) |
| | | ensurePermissionConfig(action.key) |
| | | }) |
| | | checkedPermissionKeys.value = Array.from(nextSet) |
| | | message.success(successText) |
| | | message.success(allMatchedChecked ? cancelText : successText) |
| | | } |
| | | |
| | | function handleBatchView() { |
| | | handleBatchSelectActions([/查看/, /详情/], '已批量勾选查看权限') |
| | | handleBatchToggleActions([/查看/, /详情/], '已批量勾选查看权限', '已批量取消查看权限') |
| | | } |
| | | |
| | | function handleBatchEdit() { |
| | | handleBatchSelectActions([/编辑/, /修改/], '已批量勾选编辑权限') |
| | | handleBatchToggleActions([/编辑/, /修改/], '已批量勾选编辑权限', '已批量取消编辑权限') |
| | | } |
| | | |
| | | function confirmBatchEdit() { |
| | |
| | | } |
| | | |
| | | function handleBatchDelete() { |
| | | handleBatchSelectActions([/删除/], '已批量勾选删除权限') |
| | | handleBatchToggleActions([/删除/], '已批量勾选删除权限', '已批量取消删除权限') |
| | | } |
| | | |
| | | function handleBatchExport() { |
| | | handleBatchSelectActions([/导出/], '已批量勾选导出权限') |
| | | handleBatchToggleActions([/导出/], '已批量勾选导出权限', '已批量取消导出权限') |
| | | } |
| | | |
| | | function handleResetPermissions() { |
| | |
| | | /> |
| | | </NFormItem> |
| | | |
| | | <NFormItem label="手机号是否脱敏" path="phoneDesensitization" class="basic-form-item"> |
| | | <NFormItem label="是否显示手机号" path="phoneDesensitization" class="basic-form-item"> |
| | | <NSwitch |
| | | v-model:value="formModel.phoneDesensitization" |
| | | :checked-value="1" |
| | |
| | | <div class="permission-config-wrap"> |
| | | <div class="permission-toolbar"> |
| | | <NButton :disabled="batchActionDisabled" @click="handleBatchView"> |
| | | 批量选中查看 |
| | | {{ getBatchActionLabel([/查看/, /详情/], '查看') }} |
| | | </NButton> |
| | | <NButton :disabled="batchActionDisabled" @click="handleBatchEdit"> |
| | | 批量选中编辑 |
| | | {{ getBatchActionLabel([/编辑/, /修改/], '编辑') }} |
| | | </NButton> |
| | | <NButton :disabled="batchActionDisabled" @click="handleBatchDelete"> |
| | | 批量选中删除 |
| | | {{ getBatchActionLabel([/删除/], '删除') }} |
| | | </NButton> |
| | | <NButton :disabled="batchActionDisabled" @click="handleBatchExport"> |
| | | 批量选中导出 |
| | | {{ getBatchActionLabel([/导出/], '导出') }} |
| | | </NButton> |
| | | <NButton class="w-84px" :disabled="isReadOnlyMode" @click="handleResetPermissions"> |
| | | 重置 |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="permission-detail-panel"> |
| | | <div ref="permissionDetailPanelRef" class="permission-detail-panel"> |
| | | <div v-if="selectedPermissionSections.length" class="permission-detail-inner"> |
| | | <div |
| | | v-for="section in selectedPermissionSections" |
| | | :key="section.key" |
| | | class="permission-section-row" |
| | | :data-section-key="section.key" |
| | | > |
| | | <div class="section-header-row"> |
| | | <div class="section-title-wrap"> |
| | | <span class="section-node-title">{{ section.title }}</span> |
| | | <NCheckbox |
| | | v-if="section.actions.length" |
| | | :checked="getActionStatus(section.node) === 'checked'" |
| | | :indeterminate="getActionStatus(section.node) === 'partial'" |
| | | :disabled="isReadOnlyMode" |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="section.actions.length" class="action-list"> |
| | | <div class="action-list"> |
| | | <div |
| | | class="action-item" |
| | | > |
| | | <NCheckbox |
| | | checked |
| | | disabled |
| | | /> |
| | | <span class="action-name">页面查看</span> |
| | | </div> |
| | | <div |
| | | v-for="action in section.actions" |
| | | :key="action.key" |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="permission-detail-panel"> |
| | | <div ref="permissionDetailPanelRef" class="permission-detail-panel"> |
| | | <div v-if="selectedPermissionSections.length" class="permission-detail-inner"> |
| | | <div |
| | | v-for="section in selectedPermissionSections" |
| | | :key="section.key" |
| | | class="permission-section-row" |
| | | :data-section-key="section.key" |
| | | > |
| | | <div class="section-header-row"> |
| | | <div class="section-title-wrap"> |
| | | <span class="section-node-title">{{ section.title }}</span> |
| | | <NCheckbox |
| | | v-if="section.actions.length" |
| | | :checked="getActionStatus(section.node) === 'checked'" |
| | | :indeterminate="getActionStatus(section.node) === 'partial'" |
| | | :disabled="isReadOnlyMode" |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="section.actions.length" class="action-list"> |
| | | <div class="action-list"> |
| | | <div |
| | | class="action-item" |
| | | > |
| | | <NCheckbox |
| | | checked |
| | | disabled |
| | | /> |
| | | <span class="action-name">页面查看</span> |
| | | </div> |
| | | <div |
| | | v-for="action in section.actions" |
| | | :key="action.key" |
| | |
| | | .role-form-page { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: calc(100vh - 88px); |
| | | height: calc(100vh - 88px - 28px); |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | |
| | | color: #0068ff; |
| | | font-weight: 400; |
| | | background: #e6f4ff; |
| | | flex-shrink: 0; |
| | | |
| | | &.wait-test { |
| | | background: #fff6ea; |
| | |
| | | |
| | | const fenceStatusOptions = [ |
| | | { label: '草稿', value: 0 }, |
| | | { label: '待生效', value: 1 }, |
| | | // { label: '待生效', value: 1 }, |
| | | { label: '启用中', value: 2 }, |
| | | { label: '已停用', value: 3 }, |
| | | { label: '待测试', value: 4 }, |
| | |
| | | <NButton v-permission="2026061505" text type="primary" disabled={row.flowStatus === 5 } onClick={() => handleFenceToDetail(row, 'edit')}> |
| | | 编辑 |
| | | </NButton> |
| | | <NButton v-permission="2026061506" text type="primary" disabled={row.flowStatus === 5 } onClick={() => handleFenceCopy(row)}> |
| | | <NButton v-permission="2026061506" text type="primary" onClick={() => handleFenceCopy(row)}> |
| | | 复制 |
| | | </NButton> |
| | | </NSpace> |
| | |
| | | } |
| | | } |
| | | |
| | | .fence-hover-tooltip { |
| | | position: absolute; |
| | | z-index: 5; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | min-width: 88px; |
| | | max-width: 160px; |
| | | padding: 7px 10px; |
| | | border-radius: 6px; |
| | | color: #fff; |
| | | font-size: 13px; |
| | | line-height: 18px; |
| | | white-space: nowrap; |
| | | pointer-events: none; |
| | | transform: translate(-50%, -100%); |
| | | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); |
| | | |
| | | &.is-enabled { |
| | | background: #19b144; |
| | | } |
| | | |
| | | &.is-disabled { |
| | | background: #8f959e; |
| | | } |
| | | |
| | | &.is-testing { |
| | | background: #171f26; |
| | | } |
| | | } |
| | | |
| | | .fence-hover-tooltip__text { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .fence-hover-tooltip__arrow { |
| | | position: absolute; |
| | | bottom: -5px; |
| | | left: 50%; |
| | | width: 10px; |
| | | height: 10px; |
| | | background: inherit; |
| | | transform: translateX(-50%) rotate(45deg); |
| | | } |
| | | |
| | | .map-tips { |
| | | position: absolute; |
| | | top: 46px; |
| | |
| | | 'white-space: normal', |
| | | 'word-break: break-word', |
| | | ].join(';') |
| | | text.innerHTML = '拖动橙色实心点增加顶点<br />点击白色实心点删除顶点' |
| | | text.innerHTML = `拖动${fenceCreateMode.value ? '橙色' : '蓝色'}实心点增加顶点<br />点击白色实心点删除顶点` |
| | | const arrow = document.createElement('div') |
| | | arrow.style.cssText = [ |
| | | 'position: absolute', |
| | |
| | | return |
| | | } |
| | | fenceEditGuideMarker.value.setPosition([lng, lat]) |
| | | const guideTextElement = fenceEditGuideContentElement?.querySelector?.('.fence-edit-guide__text') as HTMLDivElement | null |
| | | if (guideTextElement) { |
| | | guideTextElement.innerHTML = `拖动${fenceCreateMode.value ? '橙色' : '蓝色'}实心点增加顶点<br />点击白色实心点删除顶点` |
| | | } |
| | | applyFenceEditGuidePlacement(layout.placement, layout.offset) |
| | | } |
| | | |
| | |
| | | const cityBoundaryHoverVisible = ref(false) |
| | | const cityBoundaryHoverText = ref('') |
| | | const cityBoundaryHoverOutsideSelected = ref(false) |
| | | const fenceHoverTooltipVisible = ref(false) |
| | | const fenceHoverTooltipText = ref('') |
| | | const fenceHoverTooltipType = ref<'enabled' | 'disabled' | 'testing'>('testing') |
| | | const fenceCreateOutsideCurrentCity = ref(false) |
| | | const hoveredFencePolygon = shallowRef<any | null>(null) |
| | | const hoveredFenceBaseStrokeWeight = ref<number | null>(null) |
| | | const cityBoundaryHoverPosition = reactive({ |
| | | x: 0, |
| | | y: 0, |
| | | }) |
| | | const fenceHoverTooltipPosition = reactive({ |
| | | x: 0, |
| | | y: 0, |
| | | }) |
| | | let cityBoundaryHoverQueryTimer: ReturnType<typeof setTimeout> | null = null |
| | | let cityBoundaryHoverQuerySeq = 0 |
| | | let fenceHoverTooltipTimer: ReturnType<typeof setTimeout> | null = null |
| | | let pendingFenceHoverKey = '' |
| | | let pendingFenceHoverPixel: { x: number, y: number } | null = null |
| | | const cityBoundaryHoverTextCache = new Map<string, string>() |
| | | let skipFenceCreateCityChangeConfirm = false |
| | | let fenceCreateBoundaryModalOpening = false |
| | |
| | | } |
| | | } |
| | | |
| | | function clearFenceHoverTooltipTimer() { |
| | | if (fenceHoverTooltipTimer) { |
| | | clearTimeout(fenceHoverTooltipTimer) |
| | | fenceHoverTooltipTimer = null |
| | | } |
| | | } |
| | | |
| | | function resetFenceHoverPolygonStyle() { |
| | | if (hoveredFencePolygon.value && hoveredFenceBaseStrokeWeight.value !== null) { |
| | | hoveredFencePolygon.value.setOptions({ |
| | | strokeWeight: hoveredFenceBaseStrokeWeight.value, |
| | | }) |
| | | } |
| | | hoveredFencePolygon.value = null |
| | | hoveredFenceBaseStrokeWeight.value = null |
| | | } |
| | | |
| | | function highlightFenceHoverPolygon(polygon: any, item: any) { |
| | | if (Number(item?.areaType) !== 1) |
| | | return |
| | | |
| | | if (hoveredFencePolygon.value === polygon) |
| | | return |
| | | |
| | | resetFenceHoverPolygonStyle() |
| | | |
| | | const currentStrokeWeight = Number(polygon?.getExtData?.()?.baseStrokeWeight ?? 2) |
| | | hoveredFencePolygon.value = polygon |
| | | hoveredFenceBaseStrokeWeight.value = currentStrokeWeight |
| | | polygon.setOptions({ |
| | | strokeWeight: currentStrokeWeight + 1.5, |
| | | }) |
| | | } |
| | | |
| | | function hideFenceHoverTooltip() { |
| | | clearFenceHoverTooltipTimer() |
| | | pendingFenceHoverKey = '' |
| | | pendingFenceHoverPixel = null |
| | | resetFenceHoverPolygonStyle() |
| | | fenceHoverTooltipVisible.value = false |
| | | fenceHoverTooltipText.value = '' |
| | | fenceHoverTooltipType.value = 'testing' |
| | | } |
| | | |
| | | function getFenceHoverKey(item: any) { |
| | | return `${String(item?.electronicId || '')}-${String(item?.id || '')}` |
| | | } |
| | | |
| | | function resolveFenceOverlayType(item: any) { |
| | | if (Number(item?.flowStatus) === 5 && Number(item?.status) === 2) |
| | | return 'enabled' as const |
| | | if (Number(item?.flowStatus) === 5 && Number(item?.status) === 3) |
| | | return 'disabled' as const |
| | | return 'testing' as const |
| | | } |
| | | |
| | | function getFenceHoverTooltipText(type: 'enabled' | 'disabled' | 'testing') { |
| | | if (type === 'enabled') |
| | | return '启用中围栏' |
| | | if (type === 'disabled') |
| | | return '已停用围栏' |
| | | return '测试围栏' |
| | | } |
| | | |
| | | function parseFencePriorityTime(value: unknown) { |
| | | const timestamp = dayjs(String(value || '')).valueOf() |
| | | return Number.isFinite(timestamp) ? timestamp : 0 |
| | | } |
| | | |
| | | function getFenceOverlayPriority(item: any) { |
| | | const type = resolveFenceOverlayType(item) |
| | | return { |
| | | type, |
| | | typePriority: type === 'testing' ? 2 : type === 'enabled' ? 1 : 0, |
| | | updateTimePriority: parseFencePriorityTime(item?.updateTime), |
| | | fallbackPriority: Number(item?.electronicId ?? item?.id ?? 0) || 0, |
| | | } |
| | | } |
| | | |
| | | function updateFenceHoverTooltipPositionByPixel(pixel?: { x: number, y: number } | null) { |
| | | if (!map.value || !pixel) |
| | | return false |
| | | |
| | | const container = map.value.getContainer?.() |
| | | if (!container) |
| | | return false |
| | | |
| | | const tooltipWidth = 88 |
| | | const offsetX = 1 |
| | | const offsetY = 7 |
| | | const rawX = Number(pixel.x) + offsetX |
| | | const rawY = Number(pixel.y) - offsetY |
| | | const clampedX = Math.max(12, Math.min(rawX, container.clientWidth - 12)) |
| | | const clampedY = Math.max(12, Math.min(rawY, container.clientHeight - 12)) |
| | | |
| | | fenceHoverTooltipPosition.x = clampedX |
| | | fenceHoverTooltipPosition.y = clampedY |
| | | return true |
| | | } |
| | | |
| | | function scheduleFenceHoverTooltip(item: any, pixel?: { x: number, y: number } | null) { |
| | | if (Number(item?.areaType) !== 1 || isFenceFormMode.value || isStationFormMode.value) |
| | | return |
| | | |
| | | const hoverKey = getFenceHoverKey(item) |
| | | pendingFenceHoverKey = hoverKey |
| | | pendingFenceHoverPixel = pixel |
| | | updateFenceHoverTooltipPositionByPixel(pixel) |
| | | clearFenceHoverTooltipTimer() |
| | | fenceHoverTooltipTimer = window.setTimeout(() => { |
| | | if (pendingFenceHoverKey !== hoverKey) |
| | | return |
| | | |
| | | const nextType = resolveFenceOverlayType(item) |
| | | if (!updateFenceHoverTooltipPositionByPixel(pendingFenceHoverPixel)) |
| | | return |
| | | |
| | | fenceHoverTooltipType.value = nextType |
| | | fenceHoverTooltipText.value = getFenceHoverTooltipText(nextType) |
| | | fenceHoverTooltipVisible.value = true |
| | | fenceHoverTooltipTimer = null |
| | | }, 300) |
| | | } |
| | | |
| | | function handleCityBoundaryMouseOut() { |
| | | hideCityBoundaryHoverTooltip() |
| | | hideFenceHoverTooltip() |
| | | resetFenceEditGuideHoverState() |
| | | resetRestrictedEditGuideHoverState() |
| | | } |
| | |
| | | ) |
| | | if (activeRestrictedArea) { |
| | | if (activeRestrictedArea.points.length < 3) |
| | | return '请在地图上【橙色运营区域】绘制多边形区域,双击闭合' |
| | | return `请在地图上【${getFenceDraftAccentLabel()}运营区域】绘制多边形区域,双击闭合` |
| | | if (isFenceGeometryLocked.value) |
| | | return '【红色禁行区】已选中' |
| | | return '【红色禁行区】可调整区域点位,请保持在橙色运营区内' |
| | | return `【红色禁行区】可调整区域点位,请保持在${getFenceDraftAccentLabel()}运营区内` |
| | | } |
| | | return '' |
| | | }) |
| | |
| | | }) |
| | | } |
| | | |
| | | function getFenceDraftAccentColor() { |
| | | return fenceCreateMode.value ? '#F19216' : '#2F7BFF' |
| | | } |
| | | |
| | | function getFenceDraftAccentLabel() { |
| | | return fenceCreateMode.value ? '橙色' : '蓝色' |
| | | } |
| | | |
| | | function syncFenceStationWarningState(points: LngLatTuple[] = fenceDraftPoints.value) { |
| | | invalidFenceStationIds.value = [] |
| | | setFenceDraftPolygonStyle(fenceInteractionMode.value === 'operation-edit') |
| | |
| | | function setFenceDraftPolygonStyle(editable: boolean) { |
| | | if (!fenceDraftPolygon.value) |
| | | return |
| | | const accentColor = getFenceDraftAccentColor() |
| | | fenceDraftPolygon.value.setOptions({ |
| | | fillColor: '#F19216', |
| | | strokeColor: '#F19216', |
| | | fillColor: accentColor, |
| | | strokeColor: accentColor, |
| | | strokeWeight: 2, |
| | | strokeOpacity: 1, |
| | | fillOpacity: 0.1, |
| | |
| | | return |
| | | if (fenceDraftPolygon.value) |
| | | map.value.remove(fenceDraftPolygon.value) |
| | | const accentColor = getFenceDraftAccentColor() |
| | | fenceDraftPolygon.value = new (window as any).AMap.Polygon({ |
| | | path: points, |
| | | fillColor: '#F19216', |
| | | strokeColor: '#F19216', |
| | | fillColor: accentColor, |
| | | strokeColor: accentColor, |
| | | strokeWeight: 2, |
| | | strokeOpacity: 1, |
| | | fillOpacity: 0.1, |
| | |
| | | fenceDraftPolygon.value, |
| | | { |
| | | controlPoint: { |
| | | strokeColor: '#F19216', |
| | | strokeColor: getFenceDraftAccentColor(), |
| | | strokeOpacity: 1, |
| | | strokeWeight: 2, |
| | | }, |
| | | midControlPoint: { fillColor: '#F19216' }, |
| | | editOptions: { fillColor: '#F19216' }, |
| | | midControlPoint: { fillColor: getFenceDraftAccentColor() }, |
| | | editOptions: { fillColor: getFenceDraftAccentColor() }, |
| | | }, |
| | | ) |
| | | fencePolyEditor.value.open() |
| | |
| | | return |
| | | cleanupFenceEditorState() |
| | | lastValidFenceDrawingPoints.value = [] |
| | | const accentColor = getFenceDraftAccentColor() |
| | | fenceMouseTool.value = new (window as any).AMap.MouseTool(map.value) |
| | | fenceMouseTool.value.polygon({ |
| | | strokeColor: '#F19216', |
| | | strokeColor: accentColor, |
| | | strokeOpacity: 1, |
| | | strokeWeight: 2, |
| | | fillColor: '#F19216', |
| | | fillColor: accentColor, |
| | | fillOpacity: 0.1, |
| | | strokeStyle: 'solid', |
| | | cursor: 'pointer', |
| | |
| | | function clearMapEntities() { |
| | | if (!map.value) |
| | | return |
| | | hideFenceHoverTooltip() |
| | | if (pointMarkers.value.length) |
| | | map.value.remove(pointMarkers.value) |
| | | if (cityBoundaryPolygons.value.length) |
| | |
| | | } |
| | | return false |
| | | }) |
| | | .sort((prev: any, next: any) => { |
| | | const prevPriority = getFenceOverlayPriority(prev) |
| | | const nextPriority = getFenceOverlayPriority(next) |
| | | if (prevPriority.typePriority !== nextPriority.typePriority) |
| | | return prevPriority.typePriority - nextPriority.typePriority |
| | | if ( |
| | | prevPriority.type === 'testing' |
| | | && nextPriority.type === 'testing' |
| | | && prevPriority.updateTimePriority !== nextPriority.updateTimePriority |
| | | ) { |
| | | return prevPriority.updateTimePriority - nextPriority.updateTimePriority |
| | | } |
| | | if (prevPriority.updateTimePriority !== nextPriority.updateTimePriority) |
| | | return prevPriority.updateTimePriority - nextPriority.updateTimePriority |
| | | return prevPriority.fallbackPriority - nextPriority.fallbackPriority |
| | | }) |
| | | .map((item) => { |
| | | const areaType = Number(item.areaType) |
| | | const points = String(item.fencePoints) |
| | |
| | | const isFenceEnabled |
| | | = areaType === 1 |
| | | && enabledFenceIdSet.value.has(String(item.electronicId)) |
| | | const fencePriority = getFenceOverlayPriority(item) |
| | | // 非启用中的选中围栏使用蓝色选中态;启用中的围栏即使被选中也保持绿色。 |
| | | const isFenceSelectedBlue = isFenceDetailActive && !isFenceEnabled |
| | | const isFenceHighlighted = isFenceDetailActive || isFenceEnabled |
| | | const baseStrokeWeight = areaType === 2 |
| | | ? 0 |
| | | : isFenceSelectedBlue |
| | | ? 3 |
| | | : isFenceFlowCompletedDisabled |
| | | ? 2 |
| | | : isFenceHighlighted |
| | | ? 2 |
| | | : isActive |
| | | ? 3 |
| | | : 2 |
| | | const polygon = new (window as any).AMap.Polygon({ |
| | | path: points, |
| | | // 填充颜色 |
| | |
| | | ? '#171F26' |
| | | : '#AEB0B2', |
| | | // 线宽度 |
| | | strokeWeight: areaType === 2 |
| | | ? 0 |
| | | : isFenceSelectedBlue |
| | | ? 3 |
| | | : isFenceFlowCompletedDisabled |
| | | ? 2 |
| | | : isFenceHighlighted |
| | | ? 2 |
| | | : isActive |
| | | ? 3 |
| | | : 2, |
| | | strokeWeight: baseStrokeWeight, |
| | | // 线透明度 |
| | | strokeOpacity: areaType === 2 ? 0 : isFenceFlowCompletedDisabled ? 0.3 : isFenceHighlighted ? 1 : isFenceReadonlyDimmed ? 0.2 : 1, |
| | | // 填充透明度 |
| | |
| | | && !isFenceCreateInteractionLocked.value |
| | | && !isFenceFormMode.value |
| | | && !isStationFormMode.value, |
| | | zIndex: isReadonlyRestrictedActive ? 230 : isActive ? 220 : 205, |
| | | zIndex: areaType === 2 |
| | | ? (isReadonlyRestrictedActive ? 230 : 205) |
| | | : 240 + fencePriority.typePriority * 40 + (isActive ? 8 : 0), |
| | | }) |
| | | polygon.on('click', () => { |
| | | hideFenceHoverTooltip() |
| | | if ( |
| | | isPointMapPickActive.value |
| | | || isFenceCreateInteractionLocked.value |
| | |
| | | renderMapEntities() |
| | | handleMapEntityClick('fence', String(item.electronicId)) |
| | | }) |
| | | polygon.on('mouseover', (event: any) => { |
| | | highlightFenceHoverPolygon(polygon, item) |
| | | scheduleFenceHoverTooltip(item, event?.pixel || null) |
| | | }) |
| | | polygon.on('mousemove', (event: any) => { |
| | | pendingFenceHoverPixel = event?.pixel || null |
| | | updateFenceHoverTooltipPositionByPixel(pendingFenceHoverPixel) |
| | | }) |
| | | polygon.on('mouseout', () => { |
| | | if (pendingFenceHoverKey === getFenceHoverKey(item)) |
| | | pendingFenceHoverKey = '' |
| | | hideFenceHoverTooltip() |
| | | }) |
| | | polygon.setExtData({ |
| | | id: String(item.electronicId), |
| | | type: 'fence', |
| | | areaType, |
| | | areaId: String(item.id || ''), |
| | | baseStrokeWeight, |
| | | }) |
| | | return polygon |
| | | }) |
| | |
| | | > |
| | | {{ cityBoundaryHoverText }} |
| | | </div> |
| | | <div |
| | | v-if="fenceHoverTooltipVisible" |
| | | class="fence-hover-tooltip" |
| | | :class="`is-${fenceHoverTooltipType}`" |
| | | :style="{ |
| | | left: `${fenceHoverTooltipPosition.x}px`, |
| | | top: `${fenceHoverTooltipPosition.y}px`, |
| | | }" |
| | | > |
| | | <span class="fence-hover-tooltip__text">{{ fenceHoverTooltipText }}</span> |
| | | <span class="fence-hover-tooltip__arrow" /> |
| | | </div> |
| | | <MapInfoPopupCard |
| | | v-if="showFenceEditMapInfoPopup" |
| | | :style="mapInfoPopupStyle" |
| | |
| | | class="operate-item create-tool-trigger" |
| | | :class="{ |
| | | 'active': showCreateMenu, |
| | | 'disabled': !!createToolDisabledReason, |
| | | // 'disabled': !!createToolDisabledReason, |
| | | 'has-selection': |
| | | !showCreateMenu && !!selectedCreateToolOption, |
| | | }" |
| | |
| | | { label: "待测试", value: "wait-test" }, |
| | | { label: "测试中", value: "testing" }, |
| | | { label: "测试不通过", value: "test-fail" }, |
| | | { label: "可运营", value: "operable" }, |
| | | // { label: "可运营", value: "operable" }, |
| | | { label: "草稿", value: "draft" }, |
| | | { label: "待生效", value: "pending" }, |
| | | // { label: "待生效", value: "pending" }, |
| | | { label: "启用中", value: "enabled" }, |
| | | { label: "已停用", value: "disabled" }, |
| | | ], |
| | |
| | | import { NButton, NDataTable, NForm, NFormItem, NGi, NInput, NModal, NSelect } from 'naive-ui' |
| | | import SearchPanel from '@/components/custom/SearchPanel.vue' |
| | | import Pagination from '@/components/common/Pagination.vue' |
| | | import { fetchPointPageList } from '@/service/api/testOperationWorkbench' |
| | | import { fetchFenceAndStationAreaList, fetchPointPageList } from '@/service/api/testOperationWorkbench' |
| | | import { getMultiSelectRowClassName, shouldIgnoreTableRowClick, toggleMultiSelectRowKey } from '@/utils/table' |
| | | |
| | | const props = withDefaults(defineProps<{ |
| | |
| | | id: string |
| | | pointName: string |
| | | address: string |
| | | pointArea: string |
| | | pointType: number // 1: 运营, 2: 测试 |
| | | status: string |
| | | } |
| | | |
| | | interface PointAreaOption { |
| | | label: string |
| | | value: string |
| | | type: number |
| | | name: string |
| | | ids: number | string |
| | | } |
| | | |
| | | const model = ref({ |
| | | pointName: '', |
| | | pointType: null as number | null, |
| | | operationStatusList: null as number[] | null, |
| | | fenceAndStationList: [] as string[], |
| | | }) |
| | | |
| | | const pageParams = ref({ |
| | |
| | | { label: '测试', value: 2 }, |
| | | ] |
| | | |
| | | const pointStatusOptions = [ |
| | | { label: '待测试', value: 1 }, |
| | | { label: '测试中', value: 2 }, |
| | | { label: '测试不通过', value: 3 }, |
| | | { label: '可运营', value: 4 }, |
| | | { label: '待生效', value: 5 }, |
| | | { label: '启用中', value: 6 }, |
| | | { label: '已停用', value: 7 }, |
| | | { label: '已失效', value: 8 }, |
| | | ] |
| | | |
| | | const listData = ref<PointRow[]>([]) |
| | | const checkedRowKeys = ref<DataTableRowKey[]>([]) |
| | | const count = ref(0) |
| | | const loading = ref(false) |
| | | const allLoadedData = ref<Map<DataTableRowKey, PointRow>>(new Map()) |
| | | const pointAreaOptions = ref<PointAreaOption[]>([]) |
| | | const saveDisabled = computed(() => checkedRowKeys.value.length === 0) |
| | | const tableScrollX = 900 |
| | | |
| | | function buildPointAreaOptionValue(item: { type: number, ids: number | string }) { |
| | | return `${Number(item.type)}_${String(item.ids)}` |
| | | } |
| | | |
| | | function getSelectedPointAreaList() { |
| | | const selectedValueSet = new Set(model.value.fenceAndStationList || []) |
| | | return pointAreaOptions.value |
| | | .filter(item => selectedValueSet.has(item.value)) |
| | | .map(item => ({ |
| | | type: item.type, |
| | | name: item.name, |
| | | ids: item.ids, |
| | | })) |
| | | } |
| | | |
| | | function getPointAreaText(row?: any) { |
| | | const electronicName = String(row?.electronicName || '').trim() |
| | | const stationName = String(row?.stationName || '').trim() |
| | | |
| | | if (electronicName && stationName) |
| | | return `[区]${electronicName} , [场]${stationName}` |
| | | if (electronicName) |
| | | return `[区]${electronicName}` |
| | | if (stationName) |
| | | return `[场]${stationName}` |
| | | return '-' |
| | | } |
| | | |
| | | function isPointDisabled(row?: Partial<PointRow> | null) { |
| | | return String(row?.status || '').trim() === '已停用' |
| | | } |
| | | |
| | | async function loadPointAreaOptions() { |
| | | try { |
| | | const res = await fetchFenceAndStationAreaList() |
| | | const list = Array.isArray(res.data) ? res.data : [] |
| | | pointAreaOptions.value = list |
| | | .filter((item: any) => item && item.type && item.name !== undefined && item.ids !== undefined) |
| | | .map((item: any) => ({ |
| | | label: `${Number(item.type) === 1 ? '围栏' : '场站'}-${String(item.name || '-')}`, |
| | | value: buildPointAreaOptionValue(item), |
| | | type: Number(item.type), |
| | | name: String(item.name || ''), |
| | | ids: item.ids, |
| | | })) |
| | | } |
| | | catch (error) { |
| | | console.error('加载所属区域失败:', error) |
| | | pointAreaOptions.value = [] |
| | | } |
| | | |
| | | const optionValueSet = new Set(pointAreaOptions.value.map(item => item.value)) |
| | | model.value.fenceAndStationList = (model.value.fenceAndStationList || []).filter(value => optionValueSet.has(value)) |
| | | } |
| | | |
| | | async function getList() { |
| | |
| | | const res = await fetchPointPageList({ |
| | | pointName: model.value.pointName.trim() || undefined, |
| | | pointType: model.value.pointType || undefined, |
| | | operationStatusList: model.value.operationStatusList?.length ? model.value.operationStatusList : undefined, |
| | | fenceAndStationList: getSelectedPointAreaList(), |
| | | pageNum: pageParams.value.pageNum, |
| | | pageSize: pageParams.value.pageSize, |
| | | }) |
| | |
| | | id: String(item.id), |
| | | pointName: item.pointName || '-', |
| | | address: item.pointAddress || '-', |
| | | pointArea: getPointAreaText(item), |
| | | pointType: Number(item.pointType ?? 0), |
| | | status: getPointStatusText(item.operationStatus ?? item.operationStatus ?? 0, item.verificationStatus), |
| | | })) |
| | |
| | | (val) => { |
| | | if (val) { |
| | | checkedRowKeys.value = props.existingPoints.map(item => item.id) |
| | | model.value = { pointName: '', pointType: null } |
| | | model.value = { pointName: '', pointType: null, operationStatusList: null, fenceAndStationList: [] } |
| | | pageParams.value = { pageNum: 1, pageSize: 10 } |
| | | allLoadedData.value.clear() |
| | | props.existingPoints.forEach((item) => { |
| | | allLoadedData.value.set(item.id, item) |
| | | }) |
| | | loadPointAreaOptions() |
| | | getList() |
| | | } |
| | | else { |
| | |
| | | } |
| | | |
| | | function handleReset() { |
| | | model.value = { pointName: '', pointType: null } |
| | | model.value = { pointName: '', pointType: null, operationStatusList: null, fenceAndStationList: [] } |
| | | pageParams.value.pageNum = 1 |
| | | getList() |
| | | } |
| | |
| | | disabled: row => isPointDisabled(row), |
| | | }, |
| | | { title: '序号', key: 'index', width: 80, render: (_, index) => (pageParams.value.pageNum - 1) * pageParams.value.pageSize + index + 1 }, |
| | | { title: '点位名称', key: 'pointName', width: 250 }, |
| | | { title: '详细地址', key: 'address', flex: 1 }, |
| | | { title: '点位名称', key: 'pointName', width: 160,ellipsis:{tooltip:true} }, |
| | | { title: '所属区域', key: 'pointArea', width: 160,ellipsis:{tooptip:true} }, |
| | | { title: '详细地址', key: 'address', width: 220 ,ellipsis:{tooltip:true}}, |
| | | { title: '点位类型', key: 'pointType', width: 120, render: row => row.pointType === 2 ? '测试' : '运营' }, |
| | | { |
| | | title: '状态', |
| | | title: '点位状态', |
| | | key: 'status', |
| | | width: 100, |
| | | render: (row) => { |
| | |
| | | /> |
| | | </NFormItem> |
| | | </NGi> |
| | | |
| | | <NGi> |
| | | <NFormItem label="所属区域" path="fenceAndStationList"> |
| | | <NSelect |
| | | v-model:value="model.fenceAndStationList" |
| | | :options="pointAreaOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | multiple |
| | | max-tag-count="responsive" |
| | | > |
| | | <template #empty> |
| | | <div class="flex-center"> |
| | | <img :src="noDataImg" style="width: 50%" alt="no-data"> |
| | | </div> |
| | | </template> |
| | | </NSelect> |
| | | </NFormItem> |
| | | </NGi> |
| | | <NGi> |
| | | <NFormItem label="点位类型" path="pointType"> |
| | | <NSelect |
| | |
| | | :options="pointTypeOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | > |
| | | <template #empty> |
| | | <div class="flex-center"> |
| | | <img :src="noDataImg" style="width: 50%" alt="no-data"> |
| | | </div> |
| | | </template> |
| | | </NSelect> |
| | | </NFormItem> |
| | | </NGi> |
| | | <NGi> |
| | | <NFormItem label="点位状态" path="operationStatusList"> |
| | | <NSelect |
| | | v-model:value="model.operationStatusList" |
| | | :options="pointStatusOptions" |
| | | placeholder="请选择" |
| | | clearable |
| | | multiple |
| | | max-tag-count="responsive" |
| | | > |
| | | <template #empty> |
| | | <div class="flex-center"> |
| | |
| | | v-model:checked-row-keys="checkedRowKeys" |
| | | style="height: 100%" |
| | | flex-height |
| | | :scroll-x="tableScrollX" |
| | | :columns="columns" |
| | | :data="listData" |
| | | :loading="loading" |
| | |
| | | import { usePermission } from '@/hooks/usePermission' |
| | | import { cancelAndCloseOrder, deleteOrder, exportOrderExcel, getFleetList, getOrderList, refundOrder } from '@/service/api/transaction' |
| | | import { operationPageList } from '@/service/api/vehicleConvoy' |
| | | import { fetchVehiclePageList } from '@/service/api/fleet' |
| | | import { getRegionCascaderNoArea } from '@/service/api/operations' |
| | | import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui' |
| | | import SearchPanel from '@/components/custom/SearchPanel.vue' |
| | |
| | | render: () => <n-input v-model:value={model.value.nickName} />, |
| | | }, |
| | | { |
| | | label: t('transaction.vehicleNumber'), |
| | | label: '接单车辆', |
| | | path: 'plateNumber', |
| | | render: () => ( |
| | | <n-select |
| | |
| | | plateNumberOptions.value = [] |
| | | return |
| | | } |
| | | const res = await operationPageList({ |
| | | const res = await fetchVehiclePageList({ |
| | | pageNum: 1, |
| | | pageSize: 99999, |
| | | }) |
| | |
| | | <n-form label-placement="left" require-mark-placement="left"> |
| | | <div class="form-row"> |
| | | <n-form-item label="协议名称" required class="agreement-name-item"> |
| | | <NInput v-model:value="agreementName" placeholder="请输入" maxlength="20" clearable class="w-236px" /> |
| | | <NInput v-model:value="agreementName" placeholder="请输入" maxlength="30" clearable class="w-236px" /> |
| | | </n-form-item> |
| | | </div> |
| | | </n-form> |
| | |
| | | validator: () => { |
| | | if (!plainTextLength.value) |
| | | return new Error('请输入协议内容') |
| | | if (plainTextLength.value > 200) |
| | | return new Error('协议内容最多输入200个字符') |
| | | if (plainTextLength.value > 5000) |
| | | return new Error('协议内容最多输入5000个字符') |
| | | return true |
| | | }, |
| | | trigger: ['blur', 'change', 'input'], |