feat(businessOperations/cruise): 新增特殊点位管理功能并优化巡逻点位列表

新增特殊点位的新增与编辑页面,支持点位选择与关联车辆管理。
重构巡逻点位列表页面,替换原弹窗选择逻辑为路由跳转,新增删除确认弹窗,优化表格样式与后端分页逻辑。
更新点位相关API接口,将getSpecialPoints改为POST请求并添加分页参数,调整setSpecialPoint参数结构,新增deleteSpecialPoint和getSpecialPointDetail接口,修复类型空格问题。
优化SelectPointModal组件,支持单选模式与点击行选择功能。
新增对应的路由配置。
1个文件已添加
4个文件已修改
1116 ■■■■■ 已修改文件
src/router/routes.static.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/point.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/cruise/patrol-points/add.vue 581 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/cruise/patrol-points/components/SelectPointModal.vue 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/cruise/patrol-points/index.vue 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts
@@ -780,6 +780,21 @@
    pid: 407,
  },
  {
    name: 'patrolPointsAdd',
    path: '/businessOperations/cruise/patrol-points/add',
    title: '新增特殊点位',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/businessOperations/cruise/patrol-points/add.vue',
    id: 40711,
    keepAlive: false,
    pid: 407,
    hide: true,
    fullScreen: true,
    activeMenu: '/businessOperations/cruise/patrol-points',
  },
  {
    name: 'patrolPlan',
    path: '/businessOperations/cruise/parade-plan',
    title: '巡游计划',
src/service/api/point.ts
@@ -19,7 +19,7 @@
  return request.Post<Service.ResponseResult<any>>('/map/api/v1/point/enableDisable', data)
}
export function fetchPointDetail(id: number|string) {
export function fetchPointDetail(id: number | string) {
  return request.Get<Service.ResponseResult<Entity.Point>>('/map/api/v1/point/queryById', { params: { id } })
}
@@ -45,10 +45,27 @@
  return method
}
export function getSpecialPoints() {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/point/listSpecialPoint')
export function getSpecialPoints(params: any) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/point/listSpecialPoint', params)
}
export function setSpecialPoint(data: { chargePoints: number[], parkingPoints: number[] }) {
export function setSpecialPoint(data: {
  pointId: number | string
  pointsType: number
  vehicleIds: Array<number | string>
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/point/setSpecialPoint', data)
}
export function deleteSpecialPoint(data: {
  pointId: number | string
  pointsType: number
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/point/deleteSpecialPoint', data)
}
export function getSpecialPointDetail(data: {
  pointId: number | string
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/point/getSpecialPointDetail', data)
}
src/views/businessOperations/cruise/patrol-points/add.vue
New file
@@ -0,0 +1,581 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NTag, useMessage } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import SelectPointModal from './components/SelectPointModal.vue'
import VehicleSelectModal from '@/views/assetManagement/fleetManagement/components/VehicleSelectModal.vue'
import { getSpecialPointDetail, getSpecialPoints, setSpecialPoint } from '@/service/api/point'
import { useTabStore } from '@/store'
interface PointItem {
  id: number | string
  pointName: string
  pointAddress: string
}
interface VehicleItem {
  id: number | string
  vehicleCode: string
  plateNumber: string
  vehicleModelName?: string
  vehicleModel?: string
  procurementSource?: string
  fleetName?: string
  bindPointName?: string
  specialPointName?: string
  parkingPointName?: string
  chargePointName?: string
  bindStatus?: number
  pointBindStatus?: number
  [key: string]: any
}
const router = useRouter()
const route = useRoute()
const tabStore = useTabStore()
const message = useMessage()
const showPointModal = ref(false)
const showVehicleModal = ref(false)
const saving = ref(false)
const selectedPoint = ref<PointItem | null>(null)
const selectedVehicles = ref<VehicleItem[]>([])
const vehicleSearchModel = ref({
  plateNumber: '',
  vehicleCode: '',
})
const vehiclePageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const pageMode = computed<'add' | 'edit'>(() => route.query.mode === 'edit' ? 'edit' : 'add')
const editingPointId = computed<string>(() => String(route.query.pointId || route.query.id || ''))
const pointType = computed<'charge' | 'park'>(() => route.query.type === 'charge' ? 'charge' : 'park')
const pointTypeText = computed(() => pointType.value === 'charge' ? '充电点位' : '收车点位')
const selectedPointData = computed(() => selectedPoint.value ? [selectedPoint.value] : [])
const existingPointForModal = computed(() => {
  if (!selectedPoint.value)
    return []
  return [{
    id: selectedPoint.value.id,
    pointName: selectedPoint.value.pointName,
    pointAddress: selectedPoint.value.pointAddress,
  }]
})
const filteredVehicleList = computed(() => {
  return selectedVehicles.value.filter((item) => {
    const matchPlateNumber = !vehicleSearchModel.value.plateNumber || String(item.plateNumber || '').includes(vehicleSearchModel.value.plateNumber.trim())
    const matchVehicleCode = !vehicleSearchModel.value.vehicleCode || String(item.vehicleCode || '').includes(vehicleSearchModel.value.vehicleCode.trim())
    return matchPlateNumber && matchVehicleCode
  })
})
const pagedVehicleList = computed(() => {
  const startIndex = (vehiclePageParams.value.pageNum - 1) * vehiclePageParams.value.pageSize
  return filteredVehicleList.value.slice(startIndex, startIndex + vehiclePageParams.value.pageSize)
})
const pointColumns = computed<DataTableColumns<PointItem>>(() => [
  {
    title: '点位名称',
    key: 'pointName',
    width: 260,
    resizable: true,
    ellipsis: { tooltip: true },
  },
  {
    title: '详细地址',
    key: 'pointAddress',
    width: 420,
    resizable: true,
    ellipsis: { tooltip: true },
  },
  {
    title: '操作',
    key: 'actions',
    width: 100,
    render: row => (
      <NButton text type="primary" onClick={() => handleOpenPointModal()}>
        更换
      </NButton>
    ),
  },
])
const vehicleColumns = computed<DataTableColumns<VehicleItem>>(() => [
  {
    title: '序号',
    key: 'index',
    width: 70,
    render: (row, index) => <span>{(vehiclePageParams.value.pageNum - 1) * vehiclePageParams.value.pageSize + index + 1}</span>,
  },
  {
    title: '车辆编号',
    key: 'vehicleCode',
    minWidth: 150,
    ellipsis: { tooltip: true },
    render: row => row.vehicleCode || '-',
  },
  {
    title: '车牌号',
    key: 'plateNumber',
    minWidth: 140,
    ellipsis: { tooltip: true },
    render: row => row.plateNumber || '-',
  },
  {
    title: '型号',
    key: 'vehicleModelName',
    minWidth: 120,
    ellipsis: { tooltip: true },
    render: row => row.vehicleModelName || row.vehicleModel || '-',
  },
  {
    title: '采购来源',
    key: 'procurementSource',
    minWidth: 120,
    ellipsis: { tooltip: true },
    render: row => row.procurementSource || '-',
  },
  {
    title: '当前车队',
    key: 'fleetName',
    minWidth: 160,
    ellipsis: { tooltip: true },
    render: row => row.fleetName || '-',
  },
  {
    title: '关联状态',
    key: 'bindStatus',
    width: 140,
    render: (row) => {
      const conflictText = getVehicleConflictText(row)
      if (conflictText) {
        return (
          <NTag bordered={false} type="warning">
            存在冲突
          </NTag>
        )
      }
      return (
        <NTag bordered={false} type="success">
          正常
        </NTag>
      )
    },
  },
  {
    title: '操作',
    key: 'actions',
    width: 80,
    render: row => (
      <NButton text type="primary" onClick={() => handleRemoveVehicle(row.id)}>
        移除
      </NButton>
    ),
  },
])
const vehicleFilterItems = computed(() => [
  {
    label: '车牌号',
    path: 'plateNumber',
    render: () => <n-input v-model:value={vehicleSearchModel.value.plateNumber} placeholder="请输入" clearable />,
  },
  {
    label: '车辆编号',
    path: 'vehicleCode',
    render: () => <n-input v-model:value={vehicleSearchModel.value.vehicleCode} placeholder="请输入" clearable />,
  },
])
const isSaveDisabled = computed(() => !selectedPoint.value || saving.value)
function getVehicleConflictText(vehicle: VehicleItem) {
  const currentPointId = selectedPoint.value ? String(selectedPoint.value.id) : ''
  const conflictName = vehicle.bindPointName
    || vehicle.specialPointName
    || vehicle.parkingPointName
    || vehicle.chargePointName
  const conflictPointId = vehicle.bindPointId ? String(vehicle.bindPointId) : ''
  if (currentPointId && conflictPointId && conflictPointId === currentPointId)
    return ''
  if (selectedPoint.value && conflictName && conflictName === selectedPoint.value.pointName)
    return ''
  if (conflictName)
    return `已关联${conflictName}`
  if (vehicle.bindStatus === 1 || vehicle.pointBindStatus === 1)
    return '已关联其他点位'
  return ''
}
function getPointsTypeValue(type: 'charge' | 'park') {
  return type === 'charge' ? 1 : 2
}
async function fetchExistingPointIds(type: 'charge' | 'park') {
  const res = await getSpecialPoints({
    pageNum: 1,
    pageSize: 9999,
    pointName: '',
    pointAddress: '',
    pointsType: getPointsTypeValue(type),
  })
  return (res.data?.records || [])
    .map((item: any) => String(item.pointId))
    .filter(id => pageMode.value !== 'edit' || id !== editingPointId.value)
}
async function handleConfirmPointSelect(points: any[]) {
  const targetPoint = points[0]
  if (!targetPoint)
    return
  const existingPointIds = await fetchExistingPointIds(pointType.value)
  if (existingPointIds.includes(String(targetPoint.id))) {
    message.warning(`该${pointTypeText.value}已存在,请重新选择`)
    return
  }
  selectedPoint.value = {
    id: targetPoint.id,
    pointName: targetPoint.pointName || targetPoint.name || '',
    pointAddress: targetPoint.pointAddress || targetPoint.address || '',
  }
}
function handleOpenPointModal() {
  showPointModal.value = true
}
function handleVehicleSearch() {
  vehiclePageParams.value.pageNum = 1
}
function handleResetVehicleSearch() {
  vehicleSearchModel.value.plateNumber = ''
  vehicleSearchModel.value.vehicleCode = ''
  vehiclePageParams.value.pageNum = 1
}
function handleVehiclePageChange(page: number, pageSize: number) {
  vehiclePageParams.value.pageNum = page
  vehiclePageParams.value.pageSize = pageSize
}
function handleOpenVehicleModal() {
  showVehicleModal.value = true
}
function handleConfirmVehicleSelect(vehicles: VehicleItem[]) {
  const vehicleMap = new Map<string, VehicleItem>()
  selectedVehicles.value.forEach((item) => {
    vehicleMap.set(String(item.id), item)
  })
  vehicles.forEach((item) => {
    vehicleMap.set(String(item.id), item)
  })
  selectedVehicles.value = Array.from(vehicleMap.values())
  vehiclePageParams.value.pageNum = 1
}
function handleRemoveVehicle(vehicleId: number | string) {
  selectedVehicles.value = selectedVehicles.value.filter(item => String(item.id) !== String(vehicleId))
}
function handleCancel() {
  tabStore.closeTab(route.fullPath)
  router.push({ name: 'patrolPoints' })
}
function normalizePointData(data: any): PointItem | null {
  const pointId = data?.pointId ?? data?.id ?? data?.specialPointId
  if (pointId === null || pointId === undefined || pointId === '')
    return null
  return {
    id: pointId,
    pointName: data?.pointName || data?.name || '',
    pointAddress: data?.pointAddress || data?.address || '',
  }
}
function normalizeVehicleItem(vehicle: any): VehicleItem {
  return {
    ...vehicle,
    id: vehicle?.id ?? vehicle?.vehicleId ?? vehicle?.carId,
    vehicleCode: vehicle?.vehicleCode || vehicle?.carCode || '',
    plateNumber: vehicle?.plateNumber || '',
    vehicleModelName: vehicle?.vehicleModelName || vehicle?.vehicleModel || '',
    procurementSource: vehicle?.procurementSource || '',
    fleetName: vehicle?.fleetName || '',
    bindPointName: vehicle?.bindPointName || vehicle?.specialPointName || '',
    specialPointName: vehicle?.specialPointName || '',
    parkingPointName: vehicle?.parkingPointName || '',
    chargePointName: vehicle?.chargePointName || '',
    bindStatus: vehicle?.bindStatus,
    pointBindStatus: vehicle?.pointBindStatus,
    bindPointId: vehicle?.bindPointId ?? vehicle?.specialPointId ?? vehicle?.parkingPointId ?? vehicle?.chargePointId,
  }
}
function getDetailVehicleList(data: any) {
  const vehicleList = data?.vehicleList
    || data?.bindVehicleList
    || data?.specialPointVehicleList
    || data?.vehicles
    || []
  return Array.isArray(vehicleList) ? vehicleList.map(normalizeVehicleItem) : []
}
async function fetchDetail() {
  if (pageMode.value !== 'edit' || !editingPointId.value)
    return
  const res = await getSpecialPointDetail({ pointId: editingPointId.value })
  if (res.code !== 200 || !res.data) {
    return
  }
  selectedPoint.value = normalizePointData(res.data)
  selectedVehicles.value = getDetailVehicleList(res.data)
}
async function handleSave() {
  if (!selectedPoint.value) {
    message.warning(`请选择${pointTypeText.value}`)
    return
  }
  const conflictVehicle = selectedVehicles.value.find(item => getVehicleConflictText(item))
  if (conflictVehicle) {
    message.warning('关联车辆存在已绑定其他点位的冲突,请调整后再保存')
    return
  }
  saving.value = true
  try {
    const payload = {
      pointId: selectedPoint.value.id,
      pointsType: getPointsTypeValue(pointType.value),
      vehicleIds: selectedVehicles.value.map(item => item.id),
    }
    const res = await setSpecialPoint(payload)
    if (res.code !== 200) {
      message.error(res.msg || res.data || '保存失败')
      return
    }
    message.success('保存成功')
    window.sessionStorage.setItem('cruisePatrolPointNeedRefresh', '1')
    tabStore.closeTab(route.fullPath)
    router.push({ name: 'patrolPoints' })
  }
  finally {
    saving.value = false
  }
}
onMounted(() => {
  fetchDetail()
})
</script>
<template>
  <div class="special-point-add-page roboTaxi">
    <div class="p-12px h-full flex flex-col gap-12px">
      <n-card class="content-card" :bordered="false">
        <div class="flex justify-between items-center mb-12px">
          <div class="section-title-wrap">
            <div class="section-title-bar" />
            <span class="section-title">特殊点位信息</span>
          </div>
          <NButton type="primary" secondary @click="handleOpenPointModal">
            选择点位
          </NButton>
        </div>
        <n-data-table
          :columns="pointColumns"
          :data="selectedPointData"
          :bordered="false"
          :pagination="false"
          :style="selectedPointData.length === 0 ? { minHeight: '140px' } : {}"
          :row-key="(row: PointItem) => row.id"
        >
          <template #empty>
            <div class="empty-wrap">
              <img src="/src/assets/element-img/no-data.png" 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>
      </n-card>
      <n-card class="content-card flex-1" :bordered="false" content-style="display:flex;flex-direction:column;flex:1;min-height:0;">
        <div class="section-title-wrap mb-12px">
          <div class="section-title-bar" />
          <span class="section-title">关联车辆列表</span>
        </div>
        <div class="vehicle-search-card">
          <n-form :model="vehicleSearchModel" label-placement="left" inline :show-feedback="false">
            <SearchPanel>
              <n-gi v-for="item in vehicleFilterItems" :key="item.path">
                <n-form-item :label="item.label" :path="item.path">
                  <component :is="item.render" />
                </n-form-item>
              </n-gi>
              <template #action>
                <NButton @click="handleResetVehicleSearch">
                  重置
                </NButton>
                <NButton type="primary" @click="handleVehicleSearch">
                  查询
                </NButton>
              </template>
            </SearchPanel>
          </n-form>
        </div>
        <div class="vehicle-toolbar">
          <NButton type="primary" secondary @click="handleOpenVehicleModal">
            添加关联车辆
          </NButton>
        </div>
        <div class="flex-1 flex flex-col h-full" style="min-width: 0; overflow: hidden">
          <n-data-table
            class="flex-1"
            :columns="vehicleColumns"
            :data="pagedVehicleList"
            :bordered="false"
            :pagination="false"
            flex-height
            :row-key="(row: VehicleItem) => row.id"
            :scroll-x="1100"
          >
            <template #empty>
              <div class="empty-wrap">
                <img src="/src/assets/element-img/no-data.png" 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="filteredVehicleList.length"
            :page="vehiclePageParams.pageNum"
            :page-size="vehiclePageParams.pageSize"
            class="robotaxi-pagination mt-12px"
            @change="handleVehiclePageChange"
          />
        </div>
      </n-card>
    </div>
    <div class="page-footer">
      <NButton class="w-84px" @click="handleCancel">
        取消
      </NButton>
      <NButton type="primary" class="w-84px" :loading="saving" :disabled="isSaveDisabled" @click="handleSave">
        保存
      </NButton>
    </div>
    <SelectPointModal
      v-model:show="showPointModal"
      :existing-points="existingPointForModal"
      :max-select-count="1"
      @confirm="handleConfirmPointSelect"
    />
    <VehicleSelectModal
      v-model:visible="showVehicleModal"
      :existing-vehicles="selectedVehicles"
      @confirm="handleConfirmVehicleSelect"
    />
  </div>
</template>
<style scoped lang="scss">
.special-point-add-page {
  height: calc(100vh - 76px);
}
.page-title-card,
.content-card {
  border-radius: 6px;
}
.page-title {
  font-size: 18px;
  font-weight: 600;
  color: #171f26;
}
.section-title-wrap {
  display: flex;
  align-items: center;
}
.section-title-bar {
  width: 3px;
  height: 14px;
  margin-right: 8px;
  border-radius: 3px;
  background: #0068ff;
}
.section-title {
  font-size: 16px;
  font-weight: 600;
  color: #171f26;
}
.point-toolbar,
.vehicle-toolbar {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 12px;
}
.vehicle-search-card {
  margin-bottom: 12px;
}
.empty-wrap {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 16px 0;
}
.page-footer {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 12px;
  padding: 16px 24px;
  background: #fff;
  border-radius: 6px;
}
</style>
src/views/businessOperations/cruise/patrol-points/components/SelectPointModal.vue
@@ -1,60 +1,63 @@
<script setup lang="ts">
import { ref, h, watch } from "vue";
import { useMessage } from "naive-ui";
import { fetchPointPageList } from "@/service/api/point";
import { useBoolean } from "@/hooks";
import type { DataTableColumns } from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { fetchPointPageList } from '@/service/api/point'
import { useBoolean } from '@/hooks'
interface Point {
  id: number;
  name?: string;
  pointAddress?: string;
  address?: string;
  type?: string;
  status?: string;
  [key: string]: any;
  id: number
  name?: string
  pointAddress?: string
  address?: string
  type?: string
  status?: string
  [key: string]: any
}
const props = defineProps<{
  show: boolean;
  existingPoints?: Point[];
}>();
  show: boolean
  existingPoints?: Point[]
  maxSelectCount?: number
}>()
const emit = defineEmits<{
  "update:show": [value: boolean];
  confirm: [points: Point[]];
}>();
  'update:show': [value: boolean]
  'confirm': [points: Point[]]
}>()
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const message = useMessage();
} = useBoolean(false)
const message = useMessage()
const model = ref({
  pointName: "",
});
const selectedPoints = ref<number[]>([]);
const allSelectedMap = ref<Map<number, Point>>(new Map());
  pointName: '',
})
const selectedPoints = ref<number[]>([])
const allSelectedMap = ref<Map<number, Point>>(new Map())
const isSingleSelect = computed(() => props.maxSelectCount === 1)
const listData = ref<any[]>([]);
const listData = ref<any[]>([])
const pagination = ref({
  page: 1,
  pageSize: 10,
  itemCount: 0,
});
})
async function getList() {
  startLoading();
  startLoading()
  try {
    const params = {
      pageNum: pagination.value.page,
      pageSize: pagination.value.pageSize,
      pointName: model.value.pointName,
      operationStatusList: [6],
    };
    const { data } = await fetchPointPageList(params);
    listData.value = data.records || [];
    }
    const { data } = await fetchPointPageList(params)
    listData.value = data.records || []
    // 更新 map 中的数据,确保有最新的信息(可选)
    // listData.value.forEach(item => {
@@ -63,25 +66,26 @@
    //   }
    // })
    pagination.value.itemCount = data.total || 0;
  } finally {
    endLoading();
    pagination.value.itemCount = data.total || 0
  }
  finally {
    endLoading()
  }
}
function handlePageChange(page: number, size: number) {
  pagination.value.page = page;
  pagination.value.pageSize = size;
  getList();
  pagination.value.page = page
  pagination.value.pageSize = size
  getList()
}
const handleSearch = () => {
  pagination.value.page = 1;
  getList();
};
function handleSearch() {
  pagination.value.page = 1
  getList()
}
const handleReset = () => {
  model.value.pointName = "";
function handleReset() {
  model.value.pointName = ''
  // 重置时只清空搜索条件,不应该清空已选?或者清空所有?
  // 通常重置按钮是在搜索栏旁边,应该只重置搜索条件。
  // 原代码:selectedPoints.value = [],这会清空已选。根据用户习惯,重置搜索通常不清除已选。
@@ -91,47 +95,55 @@
  // 假设这里只是重置搜索条件。
  // 原代码:model.value.pointName = ''; selectedPoints.value = []; handleSearch();
  // 这里我改为只重置搜索。
  handleSearch();
};
  handleSearch()
}
// 专门的清空选择按钮?或者保留原逻辑?
// 为了安全,我们把“重置”改为“重置搜索”,不清除已选。
const handleResetSearch = () => {
  model.value.pointName = "";
  handleSearch();
};
function handleResetSearch() {
  model.value.pointName = ''
  handleSearch()
}
const handleCancel = () => {
  emit("update:show", false);
};
function handleCancel() {
  emit('update:show', false)
}
const handleConfirm = () => {
  const result: Point[] = [];
function handleConfirm() {
  if (props.maxSelectCount && selectedPoints.value.length > props.maxSelectCount) {
    message.warning(`最多只能选择${props.maxSelectCount}个点位`)
    return
  }
  const result: Point[] = []
  selectedPoints.value.forEach((id) => {
    if (allSelectedMap.value.has(id)) {
      result.push(allSelectedMap.value.get(id)!);
      result.push(allSelectedMap.value.get(id)!)
    }
  });
  emit("confirm", result);
  emit("update:show", false);
};
  })
  emit('confirm', result)
  emit('update:show', false)
}
function handleUpdateChecked(keys: number[]) {
  selectedPoints.value = keys;
  selectedPoints.value = isSingleSelect.value && keys.length > 1
    ? [keys[keys.length - 1]]
    : keys
  // 遍历当前页数据,如果有被选中的,存入 map
  listData.value.forEach((item) => {
    if (keys.includes(item.id)) {
    if (selectedPoints.value.includes(item.id)) {
      // 如果 map 中已有该 id,尝试合并属性以保留额外信息(如 isCharge 等)
      // 优先使用新数据覆盖,但保留新数据中没有的旧字段
      const old = allSelectedMap.value.get(item.id);
      const old = allSelectedMap.value.get(item.id)
      if (old) {
        allSelectedMap.value.set(item.id, { ...old, ...item });
      } else {
        allSelectedMap.value.set(item.id, item);
        allSelectedMap.value.set(item.id, { ...old, ...item })
      }
      else {
        allSelectedMap.value.set(item.id, item)
      }
    }
  });
  })
  // 注意:我们不需要从 map 中删除未被选中的,因为 keys 才是最终的 source of truth。
  // 只要 confirm 时遍历 keys 去 map 取即可。
@@ -142,62 +154,85 @@
  // 但我们的 props 改为了 existingPoints: Point[],所以初始化时 map 是全的。
}
function handleRowClick(row: Point) {
  if (isSingleSelect.value) {
    handleUpdateChecked([row.id])
    return
  }
  const nextKeys = selectedPoints.value.includes(row.id)
    ? selectedPoints.value.filter(id => id !== row.id)
    : [...selectedPoints.value, row.id]
  handleUpdateChecked(nextKeys)
}
function rowProps(row: Point) {
  return {
    style: 'cursor: pointer;',
    onClick: () => handleRowClick(row),
  }
}
watch(
  () => props.show,
  (newVal) => {
    if (newVal) {
      model.value.pointName = "";
      model.value.pointName = ''
      // 初始化
      selectedPoints.value = props.existingPoints
        ? props.existingPoints.map((p) => p.id)
        : [];
      allSelectedMap.value.clear();
        ? props.existingPoints.map(p => p.id)
        : []
      allSelectedMap.value.clear()
      if (props.existingPoints) {
        props.existingPoints.forEach((p) => {
          allSelectedMap.value.set(p.id, p);
        });
          allSelectedMap.value.set(p.id, p)
        })
      }
      pagination.value.page = 1;
      getList();
      pagination.value.page = 1
      getList()
    }
  }
);
  },
)
const columns = [
  { type: "selection" },
const columns = computed<DataTableColumns<Point>>(() => [
  {
    title: "序号",
    key: "index",
    type: 'selection',
    multiple: !isSingleSelect.value,
    width: 50,
  },
  {
    title: '序号',
    key: 'index',
    width: 120,
    render: (row, index) => {
      return (
        (pagination.value.page - 1) * pagination.value.pageSize + index + 1
      );
      )
    },
  },
  { title: "点位名称", key: "pointName", minWidth: 150 },
  { title: '点位名称', key: 'pointName', minWidth: 150 },
  {
    title: "详细地址",
    key: "pointAddress",
    title: '详细地址',
    key: 'pointAddress',
    minWidth: 200,
    ellipsis: { tooltip: true },
  },
  {
    title: "状态",
    key: "operationStatus",
    title: '状态',
    key: 'operationStatus',
    width: 100,
    render: () => {
      return h(
        "div",
        'div',
        {
          class: "status-tag test-starting",
          class: 'status-tag test-starting',
        },
        "启用中"
      );
        '启用中',
      )
    },
  },
];
])
</script>
<template>
@@ -226,36 +261,40 @@
      <div class="flex flex-col h-full overflow-hidden">
        <div class="mb-4 flex items-end gap-4" style="flex-shrink: 0">
          <div class="flex-1">
            <div class="mb-2 text-sm">点位名称</div>
            <div class="mb-2 text-sm">
              点位名称
            </div>
            <n-input
              v-model:value="model.pointName"
              placeholder="请输入"
              clearable
            />
          </div>
          <div class="flex-1"></div>
          <div class="flex-1" />
          <div class="flex gap-2">
            <n-button style="width: 84px; height: 32px" @click="handleReset"
              >重置</n-button
            >
            <n-button style="width: 84px; height: 32px" @click="handleReset">
              重置
            </n-button>
            <n-button
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
              >查询</n-button
            >
              查询
            </n-button>
          </div>
        </div>
        <div class="flex-1 overflow-hidden" style="min-height: 0">
          <n-data-table
            :checked-row-keys="selectedPoints"
            @update:checked-row-keys="handleUpdateChecked"
            v-model:checked-row-keys="selectedPoints"
            :columns="columns"
            :data="listData"
            flex-height
             style="height: 100%"
            style="height: 100%"
            :loading="loading"
            :row-key="(row: Point) => row.id"
            :row-props="rowProps"
            @update:checked-row-keys="handleUpdateChecked"
          >
            <template #empty>
              <div class="flex-center">
@@ -263,31 +302,32 @@
                  src="/src/assets/element-img/no-data.png"
                  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>
        </div>
         <Pagination
            style="margin-top: 12px; flex-shrink: 0"
            :count="pagination.itemCount"
            :page="pagination.page"
            :page-size="pagination.pageSize"
            @change="handlePageChange"
          />
        <Pagination
          style="margin-top: 12px; flex-shrink: 0"
          :count="pagination.itemCount"
          :page="pagination.page"
          :page-size="pagination.pageSize"
          @change="handlePageChange"
        />
      </div>
      <template #footer>
        <div class="flex justify-end gap-3">
          <n-button style="width: 84px; height: 32px" @click="handleCancel"
            >取消</n-button
          >
          <n-button style="width: 84px; height: 32px" @click="handleCancel">
            取消
          </n-button>
          <n-button
            type="primary"
            style="width: 84px; height: 32px"
            @click="handleConfirm"
            >保存</n-button
          >
            保存
          </n-button>
        </div>
      </template>
    </n-card>
src/views/businessOperations/cruise/patrol-points/index.vue
@@ -1,24 +1,24 @@
<script setup lang="tsx">
import { computed, h, onMounted, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import SelectPointModal from './components/SelectPointModal.vue'
import { getSpecialPoints, setSpecialPoint } from '@/service/api/point'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useDialog, useMessage } from 'naive-ui'
import { deleteSpecialPoint, getSpecialPoints } from '@/service/api/point'
import { useBoolean } from '@/hooks'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import OperationLogModal from '@/components/custom/OperationLogModal.vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const router = useRouter()
const dialog = useDialog()
const message = useMessage()
const showSelectModal = ref(false)
const showOperationLogModal = ref(false)
const activeTab = ref<'park' | 'charge'>('park')
const currentSelectType = ref<'charge' | 'park'>('park')
const editingPointId = ref<number | string | null>(null)
const chargePoints = ref<any[]>([])
const parkPoints = ref<any[]>([])
const total = ref(0)
const model = ref({
  pointName: '',
  pointAddress: '',
@@ -28,65 +28,44 @@
  pageSize: 10,
})
const currentExistingPoints = computed(() => {
  if (editingPointId.value !== null)
    return []
  return currentTabList.value.map(item => ({
    id: item.pointId,
    pointName: item.pointName,
    pointAddress: item.pointAddress,
  }))
})
const currentTabList = computed(() => activeTab.value === 'charge' ? chargePoints.value : parkPoints.value)
const filteredList = computed(() => {
  return currentTabList.value.filter((item) => {
    const matchPointName = !model.value.pointName || String(item.pointName || '').includes(model.value.pointName.trim())
    const matchPointAddress = !model.value.pointAddress || String(item.pointAddress || '').includes(model.value.pointAddress.trim())
    return matchPointName && matchPointAddress
  })
})
const pagedList = computed(() => {
  const startIndex = (pageParams.value.pageNum - 1) * pageParams.value.pageSize
  return filteredList.value.slice(startIndex, startIndex + pageParams.value.pageSize)
})
const columns = computed(() => [
  {
    title: '点位名称',
    key: 'pointName',
    minWidth: 220,
    width: 220,
    resizable: true,
    ellipsis: { tooltip: true },
  },
  {
    title: '详细地址',
    key: 'pointAddress',
    minWidth: 280,
    width: 280,
    resizable: true,
    ellipsis: { tooltip: true },
  },
  {
    title: '关联车辆数',
    key: 'relatedVehicleCount',
    width: 140,
    resizable: true,
    render: (row: any) => getRelatedVehicleCount(row),
  },
  {
    title: '操作',
    key: 'actions',
    width: 120,
    render: (row: any) => [
      h('span', {
        class: 'table-action',
        onClick: () => handleEditPoint(row),
      }, '编辑'),
      h('span', {
        class: 'table-action ml-16px',
        onClick: () => handleRemovePoint(activeTab.value, row.pointId),
      }, '移除'),
    ],
    render: (row: any) => (
      <div class="flex items-center">
        <n-button text type="primary" onClick={() => handleEditPoint(row)}>
          编辑
        </n-button>
        <n-button text type="primary" class="ml-12px" onClick={() => handleRemovePoint(activeTab.value, row.pointId)}>
          移除
        </n-button>
      </div>
    ),
  },
])
@@ -104,130 +83,117 @@
])
function getRelatedVehicleCount(row: any) {
  return row.relatedVehicleCount ?? row.bindVehicleCount ?? row.vehicleCount ?? 0
  return row.relatedVehicleCount ?? row.bindVehicleCount ?? row.vehicleCount ?? row.bindCount ?? 0
}
function getPointsType() {
  return activeTab.value === 'charge' ? 1 : 2
}
async function getList() {
  startLoading()
  try {
    const { data } = await getSpecialPoints()
    chargePoints.value = data?.chargePoints || []
    parkPoints.value = data?.parkingPoints || []
    const { data } = await getSpecialPoints({
      pageNum: pageParams.value.pageNum,
      pageSize: pageParams.value.pageSize,
      pointName: model.value.pointName.trim(),
      pointAddress: model.value.pointAddress.trim(),
      pointsType: getPointsType(),
    })
    const records = Array.isArray(data?.records) ? data.records : []
    total.value = data?.total || 0
    pageParams.value.pageNum = data?.current || pageParams.value.pageNum
    pageParams.value.pageSize = data?.size || pageParams.value.pageSize
    if (activeTab.value === 'charge')
      chargePoints.value = records
    else
      parkPoints.value = records
  }
  finally {
    endLoading()
  }
}
function handleSelectPoint(type: 'charge' | 'park') {
  currentSelectType.value = type
  editingPointId.value = null
  showSelectModal.value = true
}
function handleConfirmSelect(points: any[]) {
  const selectedPoints = points.map(p => ({
    pointId: p.id,
    pointName: p.pointName,
    pointAddress: p.pointAddress,
    relatedVehicleCount: p.relatedVehicleCount ?? p.bindVehicleCount ?? p.vehicleCount ?? 0,
  }))
  if (editingPointId.value !== null) {
    const targetPoint = selectedPoints[0]
    if (!targetPoint)
      return
    const targetList = currentSelectType.value === 'charge' ? chargePoints.value : parkPoints.value
    const currentIndex = targetList.findIndex(item => String(item.pointId) === String(editingPointId.value))
    const duplicateIndex = targetList.findIndex(item => String(item.pointId) === String(targetPoint.pointId))
    if (duplicateIndex !== -1 && duplicateIndex !== currentIndex) {
      message.warning('该点位已存在,请重新选择')
      return
    }
    if (currentIndex !== -1)
      targetList.splice(currentIndex, 1, targetPoint)
    editingPointId.value = null
    return
  }
  if (currentSelectType.value === 'charge') {
    const existingIds = new Set(chargePoints.value.map(p => p.pointId))
    const newPoints = selectedPoints.filter(p => !existingIds.has(p.pointId))
    chargePoints.value.push(...newPoints)
  }
  else {
    const existingIds = new Set(parkPoints.value.map(p => p.pointId))
    const newPoints = selectedPoints.filter(p => !existingIds.has(p.pointId))
    parkPoints.value.push(...newPoints)
  }
}
function handleRemovePoint(type: 'charge' | 'park', pointId: number | string) {
  if (type === 'charge') {
    chargePoints.value = chargePoints.value.filter(item => String(item.pointId) !== String(pointId))
  }
  else {
    parkPoints.value = parkPoints.value.filter(item => String(item.pointId) !== String(pointId))
  }
  dialog.warning({
    title: '删除确认',
    content: '确认移除该特殊点位吗?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: async () => {
      const res = await deleteSpecialPoint({
        pointId,
        pointsType: type === 'charge' ? 1 : 2,
      })
      if (res.code !== 200) {
        return
      }
      message.success('移除成功')
      getList()
    },
  })
}
function handleEditPoint(row: any) {
  currentSelectType.value = activeTab.value
  editingPointId.value = row.pointId
  showSelectModal.value = true
  router.push({
    name: 'patrolPointsAdd',
    query: {
      type: activeTab.value,
      mode: 'edit',
      pointId: row.pointId,
    },
  })
}
function handleSearch() {
  pageParams.value.pageNum = 1
  getList()
}
function handleResetSearch() {
  model.value.pointName = ''
  model.value.pointAddress = ''
  pageParams.value.pageNum = 1
  getList()
}
function handlePageChange(page: number, pageSize: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = pageSize
  getList()
}
function handleOpenOperationLog() {
  showOperationLogModal.value = true
}
async function handleSave() {
  if (!chargePoints.value.length || !parkPoints.value.length) {
    message.warning('请先选择充电点位和收车点位')
    return
  }
  startLoading()
  try {
    const params = {
      chargePoints: chargePoints.value.map(p => p.pointId),
      parkingPoints: parkPoints.value.map(p => p.pointId),
    }
    const { code, data } = await setSpecialPoint(params)
    if (code !== 200) {
      message.error(data || '保存失败')
      return
    }
    message.success('保存成功')
    getList()
  }
  finally {
    endLoading()
  }
function handleAdd() {
  router.push({
    name: 'patrolPointsAdd',
    query: {
      type: activeTab.value,
    },
  })
}
onMounted(() => {
  getList()
})
onActivated(() => {
  if (window.sessionStorage.getItem('cruisePatrolPointNeedRefresh') === '1') {
    window.sessionStorage.removeItem('cruisePatrolPointNeedRefresh')
    getList()
  }
})
watch(activeTab, () => {
  handleResetSearch()
  pageParams.value.pageNum = 1
  model.value.pointName = ''
  model.value.pointAddress = ''
  getList()
})
</script>
@@ -267,7 +233,7 @@
          <n-button @click="handleOpenOperationLog">
            操作日志
          </n-button>
          <n-button secondary type="primary" @click="handleSelectPoint(activeTab)">
          <n-button secondary type="primary" @click="handleAdd">
            新增
          </n-button>
        </n-flex>
@@ -277,7 +243,7 @@
        <n-data-table
          class="flex-1"
          :columns="columns"
          :data="pagedList"
          :data="currentTabList"
          :loading="loading"
          flex-height
          :bordered="false"
@@ -293,7 +259,7 @@
          </template>
        </n-data-table>
        <Pagination
          :count="filteredList.length"
          :count="total"
          :page="pageParams.pageNum"
          :page-size="pageParams.pageSize"
          class="robotaxi-pagination mt-12px"
@@ -301,12 +267,6 @@
        />
      </div>
    </n-card>
    <SelectPointModal
      v-model:show="showSelectModal"
      :existing-points="currentExistingPoints"
      @confirm="handleConfirmSelect"
    />
    <OperationLogModal v-model:show="showOperationLogModal" />
  </div>
</template>
@@ -314,14 +274,5 @@
<style scoped lang="scss">
:deep(.n-tabs-nav-scroll-content) {
  border-color: transparent !important;
}
.table-action {
  color: #0068ff;
  cursor: pointer;
}
.table-action:hover {
  opacity: 0.8;
}
</style>