优化
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { changeTruckAppointmentStatus, createTruckAppointment, getTruckAppointment, listTruckAppointments } from './platformTrucking'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
|
||||
|
||||
describe('truck appointment API', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('normalizes page values and maps filters', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '21', pages: '2' } })
|
||||
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerType: '40HC' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerType: '40HC' } })
|
||||
})
|
||||
|
||||
it('uses detail, create, and status contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
||||
vi.mocked(http.patch).mockResolvedValue({ data: null })
|
||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ expectedLoadingDate: '2026-08-10' })).resolves.toBe('9001')
|
||||
await expect(changeTruckAppointmentStatus('9001', 'DISPATCHED')).resolves.toBeNull()
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/status', { status: 'DISPATCHED' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
SaveTruckAppointmentRequest,
|
||||
TruckAppointmentPage,
|
||||
TruckAppointmentQuery,
|
||||
TruckAppointmentResource,
|
||||
} from '../types'
|
||||
|
||||
const basePath = '/platform/v1/truck-appointments'
|
||||
function dtoPayload(payload: SaveTruckAppointmentRequest) {
|
||||
const { attachmentRefs: _attachmentRefs, ...dto } = payload
|
||||
return dto
|
||||
}
|
||||
|
||||
type PagePayload = Omit<TruckAppointmentPage, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages?: number | string
|
||||
}
|
||||
|
||||
function normalizePage(payload: PagePayload): TruckAppointmentPage {
|
||||
const page = Number(payload.page || 1)
|
||||
const size = Number(payload.size || 20)
|
||||
const total = Number(payload.total || 0)
|
||||
return { records: (payload.records || []).map(normalizeResource), page, size, total, pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)) }
|
||||
}
|
||||
|
||||
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
|
||||
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'carrierId']
|
||||
const normalized = { ...resource } as Record<string, unknown>
|
||||
for (const key of idKeys) {
|
||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||
}
|
||||
return normalized as unknown as TruckAppointmentResource
|
||||
}
|
||||
|
||||
export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload>, ApiEnvelope<PagePayload>>(basePath, {
|
||||
params: {
|
||||
...query,
|
||||
keyword: query.keyword?.trim() || undefined,
|
||||
status: query.status || undefined,
|
||||
containerBookingId: query.containerBookingId || undefined,
|
||||
},
|
||||
})
|
||||
return normalizePage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getTruckAppointment(id: string | number) {
|
||||
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
|
||||
return normalizeResource(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createTruckAppointment(payload: SaveTruckAppointmentRequest) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(basePath, dtoPayload(payload))
|
||||
return normalizeResource(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateTruckAppointment(id: string | number, payload: SaveTruckAppointmentRequest) {
|
||||
const response = await http.put<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`, dtoPayload(payload))
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE' | 'LOAD' | 'DEPART' | 'COMPLETE' | 'EXCEPTION' | 'CANCEL'
|
||||
|
||||
export async function performTruckAppointmentAction(
|
||||
id: string | number,
|
||||
action: TruckAppointmentAction,
|
||||
expectedVersion: number,
|
||||
reason?: string,
|
||||
idempotencyKey?: string,
|
||||
requestHash?: string,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
|
||||
headers: { ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function rescheduleTruckAppointment(id: string | number, plannedLoadingAt: string, expectedVersion: number, reason?: string) {
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/reschedule`, { expectedVersion, reason: reason || null }, { params: { plannedLoadingAt } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
|
||||
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const draft = reactive<SaveTruckAppointmentRequest>({ ...props.modelValue })
|
||||
watch(() => props.modelValue, (value) => Object.assign(draft, value), { deep: true })
|
||||
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
|
||||
const rules: FormRules = {
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请输入承运商 ID', trigger: 'blur' }],
|
||||
}
|
||||
const relationText = computed(() => [props.resource?.customerName, props.resource?.outboundOrderId, props.resource?.containerBookingId].filter(Boolean).join(' / ') || '由服务端关联资源返回')
|
||||
async function validate() { return formRef.value?.validate() }
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">关联资源:{{ relationText }};柜型由关联柜体返回(只读)</el-alert>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商 ID" prop="carrierId"><el-input v-model="draft.carrierId" placeholder="请输入后端资源 ID" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队电话"><el-input v-model="draft.fleetPhone" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机电话"><el-input v-model="draft.driverPhone" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="件数"><el-input v-model="draft.packageCount" inputmode="numeric" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)"><el-input v-model="draft.grossWeightKg" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)"><el-input v-model="draft.volumeCbm" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量"><el-input v-model="draft.palletCount" inputmode="numeric" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费"><el-input v-model="draft.payableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费"><el-input v-model="draft.receivableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<div class="form-actions"><el-button :disabled="readonly || submitting" @click="emit('attachments')">管理附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="emit('submit')">保存</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: 12px; }
|
||||
.relation-alert { margin-bottom: 16px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { TruckAttachmentReference } from '../types'
|
||||
const props = defineProps<{ modelValue: boolean; attachments: TruckAttachmentReference[]; readonly?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean]; save: [TruckAttachmentReference[]] }>()
|
||||
const draft = reactive<TruckAttachmentReference[]>([])
|
||||
watch(() => props.attachments, (value) => { draft.splice(0, draft.length, ...(value || []).map((item) => ({ ...item }))) }, { immediate: true, deep: true })
|
||||
function add() { draft.push({ fileId: '', fileName: '' }) }
|
||||
function remove(index: number) { draft.splice(index, 1) }
|
||||
function save() { emit('save', draft.filter((item) => item.fileId.trim() && item.fileName.trim()).map((item) => ({ ...item }))); emit('update:modelValue', false) }
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog :model-value="props.modelValue" title="附件引用" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-alert type="info" :closable="false" title="此处仅维护文件服务返回的引用,不会模拟上传成功。" />
|
||||
<div v-for="(item, index) in draft" :key="index" class="attachment-row">
|
||||
<el-input v-model="item.fileId" placeholder="文件 ID" :disabled="readonly" />
|
||||
<el-input v-model="item.fileName" placeholder="文件名" :disabled="readonly" />
|
||||
<el-button text type="danger" :disabled="readonly" @click="remove(index)">删除</el-button>
|
||||
</div>
|
||||
<el-button v-if="!readonly" plain @click="add">新增引用</el-button>
|
||||
<template #footer><el-button @click="emit('update:modelValue', false)">关闭</el-button><el-button v-if="!readonly" type="primary" @click="save">保存引用</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.attachment-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin: 14px 0; }
|
||||
@media (max-width: 560px) { .attachment-row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './api/platformTrucking'
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { createTruckAppointment } from '../api/platformTrucking'
|
||||
import type { SaveTruckAppointmentRequest, TruckAttachmentReference } from '../types'
|
||||
const router = useRouter(); const open = ref(true); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ attachmentRefs: [], expectedLoadingDate: null, payableAmount: '0.00', receivableAmount: '0.00' })
|
||||
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form, attachmentRefs: form.attachmentRefs || [] }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
|
||||
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
|
||||
</script>
|
||||
<template><AppDrawer v-model="open" title="新增拖车预约" :confirm-loading="saving" :show-footer="false" @cancel="router.push({ name: 'trucking' })"><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" @save="saveAttachments" /></AppDrawer></template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getTruckAppointment, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAttachmentReference } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const open = ref(true); 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>({})
|
||||
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'))
|
||||
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, attachmentRefs: result.attachments || [] }) } 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 } }
|
||||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||||
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 } }
|
||||
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<AppDrawer v-model="open" :title="resource?.appointmentNo ? `拖车预约 ${resource.appointmentNo}` : '拖车预约详情'" :loading="loading" :show-footer="false" @cancel="router.push({ name: 'trucking' })">
|
||||
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="resource"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><el-select :model-value="resource.status" :loading="statusSaving" :disabled="statusSaving || !canChangeStatus" style="width:170px" @change="changeStatus"><el-option v-for="status in TRUCK_APPOINTMENT_STATUSES" :key="status" :value="status" :label="truckAppointmentStatusLabels[status]" /></el-select></div><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" :readonly="!canEdit" @save="saveAttachments" /></template>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TruckAppointmentsView from './TruckAppointmentsView.vue'
|
||||
import { listTruckAppointments } from '../api/platformTrucking'
|
||||
import type { TruckAppointmentResource } from '../types'
|
||||
|
||||
vi.mock('../api/platformTrucking', () => ({
|
||||
listTruckAppointments: vi.fn(),
|
||||
exportTruckAppointments: vi.fn(),
|
||||
}))
|
||||
|
||||
const row: TruckAppointmentResource = {
|
||||
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', customerName: '示例企业', orderNo: 'ORD-1',
|
||||
containerNo: 'CONT-1', containerType: '40HC', transportCompanyName: '宁波运输', driverName: '王强', tractorNo: '浙B12345',
|
||||
expectedLoadingDate: '2026-08-10', payableAmount: '3200.00', receivableAmount: '3800.00', attachments: [],
|
||||
}
|
||||
|
||||
describe('TruckAppointmentsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listTruckAppointments).mockReset().mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('loads the real list and renders relationship snapshots', async () => {
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerType: '' })
|
||||
expect(wrapper.text()).toContain('TA-9001')
|
||||
expect(wrapper.text()).toContain('40HC')
|
||||
expect(wrapper.text()).toContain('宁波运输')
|
||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus, RefreshCw } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listTruckAppointments } from '../api/platformTrucking'
|
||||
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
|
||||
|
||||
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const loaded = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '' }); const applied = ref({ ...filters.value })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '委托单位、预约单号、车牌' },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
|
||||
{ key: 'containerBookingId', type: 'input', label: '柜体 ID', placeholder: '按关联柜体筛选' },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointmentNo', prop: 'appointmentNo', label: '预约单号', minWidth: 170 },
|
||||
{ key: 'relation', label: '关联资源', minWidth: 220, slotName: 'relation' },
|
||||
{ key: 'company', label: '承运商', minWidth: 150, slotName: 'company' },
|
||||
{ key: 'driver', label: '司机/车头', minWidth: 150, slotName: 'driver' },
|
||||
{ key: 'store', label: '门店', minWidth: 130, slotName: 'store' },
|
||||
{ key: 'plannedLoadingAt', prop: 'plannedLoadingAt', label: '预计装箱时间', minWidth: 170 },
|
||||
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.payableAmount ? `¥${row.payableAmount}` : '—' },
|
||||
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.receivableAmount ? `¥${row.receivableAmount}` : '—' },
|
||||
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
|
||||
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) },
|
||||
]
|
||||
function statusLabel(value: unknown) {
|
||||
return typeof value === 'string' && value in truckAppointmentStatusLabels
|
||||
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
|
||||
: String(value || '—')
|
||||
}
|
||||
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total; loaded.value = true } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: String(model.containerBookingId || '') }; page.value = 1; void load() }
|
||||
function reset() { filters.value = { keyword: '', status: '', containerBookingId: '' }; search(filters.value) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-heading"><div><h1>拖车预约</h1><p>维护拖车预约、关联资源与派车状态</p></div><div class="heading-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增预约</el-button></div></div>
|
||||
<LoadErrorState v-if="loadError" title="拖车预约加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<QueryToolbar v-model="filters" ariaLabel="拖车预约查询" :fields="fields" @search="search" @reset="reset" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments">
|
||||
<template #relation="{ row }"><div>{{ row.customerName || '—' }}</div><small>出仓单 {{ row.outboundOrderId || '—' }} · 柜体 {{ row.containerBookingId || '—' }}</small></template>
|
||||
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
|
||||
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.vehicleNo || '—' }}</small></template>
|
||||
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||
<template #actions="{ row }"><el-button text :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) } })"><Eye :size="16" />查看</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" text :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: { edit: '1' } })"><Pencil :size="16" />编辑</el-button></template>
|
||||
</DataTable>
|
||||
<div v-if="!loading && loaded && rows.length === 0 && !loadError" class="empty-hint"><RefreshCw :size="16" />暂无拖车预约</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.page-shell { min-width: 0; padding: 24px; }
|
||||
.page-heading { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:18px; flex-wrap:wrap; }
|
||||
h1 { margin:0; font-size:24px; }.page-heading p { margin:6px 0 0; color:var(--el-text-color-secondary); }.heading-actions { display:flex; gap:8px; flex-wrap:wrap; }.heading-actions :deep(.el-button) { margin:0; }.heading-actions :deep(svg) { vertical-align:middle; margin-right:5px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
|
||||
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
|
||||
@media (max-width:600px) { .page-shell { padding:16px 12px; } h1 { font-size:20px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const truckingRoutes = [
|
||||
{
|
||||
path: 'trucking',
|
||||
name: 'trucking',
|
||||
component: () => import('./pages/TruckAppointmentsView.vue'),
|
||||
meta: { title: '拖车预约', description: '维护拖车预约及派车状态', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
},
|
||||
{
|
||||
path: 'trucking/new',
|
||||
name: 'trucking-new',
|
||||
component: () => import('./pages/TruckAppointmentCreateView.vue'),
|
||||
meta: { title: '新增拖车预约', description: '创建拖车预约', permission: 'platform:firstleg:truck-appointment:create' },
|
||||
},
|
||||
{
|
||||
path: 'trucking/:id',
|
||||
name: 'trucking-detail',
|
||||
component: () => import('./pages/TruckAppointmentDetailView.vue'),
|
||||
props: true,
|
||||
meta: { title: '拖车预约详情', description: '查看和编辑拖车预约', permission: 'platform:firstleg:truck-appointment:view' },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,134 @@
|
||||
export const TRUCK_APPOINTMENT_STATUSES = [
|
||||
'DRAFT',
|
||||
'PENDING_DISPATCH',
|
||||
'DISPATCHED',
|
||||
'PICKUP_IN_PROGRESS',
|
||||
'ARRIVED_WAREHOUSE',
|
||||
'LOADING',
|
||||
'DEPARTED',
|
||||
'COMPLETED',
|
||||
'RESCHEDULED',
|
||||
'EXCEPTION',
|
||||
'CANCELLED',
|
||||
] as const
|
||||
|
||||
export type TruckAppointmentStatus = typeof TRUCK_APPOINTMENT_STATUSES[number]
|
||||
|
||||
export const truckAppointmentStatusLabels: Record<TruckAppointmentStatus, string> = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_DISPATCH: '待派车',
|
||||
DISPATCHED: '已派车',
|
||||
PICKUP_IN_PROGRESS: '提柜中',
|
||||
ARRIVED_WAREHOUSE: '已到仓',
|
||||
LOADING: '装柜中',
|
||||
DEPARTED: '已出发',
|
||||
COMPLETED: '已完成',
|
||||
RESCHEDULED: '已改期',
|
||||
EXCEPTION: '异常',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
export interface TruckAttachmentReference {
|
||||
fileId: string
|
||||
fileName: string
|
||||
objectKey?: string | null
|
||||
}
|
||||
|
||||
export interface TruckAppointmentResource {
|
||||
id: string
|
||||
appointmentNo?: string | null
|
||||
status: TruckAppointmentStatus
|
||||
version?: number | null
|
||||
revision?: number | null
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId?: string | null
|
||||
containerBookingId?: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
carrierId?: string | null
|
||||
carrierName?: string | null
|
||||
fleetContact?: string | null
|
||||
fleetPhone?: string | null
|
||||
driverName?: string | null
|
||||
driverPhone?: string | null
|
||||
driverIdCard?: string | null
|
||||
vehicleNo?: string | null
|
||||
storeName?: string | null
|
||||
storeContact?: string | null
|
||||
storePhone?: string | null
|
||||
storeAddress?: string | null
|
||||
storeCity?: string | null
|
||||
storeZipcode?: string | null
|
||||
plannedLoadingAt?: string | null
|
||||
transitMinutes?: number | string | null
|
||||
packageCount?: number | string | null
|
||||
grossWeightKg?: string | null
|
||||
volumeCbm?: string | null
|
||||
palletCount?: number | string | null
|
||||
payableAmount?: string | null
|
||||
receivableAmount?: string | null
|
||||
currency?: string | null
|
||||
poNo?: string | null
|
||||
remark?: string | null
|
||||
orderId?: string | null
|
||||
orderNo?: string | null
|
||||
containerNo?: string | null
|
||||
attachments?: TruckAttachmentReference[] | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface SaveTruckAppointmentRequest {
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId?: string | null
|
||||
containerBookingId?: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
carrierId?: string | null
|
||||
carrierName?: string | null
|
||||
fleetContact?: string | null
|
||||
fleetPhone?: string | null
|
||||
driverName?: string | null
|
||||
driverPhone?: string | null
|
||||
driverIdCard?: string | null
|
||||
vehicleNo?: string | null
|
||||
storeName?: string | null
|
||||
storeContact?: string | null
|
||||
storePhone?: string | null
|
||||
storeAddress?: string | null
|
||||
storeCity?: string | null
|
||||
storeZipcode?: string | null
|
||||
plannedLoadingAt?: string | null
|
||||
transitMinutes?: number | string | null
|
||||
packageCount?: number | string | null
|
||||
grossWeightKg?: string | null
|
||||
volumeCbm?: string | null
|
||||
palletCount?: number | string | null
|
||||
payableAmount?: string | null
|
||||
receivableAmount?: string | null
|
||||
currency?: string | null
|
||||
poNo?: string | null
|
||||
remark?: string | null
|
||||
attachmentRefs?: TruckAttachmentReference[]
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface TruckAppointmentQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
status?: TruckAppointmentStatus | ''
|
||||
containerBookingId?: string | ''
|
||||
}
|
||||
|
||||
export interface TruckAppointmentPage {
|
||||
records: TruckAppointmentResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
Reference in New Issue
Block a user