huangxz
2026-07-22 724904b50858ad7d48ee54756e4613936ee6f72d
Merge remote-tracking branch 'origin/feat-tpd-2.0-20260721' into feat/deploy-uat2
1个文件已添加
4个文件已修改
438 ■■■■■ 已修改文件
src/service/api/testPlanManagement.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanManagement/add.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanManagement/components/CruiseDialog.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanManagement/detail.vue 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/testDemo/testPlanManagement/index.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/testPlanManagement.ts
@@ -25,6 +25,8 @@
  pointIds: string[]
  // 计划级「到下车点是否停车」配置,传布尔值;不传后端默认 true。
  isStopAtDropoff?: boolean
  strategyId?: number | string | null
  skipInvalidEnabled?: boolean
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/test-plan-demo/add', data)
}
@@ -37,6 +39,8 @@
    pointIds: string[]
    // 传了则更新「到下车点是否停车」,不传保持原值。
    isStopAtDropoff?: boolean
    strategyId?: number | string | null
    skipInvalidEnabled?: boolean
  },
) {
  return request.Post<Service.ResponseResult<any>>(`/operation/api/v1/test-plan-demo/edit/${id}`, data)
@@ -215,3 +219,28 @@
}) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/test-plan-demo/vehicle/manage', data)
}
export function fetchTradingStrategyConfig() {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/tradingStrategy/queryById')
}
export function updateTestPlanStrategy(planId: number | string, strategyId: number | string | null) {
  return request.Put<Service.ResponseResult<any>>(`/operation/api/v1/test-plan-demo/${planId}/strategy`, {
    strategyId,
  })
}
export function startTestPlanCruise(planId: number | string, data: {
  cruisePlanId: number | string
  durationMinutes: number
}) {
  return request.Post<Service.ResponseResult<any>>(`/operation/api/v1/test-plan-demo/${planId}/cruise/start`, data)
}
export function stopTestPlanCruise(planId: number | string, vehicleIds?: Array<number | string>) {
  return request.Post<Service.ResponseResult<any>>(`/operation/api/v1/test-plan-demo/${planId}/cruise/stop`, vehicleIds ? { vehicleIds } : {})
}
export function fetchTestPlanCruiseStatus(planId: number | string) {
  return request.Get<Service.ResponseResult<any>>(`/operation/api/v1/test-plan-demo/${planId}/cruise/status`)
}
src/views/testDemo/testPlanManagement/add.vue
@@ -2,7 +2,7 @@
import noDataImg from '@/assets/element-img/no-data.png'
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NSpace, NTag, NTooltip, NForm, NFormItemGi, NRadioGroup, NRadioButton, NInput, NSwitch, NDataTable, NGrid, useDialog } from 'naive-ui'
import { NButton, NSpace, NTag, NTooltip, NForm, NFormItemGi, NRadioGroup, NRadioButton, NInput, NSwitch, NSelect, NDataTable, NGrid, useDialog } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useMessage } from 'naive-ui'
import waitIcon from '@/assets/pricingManagement/wait.png'
@@ -17,6 +17,7 @@
  editTestPlan,
  fetchTestPlanDetail,
  fetchTestPlanOrderPageList,
  fetchTradingStrategyConfig,
  generateTestPlanPreviewOrders,
  startTestPlanDispatch,
  toggleTestPlanStopAtDropoff,
@@ -171,6 +172,25 @@
// ================= Step 1: 测试点位选择 =================
const planName = ref('')
const orderGenMode = ref(1) // 1: 全排列模式, 2: 串烧模式
const strategyId = ref<number | string | null>(null)
const skipInvalidEnabled = ref(false)
const strategyOptions = ref<Array<{ label: string; value: number | string | null }>>([
  { label: '默认FIFO', value: null },
])
async function loadStrategyOptions() {
  try {
    const res = await fetchTradingStrategyConfig()
    if ((res.code === 200 || res.code === 0) && res.data) {
      const s = res.data
      strategyOptions.value = [
        { label: '默认FIFO', value: null },
        { label: s.name || '当前交易策略', value: s.id },
      ]
    }
  } catch (_) {}
}
loadStrategyOptions()
interface Step1Row {
  id: string
@@ -314,6 +334,8 @@
    pointIds: step1Data.value.map(item => item.id),
    // 计划级「到下车点是否停车」配置,传布尔值。
    isStopAtDropoff: isStopAtDropoff.value,
    strategyId: strategyId.value,
    skipInvalidEnabled: skipInvalidEnabled.value,
  }
}
@@ -636,6 +658,8 @@
    orderGenMode.value = Number(detail.orderMode || 1)
    // 回显「到下车点是否停车」,后端未返回时按默认 true 处理。
    isStopAtDropoff.value = detail.isStopAtDropoff !== false
    strategyId.value = detail.strategyId ?? null
    skipInvalidEnabled.value = detail.skipInvalidEnabled === true
    // 编辑态回显时,点位 ID 同样按 string 处理。
    step1Data.value = Array.isArray(detail.points)
      ? detail.points.map((item: any) => ({
@@ -787,6 +811,20 @@
                </div>
              </n-form-item-gi>
            </n-grid>
            <n-grid :cols="24" :x-gap="24">
              <n-form-item-gi v-if="orderGenMode === 1" :span="12" label="分单策略">
                <n-select
                  v-model:value="strategyId"
                  :options="strategyOptions"
                  placeholder="默认FIFO"
                  clearable
                />
              </n-form-item-gi>
              <n-form-item-gi :span="12" label="跳过无效订单">
                <n-switch v-model:value="skipInvalidEnabled" />
                <span class="ml-8px text-12px text-[rgba(23,31,38,0.5)]">开启后派单时跳过路线不可达的订单</span>
              </n-form-item-gi>
            </n-grid>
          </n-form>
          <!-- 第一步表格操作区 -->
src/views/testDemo/testPlanManagement/components/CruiseDialog.vue
New file
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { fetchCruisePlanPageList } from '@/service/api/cruise'
import { startTestPlanCruise, stopTestPlanCruise } from '@/service/api/testPlanManagement'
const props = defineProps<{
  visible: boolean
  planId?: number | string
  cruiseStatus?: number
}>()
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 isCruising = computed(() => props.cruiseStatus === 1)
const modalTitle = computed(() => (isCruising.value ? '停止巡游' : '启动巡游'))
const formModel = ref({
  cruisePlanId: null as number | null,
  durationMinutes: null as number | null,
})
const cruisePlanOptions = ref<Array<{ label: string, value: number }>>([])
const submitting = ref(false)
async function loadCruisePlans() {
  try {
    const res = await fetchCruisePlanPageList({ pageNum: 1, pageSize: 100 })
    if (res.code === 200 || res.code === 0) {
      const records = res.data?.records || []
      cruisePlanOptions.value = records.map((item: any) => ({
        label: item.planName || `巡游计划${item.id}`,
        value: item.id,
      }))
    }
  }
  catch {}
}
watch(
  () => props.visible,
  (val) => {
    if (val && !isCruising.value) {
      formModel.value = { cruisePlanId: null, durationMinutes: null }
      loadCruisePlans()
    }
  },
)
async function handleConfirm() {
  if (isCruising.value) {
    submitting.value = true
    try {
      const res = await stopTestPlanCruise(props.planId!)
      if (res.code !== 200 && res.code !== 0) {
        throw new Error(res.msg || '停止巡游失败')
      }
      message.success('已停止巡游')
      show.value = false
      emit('success')
    }
    catch (e: any) {
      message.error(e?.message || '停止巡游失败')
    }
    finally {
      submitting.value = false
    }
    return
  }
  if (!formModel.value.cruisePlanId) {
    message.warning('请选择巡游计划')
    return
  }
  if (!formModel.value.durationMinutes || formModel.value.durationMinutes < 1 || formModel.value.durationMinutes > 999) {
    message.warning('暂停时长范围为 1~999 分钟')
    return
  }
  submitting.value = true
  try {
    const res = await startTestPlanCruise(props.planId!, {
      cruisePlanId: formModel.value.cruisePlanId,
      durationMinutes: formModel.value.durationMinutes,
    })
    if (res.code !== 200 && res.code !== 0) {
      throw new Error(res.msg || '启动巡游失败')
    }
    const data = res.data
    const startedCount = data?.started?.length || 0
    const skippedCount = data?.skipped?.length || 0
    message.success(`巡游已启动,${startedCount} 辆车已下发巡游单,${skippedCount} 辆车跳过`)
    show.value = false
    emit('success')
  }
  catch (e: any) {
    message.error(e?.message || '启动巡游失败')
  }
  finally {
    submitting.value = false
  }
}
</script>
<template>
  <n-modal
    v-model:show="show"
    preset="dialog"
    :title="modalTitle"
    :mask-closable="false"
    :positive-text="isCruising ? '确认停止' : '确认启动'"
    negative-text="取消"
    :loading="submitting"
    @positive-click="handleConfirm"
    @negative-click="show = false"
  >
    <template v-if="isCruising">
      <p>确认停止当前巡游吗?停止后将取消所有进行中的巡游订单,恢复正常乘客订单派发。</p>
    </template>
    <template v-else>
      <n-form label-placement="left" label-width="90" :show-feedback="false" class="mt-16px">
        <n-form-item label="巡游计划">
          <n-select
            v-model:value="formModel.cruisePlanId"
            :options="cruisePlanOptions"
            placeholder="请选择巡游计划"
            clearable
            filterable
          />
        </n-form-item>
        <n-form-item label="暂停时长" class="mt-12px">
          <n-input-number
            v-model:value="formModel.durationMinutes"
            :min="1"
            :max="999"
            :precision="0"
            placeholder="请输入暂停时长"
            class="w-full"
          >
            <template #suffix>
              分钟
            </template>
          </n-input-number>
        </n-form-item>
      </n-form>
    </template>
  </n-modal>
</template>
src/views/testDemo/testPlanManagement/detail.vue
@@ -18,6 +18,7 @@
  closeTestPlanOrder,
  deleteTestPlanOrders,
  exportTestPlanDetailOrders,
  fetchTestPlanCruiseStatus,
  fetchTestPlanDetail,
  fetchTestPlanDetailOrderPageList,
  fetchTestPlanMonitorDetail,
@@ -83,6 +84,9 @@
  statusColor: string
  statusBg: string
  actions: string[]
  dispatchScore?: number | string | null
  skipReason?: string
  scoreDetail?: string
}
interface VehicleRow {
@@ -433,6 +437,9 @@
        statusColor: statusStyle.textColor,
        statusBg: statusStyle.color,
        actions: resolveOrderActions(statusCode, statusName),
        dispatchScore: item.dispatchScore ?? null,
        skipReason: item.skipReason || '',
        scoreDetail: item.scoreDetail || '',
      }
    })
    orderCount.value = Number(pageData.total || 0)
@@ -516,6 +523,7 @@
    fetchOrderList(),
    fetchVehicleList(),
  ])
  await fetchCruiseRecords()
}
async function refreshOrderRelatedData() {
@@ -582,6 +590,8 @@
      </NTag>
    ),
  },
  { title: '评分', key: 'dispatchScore', width: 90, render: row => row.dispatchScore != null ? String(row.dispatchScore) : '-' },
  { title: '跳过原因', key: 'skipReason', width: 160, ellipsis: { tooltip: true }, render: row => row.skipReason || '-' },
  {
    title: '操作',
    key: 'actions',
@@ -620,6 +630,86 @@
    ),
  },
])
// ---- 巡游记录 ----
interface CruiseRow {
  cruiseId: number | string
  vehicleId: number | string
  plateNo: string
  status: number
  statusName: string
  statusColor: string
  statusBg: string
  createTime: string
  interruptReason: string
}
const CRUISE_STATUS_MAP: Record<number, { label: string, textColor: string, color: string }> = {
  0: { label: '待执行', textColor: '#764DFF', color: '#F6F0FF' },
  1: { label: '执行中', textColor: '#0068FF', color: '#E6F4FF' },
  2: { label: '已中断', textColor: '#F59E0B', color: '#FFF7E6' },
  3: { label: '已完成', textColor: '#10B981', color: '#F6FFED' },
}
const cruiseData = ref<CruiseRow[]>([])
const cruiseLoading = ref(false)
const cruiseColumns = computed<DataTableColumns<CruiseRow>>(() => [
  { title: '车辆', key: 'plateNo', width: 100, ellipsis: { tooltip: true } },
  {
    title: '状态',
    key: 'statusName',
    width: 80,
    render: row => (
      <NTag
        color={{ textColor: row.statusColor, color: row.statusBg }}
        bordered={false}
        size="small"
        style="border-radius: 4px; padding: 0 8px; height: 24px; line-height: 24px; font-weight: 400;"
      >
        {row.statusName}
      </NTag>
    ),
  },
  { title: '下发时间', key: 'createTime', width: 160 },
  { title: '中断原因', key: 'interruptReason', width: 100, ellipsis: { tooltip: true }, render: row => row.interruptReason || '-' },
])
async function fetchCruiseRecords() {
  if (!currentPlanId.value)
    return
  cruiseLoading.value = true
  try {
    const res = await fetchTestPlanCruiseStatus(currentPlanId.value)
    if (res.code !== 200 && res.code !== 0) {
      cruiseData.value = []
      return
    }
    const records = Array.isArray(res.data) ? res.data : []
    const vehicleMap = new Map(vehicleData.value.map(v => [String(v.id), v.plateNo]))
    cruiseData.value = records.map((item: any) => {
      const status = Number(item.status ?? 0)
      const style = CRUISE_STATUS_MAP[status] || { label: '-', textColor: '#8C9299', color: '#F5F5F5' }
      return {
        cruiseId: item.cruiseId ?? item.id,
        vehicleId: item.vehicleId,
        plateNo: vehicleMap.get(String(item.vehicleId)) || String(item.vehicleId),
        status,
        statusName: style.label,
        statusColor: style.textColor,
        statusBg: style.color,
        createTime: item.createTime || '-',
        interruptReason: item.interruptReason || '',
      }
    })
  }
  catch {
    cruiseData.value = []
  }
  finally {
    cruiseLoading.value = false
  }
}
function handleManageVehicle() {
  isManageTestVehicleModalVisible.value = true
@@ -988,42 +1078,71 @@
          </div>
        </div>
        <!-- 右侧:测试车辆 -->
        <!-- 右侧:测试车辆 + 巡游记录 -->
        <div
          class="bg-white p-12px rounded-6px w-[340px] flex flex-col shrink-0 shadow-sm"
          class="w-[340px] flex flex-col shrink-0 gap-12px"
        >
          <!-- 标题与按钮 -->
          <div class="flex items-center justify-between mb-12px shrink-0">
            <div class="flex items-center">
              <div class="w-3px h-16px bg-[#0068FF] mr-8px rounded-sm" />
              <span class="font-bold text-16px text-[#171F26]">测试车辆</span>
          <!-- 测试车辆 -->
          <div
            class="bg-white p-12px rounded-6px flex flex-col flex-1 min-h-0 shadow-sm"
          >
            <!-- 标题与按钮 -->
            <div class="flex items-center justify-between mb-12px shrink-0">
              <div class="flex items-center">
                <div class="w-3px h-16px bg-[#0068FF] mr-8px rounded-sm" />
                <span class="font-bold text-16px text-[#171F26]">测试车辆</span>
              </div>
              <NButton type="primary" secondary @click="handleManageVehicle">
                管理测试车辆
              </NButton>
            </div>
            <NButton type="primary" secondary @click="handleManageVehicle">
              管理测试车辆
            </NButton>
            <!-- 表格 -->
            <div class="flex-1 min-h-0">
              <NDataTable
                class="h-full"
                :columns="vehicleColumns"
                :data="vehicleData"
                :loading="vehicleLoading"
                :row-key="(row) => row.id"
                flex-height
                :bordered="false"
              >
                <template #empty>
                  <div class="flex-center">
                    <img
                      :src="noDataImg"
                      style="width: 123px; height: 125px"
                      alt="no data"
                    >
                  </div>
                </template>
              </NDataTable>
            </div>
          </div>
          <!-- 表格 -->
          <div class="flex-1 min-h-0">
            <NDataTable
              class="h-full"
              :columns="vehicleColumns"
              :data="vehicleData"
              :loading="vehicleLoading"
              :row-key="(row) => row.id"
              flex-height
              :bordered="false"
            >
              <template #empty>
                <div class="flex-center">
                  <img
                    :src="noDataImg"
                    style="width: 123px; height: 125px"
                    alt="no data"
                  >
                </div>
              </template>
            </NDataTable>
          <!-- 巡游记录 -->
          <div
            v-if="cruiseData.length > 0"
            class="bg-white p-12px rounded-6px flex flex-col max-h-[280px] shadow-sm"
          >
            <div class="flex items-center mb-12px shrink-0">
              <div class="w-3px h-16px bg-[#A034FF] mr-8px rounded-sm" />
              <span class="font-bold text-16px text-[#171F26]">巡游记录</span>
              <span class="ml-8px text-12px text-[rgba(23,31,38,0.5)]">{{ cruiseData.length }}条</span>
            </div>
            <div class="flex-1 min-h-0">
              <NDataTable
                class="h-full"
                :columns="cruiseColumns"
                :data="cruiseData"
                :loading="cruiseLoading"
                :row-key="(row) => row.cruiseId"
                flex-height
                :bordered="false"
                size="small"
              />
            </div>
          </div>
        </div>
      </div>
src/views/testDemo/testPlanManagement/index.vue
@@ -7,6 +7,7 @@
import { useBoolean } from '@/hooks'
import { closeTestPlanDispatch, deleteTestPlan, fetchTestPlanPageList, interruptTestPlanDispatch, resumeTestPlanDispatch } from '@/service/api/testPlanManagement'
import { getTestPlanStatusLabel, getTestPlanStatusStyle, TEST_PLAN_STATUS_OPTIONS } from './constants'
import CruiseDialog from './components/CruiseDialog.vue'
const router = useRouter()
const dialog = useDialog()
@@ -21,6 +22,7 @@
  vehicleNum: number
  orderNum: number
  status: number
  cruiseStatus: number
}
const initialModel = {
@@ -122,6 +124,7 @@
      vehicleCount: Number(item.vehicleCount ?? 0),
      orderCount: Number(item.orderCount ?? 0),
      status: Number(item.status ?? 0),
      cruiseStatus: Number(item.cruiseStatus ?? 0),
    }))
    count.value = Number(res.data?.total ?? records.length ?? 0)
  }
@@ -267,6 +270,18 @@
  })
}
const showCruiseDialog = ref(false)
const cruiseRow = ref<TestPlanRow | null>(null)
function handleCruise(row: TestPlanRow) {
  cruiseRow.value = row
  showCruiseDialog.value = true
}
function handleCruiseSuccess() {
  getList()
}
const columns = computed<DataTableColumns<TestPlanRow>>(() => [
  {
    title: '序号',
@@ -330,13 +345,17 @@
    width: 120,
    align: 'left',
    render: (row) => {
      return <NTag color={getStatusColor(row.status)} bordered={false} size="small" style="border-radius: 4px;">{getStatusLabel(row.status)}</NTag>
      const tags = [<NTag color={getStatusColor(row.status)} bordered={false} size="small" style="border-radius: 4px;">{getStatusLabel(row.status)}</NTag>]
      if (row.cruiseStatus === 1) {
        tags.push(<NTag type="warning" bordered={false} size="small" style="border-radius: 4px; margin-left: 4px;">巡游中</NTag>)
      }
      return <>{tags}</>
    },
  },
  {
    title: '操作',
    key: 'actions',
    width: 200,
    width: 280,
    align: 'left',
    fixed: 'right',
    render: (row: TestPlanRow) => {
@@ -350,6 +369,7 @@
      if (row.status === 2) { // 测试中
        actions.push(<NButton text type="primary" onClick={() => handleDetail(row)} v-permission="2026061523">详情</NButton>)
        actions.push(<NButton text type="primary" onClick={() => handleInterrupt(row)} v-permission="2026061525">中断测试</NButton>)
        actions.push(<NButton text type="primary" onClick={() => handleCruise(row)} v-permission="2026061525">{row.cruiseStatus === 1 ? '停止巡游' : '巡游策略'}</NButton>)
      }
      if (row.status === 3) { // 已暂停
        actions.push(<NButton text type="primary" onClick={() => handleDetail(row)} v-permission="2026061523">详情</NButton>)
@@ -502,6 +522,12 @@
    </n-card>
    <OperationLogModal v-model:show="showOperationLogModal" :business-type="57" />
    <CruiseDialog
      v-model:visible="showCruiseDialog"
      :plan-id="cruiseRow?.id"
      :cruise-status="cruiseRow?.cruiseStatus"
      @success="handleCruiseSuccess"
    />
  </div>
</template>