zhuml4
2026-07-30 84ba9b3aa3c7b23faba0f728235862017efe489c
Merge branch 'feat-730-uturn' into 'release-1.2.9'

feat: 更新路径管理列表页和菜单顺序

See merge request robotaxi1/xp-robotaxi-web!6
3个文件已添加
4个文件已修改
2378 ■■■■■ 已修改文件
src/components/common/Pagination.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/uturnAvoidConfig.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/http/alova.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/pathManagement/RouteDebug.vue 1204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/pathManagement/index.vue 956 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/config/index.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/common/Pagination.vue
@@ -46,7 +46,15 @@
  'quick-jumper',
]
// 切换每页条数的同一 tick 内,naive-ui 可能因页码超出新总页数而额外触发一次 update:page(钳制到最大页),
// 用标记忽略该次回调,保证重置到第一页的行为不被覆盖
let ignorePageUpdate = false
function handleUpdatePage(val: number) {
  if (ignorePageUpdate) {
    innerPage.value = 1
    return
  }
  innerPage.value = val
  emit('update:page', val)
  emit('change', val, innerPageSize.value)
@@ -54,11 +62,15 @@
function handleUpdatePageSize(val: number) {
  innerPageSize.value = val
  // 切换每页条数时重置到第一页,避免当前页码超出新总页数导致查询结果为空
  innerPage.value = 1
  ignorePageUpdate = true
  nextTick(() => {
    ignorePageUpdate = false
  })
  emit('update:pageSize', val)
  // page size change usually resets page to 1, but Naive UI might handle it differently or user logic might vary.
  // Generally, we keep current page unless it's out of bounds, which Naive UI handles.
  // However, usually we want to emit change event.
  emit('change', innerPage.value, val)
  emit('update:page', 1)
  emit('change', 1, val)
}
const pageSizes = props.pageSizes
</script>
src/router/routes.static.ts
@@ -637,6 +637,18 @@
    activeMenu: '/businessOperations/operationWorkbench/map',
  },
  {
    name: 'pathManagement',
    path: '/businessOperations/pathManagement',
    title: '路径管理',
    requiresAuth: true,
    icon: 'icon-park-outline:guide-board',
    menuType: 'page',
    componentPath: '/businessOperations/pathManagement/index.vue',
    id: 2026072301,
    keepAlive: true,
    pid: 4,
  },
  {
    name: 'siteInfoList',
    path: '/businessOperations/siteInfoList',
    title: '点位现场信息',
src/service/api/uturnAvoidConfig.ts
New file
@@ -0,0 +1,144 @@
import { request } from '../http'
/** 掉头弯规避配置分页列表 */
export function fetchUturnConfigPageList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/map/api/v1/uturnAvoidConfig/pageList', params)
}
/** 掉头弯规避配置详情 */
export function fetchUturnConfigDetail(id: number | string) {
  return request.Get<Service.ResponseResult<any>>('/map/api/v1/uturnAvoidConfig/queryById', { params: { id } })
}
/** 新增掉头弯规避配置 */
export function addUturnConfig(data: any) {
  return request.Post<Service.ResponseResult<any>>('/map/api/v1/uturnAvoidConfig/add', data)
}
/** 编辑掉头弯规避配置 */
export function editUturnConfig(data: any) {
  return request.Post<Service.ResponseResult<any>>('/map/api/v1/uturnAvoidConfig/edit', data)
}
/** 删除掉头弯规避配置 */
export function deleteUturnConfig(id: number | string) {
  return request.Post<Service.ResponseResult<any>>(`/map/api/v1/uturnAvoidConfig/delete?id=${id}`)
}
/** 启用/禁用掉头弯规避配置 */
export function enableSwitchUturnConfig(id: number | string, enabled: boolean) {
  return request.Post<Service.ResponseResult<any>>(`/map/api/v1/uturnAvoidConfig/enableSwitch?id=${id}&enabled=${enabled}`)
}
/** 批量导出掉头弯规避配置 */
export function exportUturnConfigExcel(data: any) {
  const method = request.Post<Blob>('/map/api/v1/uturnAvoidConfig/exportExcel', data, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
/** 下载掉头弯规避配置导入模板 */
export function downloadUturnConfigTemplate() {
  const method = request.Get<Blob>('/map/api/v1/uturnAvoidConfig/downloadTemplate', { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
/** 批量导入掉头弯规避配置 */
export function batchImportUturnConfig(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  return request.Post<Service.ResponseResult<number>>('/map/api/v1/uturnAvoidConfig/batchImport', formData)
}
// ==================== 调试接口(设计文档 5.6) ====================
export interface DebugRouteParams {
  startLng: number
  startLat: number
  endLng: number
  endLat: number
  waypoints?: Array<{ longitude: number, latitude: number }>
  cityCode?: string
  startPointId?: number
  routePlanRule?: number
  /** 围栏场景类型:1=生效运营(默认) 2=测试中运营 */
  sceneType?: number
}
export interface HitDetail {
  configId: number
  name: string
  uturnPoints: Array<{ longitude: number, latitude: number }>
  detourWaypoints: Array<{ longitude: number, latitude: number }>
  effectiveRadius: number
  minDistanceMeters: number
  hitSegmentIndex: number
  headingDiffDegrees: number
}
export interface FenceAreaVo {
  areaId: number
  areaName: string
  /** 1=运营区 2=禁行区 */
  areaType: number
  /** 1=启用 2=禁用 */
  areaStatus: number
  /** 围栏点串:经度_纬度,经度_纬度 */
  fencePoints: string
}
/**
 * 单条候选路径的调试结果。主链路会对高德返回的每一条围栏内候选都跑一次命中检测,
 * 命中可能发生在「备选二」等非首条候选上,故调试页面逐条展示。
 */
export interface CandidateResult {
  /** 候选序号,0=高德主推荐 */
  index: number
  routeID: string
  /** 高德路径标签,如 大众常选 / 备选二 */
  label?: string
  distance: number
  duration: number
  polyline: Array<{ longitude: number, latitude: number }>
  hitConfigs: HitDetail[]
}
export interface DebugRouteResult {
  path: {
    polyline: Array<{ longitude: number, latitude: number }>
    distance: number
    duration: number
    label?: string
  } | null
  hitConfigs: HitDetail[]
  /** 全部围栏内候选路径及各自命中情况,与主链路逐条检测口径一致 */
  candidates: CandidateResult[]
  /** 围栏内候选路径条数 */
  routeCandidateCount: number
  /** 候选路径中命中了 uturn 配置的条数;>0 即表示主链路会触发掉头弯重算 */
  hitCandidateCount: number
  /** 前置过滤后参与匹配的候选配置数 */
  candidateCount: number
  timings: { routeMs: number, matchMs: number }
  /** 围栏校验是否通过;false 时 path 为空 */
  fenceCheckPassed: boolean
  /** 围栏校验不通过原因 */
  fenceCheckMessage?: string
  /** 起点命中的有效围栏 id */
  fenceId?: string
  /** 起点命中的有效围栏名称 */
  fenceName?: string
  /** 围栏区域几何(运营区/禁行区) */
  fenceAreas?: FenceAreaVo[]
}
/** 调试算路:纯算路 + 对全部围栏内候选逐条匹配 */
export function debugRoute(params: DebugRouteParams) {
  return request.Post<Service.ResponseResult<DebugRouteResult>>('/map/api/v1/uturnAvoidConfig/debug/route', params)
}
/** 另存为 detourWaypoints:把临时途经点写入指定配置 */
export function promoteWaypoints(data: { configId: number, detourWaypoints: Array<{ longitude: number, latitude: number }> }) {
  return request.Post<Service.ResponseResult<void>>('/map/api/v1/uturnAvoidConfig/debug/promote-waypoints', data)
}
src/service/http/alova.ts
@@ -107,8 +107,17 @@
        if (status === 200) {
          // 返回blob数据
          if (method.meta?.isBlob)
          if (method.meta?.isBlob) {
            // 后端异常时返回的是 JSON 错误体(如「系统繁忙,请稍后重试」),
            // 不能当成文件流返回,否则会被下载成内容为错误信息的损坏文件
            const contentType = response.headers.get('content-type') ?? ''
            if (contentType.includes('application/json')) {
              const apiData = await response.json()
              const errorResult = handleBusinessError(apiData, _backendConfig)
              return handleServiceResult(errorResult, false)
            }
            return response.blob()
          }
          // 返回json数据
          const apiData = await response.json()
src/views/businessOperations/pathManagement/RouteDebug.vue
New file
@@ -0,0 +1,1204 @@
<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>
src/views/businessOperations/pathManagement/index.vue
New file
@@ -0,0 +1,956 @@
<script setup lang="tsx">
import noDataImg from '@/assets/element-img/no-data.png'
import type { DataTableColumns } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { NButton, NDatePicker, NInput, NSelect, NSwitch } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import RouteDebug from './RouteDebug.vue'
import {
  addUturnConfig,
  batchImportUturnConfig,
  downloadUturnConfigTemplate,
  editUturnConfig,
  enableSwitchUturnConfig,
  exportUturnConfigExcel,
  fetchUturnConfigPageList,
} from '@/service/api/uturnAvoidConfig'
import { fetchPointList } from '@/service/api/point'
import { downloadFile } from '@/utils/common'
import { getMultiSelectRowClassName, shouldIgnoreTableRowClick, toggleMultiSelectRowKey } from '@/utils/table'
import dayjs from 'dayjs'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
// ==================== Tab 切换 ====================
type ConfigTab = 'NARROW_UTURN' | 'STATION_START' | 'ROUTE_DEBUG'
const TABS: Array<{ label: string, value: ConfigTab }> = [
  { label: '窄路调头', value: 'NARROW_UTURN' },
  { label: '站点附近起步', value: 'STATION_START' },
  { label: '路径调试', value: 'ROUTE_DEBUG' },
]
const activeTab = ref<ConfigTab>('NARROW_UTURN')
const isDebugTab = computed(() => activeTab.value === 'ROUTE_DEBUG')
function handleTabChange(tab: ConfigTab) {
  activeTab.value = tab
  if (tab !== 'ROUTE_DEBUG') {
    handleResetSearch()
  }
}
// ==================== 搜索条件 ====================
const initialModel = {
  id: '',
  name: '',
  createTime: null as [number, number] | null,
  enabled: null as boolean | null,
}
const model = ref({ ...initialModel })
const isStatusSelectOpen = ref(false)
// 点位选项(用于「站点附近起步」Tab 的点位名称筛选与点位经纬度展示)
interface PointOption {
  label: string
  value: string
  id: number
  lon?: number
  lat?: number
}
const pointOptions = ref<PointOption[]>([])
const pointMap = ref<Record<number, { name: string, lon?: number, lat?: number }>>({})
async function loadPointOptions() {
  try {
    const res = await fetchPointList({})
    if (res.code === 200 && Array.isArray(res.data)) {
      const map: Record<number, { name: string, lon?: number, lat?: number }> = {}
      const opts: PointOption[] = []
      res.data.forEach((p) => {
        if (p.id == null)
          return
        const name = p.pointName || ''
        map[p.id] = { name, lon: p.pointLon, lat: p.pointLat }
        opts.push({ label: name, value: name, id: p.id, lon: p.pointLon, lat: p.pointLat })
      })
      pointMap.value = map
      pointOptions.value = opts
    }
  }
  catch {}
}
const statusOptions = [
  { label: '启用中', value: true },
  { label: '已停用', value: false },
]
const filterItems = computed(() => [
  {
    label: 'ID',
    path: 'id',
    render: () => <NInput v-model:value={model.value.id} placeholder="请输入ID" clearable allowInput={(v: string) => !v || /^\d+$/.test(v)} />,
  },
  {
    label: activeTab.value === 'NARROW_UTURN' ? '位置名称' : '点位名称',
    path: 'name',
    render: () => activeTab.value === 'NARROW_UTURN'
      ? <NInput v-model:value={model.value.name} placeholder="请输入位置名称" clearable />
      : (
          <NSelect
            v-model:value={model.value.name}
            options={pointOptions.value}
            placeholder="请选择点位名称"
            filterable
            clearable
          />
        ),
  },
  {
    label: '创建时间',
    path: 'createTime',
    render: () => (
      <NDatePicker
        format="yyyy/MM/dd"
        v-model:value={model.value.createTime}
        type="daterange"
        clearable
        actions={[]}
        close-on-select={true}
      />
    ),
  },
  {
    label: '状态',
    path: 'enabled',
    render: () => (
      <NSelect
        v-model:value={model.value.enabled}
        options={statusOptions}
        placeholder="请选择状态"
        clearable
        onUpdate:show={(value: boolean) => isStatusSelectOpen.value = value}
      >
        {{
          arrow: () => (
            <img
              src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
              class={`arrow-icon ${isStatusSelectOpen.value ? 'arrow-icon--open' : ''}`}
              style={{ width: '16px', height: '16px' }}
              alt="arrow"
            />
          ),
        }}
      </NSelect>
    ),
  },
])
// ==================== 列表数据 ====================
const listData = ref<any[]>([])
const checkedRowKeys = ref<Array<string | number>>([])
const TABLE_ROW_CLICK_IGNORE_SELECTORS = ['.table-row-link']
const pagination = reactive({
  page: 1,
  pageSize: 10,
  itemCount: 0,
})
function handleSearch() {
  pagination.page = 1
  getList()
}
function handleResetSearch() {
  model.value = { ...initialModel }
  handleSearch()
}
async function getList() {
  startLoading()
  try {
    const params: any = {
      pageNum: pagination.page,
      pageSize: pagination.pageSize,
      configType: activeTab.value,
    }
    if (model.value.id)
      params.id = model.value.id.trim()
    if (model.value.name)
      params.name = model.value.name.trim()
    if (model.value.enabled !== null)
      params.enabled = model.value.enabled
    if (model.value.createTime) {
      params.createTimeStart = dayjs(model.value.createTime[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss')
      params.createTimeEnd = dayjs(model.value.createTime[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss')
    }
    const res = await fetchUturnConfigPageList(params)
    if (res.code === 200 && res.data) {
      listData.value = res.data.records || []
      pagination.itemCount = res.data.total || 0
    }
  }
  finally {
    endLoading()
  }
}
// ==================== 表格列 ====================
function formatPointsJson(json: string | null) {
  if (!json) return '-'
  try {
    const arr = JSON.parse(json)
    if (Array.isArray(arr)) {
      return arr.map((p: number[]) => `${p[0]},${p[1]}`).join('; ')
    }
    return json
  }
  catch {
    return json || '-'
  }
}
const columns = computed<DataTableColumns<any>>(() => {
  const cols: DataTableColumns<any> = [
    { type: 'selection', width: 48, align: 'center' },
    { title: 'ID', key: 'id', width: 80, align: 'left', ellipsis: true, resizable: true },
    {
      title: activeTab.value === 'NARROW_UTURN' ? '位置名称' : '点位名称',
      key: 'name',
      width: 180,
      align: 'left',
      ellipsis: { tooltip: true },
      resizable: true,
    },
    {
      title: '状态',
      key: 'enabled',
      width: 100,
      align: 'left',
      render(row) {
        const isEnabled = row.enabled === true || row.enabled === 1
        return (
          <div class={`status-tag ${isEnabled ? 'status-enabled' : 'status-disabled'}`}>
            {isEnabled ? '启用中' : '已停用'}
          </div>
        )
      },
    },
  ]
  if (activeTab.value === 'STATION_START') {
    cols.push({
      title: '点位经纬度',
      key: 'startPointCoord',
      width: 200,
      align: 'left',
      ellipsis: { tooltip: true },
      resizable: true,
      render(row) {
        // 优先回显后端保存时落库的点位经纬度快照(0=历史数据未回填)
        if (row.startPointLon && row.startPointLat)
          return `(${row.startPointLon},${row.startPointLat})`
        // 历史数据兜底:实时反查点位列表
        const info = row.startPointId ? pointMap.value[row.startPointId] : undefined
        if (info && info.lon != null && info.lat != null)
          return `(${info.lon},${info.lat})`
        const lon = row.pointLon
        const lat = row.pointLat
        if (lon != null && lat != null)
          return `(${lon},${lat})`
        return '-'
      },
    })
  }
  cols.push(
    {
      title: activeTab.value === 'NARROW_UTURN' ? '路径经纬度' : '路径点经纬度',
      key: 'uturnPointsJson',
      width: 240,
      align: 'left',
      ellipsis: { tooltip: true },
      resizable: true,
      render(row) {
        return formatPointsJson(row.uturnPointsJson)
      },
    },
    {
      title: activeTab.value === 'NARROW_UTURN' ? '途径点经纬度' : '途径点',
      key: 'detourWaypointsJson',
      width: 240,
      align: 'left',
      ellipsis: { tooltip: true },
      resizable: true,
      render(row) {
        return formatPointsJson(row.detourWaypointsJson)
      },
    },
    {
      title: '容差半径',
      key: 'radius',
      width: 100,
      align: 'left',
      render(row) {
        return row.radius ? `${row.radius}m` : '-'
      },
    },
    {
      title: '创建时间',
      key: 'createTime',
      width: 180,
      align: 'left',
      ellipsis: true,
      render(row) {
        return row.createTime ? dayjs(row.createTime).format('YYYY/MM/DD HH:mm:ss') : '-'
      },
    },
    {
      title: '操作',
      key: 'actions',
      width: 140,
      align: 'left',
      fixed: 'right',
      render(row) {
        const isEnabled = row.enabled === true || row.enabled === 1
        return (
          <div style="display: flex; gap: 12px; align-items: center;">
            <NButton text type="primary" onClick={() => handleEdit(row)}>
              编辑
            </NButton>
            <NButton text type={isEnabled ? 'error' : 'primary'} onClick={() => handleEnableSwitch(row)}>
              {isEnabled ? '停用' : '启用'}
            </NButton>
          </div>
        )
      },
    },
  )
  return cols
})
// ==================== 操作事件 ====================
function handleEnableSwitch(row: any) {
  const isEnabled = row.enabled === true || row.enabled === 1
  const actionText = isEnabled ? '停用' : '启用'
  const titleText = activeTab.value === 'NARROW_UTURN' ? '路径终点规则' : '路径点规则'
  dialog.warning({
    title: `是否确认${actionText}${titleText}?`,
    positiveText: '确定',
    negativeText: '取消',
    maskClosable: false,
    onPositiveClick: async () => {
      try {
        const res = await enableSwitchUturnConfig(row.id, !isEnabled)
        if (res.code === 200) {
          message.success(`${actionText}成功`)
          getList()
        }
      }
      catch {}
    },
  })
}
// ==================== 新增/编辑弹窗 ====================
const showFormModal = ref(false)
const { bool: submitLoading, setTrue: startSubmitLoading, setFalse: endSubmitLoading } = useBoolean(false)
const formRef = ref()
type ModalMode = 'add' | 'edit'
const modalMode = ref<ModalMode>('add')
const formModel = ref({
  id: null as number | null,
  name: '',
  cityCode: '440100',
  uturnPointsText: '',
  detourWaypointsText: '',
  radius: null as number | null,
  enabled: true,
  startPointId: null as number | null,
  // 后端落库的点位经纬度快照(编辑回显用,0/null=未回填)
  startPointLon: null as number | null,
  startPointLat: null as number | null,
})
const modalTitle = computed(() => (modalMode.value === 'edit' ? '编辑' : '新增'))
const isEditMode = computed(() => modalMode.value === 'edit')
// 新增弹窗用点位下拉选项(value 为 id,便于绑定 startPointId)
const pointOptionsForModal = computed(() =>
  pointOptions.value.map(p => ({ label: p.label, value: p.id }))
)
// 「站点附近起步」弹窗中已选点位的经纬度回显(只读展示)
// 编辑时优先回显后端落库快照;新增/快照缺失时从点位列表实时带出
const selectedPointCoord = computed(() => {
  const id = formModel.value.startPointId
  if (id == null) return ''
  const { startPointLon, startPointLat } = formModel.value
  if (startPointLon && startPointLat)
    return `${startPointLon},${startPointLat}`
  const info = pointMap.value[id]
  if (!info || info.lon == null || info.lat == null) return ''
  return `${info.lon},${info.lat}`
})
function resetFormModel() {
  formModel.value = {
    id: null,
    name: '',
    cityCode: '440100',
    uturnPointsText: '',
    detourWaypointsText: '',
    radius: null,
    enabled: true,
    startPointId: null,
    startPointLon: null,
    startPointLat: null,
  }
}
// 站点附近起步:选点位时直接用点位名称填充配置名称字段
watch(() => formModel.value.startPointId, (id) => {
  if (activeTab.value !== 'STATION_START')
    return
  if (id && pointMap.value[id]?.name)
    formModel.value.name = pointMap.value[id].name
})
function handleAdd() {
  modalMode.value = 'add'
  resetFormModel()
  showFormModal.value = true
}
function closeFormModal() {
  showFormModal.value = false
  endSubmitLoading()
}
/**
 * 将后端返回的 uturnPointsJson / detourWaypointsJson 反解为 textarea 文本,格式:lng,lat;lng,lat
 */
function pointsJsonToText(json: string | null | undefined): string {
  if (!json) return ''
  try {
    const arr = JSON.parse(json)
    if (Array.isArray(arr)) {
      return arr
        .filter((p: number[]) => Array.isArray(p) && p.length >= 2)
        .map((p: number[]) => `${p[0]},${p[1]}`)
        .join(';')
    }
  }
  catch {}
  return ''
}
/**
 * 解析行数据的点位经纬度:优先后端落库快照,历史数据兜底反查点位列表(与列表列回显口径一致)
 */
function resolveStartPointCoord(row: any): { lon: number | null, lat: number | null } {
  if (row.startPointLon && row.startPointLat)
    return { lon: row.startPointLon, lat: row.startPointLat }
  const info = row.startPointId ? pointMap.value[row.startPointId] : undefined
  if (info && info.lon != null && info.lat != null)
    return { lon: info.lon, lat: info.lat }
  if (row.pointLon != null && row.pointLat != null)
    return { lon: row.pointLon, lat: row.pointLat }
  return { lon: null, lat: null }
}
/**
 * 编辑操作:仅允许修改「途经点」与「容差半径」,其余字段(含点位经纬度)回填后置为禁用。
 */
function handleEdit(row: any) {
  modalMode.value = 'edit'
  const coord = resolveStartPointCoord(row)
  formModel.value = {
    id: row.id,
    name: row.name || '',
    cityCode: row.cityCode || '440100',
    uturnPointsText: pointsJsonToText(row.uturnPointsJson),
    detourWaypointsText: pointsJsonToText(row.detourWaypointsJson),
    radius: row.radius ?? null,
    enabled: row.enabled === true || row.enabled === 1,
    startPointId: row.startPointId ?? null,
    startPointLon: coord.lon,
    startPointLat: coord.lat,
  }
  showFormModal.value = true
}
function parsePointsText(text: string): number[][] | null {
  if (!text.trim()) return null
  const points = text.split(';').filter(s => s.trim()).map((s) => {
    const parts = s.trim().split(',').map(Number)
    return parts
  })
  if (points.some(p => p.length < 2 || p.some(isNaN))) return null
  return points
}
/** 校验坐标点数组的经纬度是否在合法范围内 */
function validateCoordinateRange(points: number[][], fieldName: string): string | null {
  for (let i = 0; i < points.length; i++) {
    const lng = points[i][0]
    const lat = points[i][1]
    if (lng < -180 || lng > 180 || lat < -90 || lat > 90) {
      return `${fieldName}第${i + 1}个点经纬度超出合法范围(经度: -180~180, 纬度: -90~90)`
    }
  }
  return null
}
// 表单校验规则:错误信息展示在字段下方(名称/点位按 tab 条件必填)
const formRules = {
  name: {
    required: true,
    trigger: ['blur', 'input'],
    validator: (_rule: any, value: string) => {
      if (activeTab.value === 'NARROW_UTURN') {
        if (!value?.trim())
          return new Error('请输入位置名称')
        if (value.length > 100)
          return new Error('位置名称不能超过100字')
      }
      return true
    },
  },
  startPointId: {
    required: true,
    trigger: ['blur', 'change'],
    validator: (_rule: any, value: number | null) => {
      if (activeTab.value === 'STATION_START' && !value)
        return new Error('请选择点位名称')
      return true
    },
  },
  uturnPointsText: {
    required: true,
    trigger: ['blur', 'input'],
    validator: (_rule: any, value: string) => {
      const points = parsePointsText(value || '')
      if (!points || points.length < 2)
        return new Error('请输入有效的路径点(至少2个点,格式:lng,lat;lng,lat)')
      const err = validateCoordinateRange(points, '路径点')
      if (err)
        return new Error(err)
      return true
    },
  },
  detourWaypointsText: {
    required: true,
    trigger: ['blur', 'input'],
    validator: (_rule: any, value: string) => {
      const points = parsePointsText(value || '')
      if (!points || points.length < 1)
        return new Error('请输入有效的途径点(至少1个点,格式:lng,lat;lng,lat)')
      const err = validateCoordinateRange(points, '途经点')
      if (err)
        return new Error(err)
      return true
    },
  },
  radius: {
    trigger: ['blur', 'input'],
    validator: () => {
      const r = formModel.value.radius
      if (r !== null && (r < 0 || r > 99))
        return new Error('容差半径必须在 0~99 之间(0=未配置,走默认)')
      return true
    },
  },
}
async function handleFormSubmit() {
  await formRef.value?.validate()
  const uturnPoints = parsePointsText(formModel.value.uturnPointsText)!
  const detourWaypoints = parsePointsText(formModel.value.detourWaypointsText)!
  // 站点附近起步时从 pointMap 获取点位名称;反查不到时保留原名称,允许为空(后端按点位名称回填)
  let submitName = formModel.value.name.trim()
  if (activeTab.value === 'STATION_START' && formModel.value.startPointId) {
    submitName = pointMap.value[formModel.value.startPointId]?.name || submitName
  }
  const payload: any = {
    configType: activeTab.value,
    cityCode: formModel.value.cityCode || '440100',
    name: submitName,
    uturnPoints: uturnPoints,
    detourWaypoints: detourWaypoints,
    radius: formModel.value.radius ?? undefined,
    enabled: formModel.value.enabled,
    startPointId: activeTab.value === 'STATION_START' ? formModel.value.startPointId : undefined,
  }
  try {
    startSubmitLoading()
    let res
    if (isEditMode.value) {
      if (formModel.value.id == null) {
        message.warning('缺失记录 ID,无法保存')
        return
      }
      payload.id = formModel.value.id
      res = await editUturnConfig(payload)
    }
    else {
      res = await addUturnConfig(payload)
    }
    if (res.code === 200) {
      message.success(isEditMode.value ? '修改成功' : '新增成功')
      showFormModal.value = false
      getList()
    }
  }
  catch {}
  finally {
    endSubmitLoading()
  }
}
// ==================== 导出 / 导入 / 下载模板 ====================
async function handleBatchExport() {
  if (checkedRowKeys.value.length === 0) {
    message.warning('请先选择记录')
    return
  }
  try {
    const params = {
      configType: activeTab.value,
      ids: checkedRowKeys.value,
    }
    const res = await exportUturnConfigExcel(params)
    if (res instanceof Blob) {
      const tabName = activeTab.value === 'NARROW_UTURN' ? '窄路调头' : '站点附近起步'
      downloadFile(res, `${tabName}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`)
      message.success('导出成功')
    }
    else {
      message.error('导出失败')
    }
  }
  catch {}
}
async function handleDownloadTemplate() {
  try {
    const res = await downloadUturnConfigTemplate()
    if (res instanceof Blob) {
      downloadFile(res, '掉头弯规避配置导入模板.xlsx')
      message.success('模板下载成功')
    }
  }
  catch {}
}
const fileInputRef = ref<HTMLInputElement | null>(null)
function handleImportClick() {
  fileInputRef.value?.click()
}
async function handleFileChange(event: Event) {
  const target = event.target as HTMLInputElement
  const file = target.files?.[0]
  if (!file) return
  try {
    const res = await batchImportUturnConfig(file)
    if (res.code === 200) {
      message.success(`导入成功,共${res.data}条`)
      getList()
    }
  }
  catch {}
  finally {
    target.value = ''
  }
}
// ==================== 行选择 ====================
function handleCheck(rowKeys: Array<string | number>) {
  checkedRowKeys.value = rowKeys
}
function toggleRowSelection(row: any) {
  checkedRowKeys.value = toggleMultiSelectRowKey(checkedRowKeys.value, row.id)
}
function getRowProps(row: any) {
  return {
    style: 'cursor: pointer;',
    onClick: (event: MouseEvent) => {
      if (shouldIgnoreTableRowClick(event.target, TABLE_ROW_CLICK_IGNORE_SELECTORS))
        return
      toggleRowSelection(row)
    },
  }
}
function getRowClassName(row: any) {
  return getMultiSelectRowClassName(row.id, checkedRowKeys.value)
}
function changePage(page: number, size: number) {
  pagination.page = page
  pagination.pageSize = size
  getList()
}
onMounted(() => {
  loadPointOptions()
  getList()
})
</script>
<template>
  <div class="h-full roboTaxi flex flex-col path-management-page">
    <!-- Tab 切换 -->
    <n-tabs :value="activeTab" type="line" animated @update:value="handleTabChange">
      <n-tab v-for="tab in TABS" :key="tab.value" :name="tab.value">
        {{ tab.label }}
      </n-tab>
    </n-tabs>
    <!-- 路径调试 Tab 内容 -->
    <RouteDebug v-if="isDebugTab" class="flex-1 min-h-0" />
    <!-- 搜索面板 -->
    <div v-show="!isDebugTab" class="roboTaxi-form-card mb-12px">
      <n-form :model="model" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
          <n-gi v-for="item in filterItems" :key="item.path" :span="item.span || 1">
            <n-form-item :label="item.label" :path="item.path">
              <component :is="item.render" />
            </n-form-item>
          </n-gi>
          <template #action>
            <NButton @click="handleResetSearch">
              {{ t('common.reset') }}
            </NButton>
            <NButton type="primary" @click="handleSearch">
              {{ t('common.search') }}
            </NButton>
          </template>
        </SearchPanel>
      </n-form>
    </div>
    <!-- 数据表格 -->
    <n-card
      v-show="!isDebugTab"
      class="flex-1 robotaxi-table-card path-management-list"
      content-style="display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0;"
    >
      <template #header>
        <n-flex justify="space-between" align="center">
          <n-flex align="center" :wrap="false">
            <span v-if="checkedRowKeys.length > 0" style="color: rgba(23, 31, 38, 0.6);font-weight: 400;">
              已选择{{ checkedRowKeys.length }}条记录
            </span>
            <NButton :disabled="checkedRowKeys.length === 0" @click="handleBatchExport">
              批量导出
            </NButton>
          </n-flex>
          <n-flex>
            <NButton @click="handleDownloadTemplate">
              下载模板
            </NButton>
            <NButton @click="handleImportClick">
              批量导入
            </NButton>
            <input
              ref="fileInputRef"
              type="file"
              accept=".xlsx,.xls,.csv"
              style="display: none;"
              @change="handleFileChange"
            >
            <NButton type="primary" @click="handleAdd">
              新增
            </NButton>
          </n-flex>
        </n-flex>
      </template>
      <div class="flex-1 flex flex-col h-full" style="min-width: 0; overflow: hidden;">
        <n-data-table
          class="flex-1"
          :columns="columns"
          :data="listData"
          :checked-row-keys="checkedRowKeys"
          :loading="loading"
          flex-height
          :bordered="false"
          :header-height="18"
          :row-height="18"
          :row-props="getRowProps"
          :row-class-name="getRowClassName"
          :scroll-x="1400"
          :row-key="(row: any) => row.id"
          @update:checked-row-keys="handleCheck"
        >
          <template #empty>
            <div class="flex-center">
              <img :src="noDataImg" style="width:123px;height:125px" @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }">
            </div>
          </template>
        </n-data-table>
        <Pagination
          :count="pagination.itemCount"
          :page="pagination.page"
          :page-size="pagination.pageSize"
          class="robotaxi-pagination mt-12px"
          @change="changePage"
        />
      </div>
    </n-card>
    <!-- 新增/编辑弹窗 -->
    <n-modal
      v-model:show="showFormModal"
      preset="card"
      :title="modalTitle"
      :mask-closable="false"
      :segmented="{
        content: true,
        action: true,
      }"
      class="w-600px"
    >
      <n-form
        ref="formRef"
        label-placement="top"
        require-mark-placement="left"
        :rules="formRules"
        :model="formModel"
      >
        <!-- 窄路调头:位置名称文本输入 -->
        <n-form-item v-if="activeTab === 'NARROW_UTURN'" label="位置名称" path="name">
          <NInput
            v-model:value="formModel.name"
            placeholder="请输入位置名称"
            :maxlength="100"
            show-count
            :disabled="isEditMode"
          />
        </n-form-item>
        <!-- 站点附近起步:点位名称下拉选择 -->
        <n-form-item v-if="activeTab === 'STATION_START'" label="点位名称" path="startPointId">
          <NSelect
            v-model:value="formModel.startPointId"
            :options="pointOptionsForModal"
            placeholder="请选择点位名称"
            filterable
            clearable
            :disabled="isEditMode"
          />
        </n-form-item>
        <!-- 站点附近起步:已选点位经纬度只读回显,编辑时禁止修改 -->
        <n-form-item v-if="activeTab === 'STATION_START'" label="点位经纬度">
          <NInput
            :value="selectedPointCoord"
            placeholder="选择点位后自动带出"
            readonly
            :disabled="isEditMode"
          />
        </n-form-item>
        <n-form-item label="路径点" path="uturnPointsText">
          <NInput
            v-model:value="formModel.uturnPointsText"
            type="textarea"
            placeholder="请输入路径点"
            :autosize="{ minRows: 3, maxRows: 5 }"
            :maxlength="300"
            show-count
            :disabled="isEditMode"
          />
        </n-form-item>
        <n-form-item label="途经点" path="detourWaypointsText">
          <NInput
            v-model:value="formModel.detourWaypointsText"
            type="textarea"
            placeholder="请输入途经点"
            :autosize="{ minRows: 3, maxRows: 5 }"
            :maxlength="300"
            show-count
          />
        </n-form-item>
        <n-form-item label="容差半径" path="radius">
          <NInput
            :value="formModel.radius !== null ? String(formModel.radius) : ''"
            placeholder="请输入容差半径(0=未配置,走默认)"
            :allow-input="(v: string) => !v || /^\d{1,2}$/.test(v)"
            @update:value="(v: string) => formModel.radius = v ? Number(v) : null"
          />
        </n-form-item>
        <n-form-item v-if="!isEditMode" label="是否立即启用" label-placement="left">
          <NSwitch v-model:value="formModel.enabled">
            <template #checked>
              是
            </template>
            <template #unchecked>
              否
            </template>
          </NSwitch>
        </n-form-item>
      </n-form>
      <template #action>
        <n-space justify="end">
          <NButton @click="closeFormModal">
            取消
          </NButton>
          <NButton type="primary" :loading="submitLoading" @click="handleFormSubmit">
            保存
          </NButton>
        </n-space>
      </template>
    </n-modal>
  </div>
</template>
<style lang="scss">
// 与其他模块 tab 风格对齐:隐藏 line 型 tab 导航栏的整行底部分割线
.path-management-page {
  .n-tabs-nav-scroll-content {
    border-color: transparent !important;
  }
}
.path-management-list {
  .n-data-table-th {
    background-color: #fafafc;
    font-weight: 500;
  }
  .status-tag {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 4px;
    font-family: PingFang SC;
    font-size: 12px;
    font-weight: normal;
    line-height: 16px;
    text-align: center;
    &.status-enabled {
      background: #E9F9ED;
      color: #19B144;
    }
    &.status-disabled {
      background: #E8E9E9;
      color: #171F26;
    }
  }
}
</style>
src/views/system/config/index.vue
@@ -18,6 +18,7 @@
  headShowCount: string
  nearPointDistance: any
  nearPointDistanceSearch: any
  orderExceptionalTimeout: any
  operateFree: boolean
  tripShareStatus: boolean
  temperatureUnit: string
@@ -50,6 +51,7 @@
  headShowCount: '',
  nearPointDistance: '',
  nearPointDistanceSearch: '',
  orderExceptionalTimeout: '',
  operateFree: true,
  tripShareStatus: true,
  temperatureUnit: '℃',
@@ -112,6 +114,12 @@
  formModel.value.nearPointDistanceSearch = normalizedValue
}
function handleOrderExceptionalTimeoutChange(value: string) {
  // 仅保留数字,并在输入阶段直接截断为最多4位,确保不超过9999
  const normalizedValue = value.replace(/\D/g, '').slice(0, 4)
  formModel.value.orderExceptionalTimeout = normalizedValue
}
const rules: FormRules = {
  customerServicePhone: [
    { required: true, message: '请输入客服电话', trigger: 'blur' },
@@ -151,6 +159,9 @@
    { required: true, message: '请输入正整数', trigger: 'blur' },
  ],
  nearPointDistanceSearch: [
    { required: true, message: '请输入正整数', trigger: 'blur' },
  ],
  orderExceptionalTimeout: [
    { required: true, message: '请输入正整数', trigger: 'blur' },
  ],
  temperatureUnit: [
@@ -196,6 +207,7 @@
      headShowCount: Number(formModel.value.headShowCount ?? 0),
      nearPointDistance: Number(formModel.value.nearPointDistance ?? 0),
      nearPointDistanceSearch: Number(formModel.value.nearPointDistanceSearch ?? 0),
      orderExceptionalTimeout: Number(formModel.value.orderExceptionalTimeout ?? 0),
      operateFree: formModel.value.operateFree,
      tripShareStatus: formModel.value.tripShareStatus,
      temperatureUnit: formModel.value.temperatureUnit,
@@ -237,6 +249,7 @@
      formModel.value.headShowCount = d.headShowCount ? d.headShowCount.toString() : ''
      formModel.value.nearPointDistance = d.nearPointDistance ? d.nearPointDistance.toString() : ''
      formModel.value.nearPointDistanceSearch = d.nearPointDistanceSearch ? d.nearPointDistanceSearch.toString() : ''
      formModel.value.orderExceptionalTimeout = d.orderExceptionalTimeout ? d.orderExceptionalTimeout.toString() : ''
      formModel.value.operateFree = d.operateFree
      formModel.value.tripShareStatus = d.tripShareStatus
      formModel.value.temperatureUnit = d.temperatureUnit || '℃'
@@ -323,6 +336,24 @@
          </div>
          <div class="form-row gap-x-40px!">
            <n-form-item label="订单异常超时时间" path="orderExceptionalTimeout" required class="row-item">
              <div class="inline-input">
                <n-input-group style="width: 300px;">
                  <n-input
                    :value="formModel.orderExceptionalTimeout" placeholder="请输入"
                    :allow-input="onlyAllowNumberAndDash"
                    style="width: 100%;"
                    @update:value="handleOrderExceptionalTimeoutChange"
                  />
                  <n-input-group-label>分钟</n-input-group-label>
                </n-input-group>
                <span class="inline-note">远驾超过该时长自动取消/改派订单</span>
              </div>
            </n-form-item>
            <div class="row-item" />
          </div>
          <div class="form-row gap-x-40px!">
            <n-form-item label="是否开启行程分享" path="tripShareStatus" required class="row-item">
              <n-switch v-model:value="formModel.tripShareStatus" :unchecked-value="0" :checked-value="1" />
            </n-form-item>