| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { NButton, NSelect, NCard, NSpace, NTag, NAlert, NInput, NRadioGroup, NRadio } from 'naive-ui' |
| | | import { useAMapLoader } from '@/composables/useAMapLoader' |
| | | import { fetchPointList } from '@/service/api/point' |
| | | import { getRoutePlanList } from '@/service/api/transaction' |
| | | import { debugRoute, promoteWaypoints } from '@/service/api/uturnAvoidConfig' |
| | | import type { CandidateResult, DebugRouteResult, FenceAreaVo, HitDetail } from '@/service/api/uturnAvoidConfig' |
| | | |
| | | const message = useMessage() |
| | | const dialog = useDialog() |
| | | |
| | | // ==================== 点位数据 ==================== |
| | | interface PointOption { |
| | | label: string |
| | | value: number |
| | | lng: number |
| | | lat: number |
| | | cityCode?: string |
| | | } |
| | | const pointOptions = ref<PointOption[]>([]) |
| | | const startPointId = ref<number | null>(null) |
| | | const endPointId = ref<number | null>(null) |
| | | |
| | | // 手工输入模式 |
| | | type InputMode = 'select' | 'manual' |
| | | const startMode = ref<InputMode>('select') |
| | | const endMode = ref<InputMode>('select') |
| | | const manualStartLng = ref('') |
| | | const manualStartLat = ref('') |
| | | const manualEndLng = ref('') |
| | | const manualEndLat = ref('') |
| | | |
| | | const startPoint = computed(() => { |
| | | if (startMode.value === 'manual') { |
| | | const lng = parseFloat(manualStartLng.value) |
| | | const lat = parseFloat(manualStartLat.value) |
| | | if (!isNaN(lng) && !isNaN(lat) && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) { |
| | | return { label: '手工输入', value: 0, lng, lat, cityCode: undefined } |
| | | } |
| | | return undefined |
| | | } |
| | | return pointOptions.value.find(p => p.value === startPointId.value) |
| | | }) |
| | | const endPoint = computed(() => { |
| | | if (endMode.value === 'manual') { |
| | | const lng = parseFloat(manualEndLng.value) |
| | | const lat = parseFloat(manualEndLat.value) |
| | | if (!isNaN(lng) && !isNaN(lat) && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) { |
| | | return { label: '手工输入', value: 0, lng, lat, cityCode: undefined } |
| | | } |
| | | return undefined |
| | | } |
| | | return pointOptions.value.find(p => p.value === endPointId.value) |
| | | }) |
| | | |
| | | async function loadPoints() { |
| | | try { |
| | | const res = await fetchPointList({}) |
| | | if (res.code === 200 && Array.isArray(res.data)) { |
| | | pointOptions.value = res.data |
| | | .filter((p: any) => p.id != null && p.pointLon != null && p.pointLat != null) |
| | | .map((p: any) => ({ |
| | | label: `${p.pointName || ''} (${p.id})`, |
| | | value: p.id, |
| | | lng: p.pointLon, |
| | | lat: p.pointLat, |
| | | cityCode: p.cityCode, |
| | | })) |
| | | } |
| | | } |
| | | catch {} |
| | | } |
| | | |
| | | // ==================== 城市路线规划策略 ==================== |
| | | interface RoutePlanItem { |
| | | id: number |
| | | planName: string |
| | | cityName: string |
| | | routePlanRule: number |
| | | remarks?: string |
| | | } |
| | | |
| | | // 与「交易管理-路线规划」页面一致的优先规则文案 |
| | | const ROUTE_PLAN_RULE_LABELS: Record<number, string> = { |
| | | 0: '速度优先(只返回一条路线),此路线不一定距离最短', |
| | | 1: '费用优先(只返回一条路线),不走收费路段,且耗时最少的路线', |
| | | 2: '常规最快(只返回一条路线),综合距离/耗时规划结果', |
| | | 32: '默认,高德推荐,同高德地图APP默认', |
| | | 33: '躲避拥堵', |
| | | 34: '高速优先', |
| | | 35: '不走高速', |
| | | 36: '少收费', |
| | | 37: '大路优先', |
| | | 38: '速度最快', |
| | | 39: '躲避拥堵+高速优先', |
| | | 40: '躲避拥堵+不走高速', |
| | | 41: '躲避拥堵+少收费', |
| | | 42: '少收费+不走高速', |
| | | 43: '躲避拥堵+少收费+不走高速', |
| | | 44: '躲避拥堵+大路优先', |
| | | 45: '躲避拥堵+速度最快', |
| | | } |
| | | |
| | | function ruleLabel(rule: number | null | undefined) { |
| | | if (rule == null) return '未指定' |
| | | return ROUTE_PLAN_RULE_LABELS[rule] || `策略${rule}` |
| | | } |
| | | |
| | | const cityCodeInput = ref('440100') |
| | | const routePlanLoading = ref(false) |
| | | const routePlans = ref<RoutePlanItem[]>([]) |
| | | const selectedRoutePlanId = ref<number | null>(null) |
| | | |
| | | const routePlanOptions = computed(() => |
| | | routePlans.value.map(p => ({ |
| | | label: `${p.planName}(${ruleLabel(p.routePlanRule)})`, |
| | | value: p.id, |
| | | })), |
| | | ) |
| | | const selectedRoutePlan = computed(() => routePlans.value.find(p => p.id === selectedRoutePlanId.value) || null) |
| | | /** 传给高德的算路策略值,与线上主链路口径一致(xp_route_plan.route_plan_rule) */ |
| | | const selectedRoutePlanRule = computed(() => selectedRoutePlan.value?.routePlanRule) |
| | | |
| | | /** 读取当前城市已审核通过的路线规划策略(同线上取数口径:cityCode + auditStatus=2) */ |
| | | async function loadRoutePlans() { |
| | | const cityCode = cityCodeInput.value.trim() |
| | | if (!cityCode) { |
| | | message.warning('请先选择起点点位带出城市,或手工输入城市Code') |
| | | return |
| | | } |
| | | routePlanLoading.value = true |
| | | try { |
| | | const res = await getRoutePlanList({ pageNum: 1, pageSize: 50, cityCode, auditStatus: 2 }) |
| | | if (res.code === 200 && res.data) { |
| | | const records = res.data.records || [] |
| | | routePlans.value = records.map((r: any) => ({ |
| | | id: r.id, |
| | | planName: r.planName || `策略${r.id}`, |
| | | cityName: (r.cityName || '') + (r.districtName || ''), |
| | | routePlanRule: r.routePlanRule, |
| | | remarks: r.remarks, |
| | | })) |
| | | if (routePlans.value.length === 0) { |
| | | selectedRoutePlanId.value = null |
| | | message.info(`城市 ${cityCode} 暂无已审核通过的路线规划策略,算路将不传策略值`) |
| | | } |
| | | else { |
| | | // 默认选中第一条,多条时用户可在下拉中切换测试 |
| | | if (!routePlans.value.some(p => p.id === selectedRoutePlanId.value)) |
| | | selectedRoutePlanId.value = routePlans.value[0].id |
| | | } |
| | | } |
| | | } |
| | | catch { |
| | | message.error('读取路线规划策略失败') |
| | | } |
| | | finally { |
| | | routePlanLoading.value = false |
| | | } |
| | | } |
| | | |
| | | // ==================== 途经点管理 ==================== |
| | | const waypoints = ref<Array<{ lng: number, lat: number }>>([]) |
| | | const addWaypointMode = ref(false) |
| | | const manualWpLng = ref('') |
| | | const manualWpLat = ref('') |
| | | |
| | | function addManualWaypoint() { |
| | | const lng = parseFloat(manualWpLng.value) |
| | | const lat = parseFloat(manualWpLat.value) |
| | | if (isNaN(lng) || isNaN(lat) || lng < -180 || lng > 180 || lat < -90 || lat > 90) { |
| | | message.warning('请输入有效的经纬度') |
| | | return |
| | | } |
| | | waypoints.value.push({ lng, lat }) |
| | | manualWpLng.value = '' |
| | | manualWpLat.value = '' |
| | | renderMapLayers() |
| | | } |
| | | |
| | | function removeWaypoint(index: number) { |
| | | waypoints.value.splice(index, 1) |
| | | renderMapLayers() |
| | | } |
| | | |
| | | function moveWaypoint(index: number, direction: -1 | 1) { |
| | | const newIndex = index + direction |
| | | if (newIndex < 0 || newIndex >= waypoints.value.length) return |
| | | const temp = waypoints.value[index] |
| | | waypoints.value[index] = waypoints.value[newIndex] |
| | | waypoints.value[newIndex] = temp |
| | | renderMapLayers() |
| | | } |
| | | |
| | | function clearWaypoints() { |
| | | waypoints.value = [] |
| | | renderMapLayers() |
| | | } |
| | | |
| | | /** 从命中配置中回显 detourWaypoints 作为当前途经点 */ |
| | | function loadDetourWaypointsFromHit(hit: HitDetail) { |
| | | if (!hit.detourWaypoints || hit.detourWaypoints.length === 0) { |
| | | message.warning('该配置暂无已配置的途经点') |
| | | return |
| | | } |
| | | waypoints.value = hit.detourWaypoints.map(p => ({ lng: p.longitude, lat: p.latitude })) |
| | | renderMapLayers() |
| | | message.success(`已加载 ${hit.detourWaypoints.length} 个途经点(来自 ${hit.name})`) |
| | | } |
| | | |
| | | // ==================== 地图 ==================== |
| | | const mapContainerRef = ref<HTMLDivElement | null>(null) |
| | | let mapInstance: any = null |
| | | const { load: loadAMap } = useAMapLoader() |
| | | |
| | | // 地图图层对象 |
| | | // R1 用 candidatePolylines 承载「全部围栏内候选」:主链路会对每条候选各跑一次命中检测, |
| | | // 只画首条会让命中落在「备选二」上的场景在页面上看不出来。 |
| | | let candidatePolylines: any[] = [] |
| | | let r2Polyline: any = null |
| | | let hitCircles: any[] = [] |
| | | let hitPolylines: any[] = [] |
| | | let detourMarkers: any[] = [] |
| | | let waypointMarkers: any[] = [] |
| | | let startMarker: any = null |
| | | let endMarker: any = null |
| | | let fencePolygons: any[] = [] |
| | | |
| | | async function initMap() { |
| | | await loadAMap() |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapContainerRef.value) return |
| | | |
| | | mapInstance = new AMap.Map(mapContainerRef.value, { |
| | | zoom: 14, |
| | | center: [113.32, 23.12], // 广州默认中心 |
| | | viewMode: '2D', |
| | | }) |
| | | |
| | | // 地图点击事件 - 添加途经点 |
| | | mapInstance.on('click', (e: any) => { |
| | | if (!addWaypointMode.value) return |
| | | waypoints.value.push({ lng: e.lnglat.getLng(), lat: e.lnglat.getLat() }) |
| | | renderMapLayers() |
| | | }) |
| | | } |
| | | |
| | | /** 只清除命中相关图层:切换候选时复用,避免连带清掉候选路径与围栏 */ |
| | | function clearHitLayers() { |
| | | hitCircles.forEach(c => c.setMap(null)); hitCircles = [] |
| | | hitPolylines.forEach(p => p.setMap(null)); hitPolylines = [] |
| | | detourMarkers.forEach(m => m.setMap(null)); detourMarkers = [] |
| | | } |
| | | |
| | | function clearMapLayers() { |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapInstance) return |
| | | |
| | | candidatePolylines.forEach(p => p.setMap(null)); candidatePolylines = [] |
| | | if (r2Polyline) { r2Polyline.setMap(null); r2Polyline = null } |
| | | clearHitLayers() |
| | | waypointMarkers.forEach(m => m.setMap(null)); waypointMarkers = [] |
| | | fencePolygons.forEach(p => p.setMap(null)); fencePolygons = [] |
| | | if (startMarker) { startMarker.setMap(null); startMarker = null } |
| | | if (endMarker) { endMarker.setMap(null); endMarker = null } |
| | | } |
| | | |
| | | /** 解析围栏点串 "经度_纬度,经度_纬度" → [[lng, lat], ...] */ |
| | | function parseFencePoints(fencePoints: string): number[][] { |
| | | if (!fencePoints) |
| | | return [] |
| | | return fencePoints.split(',').map((pair) => { |
| | | const [lng, lat] = pair.split('_').map(Number) |
| | | return [lng, lat] |
| | | }).filter(([lng, lat]) => !isNaN(lng) && !isNaN(lat)) |
| | | } |
| | | |
| | | /** 渲染围栏区域:运营区绿色半透明,禁行区红色(与围栏管理页同款样式) */ |
| | | function renderFenceAreas(areas: FenceAreaVo[] | undefined) { |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapInstance || !areas?.length) return |
| | | |
| | | fencePolygons.forEach(p => p.setMap(null)) |
| | | fencePolygons = [] |
| | | |
| | | areas.forEach((area) => { |
| | | if (area.areaStatus !== 1) return // 只渲染启用的区域 |
| | | const path = parseFencePoints(area.fencePoints) |
| | | if (path.length < 3) return |
| | | const isRestricted = area.areaType === 2 |
| | | const polygon = new AMap.Polygon({ |
| | | path, |
| | | fillColor: isRestricted ? '#EF938B' : '#19B144', |
| | | strokeColor: isRestricted ? '#EF938B' : '#19B144', |
| | | strokeWeight: 2, |
| | | strokeOpacity: 1, |
| | | fillOpacity: isRestricted ? 0.6 : 0.08, |
| | | strokeStyle: isRestricted ? 'solid' : 'dashed', |
| | | bubble: true, |
| | | zIndex: isRestricted ? 20 : 10, |
| | | }) |
| | | polygon.setMap(mapInstance) |
| | | fencePolygons.push(polygon) |
| | | }) |
| | | } |
| | | |
| | | function renderMapLayers() { |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapInstance) return |
| | | |
| | | // 清除途经点标记并重新绘制 |
| | | waypointMarkers.forEach(m => m.setMap(null)) |
| | | waypointMarkers = [] |
| | | |
| | | waypoints.value.forEach((wp, idx) => { |
| | | const marker = new AMap.Marker({ |
| | | position: [wp.lng, wp.lat], |
| | | content: `<div style="background:#7c3aed;color:#fff;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:bold;border:2px solid #fff;box-shadow:0 2px 4px rgba(0,0,0,0.3);">${idx + 1}</div>`, |
| | | offset: new AMap.Pixel(-11, -11), |
| | | draggable: true, |
| | | zIndex: 120, |
| | | }) |
| | | marker.on('dragend', (e: any) => { |
| | | waypoints.value[idx] = { lng: e.lnglat.getLng(), lat: e.lnglat.getLat() } |
| | | }) |
| | | marker.setMap(mapInstance) |
| | | waypointMarkers.push(marker) |
| | | }) |
| | | |
| | | // 起终点标记 |
| | | if (startMarker) { startMarker.setMap(null); startMarker = null } |
| | | if (endMarker) { endMarker.setMap(null); endMarker = null } |
| | | |
| | | if (startPoint.value) { |
| | | startMarker = new AMap.Marker({ |
| | | position: [startPoint.value.lng, startPoint.value.lat], |
| | | content: '<div style="background:#2563eb;color:#fff;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;border:2px solid #fff;box-shadow:0 2px 4px rgba(0,0,0,0.3);">起</div>', |
| | | offset: new AMap.Pixel(-12, -12), |
| | | zIndex: 110, |
| | | }) |
| | | startMarker.setMap(mapInstance) |
| | | } |
| | | if (endPoint.value) { |
| | | endMarker = new AMap.Marker({ |
| | | position: [endPoint.value.lng, endPoint.value.lat], |
| | | content: '<div style="background:#dc2626;color:#fff;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;border:2px solid #fff;box-shadow:0 2px 4px rgba(0,0,0,0.3);">终</div>', |
| | | offset: new AMap.Pixel(-12, -12), |
| | | zIndex: 110, |
| | | }) |
| | | endMarker.setMap(mapInstance) |
| | | } |
| | | } |
| | | |
| | | function renderRoute(polylineData: Array<{ longitude: number, latitude: number }>, color: string, isR2 = false) { |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapInstance || !polylineData || polylineData.length < 2) return |
| | | |
| | | const path = polylineData.map(p => [p.longitude, p.latitude]) |
| | | const polyline = new AMap.Polyline({ |
| | | path, |
| | | strokeColor: color, |
| | | strokeWeight: isR2 ? 5 : 4, |
| | | strokeOpacity: 0.85, |
| | | strokeStyle: isR2 ? 'dashed' : 'solid', |
| | | zIndex: isR2 ? 60 : 50, |
| | | }) |
| | | polyline.setMap(mapInstance) |
| | | return polyline |
| | | } |
| | | |
| | | function renderHitConfigs(hits: HitDetail[]) { |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapInstance) return |
| | | |
| | | hits.forEach((hit) => { |
| | | // 绘制 uturnPoints 折线(红色高亮) |
| | | if (hit.uturnPoints && hit.uturnPoints.length >= 2) { |
| | | const path = hit.uturnPoints.map(p => [p.longitude, p.latitude]) |
| | | const polyline = new AMap.Polyline({ |
| | | path, |
| | | strokeColor: '#dc2626', |
| | | strokeWeight: 6, |
| | | strokeOpacity: 0.9, |
| | | zIndex: 80, |
| | | }) |
| | | polyline.setMap(mapInstance) |
| | | hitPolylines.push(polyline) |
| | | } |
| | | |
| | | // 绘制 effectiveRadius 圆环 |
| | | if (hit.uturnPoints && hit.uturnPoints.length > 0) { |
| | | const center = hit.uturnPoints[Math.floor(hit.uturnPoints.length / 2)] |
| | | const circle = new AMap.Circle({ |
| | | center: [center.longitude, center.latitude], |
| | | radius: hit.effectiveRadius, |
| | | strokeColor: '#dc2626', |
| | | strokeWeight: 2, |
| | | strokeOpacity: 0.6, |
| | | fillColor: '#dc2626', |
| | | fillOpacity: 0.1, |
| | | zIndex: 70, |
| | | }) |
| | | circle.setMap(mapInstance) |
| | | hitCircles.push(circle) |
| | | } |
| | | |
| | | // 绘制已有 detourWaypoints(橙色标记) |
| | | if (hit.detourWaypoints && hit.detourWaypoints.length > 0) { |
| | | hit.detourWaypoints.forEach((dp, idx) => { |
| | | const marker = new AMap.Marker({ |
| | | position: [dp.longitude, dp.latitude], |
| | | content: `<div style="background:#f97316;color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;border:1px solid #fff;">${idx + 1}</div>`, |
| | | offset: new AMap.Pixel(-9, -9), |
| | | zIndex: 90, |
| | | }) |
| | | marker.setMap(mapInstance) |
| | | detourMarkers.push(marker) |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | // ==================== 候选路径渲染 ==================== |
| | | // 当前选中的候选序号(0=高德主推荐) |
| | | const selectedCandidateIndex = ref(0) |
| | | |
| | | // 候选配色:选中蓝色粗线;未选中但命中掉头弯的用橙色(一眼看到命中发生在哪条);其余浅灰 |
| | | const CANDIDATE_COLOR_SELECTED = '#2563eb' |
| | | const CANDIDATE_COLOR_HIT = '#f97316' |
| | | const CANDIDATE_COLOR_PLAIN = '#94a3b8' |
| | | |
| | | /** 渲染全部围栏内候选路径,点击任一条可切换查看 */ |
| | | function renderCandidates(list: CandidateResult[]) { |
| | | const AMap = (window as any).AMap |
| | | if (!AMap || !mapInstance) return |
| | | |
| | | candidatePolylines.forEach(p => p.setMap(null)) |
| | | candidatePolylines = [] |
| | | |
| | | list.forEach((c) => { |
| | | if (!c.polyline || c.polyline.length < 2) return |
| | | const isSelected = c.index === selectedCandidateIndex.value |
| | | const isHit = (c.hitConfigs?.length || 0) > 0 |
| | | const polyline = new AMap.Polyline({ |
| | | path: c.polyline.map(p => [p.longitude, p.latitude]), |
| | | strokeColor: isSelected ? CANDIDATE_COLOR_SELECTED : (isHit ? CANDIDATE_COLOR_HIT : CANDIDATE_COLOR_PLAIN), |
| | | strokeWeight: isSelected ? 6 : 3, |
| | | strokeOpacity: isSelected ? 0.9 : 0.55, |
| | | strokeStyle: 'solid', |
| | | cursor: 'pointer', |
| | | zIndex: isSelected ? 55 : 45, |
| | | }) |
| | | polyline.on('click', () => selectCandidate(c.index)) |
| | | polyline.setMap(mapInstance) |
| | | candidatePolylines.push(polyline) |
| | | }) |
| | | } |
| | | |
| | | /** 切换当前查看的候选:重画候选高亮 + 只画该候选的命中图层 */ |
| | | function selectCandidate(index: number) { |
| | | selectedCandidateIndex.value = index |
| | | const list = r1Result.value?.candidates || [] |
| | | renderCandidates(list) |
| | | clearHitLayers() |
| | | const cur = list.find(c => c.index === index) |
| | | if (cur?.hitConfigs?.length) renderHitConfigs(cur.hitConfigs) |
| | | selectedHitConfigId.value = cur?.hitConfigs?.[0]?.configId ?? null |
| | | } |
| | | |
| | | // ==================== 调试逻辑 ==================== |
| | | const loading = ref(false) |
| | | const r1Result = ref<DebugRouteResult | null>(null) |
| | | const r2Result = ref<DebugRouteResult | null>(null) |
| | | // 记录各次算路实际使用的策略值,切换策略对比时结果不歧义 |
| | | const r1RuleUsed = ref<number | undefined>(undefined) |
| | | const r2RuleUsed = ref<number | undefined>(undefined) |
| | | // 围栏场景:1=生效运营(默认) 2=测试中运营,同主链路 sceneType 口径 |
| | | const sceneType = ref(1) |
| | | // 围栏校验不通过时的提示(起终点不同围栏/起点不在围栏内) |
| | | const fenceCheckError = ref('') |
| | | |
| | | async function handleCalculateR1() { |
| | | if (!startPoint.value || !endPoint.value) { |
| | | message.warning('请先选择起点和终点') |
| | | return |
| | | } |
| | | loading.value = true |
| | | clearMapLayers() |
| | | r1Result.value = null |
| | | r2Result.value = null |
| | | selectedCandidateIndex.value = 0 |
| | | selectedHitConfigId.value = null |
| | | fenceCheckError.value = '' |
| | | r1RuleUsed.value = selectedRoutePlanRule.value |
| | | |
| | | try { |
| | | const res = await debugRoute({ |
| | | startLng: startPoint.value.lng, |
| | | startLat: startPoint.value.lat, |
| | | endLng: endPoint.value.lng, |
| | | endLat: endPoint.value.lat, |
| | | waypoints: [], |
| | | cityCode: startPoint.value.cityCode || cityCodeInput.value.trim() || undefined, |
| | | startPointId: startPoint.value.value || undefined, |
| | | routePlanRule: selectedRoutePlanRule.value, |
| | | sceneType: sceneType.value, |
| | | }) |
| | | if (res.code === 200 && res.data) { |
| | | r1Result.value = res.data |
| | | // 先叠加围栏/禁行区(校验失败时也尽量展示起点围栏,便于定位问题) |
| | | renderFenceAreas(res.data.fenceAreas) |
| | | if (!res.data.fenceCheckPassed) { |
| | | fenceCheckError.value = res.data.fenceCheckMessage || '围栏校验不通过,不允许算路' |
| | | renderMapLayers() |
| | | fitMapView() |
| | | message.error(fenceCheckError.value) |
| | | return |
| | | } |
| | | // 渲染全部围栏内候选:与主链路 getDistanceAndTimeList 口径一致。 |
| | | // 默认选中第一条命中的候选(命中才是要看的),全部未命中时选高德主推荐。 |
| | | const candidateList = res.data.candidates || [] |
| | | const firstHit = candidateList.find(c => (c.hitConfigs?.length || 0) > 0) |
| | | selectedCandidateIndex.value = firstHit ? firstHit.index : 0 |
| | | renderCandidates(candidateList) |
| | | const cur = candidateList.find(c => c.index === selectedCandidateIndex.value) |
| | | if (cur?.hitConfigs?.length) renderHitConfigs(cur.hitConfigs) |
| | | selectedHitConfigId.value = cur?.hitConfigs?.[0]?.configId ?? null |
| | | renderMapLayers() |
| | | fitMapView() |
| | | message.success(`R1 算路完成(围栏 ${res.data.fenceName || res.data.fenceId},禁行区 ${prohibitedAreaCount.value} 个),围栏内候选 ${res.data.routeCandidateCount} 条 / 命中 ${res.data.hitCandidateCount} 条,参与匹配配置 ${res.data.candidateCount} 条`) |
| | | } |
| | | } |
| | | catch (e: any) { |
| | | message.error('R1 算路失败') |
| | | } |
| | | finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | async function handleCalculateR2() { |
| | | if (!startPoint.value || !endPoint.value) { |
| | | message.warning('请先选择起点和终点') |
| | | return |
| | | } |
| | | if (waypoints.value.length === 0) { |
| | | message.warning('请先添加临时途经点') |
| | | return |
| | | } |
| | | loading.value = true |
| | | // 清除 R2 相关 |
| | | if (r2Polyline) { r2Polyline.setMap(null); r2Polyline = null } |
| | | r2Result.value = null |
| | | r2RuleUsed.value = selectedRoutePlanRule.value |
| | | |
| | | try { |
| | | const res = await debugRoute({ |
| | | startLng: startPoint.value.lng, |
| | | startLat: startPoint.value.lat, |
| | | endLng: endPoint.value.lng, |
| | | endLat: endPoint.value.lat, |
| | | waypoints: waypoints.value.map(wp => ({ longitude: wp.lng, latitude: wp.lat })), |
| | | cityCode: startPoint.value.cityCode || cityCodeInput.value.trim() || undefined, |
| | | startPointId: startPoint.value.value || undefined, |
| | | routePlanRule: selectedRoutePlanRule.value, |
| | | sceneType: sceneType.value, |
| | | }) |
| | | if (res.code === 200 && res.data) { |
| | | if (!res.data.fenceCheckPassed) { |
| | | fenceCheckError.value = res.data.fenceCheckMessage || '围栏校验不通过,不允许算路' |
| | | message.error(fenceCheckError.value) |
| | | return |
| | | } |
| | | r2Result.value = res.data |
| | | // 渲染 R2 路径(绿色虚线) |
| | | if (res.data.path?.polyline) { |
| | | r2Polyline = renderRoute(res.data.path.polyline, '#16a34a', true) |
| | | } |
| | | message.success(`R2 重算完成,命中 ${res.data.hitConfigs?.length || 0} 条`) |
| | | } |
| | | } |
| | | catch { |
| | | message.error('R2 重算失败') |
| | | } |
| | | finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | function fitMapView() { |
| | | if (!mapInstance) return |
| | | const allPoints: number[][] = [] |
| | | if (startPoint.value) allPoints.push([startPoint.value.lng, startPoint.value.lat]) |
| | | if (endPoint.value) allPoints.push([endPoint.value.lng, endPoint.value.lat]) |
| | | if (r1Result.value?.path?.polyline) { |
| | | r1Result.value.path.polyline.forEach(p => allPoints.push([p.longitude, p.latitude])) |
| | | } |
| | | if (allPoints.length > 1) { |
| | | mapInstance.setFitView(null, false, [60, 60, 60, 60]) |
| | | } |
| | | else if (allPoints.length === 1) { |
| | | mapInstance.setCenter(allPoints[0]) |
| | | } |
| | | } |
| | | |
| | | // ==================== promote waypoints ==================== |
| | | const selectedHitConfigId = ref<number | null>(null) |
| | | |
| | | function handlePromoteWaypoints() { |
| | | if (!selectedHitConfigId.value) { |
| | | message.warning('请选择要写入的配置') |
| | | return |
| | | } |
| | | if (waypoints.value.length === 0) { |
| | | message.warning('当前无临时途经点') |
| | | return |
| | | } |
| | | dialog.warning({ |
| | | title: '确认另存为 detourWaypoints', |
| | | content: `确定要将当前 ${waypoints.value.length} 个临时途经点写入配置 ID=${selectedHitConfigId.value} 的 detourWaypoints 吗?此操作将覆盖原有途经点。`, |
| | | positiveText: '确定写入', |
| | | negativeText: '取消', |
| | | onPositiveClick: async () => { |
| | | try { |
| | | const res = await promoteWaypoints({ |
| | | configId: selectedHitConfigId.value!, |
| | | detourWaypoints: waypoints.value.map(wp => ({ longitude: wp.lng, latitude: wp.lat })), |
| | | }) |
| | | if (res.code === 200) { |
| | | message.success('途经点写入成功') |
| | | } |
| | | } |
| | | catch { |
| | | message.error('写入失败') |
| | | } |
| | | }, |
| | | }) |
| | | } |
| | | |
| | | // ==================== 结果计算 ==================== |
| | | const avoidStatus = computed(() => { |
| | | if (!r1Result.value || !r2Result.value) return null |
| | | // R1 口径与主链路一致:任一围栏内候选命中就会触发掉头弯重算,不只看首条 |
| | | const r1Hits = r1Result.value.hitCandidateCount || 0 |
| | | const r2Hits = r2Result.value.hitConfigs?.length || 0 |
| | | if (r1Hits === 0) return { type: 'info' as const, text: 'R1 全部候选均未命中掉头弯' } |
| | | if (r2Hits === 0) return { type: 'success' as const, text: '成功绕开' } |
| | | if (r2Hits < r1Hits) return { type: 'warning' as const, text: '部分绕开' } |
| | | return { type: 'error' as const, text: '未绕开' } |
| | | }) |
| | | |
| | | /** 当前在地图上高亮、并展示命中详情的候选 */ |
| | | const selectedCandidate = computed<CandidateResult | null>(() => |
| | | (r1Result.value?.candidates || []).find(c => c.index === selectedCandidateIndex.value) || null, |
| | | ) |
| | | |
| | | const hitConfigOptions = computed(() => { |
| | | const hits = selectedCandidate.value?.hitConfigs || [] |
| | | return hits.map(h => ({ label: `${h.name} (ID:${h.configId})`, value: h.configId })) |
| | | }) |
| | | |
| | | /** 起点围栏的启用禁行区数量 */ |
| | | const prohibitedAreaCount = computed(() => |
| | | (r1Result.value?.fenceAreas || []).filter(a => a.areaType === 2 && a.areaStatus === 1).length, |
| | | ) |
| | | |
| | | // 起点变化时自动聚焦地图,并带出城市 Code |
| | | watch(startPointId, () => { |
| | | renderMapLayers() |
| | | if (startPoint.value && mapInstance) { |
| | | mapInstance.setCenter([startPoint.value.lng, startPoint.value.lat]) |
| | | mapInstance.setZoom(15) |
| | | } |
| | | if (startPoint.value?.cityCode) |
| | | cityCodeInput.value = startPoint.value.cityCode |
| | | }) |
| | | |
| | | // 城市 Code 变化时自动重新加载该城市的路线规划策略(城市行已隐藏,无手动读取入口) |
| | | watch(cityCodeInput, () => { |
| | | loadRoutePlans() |
| | | }) |
| | | |
| | | watch(endPointId, () => { |
| | | renderMapLayers() |
| | | }) |
| | | |
| | | // 手工输入经纬度变化时刷新地图标记 |
| | | watch([manualStartLng, manualStartLat], () => { |
| | | if (startMode.value === 'manual') { |
| | | renderMapLayers() |
| | | if (startPoint.value && mapInstance) { |
| | | mapInstance.setCenter([startPoint.value.lng, startPoint.value.lat]) |
| | | mapInstance.setZoom(15) |
| | | } |
| | | } |
| | | }) |
| | | |
| | | watch([manualEndLng, manualEndLat], () => { |
| | | if (endMode.value === 'manual') { |
| | | renderMapLayers() |
| | | } |
| | | }) |
| | | |
| | | watch(startMode, () => { renderMapLayers() }) |
| | | watch(endMode, () => { renderMapLayers() }) |
| | | |
| | | // ==================== 生命周期 ==================== |
| | | onMounted(async () => { |
| | | await loadPoints() |
| | | await initMap() |
| | | // 默认加载 440100 广州的路线规划策略 |
| | | await loadRoutePlans() |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="route-debug"> |
| | | <!-- 左侧控制面板 --> |
| | | <div class="route-debug__panel"> |
| | | <!-- 起终点选择 --> |
| | | <NCard title="起终点" size="small" :bordered="false" class="panel-card"> |
| | | <NSpace vertical :size="12"> |
| | | <!-- 起点 --> |
| | | <div> |
| | | <div class="field-row" style="margin-bottom: 6px;"> |
| | | <span class="field-label">起点:</span> |
| | | <NRadioGroup v-model:value="startMode" size="small" style="flex: 1;"> |
| | | <NRadio value="select">选点位</NRadio> |
| | | <NRadio value="manual">手工输入</NRadio> |
| | | </NRadioGroup> |
| | | </div> |
| | | <NSelect |
| | | v-if="startMode === 'select'" |
| | | v-model:value="startPointId" |
| | | :options="pointOptions" |
| | | placeholder="搜索并选择起点" |
| | | filterable |
| | | clearable |
| | | /> |
| | | <div v-else class="manual-coord-row"> |
| | | <NInput v-model:value="manualStartLng" placeholder="经度" size="small" /> |
| | | <NInput v-model:value="manualStartLat" placeholder="纬度" size="small" /> |
| | | </div> |
| | | </div> |
| | | <!-- 终点 --> |
| | | <div> |
| | | <div class="field-row" style="margin-bottom: 6px;"> |
| | | <span class="field-label">终点:</span> |
| | | <NRadioGroup v-model:value="endMode" size="small" style="flex: 1;"> |
| | | <NRadio value="select">选点位</NRadio> |
| | | <NRadio value="manual">手工输入</NRadio> |
| | | </NRadioGroup> |
| | | </div> |
| | | <NSelect |
| | | v-if="endMode === 'select'" |
| | | v-model:value="endPointId" |
| | | :options="pointOptions" |
| | | placeholder="搜索并选择终点" |
| | | filterable |
| | | clearable |
| | | /> |
| | | <div v-else class="manual-coord-row"> |
| | | <NInput v-model:value="manualEndLng" placeholder="经度" size="small" /> |
| | | <NInput v-model:value="manualEndLat" placeholder="纬度" size="small" /> |
| | | </div> |
| | | </div> |
| | | <!-- 围栏场景:与主链路 sceneType 口径一致,算路前校验起终点同围栏并携带禁行区 --> |
| | | <div class="field-row"> |
| | | <span class="field-label">围栏:</span> |
| | | <NRadioGroup v-model:value="sceneType" size="small" style="flex: 1;"> |
| | | <NRadio :value="1">生效运营</NRadio> |
| | | <NRadio :value="2">测试中运营</NRadio> |
| | | </NRadioGroup> |
| | | </div> |
| | | <NButton type="primary" :loading="loading" block @click="handleCalculateR1"> |
| | | 计算 R1 原路径 |
| | | </NButton> |
| | | <NAlert v-if="fenceCheckError" type="error" :bordered="false"> |
| | | {{ fenceCheckError }} |
| | | </NAlert> |
| | | </NSpace> |
| | | </NCard> |
| | | |
| | | <!-- 城市路线规划策略 --> |
| | | <NCard title="路线规划策略" size="small" :bordered="false" class="panel-card"> |
| | | <NSpace vertical :size="8"> |
| | | <template v-if="routePlans.length > 0"> |
| | | <div class="field-row"> |
| | | <span class="field-label">策略:</span> |
| | | <NSelect |
| | | v-model:value="selectedRoutePlanId" |
| | | :options="routePlanOptions" |
| | | placeholder="选择路线规划策略" |
| | | size="small" |
| | | filterable |
| | | style="flex: 1;" |
| | | /> |
| | | </div> |
| | | <div v-if="selectedRoutePlan" class="hit-detail"> |
| | | {{ selectedRoutePlan.cityName }} · 策略值 {{ selectedRoutePlan.routePlanRule }}({{ ruleLabel(selectedRoutePlan.routePlanRule) }}) |
| | | <template v-if="selectedRoutePlan.remarks">· {{ selectedRoutePlan.remarks }}</template> |
| | | </div> |
| | | <div class="hit-detail"> |
| | | 算路时会把选中策略值作为 strategy 传给高德,与线上路线规划口径一致 |
| | | </div> |
| | | </template> |
| | | <div v-else class="empty-tip"> |
| | | 选择起点后自动加载当前城市已审核通过的路线规划策略;多条时可选取其中一条测试 |
| | | </div> |
| | | </NSpace> |
| | | </NCard> |
| | | |
| | | <!-- 途经点管理 --> |
| | | <NCard title="临时途经点" size="small" :bordered="false" class="panel-card"> |
| | | <template #header-extra> |
| | | <NButton |
| | | size="tiny" |
| | | :type="addWaypointMode ? 'primary' : 'default'" |
| | | @click="addWaypointMode = !addWaypointMode" |
| | | > |
| | | {{ addWaypointMode ? '关闭点选' : '地图点选' }} |
| | | </NButton> |
| | | </template> |
| | | <NSpace vertical :size="8"> |
| | | <!-- 手工输入途经点 --> |
| | | <div class="manual-coord-row"> |
| | | <NInput v-model:value="manualWpLng" placeholder="经度" size="small" @keyup.enter="addManualWaypoint" /> |
| | | <NInput v-model:value="manualWpLat" placeholder="纬度" size="small" @keyup.enter="addManualWaypoint" /> |
| | | <NButton size="small" type="primary" @click="addManualWaypoint">添加</NButton> |
| | | </div> |
| | | <div v-if="waypoints.length === 0" class="empty-tip"> |
| | | 可通过上方输入框手工添加,或开启「地图点选」在地图上点击添加 |
| | | </div> |
| | | <div v-for="(wp, idx) in waypoints" :key="idx" class="waypoint-item"> |
| | | <span class="waypoint-index">{{ idx + 1 }}</span> |
| | | <span class="waypoint-coord">{{ wp.lng.toFixed(6) }}, {{ wp.lat.toFixed(6) }}</span> |
| | | <NButton text size="tiny" :disabled="idx === 0" @click="moveWaypoint(idx, -1)">↑</NButton> |
| | | <NButton text size="tiny" :disabled="idx === waypoints.length - 1" @click="moveWaypoint(idx, 1)">↓</NButton> |
| | | <NButton text type="error" size="tiny" @click="removeWaypoint(idx)">×</NButton> |
| | | </div> |
| | | <NSpace> |
| | | <NButton size="small" :disabled="waypoints.length === 0" @click="clearWaypoints"> |
| | | 清空 |
| | | </NButton> |
| | | <NButton |
| | | size="small" |
| | | type="primary" |
| | | :loading="loading" |
| | | :disabled="waypoints.length === 0" |
| | | @click="handleCalculateR2" |
| | | > |
| | | 重算 R2 |
| | | </NButton> |
| | | </NSpace> |
| | | </NSpace> |
| | | </NCard> |
| | | |
| | | <!-- 候选路径:与主链路一致,逐条展示围栏内候选及各自命中情况 --> |
| | | <NCard v-if="r1Result?.candidates?.length" title="候选路径" size="small" :bordered="false" class="panel-card"> |
| | | <template #header-extra> |
| | | <NTag size="tiny" :type="(r1Result.hitCandidateCount || 0) > 0 ? 'warning' : 'default'"> |
| | | {{ r1Result.routeCandidateCount }} 条 / 命中 {{ r1Result.hitCandidateCount }} 条 |
| | | </NTag> |
| | | </template> |
| | | <NSpace vertical :size="6"> |
| | | <div class="hit-detail"> |
| | | 主链路对每条围栏内候选都做命中检测,任一命中即触发重算;点击可切换查看 |
| | | </div> |
| | | <div |
| | | v-for="c in r1Result.candidates" |
| | | :key="c.index" |
| | | class="candidate-item" |
| | | :class="{ 'candidate-item--active': c.index === selectedCandidateIndex }" |
| | | @click="selectCandidate(c.index)" |
| | | > |
| | | <div class="candidate-line"> |
| | | <NTag size="tiny" :type="c.index === selectedCandidateIndex ? 'info' : 'default'">#{{ c.index + 1 }}</NTag> |
| | | <span class="candidate-label">{{ c.label || '未命名候选' }}</span> |
| | | <NTag v-if="(c.hitConfigs?.length || 0) > 0" size="tiny" type="warning">命中 {{ c.hitConfigs.length }}</NTag> |
| | | </div> |
| | | <div class="candidate-meta"> |
| | | {{ (c.distance / 1000).toFixed(2) }} km · {{ Math.round(c.duration / 60) }} min · {{ c.routeID?.slice(0, 8) }} |
| | | </div> |
| | | </div> |
| | | </NSpace> |
| | | </NCard> |
| | | |
| | | <!-- 结果面板 --> |
| | | <NCard title="结果对比" size="small" :bordered="false" class="panel-card"> |
| | | <NSpace vertical :size="8"> |
| | | <div v-if="r1Result" class="result-section"> |
| | | <div class="result-title"><NTag type="info" size="small">R1 原路径</NTag></div> |
| | | <div v-if="r1Result.fenceId" class="result-row">围栏:{{ r1Result.fenceName || '-' }} (ID:{{ r1Result.fenceId }}) · 禁行区 {{ prohibitedAreaCount }} 个</div> |
| | | <div class="result-row">策略:{{ r1RuleUsed != null ? `${r1RuleUsed}(${ruleLabel(r1RuleUsed)})` : '未传(高德默认)' }}</div> |
| | | <div class="result-row">围栏内候选:{{ r1Result.routeCandidateCount }} 条,其中 {{ r1Result.hitCandidateCount }} 条命中</div> |
| | | <div class="result-row">当前候选:#{{ selectedCandidateIndex + 1 }}{{ selectedCandidate?.label ? ` ${selectedCandidate.label}` : '' }}</div> |
| | | <div class="result-row">距离:{{ selectedCandidate?.distance != null ? (selectedCandidate.distance / 1000).toFixed(2) + ' km' : '-' }}</div> |
| | | <div class="result-row">耗时:{{ selectedCandidate?.duration != null ? Math.round(selectedCandidate.duration / 60) + ' min' : '-' }}</div> |
| | | <div class="result-row">当前候选命中:{{ selectedCandidate?.hitConfigs?.length || 0 }} 条</div> |
| | | <div class="result-row">算路 {{ r1Result.timings?.routeMs }}ms / 匹配 {{ r1Result.timings?.matchMs }}ms</div> |
| | | </div> |
| | | <div v-if="r2Result" class="result-section"> |
| | | <div class="result-title"><NTag type="success" size="small">R2 重算路径</NTag></div> |
| | | <div class="result-row">策略:{{ r2RuleUsed != null ? `${r2RuleUsed}(${ruleLabel(r2RuleUsed)})` : '未传(高德默认)' }}</div> |
| | | <div class="result-row">距离:{{ r2Result.path?.distance ? (r2Result.path.distance / 1000).toFixed(2) + ' km' : '-' }}</div> |
| | | <div class="result-row">耗时:{{ r2Result.path?.duration ? Math.round(r2Result.path.duration / 60) + ' min' : '-' }}</div> |
| | | <div class="result-row">命中:{{ r2Result.hitConfigs?.length || 0 }} 条</div> |
| | | <div class="result-row">算路 {{ r2Result.timings?.routeMs }}ms / 匹配 {{ r2Result.timings?.matchMs }}ms</div> |
| | | </div> |
| | | <NAlert v-if="avoidStatus" :type="avoidStatus.type" :bordered="false" style="margin-top: 4px;"> |
| | | {{ avoidStatus.text }} |
| | | </NAlert> |
| | | </NSpace> |
| | | </NCard> |
| | | |
| | | <!-- 命中详情 & promote:只展示当前选中候选的命中,与地图高亮一致 --> |
| | | <NCard v-if="selectedCandidate?.hitConfigs?.length" size="small" :bordered="false" class="panel-card"> |
| | | <template #header> |
| | | 命中配置详情(候选 #{{ selectedCandidateIndex + 1 }}) |
| | | </template> |
| | | <NSpace vertical :size="8"> |
| | | <div v-for="hit in selectedCandidate.hitConfigs" :key="hit.configId" class="hit-item"> |
| | | <div class="hit-name">{{ hit.name }} <NTag size="tiny">ID:{{ hit.configId }}</NTag></div> |
| | | <div class="hit-detail">半径 {{ hit.effectiveRadius }}m · 距离 {{ hit.minDistanceMeters.toFixed(1) }}m · 段号 {{ hit.hitSegmentIndex }}</div> |
| | | <div v-if="hit.headingDiffDegrees >= 0" class="hit-detail">方向差 {{ hit.headingDiffDegrees.toFixed(1) }}°</div> |
| | | <div v-if="hit.detourWaypoints?.length" class="hit-detail"> |
| | | 已配置途经点:{{ hit.detourWaypoints.length }} 个 |
| | | <NButton text type="primary" size="tiny" style="margin-left: 4px;" @click="loadDetourWaypointsFromHit(hit)"> |
| | | 回显到临时途经点 |
| | | </NButton> |
| | | </div> |
| | | </div> |
| | | <div style="border-top: 1px solid #e5e7eb; padding-top: 8px; margin-top: 4px;"> |
| | | <div class="field-row" style="margin-bottom: 8px;"> |
| | | <span class="field-label">写入目标:</span> |
| | | <NSelect |
| | | v-model:value="selectedHitConfigId" |
| | | :options="hitConfigOptions" |
| | | placeholder="选择配置" |
| | | size="small" |
| | | style="flex: 1;" |
| | | /> |
| | | </div> |
| | | <NButton |
| | | type="warning" |
| | | size="small" |
| | | block |
| | | :disabled="!selectedHitConfigId || waypoints.length === 0" |
| | | @click="handlePromoteWaypoints" |
| | | > |
| | | 另存为 detourWaypoints |
| | | </NButton> |
| | | </div> |
| | | </NSpace> |
| | | </NCard> |
| | | </div> |
| | | |
| | | <!-- 右侧地图 --> |
| | | <div class="route-debug__map"> |
| | | <div ref="mapContainerRef" class="map-container" /> |
| | | <!-- 图例 --> |
| | | <div class="map-legend"> |
| | | <span class="legend-item"><span class="legend-line" style="background:#2563eb;" /> 当前候选</span> |
| | | <span class="legend-item"><span class="legend-line" style="background:#f97316;" /> 其他命中候选</span> |
| | | <span class="legend-item"><span class="legend-line" style="background:#94a3b8;" /> 其他未命中候选</span> |
| | | <span class="legend-item"><span class="legend-line legend-dashed" style="background:#16a34a;" /> R2 重算</span> |
| | | <span class="legend-item"><span class="legend-line" style="background:#dc2626;" /> 命中掉头弯</span> |
| | | <span class="legend-item"><span class="legend-rect" style="border-color:#19B144;background:rgba(25,177,68,0.15);" /> 运营围栏</span> |
| | | <span class="legend-item"><span class="legend-rect" style="border-color:#EF938B;background:rgba(239,147,139,0.6);" /> 禁行区</span> |
| | | <span class="legend-item"><span class="legend-dot" style="background:#f97316;" /> 已有途经点</span> |
| | | <span class="legend-item"><span class="legend-dot" style="background:#7c3aed;" /> 临时途经点</span> |
| | | </div> |
| | | <!-- 提示 --> |
| | | <div v-if="addWaypointMode" class="map-tip"> |
| | | 点选模式已开启:在地图上点击添加途经点 |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style lang="scss" scoped> |
| | | .route-debug { |
| | | display: flex; |
| | | height: 100%; |
| | | gap: 12px; |
| | | |
| | | &__panel { |
| | | width: 340px; |
| | | flex-shrink: 0; |
| | | overflow-y: auto; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | &__map { |
| | | flex: 1; |
| | | position: relative; |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | border: 1px solid #e5e7eb; |
| | | } |
| | | } |
| | | |
| | | .panel-card { |
| | | :deep(.n-card-header) { |
| | | padding: 10px 12px 6px; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | } |
| | | :deep(.n-card__content) { |
| | | padding: 8px 12px 12px; |
| | | } |
| | | } |
| | | |
| | | .field-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .field-label { |
| | | font-size: 12px; |
| | | color: #6b7280; |
| | | white-space: nowrap; |
| | | min-width: 42px; |
| | | } |
| | | |
| | | .manual-coord-row { |
| | | display: flex; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .empty-tip { |
| | | font-size: 12px; |
| | | color: #9ca3af; |
| | | text-align: center; |
| | | padding: 8px 0; |
| | | } |
| | | |
| | | .waypoint-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | font-size: 12px; |
| | | padding: 2px 0; |
| | | } |
| | | |
| | | .waypoint-index { |
| | | background: #7c3aed; |
| | | color: #fff; |
| | | width: 18px; |
| | | height: 18px; |
| | | border-radius: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 10px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .waypoint-coord { |
| | | flex: 1; |
| | | color: #374151; |
| | | font-family: monospace; |
| | | font-size: 11px; |
| | | } |
| | | |
| | | .result-section { |
| | | padding: 6px 0; |
| | | border-bottom: 1px solid #f3f4f6; |
| | | &:last-child { border-bottom: none; } |
| | | } |
| | | |
| | | .result-title { |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .result-row { |
| | | font-size: 12px; |
| | | color: #4b5563; |
| | | line-height: 1.8; |
| | | } |
| | | |
| | | .candidate-item { |
| | | padding: 6px 8px; |
| | | border: 1px solid #e5e7eb; |
| | | border-radius: 4px; |
| | | cursor: pointer; |
| | | transition: border-color 0.15s, background 0.15s; |
| | | |
| | | &:hover { |
| | | border-color: #93c5fd; |
| | | } |
| | | |
| | | &--active { |
| | | border-color: #2563eb; |
| | | background: #eff6ff; |
| | | } |
| | | } |
| | | |
| | | .candidate-line { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .candidate-label { |
| | | flex: 1; |
| | | font-size: 12px; |
| | | font-weight: 500; |
| | | color: #1f2937; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .candidate-meta { |
| | | margin-top: 2px; |
| | | font-size: 11px; |
| | | color: #6b7280; |
| | | font-family: monospace; |
| | | } |
| | | |
| | | .hit-item { |
| | | padding: 6px 0; |
| | | border-bottom: 1px solid #f3f4f6; |
| | | &:last-child { border-bottom: none; } |
| | | } |
| | | |
| | | .hit-name { |
| | | font-size: 12px; |
| | | font-weight: 500; |
| | | color: #1f2937; |
| | | margin-bottom: 2px; |
| | | } |
| | | |
| | | .hit-detail { |
| | | font-size: 11px; |
| | | color: #6b7280; |
| | | } |
| | | |
| | | .map-container { |
| | | width: 100%; |
| | | height: 100%; |
| | | min-height: 500px; |
| | | } |
| | | |
| | | .map-legend { |
| | | position: absolute; |
| | | bottom: 12px; |
| | | left: 12px; |
| | | background: rgba(255, 255, 255, 0.95); |
| | | padding: 8px 12px; |
| | | border-radius: 6px; |
| | | display: flex; |
| | | gap: 12px; |
| | | font-size: 11px; |
| | | color: #374151; |
| | | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); |
| | | } |
| | | |
| | | .legend-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .legend-line { |
| | | width: 16px; |
| | | height: 3px; |
| | | border-radius: 1px; |
| | | } |
| | | |
| | | .legend-dashed { |
| | | background: repeating-linear-gradient(90deg, #16a34a 0, #16a34a 4px, transparent 4px, transparent 7px) !important; |
| | | } |
| | | |
| | | .legend-dot { |
| | | width: 10px; |
| | | height: 10px; |
| | | border-radius: 50%; |
| | | } |
| | | |
| | | .legend-rect { |
| | | width: 12px; |
| | | height: 10px; |
| | | border: 1.5px solid; |
| | | border-radius: 2px; |
| | | } |
| | | |
| | | .map-tip { |
| | | position: absolute; |
| | | top: 12px; |
| | | left: 50%; |
| | | transform: translateX(-50%); |
| | | background: rgba(124, 58, 237, 0.9); |
| | | color: #fff; |
| | | padding: 6px 14px; |
| | | border-radius: 4px; |
| | | font-size: 12px; |
| | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); |
| | | } |
| | | </style> |