This commit is contained in:
2026-08-12 14:08:33 +08:00
parent 4fb8b85c13
commit 975e0057e3
20 changed files with 378 additions and 29 deletions
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentDetailView.vue'), 'utf8')
describe('TruckAppointmentDetailView status contract', () => {
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
expect(source).toContain('const targets: TruckAppointmentStatus[] =')
expect(source).not.toContain('const targets: string[] =')
expect(source).toContain("['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '')")
expect(source).toContain("status !== resource.value?.status")
expect(source).toContain("statuses.indexOf(status) === index")
})
})
@@ -16,7 +16,7 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0 })
const history = ref<TruckAppointmentStatusHistory[]>([])
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && !['COMPLETED','CANCELLED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories }
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
@@ -25,7 +25,9 @@ const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppoint
const availableTargets = computed<TruckAppointmentStatus[]>(() => {
if (!resource.value || ['COMPLETED', 'CANCELLED'].includes(resource.value.status)) return []
const primary = primaryTargetByStatus[resource.value.status]
return [...(primary ? [primary] : []), 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
const targets: TruckAppointmentStatus[] = [...(primary ? [primary] : []), 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
return targets
.filter((status, index, statuses) => status !== resource.value?.status && statuses.indexOf(status) === index)
})
async function changeStatus(status: TruckAppointmentStatus) { if (!resource.value || status === resource.value.status) return; try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false } }
onMounted(load)
@@ -87,11 +87,25 @@ describe('TruckAppointmentsView', () => {
it('shows the two frequent row actions directly without a more menu', () => {
expect(source).toContain("v-if=\"access.can('platform:firstleg:truck-appointment:view')\"")
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).not.toContain('MoreHorizontal')
})
it('hides edit for completed appointments rejected by the backend edit contract', async () => {
vi.mocked(listTruckAppointments).mockResolvedValue({
records: [{ ...row, status: 'COMPLETED' }],
page: 1,
size: 20,
total: 1,
pages: 1,
})
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('button[aria-label="编辑 TA-9001"]').exists()).toBe(false)
})
it('hides the detail action when the account lacks the backend view permission', async () => {
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list']
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
@@ -65,7 +65,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
</DataTable>
</section>
</ListPageLayout>