huangxz
2026-08-11 da4cfd4fb8ab95b441aa2a2c3a5d134ff98e7d6b
feat(tpd): 订单列表支持单笔与批量修改目的地

服务中的订单新增「修改目的地」行按钮与「批量修改目的地」入口,共用一个选择点位弹窗
(点位名称模糊搜索 + 单选 + 分页)。是否可改由后端返回的 changeDestAllowed 决定,
超过次数上限后按钮自动消失、勾选框置灰。

订单记录列合并跳过原因与改点记录,按 50 字截断并悬浮显示全文。
行点击选中同步加上与勾选框一致的禁用判断,否则点行仍能选中被灰掉的行。
操作列加宽到 260 后同步把 scroll-x 调到 1750,避免固定操作列拖不到最右。
1个文件已添加
2个文件已修改
412 ■■■■■ 已修改文件
src/service/api/testPlanManagement.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanManagement/components/ChangeDestinationModal.vue 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanManagement/detail.vue 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/testPlanManagement.ts
@@ -173,6 +173,23 @@
  })
}
// 单笔与批量共用:单笔即 orderIds 只放一个订单。部分失败也返回成功码,逐单结果在 data 里
export function changeTestPlanOrderDestination(data: {
  planId: number | string
  orderIds: Array<number | string>
  endPointId: number | string
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/test-plan-demo/order/changeDestination', data)
}
export function fetchTestPlanEnabledPointPage(data: {
  pointName?: string
  pageNum: number
  pageSize: number
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/test-plan-demo/detail/pageEnabledPoints', data)
}
export function reassignTestPlanOrder(data: {
  orderId: number | string
  planId: number | string
src/views/testDemo/testPlanManagement/components/ChangeDestinationModal.vue
New file
@@ -0,0 +1,293 @@
<script setup lang="tsx">
import noDataImg from '@/assets/element-img/no-data.png'
import type { DataTableColumns } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { NButton, NDataTable, NForm, NFormItem, NGi, NInput, NModal, useMessage } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import { changeTestPlanOrderDestination, fetchTestPlanEnabledPointPage } from '@/service/api/testPlanManagement'
import { getMultiSelectRowClassName, shouldIgnoreTableRowClick } from '@/utils/table'
interface PointRow {
  id: number | string
  pointName: string
  pointAddress: string
}
const props = defineProps<{
  visible: boolean
  planId?: number | string
  // 单笔与批量共用:行按钮传单个订单,批量按钮传勾选的全部订单
  orderIds: Array<number | string>
}>()
const emit = defineEmits<{
  (e: 'update:visible', val: boolean): void
  (e: 'success'): void
}>()
const message = useMessage()
const show = computed({
  get: () => props.visible,
  set: val => emit('update:visible', val),
})
const model = ref({
  pointName: '',
})
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const listData = ref<PointRow[]>([])
const checkedRowKeys = ref<Array<number | string>>([])
const count = ref(0)
const loading = ref(false)
const submitting = ref(false)
async function getList() {
  loading.value = true
  try {
    const res = await fetchTestPlanEnabledPointPage({
      pageNum: pageParams.value.pageNum,
      pageSize: pageParams.value.pageSize,
      pointName: model.value.pointName.trim() || undefined,
    })
    if (res.code !== 200 && res.code !== 0) {
      throw new Error(res.msg || '获取点位列表失败')
    }
    const pageData = res.data || {}
    const records = Array.isArray(pageData.records) ? pageData.records : []
    listData.value = records.map((item: any) => ({
      id: item.pointId ?? item.id,
      pointName: item.pointName || '-',
      pointAddress: item.pointAddress || '-',
    }))
    count.value = Number(pageData.total || 0)
  }
  catch (error: any) {
    listData.value = []
    count.value = 0
  }
  finally {
    loading.value = false
  }
}
watch(
  () => props.visible,
  (val) => {
    if (val) {
      model.value = { pointName: '' }
      pageParams.value = { pageNum: 1, pageSize: 10 }
      checkedRowKeys.value = []
      getList()
    }
    else {
      checkedRowKeys.value = []
    }
  },
)
function handleSearch() {
  pageParams.value.pageNum = 1
  getList()
}
function handleReset() {
  model.value = { pointName: '' }
  pageParams.value.pageNum = 1
  pageParams.value.pageSize = 10
  getList()
}
function changePage(page: number, size: number) {
  pageParams.value.pageNum = size !== pageParams.value.pageSize ? 1 : page
  pageParams.value.pageSize = size
  getList()
}
function handleCancel() {
  show.value = false
}
async function handleConfirm() {
  if (!props.planId || !props.orderIds.length) {
    message.warning('订单信息缺失')
    return
  }
  if (checkedRowKeys.value.length !== 1) {
    message.warning('请选择要修改的目的地')
    return
  }
  const selectedPoint = listData.value.find(item => String(item.id) === String(checkedRowKeys.value[0]))
  submitting.value = true
  try {
    const res = await changeTestPlanOrderDestination({
      planId: props.planId,
      orderIds: [...props.orderIds],
      endPointId: checkedRowKeys.value[0],
    })
    if (res.code !== 200 && res.code !== 0) {
      throw new Error(res.msg || '修改目的地失败')
    }
    // 后端逐单返回结果,部分失败也是 200:按失败明细提示,避免整批被当成成功
    const results = Array.isArray(res.data) ? res.data : []
    const failed = results.filter((item: any) => item?.success === false)
    if (failed.length) {
      const detail = failed
        .map((item: any) => `${item.orderNumber || item.orderId}:${item.failReason || '未知原因'}`)
        .join(';')
      message.error(`修改目的地失败(${detail})`)
    }
    else {
      message.success(`车辆修改目的地为${selectedPoint?.pointName || ''}成功`)
    }
    emit('success')
    show.value = false
  }
  catch (error: any) {
    message.error(error?.message || '修改目的地失败')
  }
  finally {
    submitting.value = false
  }
}
function getRowClassName(row: PointRow) {
  return getMultiSelectRowClassName(row.id, checkedRowKeys.value)
}
function getRowProps(row: PointRow) {
  return {
    style: 'cursor: pointer;',
    onClick: (event: MouseEvent) => {
      if (shouldIgnoreTableRowClick(event.target))
        return
      // 单选由 selection 列的 multiple: false 提供,行点击只是替换选中项
      checkedRowKeys.value = [row.id]
    },
  }
}
const columns = computed<DataTableColumns<PointRow>>(() => [
  {
    type: 'selection',
    width: 60,
    align: 'center',
    multiple: false,
  },
  {
    title: '序号',
    key: 'index',
    width: 80,
    render: (_, index) => (pageParams.value.pageNum - 1) * pageParams.value.pageSize + index + 1,
  },
  { title: '点位名称', key: 'pointName', width: 240, ellipsis: { tooltip: true } },
  { title: '详细地址', key: 'pointAddress', flex: 1, ellipsis: { tooltip: true } },
  // 查询条件已固定只取启用中点位,状态列直接展示,不再回传
  { title: '状态', key: 'status', width: 120, render: () => '启用中' },
])
</script>
<template>
  <NModal
    v-model:show="show"
    :mask-closable="false"
    preset="card"
    title="选择点位"
    :auto-focus="false"
    style="width: 1000px; height: 80vh;"
    :segmented="{ content: true }"
  >
    <div class="flex flex-col h-full overflow-hidden">
      <div class="mb-12px" style="flex-shrink: 0">
        <NForm :model="model" label-placement="left" inline :show-feedback="false">
          <SearchPanel>
            <NGi>
              <NFormItem label="点位名称" path="pointName">
                <NInput
                  v-model:value="model.pointName"
                  placeholder="请输入"
                  clearable
                />
              </NFormItem>
            </NGi>
            <template #action>
              <NButton style="width: 84px; height: 32px" @click="handleReset">
                重置
              </NButton>
              <NButton
                type="primary"
                style="width: 84px; height: 32px"
                @click="handleSearch"
              >
                查询
              </NButton>
            </template>
          </SearchPanel>
        </NForm>
      </div>
      <div class="flex-1 overflow-hidden" style="min-height: 0">
        <NDataTable
          v-model:checked-row-keys="checkedRowKeys"
          style="height: 100%"
          flex-height
          :columns="columns"
          :data="listData"
          :loading="loading"
          :bordered="false"
          :row-key="(row) => row.id"
          :row-class-name="getRowClassName"
          :row-props="getRowProps"
          :pagination="false"
        >
          <template #empty>
            <div class="flex-center">
              <img
                :src="noDataImg"
                style="width: 123px; height: 125px"
                alt="no-data"
              >
            </div>
          </template>
        </NDataTable>
      </div>
      <Pagination
        :count="count"
        :page="pageParams.pageNum"
        :page-size="pageParams.pageSize"
        style="margin-top: 12px; flex-shrink: 0"
        @change="changePage"
      />
    </div>
    <template #footer>
      <div class="flex justify-end gap-3" style="gap: 12px;">
        <NButton class="footer-btn" @click="handleCancel">
          取消
        </NButton>
        <NButton type="primary" class="footer-btn" :loading="submitting" @click="handleConfirm">
          保存
        </NButton>
      </div>
    </template>
  </NModal>
</template>
<style scoped lang="scss">
.footer-btn {
  width: 84px;
  height: 32px;
}
:deep(.robotaxi-table-row--selected td) {
  background-color: #f0f7ff !important;
}
</style>
src/views/testDemo/testPlanManagement/detail.vue
@@ -1,8 +1,8 @@
<script setup lang="tsx">
import noDataImg from '@/assets/element-img/no-data.png'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, h, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NDataTable, NForm, NFormItem, NSpace, NTabPane, NTabs, NTag, useDialog, useMessage } from 'naive-ui'
import { NButton, NDataTable, NForm, NFormItem, NSpace, NTabPane, NTabs, NTag, NTooltip, useDialog, useMessage } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import Pagination from '@/components/common/Pagination.vue'
import SearchPanel from '@/components/custom/SearchPanel.vue'
@@ -28,6 +28,7 @@
} from '@/service/api/testPlanManagement'
import { getTestPlanDisplayStatus } from './constants'
import AssignVehicleModal from './components/AssignVehicleModal.vue'
import ChangeDestinationModal from './components/ChangeDestinationModal.vue'
import ManageTestVehicleModal from './components/ManageTestVehicleModal.vue'
import CruiseOrderTab from './components/CruiseOrderTab.vue'
@@ -92,6 +93,8 @@
  skipReason?: string
  scoreDetail?: string
  chainDispatchedName?: string
  changeDestRecord?: string
  changeDestAllowed?: boolean
}
interface VehicleRow {
@@ -111,6 +114,9 @@
let orderRefreshTimer: ReturnType<typeof setInterval> | null = null
const isAssignVehicleModalVisible = ref(false)
const isChangeDestModalVisible = ref(false)
// 单笔与批量共用一个弹窗:行按钮放单个订单,批量按钮放勾选的全部订单
const changeDestOrderIds = ref<Array<number | string>>([])
const isManageTestVehicleModalVisible = ref(false)
const currentAssignOrderId = ref<number | string | null>(null)
const currentAssignVehicleId = ref<number | string | null>(null)
@@ -312,7 +318,7 @@
  return ORDER_STATUS_STYLE_MAP[resolvedStatusName] || { textColor: '#8C9299', color: '#F5F5F5' }
}
function resolveOrderActions(status?: number, statusName?: string) {
function resolveOrderActions(status?: number, statusName?: string, changeDestAllowed = false) {
  const normalizedStatusName = String(statusName || '').trim()
  if (normalizedStatusName === '已完成')
    return ['详情', '删除']
@@ -327,7 +333,10 @@
    case 4:
      return ['取消订单', '改派', '删除']
    case 6:
      return ['关闭订单', '详情', '删除']
      // 是否可改点由后端算(服务中 + 未达次数上限),超限后按钮自动消失
      return changeDestAllowed
        ? ['修改目的地', '关闭订单', '详情', '删除']
        : ['关闭订单', '详情', '删除']
    case 10:
      return ['详情', '删除']
    default:
@@ -466,11 +475,13 @@
        status: statusName,
        statusColor: statusStyle.textColor,
        statusBg: statusStyle.color,
        actions: resolveOrderActions(statusCode, statusName),
        actions: resolveOrderActions(statusCode, statusName, item.changeDestAllowed === true),
        dispatchScore: item.dispatchScore ?? null,
        skipReason: item.skipReason || '',
        scoreDetail: item.scoreDetail || '',
        chainDispatchedName: item.chainDispatchedName || '',
        changeDestRecord: item.changeDestRecord || '',
        changeDestAllowed: item.changeDestAllowed === true,
      }
    })
    orderCount.value = Number(pageData.total || 0)
@@ -619,7 +630,8 @@
}
const orderColumns = computed<DataTableColumns<OrderRow>>(() => [
  { type: 'selection', width: 50, align: 'center' },
  // 勾选仅用于「批量修改目的地」,所以非服务中/已超次数的行不可勾选
  { type: 'selection', width: 50, align: 'center', disabled: row => !row.changeDestAllowed },
  { title: '订单编号', key: 'orderNo', width: 160, ellipsis: { tooltip: true } },
  { title: '下发时间', key: 'issueTime', width: 180 },
  { title: '起点', key: 'startPoint', width: 180, ellipsis: { tooltip: true } },
@@ -642,11 +654,38 @@
  },
  { title: '评分', key: 'dispatchScore', width: 90, render: row => row.dispatchScore != null ? String(row.dispatchScore) : '-' },
  { title: '连环派单', key: 'chainDispatchedName', width: 110, render: row => row.chainDispatchedName || '-' },
  { title: '订单记录', key: 'skipReason', width: 280, ellipsis: { tooltip: true }, render: row => row.skipReason || '-' },
  {
    title: '订单记录',
    key: 'skipReason',
    width: 280,
    render: (row) => {
      // 跳过/超时原因与改点记录都属于订单记录,合并展示;按字符数截断而不是按列宽
      const fullText = [row.skipReason, row.changeDestRecord].filter(Boolean).join(';')
      if (!fullText)
        return '-'
      const displayText = fullText.length > 50 ? `${fullText.slice(0, 50)}...` : fullText
      return h(
        NTooltip,
        { placement: 'top', trigger: 'hover', disabled: fullText.length <= 50 },
        {
          trigger: () => h('span', {
            style: {
              display: 'inline-block',
              maxWidth: '260px',
              overflow: 'hidden',
              textOverflow: 'ellipsis',
              whiteSpace: 'nowrap',
            },
          }, displayText),
          default: () => fullText,
        },
      )
    },
  },
  {
    title: '操作',
    key: 'actions',
    width: 220,
    width: 260,
    fixed: 'right',
    render: row => (
      <NSpace size={12}>
@@ -718,7 +757,19 @@
}
function toggleOrderRowSelection(row: OrderRow) {
  // 与勾选框的 disabled 规则保持一致,否则点击行仍能选中被灰掉的行
  if (!row.changeDestAllowed)
    return
  orderCheckedRowKeys.value = toggleMultiSelectRowKey(orderCheckedRowKeys.value, row.orderId)
}
function handleBatchChangeDestination() {
  if (!orderCheckedRowKeys.value.length) {
    message.warning('请勾选要修改目的地的订单')
    return
  }
  changeDestOrderIds.value = [...orderCheckedRowKeys.value]
  isChangeDestModalVisible.value = true
}
function getOrderRowClassName(row: OrderRow) {
@@ -815,6 +866,11 @@
    isAssignVehicleModalVisible.value = true
    return
  }
  if (action === '修改目的地') {
    changeDestOrderIds.value = [row.orderId]
    isChangeDestModalVisible.value = true
    return
  }
  if (action === '删除') {
    confirmDeleteOrders([row.orderId])
    return
@@ -864,6 +920,13 @@
  currentAssignOrderId.value = null
  currentAssignVehicleId.value = null
  currentAssignActionType.value = 'assign'
  await refreshOrderRelatedData()
}
async function handleChangeDestSuccess() {
  isChangeDestModalVisible.value = false
  changeDestOrderIds.value = []
  orderCheckedRowKeys.value = []
  await refreshOrderRelatedData()
}
@@ -1010,9 +1073,17 @@
              <!-- 操作按钮区 -->
              <div class="flex items-center justify-between gap-8px mb-12px shrink-0">
                <NButton :loading="exportLoading" @click="handleExportOrders">
                  导出订单
                </NButton>
                <div class="flex items-center gap-8px">
                  <NButton :loading="exportLoading" @click="handleExportOrders">
                    导出订单
                  </NButton>
                  <NButton
                    :disabled="!orderCheckedRowKeys.length"
                    @click="handleBatchChangeDestination"
                  >
                    批量修改目的地
                  </NButton>
                </div>
                <span class="text-13px text-[rgba(23,31,38,0.6)]">
                  订单列表每60S自动刷新一次
                </span>
@@ -1031,7 +1102,7 @@
                  :row-props="getOrderRowProps"
                  flex-height
                  :bordered="false"
                  :scroll-x="1710"
                  :scroll-x="1750"
                  @update:checked-row-keys="handleOrderCheck"
                >
                  <template #empty>
@@ -1123,6 +1194,13 @@
      @success="handleAssignVehicleSuccess"
    />
    <ChangeDestinationModal
      v-model:visible="isChangeDestModalVisible"
      :plan-id="currentPlanId"
      :order-ids="changeDestOrderIds"
      @success="handleChangeDestSuccess"
    />
    <ManageTestVehicleModal
      v-model:visible="isManageTestVehicleModalVisible"
      :plan-id="currentPlanId"