feat(tpd): 订单列表支持单笔与批量修改目的地
服务中的订单新增「修改目的地」行按钮与「批量修改目的地」入口,共用一个选择点位弹窗
(点位名称模糊搜索 + 单选 + 分页)。是否可改由后端返回的 changeDestAllowed 决定,
超过次数上限后按钮自动消失、勾选框置灰。
订单记录列合并跳过原因与改点记录,按 50 字截断并悬浮显示全文。
行点击选中同步加上与勾选框一致的禁用判断,否则点行仍能选中被灰掉的行。
操作列加宽到 260 后同步把 scroll-x 调到 1750,避免固定操作列拖不到最右。
| | |
| | | }) |
| | | } |
| | | |
| | | // 单笔与批量共用:单笔即 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 |
| New file |
| | |
| | | <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> |
| | |
| | | <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' |
| | |
| | | } 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' |
| | | |
| | |
| | | skipReason?: string |
| | | scoreDetail?: string |
| | | chainDispatchedName?: string |
| | | changeDestRecord?: string |
| | | changeDestAllowed?: boolean |
| | | } |
| | | |
| | | interface VehicleRow { |
| | |
| | | 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) |
| | |
| | | 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 ['详情', '删除'] |
| | |
| | | case 4: |
| | | return ['取消订单', '改派', '删除'] |
| | | case 6: |
| | | return ['关闭订单', '详情', '删除'] |
| | | // 是否可改点由后端算(服务中 + 未达次数上限),超限后按钮自动消失 |
| | | return changeDestAllowed |
| | | ? ['修改目的地', '关闭订单', '详情', '删除'] |
| | | : ['关闭订单', '详情', '删除'] |
| | | case 10: |
| | | return ['详情', '删除'] |
| | | default: |
| | |
| | | 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) |
| | |
| | | } |
| | | |
| | | 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 } }, |
| | |
| | | }, |
| | | { 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}> |
| | |
| | | } |
| | | |
| | | 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) { |
| | |
| | | isAssignVehicleModalVisible.value = true |
| | | return |
| | | } |
| | | if (action === '修改目的地') { |
| | | changeDestOrderIds.value = [row.orderId] |
| | | isChangeDestModalVisible.value = true |
| | | return |
| | | } |
| | | if (action === '删除') { |
| | | confirmDeleteOrders([row.orderId]) |
| | | return |
| | |
| | | currentAssignOrderId.value = null |
| | | currentAssignVehicleId.value = null |
| | | currentAssignActionType.value = 'assign' |
| | | await refreshOrderRelatedData() |
| | | } |
| | | |
| | | async function handleChangeDestSuccess() { |
| | | isChangeDestModalVisible.value = false |
| | | changeDestOrderIds.value = [] |
| | | orderCheckedRowKeys.value = [] |
| | | await refreshOrderRelatedData() |
| | | } |
| | | |
| | |
| | | |
| | | <!-- 操作按钮区 --> |
| | | <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> |
| | |
| | | :row-props="getOrderRowProps" |
| | | flex-height |
| | | :bordered="false" |
| | | :scroll-x="1710" |
| | | :scroll-x="1750" |
| | | @update:checked-row-keys="handleOrderCheck" |
| | | > |
| | | <template #empty> |
| | |
| | | @success="handleAssignVehicleSuccess" |
| | | /> |
| | | |
| | | <ChangeDestinationModal |
| | | v-model:visible="isChangeDestModalVisible" |
| | | :plan-id="currentPlanId" |
| | | :order-ids="changeDestOrderIds" |
| | | @success="handleChangeDestSuccess" |
| | | /> |
| | | |
| | | <ManageTestVehicleModal |
| | | v-model:visible="isManageTestVehicleModalVisible" |
| | | :plan-id="currentPlanId" |