<script setup lang="tsx">
|
import noDataImg from '@/assets/element-img/no-data.png'
|
import type { DataTableColumns } from 'naive-ui'
|
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
|
import { computed, onMounted, ref } from 'vue'
|
import { useRouter } from 'vue-router'
|
import { useBoolean } from '@/hooks'
|
import { closeTestPlanDispatch, deleteTestPlan, fetchTestPlanPageList, interruptTestPlanDispatch, resumeTestPlanDispatch } from '@/service/api/testPlanManagement'
|
import { getTestPlanDisplayStatus, TEST_PLAN_STATUS_CRUISING_VALUE, TEST_PLAN_STATUS_OPTIONS } from './constants'
|
import CruiseDialog from './components/CruiseDialog.vue'
|
|
const router = useRouter()
|
const dialog = useDialog()
|
const message = useMessage()
|
|
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
|
|
interface TestPlanRow {
|
id: number
|
planName: string
|
pointNum: number
|
vehicleNum: number
|
orderNum: number
|
status: number
|
cruiseStatus: number
|
}
|
|
const initialModel = {
|
planName: '',
|
status: null as number | null,
|
minPointCount: null as number | null,
|
maxPointCount: null as number | null,
|
minVehicleCount: null as number | null,
|
maxVehicleCount: null as number | null,
|
minOrderCount: null as number | null,
|
maxOrderCount: null as number | null,
|
plateNumber: '',
|
pointName: '',
|
}
|
const model = ref({ ...initialModel })
|
|
const pageParams = ref({
|
pageNum: 1,
|
pageSize: 10,
|
})
|
const count = ref(0)
|
const listData = ref<TestPlanRow[]>([])
|
|
const tableScrollX = 1200
|
|
function handleResetSearch() {
|
model.value = { ...initialModel }
|
pageParams.value.pageNum = 1
|
getList()
|
}
|
|
function renderRange(minKey: keyof typeof initialModel, maxKey: keyof typeof initialModel) {
|
return (
|
<div class="flex items-center gap-8px">
|
<n-input-number
|
v-model:value={model.value[minKey]}
|
placeholder="请输入最小值"
|
clearable
|
precision={0}
|
show-button={false}
|
class="flex-1"
|
/>
|
<span>~</span>
|
<n-input-number
|
v-model:value={model.value[maxKey]}
|
placeholder="请输入最大值"
|
clearable
|
precision={0}
|
show-button={false}
|
class="flex-1"
|
/>
|
</div>
|
)
|
}
|
|
function validateRange(minVal: number | null, maxVal: number | null, label: string): boolean {
|
if (minVal !== null && maxVal !== null && minVal > maxVal) {
|
message.warning(`${label}的最小值不能大于最大值`)
|
return false
|
}
|
return true
|
}
|
|
function buildQueryParams() {
|
// 「巡游中」是 status=2 + cruiseStatus=1 的组合状态,前端用哨兵值 -1 表示。
|
// 命中时只传 cruiseStatus=1,让后端返回所有 cruiseStatus=1 的计划。
|
const rawStatus = model.value.status
|
const isCruising = rawStatus === TEST_PLAN_STATUS_CRUISING_VALUE
|
return {
|
...pageParams.value,
|
planName: model.value.planName?.trim() || undefined,
|
status: isCruising ? undefined : (rawStatus || undefined),
|
cruiseStatus: isCruising ? 1 : undefined,
|
minPointCount: model.value.minPointCount ?? undefined,
|
maxPointCount: model.value.maxPointCount ?? undefined,
|
minVehicleCount: model.value.minVehicleCount ?? undefined,
|
maxVehicleCount: model.value.maxVehicleCount ?? undefined,
|
minOrderCount: model.value.minOrderCount ?? undefined,
|
maxOrderCount: model.value.maxOrderCount ?? undefined,
|
plateNumber: model.value.plateNumber?.trim() || undefined,
|
pointName: model.value.pointName?.trim() || undefined,
|
}
|
}
|
|
async function getList() {
|
startLoading()
|
try {
|
const res = await fetchTestPlanPageList(buildQueryParams())
|
|
if (res.code !== 200 && res.code !== 0) {
|
throw new Error(res.msg || '获取测试计划列表失败')
|
}
|
|
// 列表接口字段名可能仍在调整,这里对常见返回字段做一层兼容映射。
|
const records = Array.isArray(res.data?.records) ? res.data.records : []
|
listData.value = records.map((item: any) => ({
|
id: item.id,
|
planName: item.planName || '-',
|
pointCount: Number(item.pointCount ?? 0),
|
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)
|
}
|
catch (e: any) {
|
listData.value = []
|
count.value = 0
|
// message.error(e?.message || '获取测试计划列表失败')
|
}
|
finally {
|
endLoading()
|
}
|
}
|
|
function changePage(page: number, size: number) {
|
pageParams.value.pageNum = page
|
pageParams.value.pageSize = size
|
getList()
|
}
|
|
function handleSearch() {
|
if (!validateRange(model.value.minPointCount, model.value.maxPointCount, '关联点位'))
|
return
|
if (!validateRange(model.value.minVehicleCount, model.value.maxVehicleCount, '关联车辆数'))
|
return
|
if (!validateRange(model.value.minOrderCount, model.value.maxOrderCount, '测试订单数'))
|
return
|
pageParams.value.pageNum = 1
|
getList()
|
}
|
|
function handleAdd() {
|
router.push({ name: 'testPlanManagementAdd' })
|
}
|
|
const showOperationLogModal = ref(false)
|
|
function handleOpenOperationLog() {
|
showOperationLogModal.value = true
|
}
|
|
function handleDetail(row: TestPlanRow) {
|
router.push({ name: 'testPlanManagementDetail', query: { id: String(row.id) } })
|
}
|
|
function handleEdit(row: TestPlanRow) {
|
router.push({ name: 'testPlanManagementEdit', query: { id: String(row.id), mode: 'edit' } })
|
}
|
|
function handleCopy(row: TestPlanRow) {
|
// 复制改为“预填新增表单”,不立即落库;用户完善并保存后才真正创建,
|
// 避免复制后未完善就残留一条不完整的测试计划。
|
router.push({ name: 'testPlanManagementAdd', query: { copyFrom: String(row.id) } })
|
}
|
|
function handleDelete(row: TestPlanRow) {
|
dialog.create({
|
title: '确认删除该测试计划吗?',
|
content: '删除后无法恢复',
|
positiveText: '确定',
|
negativeText: '取消',
|
maskClosable: false,
|
autoFocus: false,
|
onPositiveClick: async () => {
|
try {
|
const res = await deleteTestPlan(row.id)
|
if (res.code !== 200 && res.code !== 0) {
|
throw new Error(res.msg || '删除失败')
|
}
|
message.success('删除成功')
|
await getList()
|
}
|
catch (error: any) {
|
// message.error(error?.message || '删除失败')
|
throw error
|
}
|
},
|
})
|
}
|
|
function handleInterrupt(row: TestPlanRow) {
|
dialog.warning({
|
maskClosable: false,
|
title: '是否确认中断下发',
|
content: '中断后,系统将暂停下发剩余订单',
|
positiveText: '确定',
|
negativeText: '取消',
|
onPositiveClick: async () => {
|
try {
|
const res = await interruptTestPlanDispatch(row.id)
|
if (res.code !== 200 && res.code !== 0) {
|
// throw new Error(res.msg || '中断测试失败')
|
}
|
message.success('已中断')
|
await getList()
|
}
|
catch (error: any) {
|
// message.error(error?.message || '中断测试失败')
|
throw error
|
}
|
},
|
})
|
}
|
|
function handleClose(row: TestPlanRow) {
|
dialog.warning({
|
maskClosable: false,
|
title: '确认关闭测试吗?',
|
content: '关闭后测试计划将终止',
|
positiveText: '确定',
|
negativeText: '取消',
|
onPositiveClick: async () => {
|
try {
|
const res = await closeTestPlanDispatch(row.id)
|
if (res.code !== 200 && res.code !== 0) {
|
throw new Error(res.msg || '关闭测试失败')
|
}
|
message.success('已关闭')
|
await getList()
|
}
|
catch (error: any) {
|
// message.error(error?.message || '关闭测试失败')
|
throw error
|
}
|
},
|
})
|
}
|
|
function handleContinue(row: TestPlanRow) {
|
dialog.info({
|
maskClosable: false,
|
title: '确认继续测试吗?',
|
content: '测试计划将继续执行',
|
positiveText: '确定',
|
negativeText: '取消',
|
onPositiveClick: async () => {
|
try {
|
const res = await resumeTestPlanDispatch(row.id)
|
if (res.code !== 200 && res.code !== 0) {
|
throw new Error(res.msg || '继续测试失败')
|
}
|
message.success('已继续')
|
await getList()
|
}
|
catch (error: any) {
|
// message.error(error?.message || '继续测试失败')
|
throw error
|
}
|
},
|
})
|
}
|
|
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: '序号',
|
key: 'index',
|
width: 80,
|
align: 'left',
|
render: (_, index) => (pageParams.value.pageNum - 1) * pageParams.value.pageSize + index + 1,
|
},
|
{
|
title: '测试计划名称',
|
key: 'planName',
|
width: 260,
|
align: 'left',
|
resizable: true,
|
ellipsis: true,
|
// render(row) {
|
// return (
|
// <NTooltip trigger="hover">
|
// {{
|
// trigger: () => (
|
// <div
|
// class="cursor-pointer text-ellipsis whitespace-nowrap w-full overflow-hidden text-[#0068FF]"
|
// onClick={() => handleDetail(row)}
|
// >
|
// {row.planName}
|
// </div>
|
// ),
|
// default: () => row.planName,
|
// }}
|
// </NTooltip>
|
// )
|
// },
|
},
|
{
|
title: '关联点位',
|
key: 'pointCount',
|
width: 140,
|
align: 'left',
|
resizable: true,
|
ellipsis: true,
|
},
|
{
|
title: '关联车辆数',
|
key: 'vehicleCount',
|
width: 140,
|
align: 'left',
|
resizable: true,
|
ellipsis: true,
|
},
|
{
|
title: '测试订单数',
|
key: 'orderCount',
|
width: 140,
|
align: 'left',
|
resizable: true,
|
ellipsis: true,
|
},
|
{
|
title: '状态',
|
key: 'status',
|
width: 120,
|
align: 'left',
|
render: (row) => {
|
const { label, ...color } = getTestPlanDisplayStatus(row.status, row.cruiseStatus)
|
return <NTag color={color} bordered={false} size="small" style="border-radius: 4px;">{label}</NTag>
|
},
|
},
|
{
|
title: '操作',
|
key: 'actions',
|
width: 280,
|
align: 'left',
|
fixed: 'right',
|
render: (row: TestPlanRow) => {
|
// 操作项跟随计划状态变化,避免在模板里堆叠过多条件分支。
|
const actions = []
|
|
if (row.status === 1) { // 待下发
|
actions.push(<NButton text type="primary" onClick={() => handleCopy(row)}>复制</NButton>)
|
actions.push(<NButton text type="primary" onClick={() => handleEdit(row)} v-permission="2026061522">编辑</NButton>)
|
actions.push(<NButton text type="primary" onClick={() => handleDelete(row)} v-permission="2026061524">删除</NButton>)
|
}
|
if (row.status === 2) { // 测试中
|
actions.push(<NButton text type="primary" onClick={() => handleCopy(row)}>复制</NButton>)
|
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={() => handleCopy(row)}>复制</NButton>)
|
actions.push(<NButton text type="primary" onClick={() => handleDetail(row)} v-permission="2026061523">详情</NButton>)
|
actions.push(<NButton text type="primary" onClick={() => handleClose(row)} v-permission="2026061526">关闭测试</NButton>)
|
actions.push(<NButton text type="primary" onClick={() => handleContinue(row)} v-permission="2026061527">继续测试</NButton>)
|
}
|
if (row.status === 4 || row.status === 5) { // 已完成 / 关闭测试
|
actions.push(<NButton text type="primary" onClick={() => handleCopy(row)}>复制</NButton>)
|
actions.push(<NButton text type="primary" onClick={() => handleDetail(row)} v-permission="2026061523">详情</NButton>)
|
}
|
|
return (
|
<NSpace justify="start" size={12} wrap={false}>
|
{actions}
|
</NSpace>
|
)
|
},
|
},
|
])
|
|
const isStatusSelectOpen = ref(false)
|
|
// 顺序经过排布校验:SearchPanel 按容器宽度取 2~6 列,区间筛选占 2 列。
|
// 先放两个 span=2 区间项再放 span=1 项,保证常见 5/6 列宽度下
|
// 「测试订单数、车牌号、点位名称」始终落在同一行(QA 报排版错位即源于旧顺序)。
|
const filterItems = computed(() => [
|
{
|
label: '关联点位',
|
path: 'pointCount',
|
span: 2,
|
render: () => renderRange('minPointCount', 'maxPointCount'),
|
},
|
{
|
label: '关联车辆数',
|
path: 'vehicleCount',
|
span: 2,
|
render: () => renderRange('minVehicleCount', 'maxVehicleCount'),
|
},
|
{
|
label: '计划名称',
|
path: 'planName',
|
render: () => <n-input v-model:value={model.value.planName} placeholder="请输入" clearable />,
|
},
|
{
|
label: '计划状态',
|
path: 'status',
|
render: () => (
|
<n-select
|
v-model:value={model.value.status}
|
options={TEST_PLAN_STATUS_OPTIONS}
|
placeholder="请选择"
|
clearable
|
onUpdate:show={(val: boolean) => isStatusSelectOpen.value = val}
|
>
|
{{
|
arrow: () => (
|
<img
|
src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
|
class={`arrow-icon ${isStatusSelectOpen.value ? 'arrow-icon--open' : ''}`}
|
style={{ width: '16px', height: '16px' }}
|
alt="arrow"
|
/>
|
),
|
empty: () => (
|
<div class="flex-center">
|
<img
|
src={noDataImg}
|
style={{ width: '50%' }}
|
alt="no-data"
|
/>
|
</div>
|
),
|
}}
|
</n-select>
|
),
|
},
|
{
|
label: '测试订单数',
|
path: 'orderCount',
|
span: 2,
|
render: () => renderRange('minOrderCount', 'maxOrderCount'),
|
},
|
{
|
label: '车牌号',
|
path: 'plateNumber',
|
render: () => <n-input v-model:value={model.value.plateNumber} placeholder="请输入" clearable />,
|
},
|
{
|
label: '点位名称',
|
path: 'pointName',
|
render: () => <n-input v-model:value={model.value.pointName} placeholder="请输入" clearable />,
|
},
|
])
|
|
onMounted(() => {
|
getList()
|
})
|
</script>
|
|
<template>
|
<div class="roboTaxi">
|
<div class="roboTaxi-form-card mb-12px">
|
<n-form :model="model" label-placement="left" inline :show-feedback="false">
|
<SearchPanel>
|
<n-gi v-for="item in filterItems" :key="item.path" :span="item.span || 1" class="n-grid-item">
|
<n-form-item :label="item.label" :path="item.path">
|
<component :is="item.render" />
|
</n-form-item>
|
</n-gi>
|
<template #action>
|
<NButton @click="handleResetSearch">
|
重置
|
</NButton>
|
<NButton type="primary" @click="handleSearch">
|
查询
|
</NButton>
|
</template>
|
</SearchPanel>
|
</n-form>
|
</div>
|
|
<n-card
|
class="flex-1 robotaxi-table-card"
|
content-style="display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0;"
|
>
|
<template #header>
|
<n-flex justify="flex-end">
|
<NButton v-permission="2026061521" @click="handleOpenOperationLog">
|
操作日志
|
</NButton>
|
<NButton v-permission="2026061520" type="primary" secondary style="background-color: #E6F4FF; border-color: #7ABDFF; color: #0068FF;" @click="handleAdd">
|
添加测试计划
|
</NButton>
|
|
</n-flex>
|
</template>
|
|
<div class="flex-1 flex flex-col h-full" style="min-width: 0; overflow: hidden">
|
<n-data-table
|
class="flex-1"
|
:columns="columns"
|
:data="listData"
|
:loading="loading"
|
flex-height
|
:bordered="false"
|
:scroll-x="tableScrollX"
|
:header-height="18"
|
:row-height="18"
|
>
|
<template #empty>
|
<div class="flex-center">
|
<img :src="noDataImg" style="width:123px;height:125px" alt="no-data">
|
</div>
|
</template>
|
</n-data-table>
|
<Pagination
|
:count="count"
|
:page="pageParams.pageNum"
|
:page-size="pageParams.pageSize"
|
class="robotaxi-pagination mt-12px"
|
@change="changePage"
|
/>
|
</div>
|
</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>
|
|
<style scoped></style>
|