feat(fleet): integrate trip management and fee coverage workflows
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.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 { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { createDriver, listDriverWarehouses, updateDriver, uploadDriverLicense } from '../api/drivers'
|
||||
import type { DriverDetail, DriverDraft, DriverProfile, DriverWarehouseOption } from '../driverTypes'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ driver: DriverDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [driver: DriverDetail] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseLoading = ref(false), warehouseOptions = ref<DriverWarehouseOption[]>([])
|
||||
const current = ref<DriverDetail | null>(null), selectedFile = ref<File | null>(null), fileInput = ref<HTMLInputElement | null>(null)
|
||||
const emptyProfile = (): DriverProfile => ({ warehouseId: null, vehicleModels: null, languagesAndArea: null, medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null })
|
||||
const form = reactive<DriverDraft>({ name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
|
||||
const profile = computed(() => form.profile!)
|
||||
const zones = ['America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver', 'Asia/Shanghai', 'Europe/London', 'UTC']
|
||||
let warehouseRequest = 0, editSession = 0, lastSaved = ''
|
||||
async function loadWarehouses() {
|
||||
const request = ++warehouseRequest
|
||||
warehouseLoading.value = true; warehouseError.value = null
|
||||
try { const rows = await listDriverWarehouses(form.region); if (request === warehouseRequest) warehouseOptions.value = rows }
|
||||
catch (cause) { if (request === warehouseRequest) { warehouseOptions.value = []; warehouseError.value = getApiErrorDetails(cause, '仓库候选加载失败') } }
|
||||
finally { if (request === warehouseRequest) warehouseLoading.value = false }
|
||||
}
|
||||
function changeRegion() { profile.value.warehouseId = null; void loadWarehouses() }
|
||||
watch([open, () => props.driver?.id], ([value]) => {
|
||||
editSession++; saving.value = false
|
||||
if (!value) { warehouseRequest++; return }
|
||||
current.value = props.driver; selectedFile.value = null; error.value = null; lastSaved = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
Object.assign(form, props.driver ? { ...props.driver.details, profile: { ...emptyProfile(), ...props.driver.details.profile } }
|
||||
: { name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
|
||||
void loadWarehouses()
|
||||
})
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] || null
|
||||
if (file && (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
|
||||
ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
|
||||
}
|
||||
selectedFile.value = file
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
if (!form.name.trim() || !form.phone.trim() || !form.licenseZone.trim()) { ElMessage.warning('请填写姓名、电话和证照时区'); return }
|
||||
saving.value = true; error.value = null
|
||||
const session = editSession, sourceDriverId = props.driver?.id
|
||||
const isCurrent = () => session === editSession && open.value && props.driver?.id === sourceDriverId
|
||||
let persisted = false
|
||||
try {
|
||||
const details: DriverDraft = { ...form, name: form.name.trim(), phone: form.phone.trim(), profile: { ...profile.value } }
|
||||
const fingerprint = JSON.stringify(details)
|
||||
if (!current.value || fingerprint !== lastSaved) {
|
||||
const savedDriver = current.value ? await updateDriver(current.value.id, current.value.recordVersion, details) : await createDriver(details)
|
||||
if (!isCurrent()) return
|
||||
current.value = savedDriver
|
||||
lastSaved = fingerprint
|
||||
}
|
||||
persisted = true
|
||||
if (selectedFile.value && current.value) {
|
||||
const uploadedDriver = await uploadDriverLicense(current.value.id, current.value.recordVersion, selectedFile.value)
|
||||
if (!isCurrent()) return
|
||||
current.value = uploadedDriver
|
||||
selectedFile.value = null
|
||||
}
|
||||
if (current.value) emit('saved', current.value)
|
||||
ElMessage.success('司机档案已保存'); open.value = false
|
||||
} catch (cause) {
|
||||
if (!isCurrent()) return
|
||||
if (persisted && current.value) emit('saved', current.value)
|
||||
const details = getApiErrorDetails(cause, '司机保存失败')
|
||||
error.value = { ...details, message: persisted ? `档案已保存,驾驶证上传失败:${details.message}。可直接重试。` : details.message }
|
||||
} finally { if (isCurrent()) saving.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { warehouseRequest++; editSession++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form driver-form" :disabled="saving" @submit.prevent="save">
|
||||
<h3 class="full-width">基础信息</h3>
|
||||
<el-form-item label="司机姓名" required><el-input v-model="form.name" maxlength="128" placeholder="请输入司机姓名" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="form.phone" maxlength="64" placeholder="请输入联系电话" /></el-form-item>
|
||||
<el-form-item label="司机归属"><el-select v-model="form.ownership"><el-option label="自有司机" value="OWNED" /><el-option label="外派司机" value="DISPATCHED" /></el-select></el-form-item>
|
||||
<el-form-item label="区域"><el-select v-model="form.region" @change="changeRegion"><el-option label="国内" value="DOMESTIC" /><el-option label="海外" value="OVERSEAS" /></el-select></el-form-item>
|
||||
<el-form-item label="所属仓库"><el-select v-model="profile.warehouseId" clearable filterable :loading="warehouseLoading" placeholder="选择所属仓库"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" :value="warehouse.id" /></el-select></el-form-item>
|
||||
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
|
||||
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
|
||||
<h3 class="full-width">驾驶资质</h3>
|
||||
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
|
||||
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
|
||||
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
|
||||
<el-form-item label="驾照签发日"><el-date-picker v-model="form.licenseIssuedOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="驾照到期日"><el-date-picker v-model="form.licenseExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<p class="full-width form-hint">驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p>
|
||||
<h3 class="full-width">证照附件</h3>
|
||||
<div class="full-width">
|
||||
<label v-if="access.can(permission.driverUpload)">驾驶证(PDF / JPG / PNG,最大 10MiB)<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
|
||||
<p class="form-hint">{{ selectedFile?.name || (current?.licenseFileId ? '已有驾驶证附件;上传新文件后需要重新审核。' : '可先保存档案,补齐驾驶证后才能通过审核。') }}</p>
|
||||
</div>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充司机档案说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.driver-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.driver-form h3 { margin: 0; }
|
||||
.driver-form :deep(.el-select), .driver-form :deep(.el-date-editor) { width: 100%; }
|
||||
.form-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadDriverLicense } from '../api/drivers'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ driverId: string; fileId: string; driverName: string }>()
|
||||
const url = ref(''), contentType = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
function release() { if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
|
||||
async function load() {
|
||||
const current = ++request
|
||||
release(); error.value = null; loading.value = true
|
||||
try {
|
||||
const blob = await downloadDriverLicense(props.driverId, props.fileId)
|
||||
if (current !== request || !open.value) return
|
||||
contentType.value = blob.type; url.value = URL.createObjectURL(blob)
|
||||
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '驾驶证读取失败') }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
watch([open, () => props.driverId, () => props.fileId], () => {
|
||||
if (open.value && props.driverId && props.fileId) void load()
|
||||
else { request++; release(); loading.value = false }
|
||||
})
|
||||
onBeforeUnmount(() => { request++; release() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="驾驶证附件" width="620px" :show-confirm="false" cancel-text="关闭">
|
||||
<p>{{ driverName }} · 驾驶证附件</p>
|
||||
<LoadErrorState v-if="error" title="驾驶证读取失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" class="license-preview">
|
||||
<iframe v-if="url && contentType === 'application/pdf'" :src="url" title="驾驶证 PDF 预览" />
|
||||
<img v-else-if="url" :src="url" :alt="`${driverName}的驾驶证`" />
|
||||
</div>
|
||||
<a v-if="url" :href="url" :download="`驾驶证.${contentType === 'application/pdf' ? 'pdf' : contentType === 'image/png' ? 'png' : 'jpg'}`">下载驾驶证</a>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.license-preview { min-height: 160px; }
|
||||
.license-preview iframe { width: 100%; height: 560px; border: 0; }
|
||||
.license-preview img { display: block; max-width: 100%; max-height: 560px; margin: auto; object-fit: contain; }
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip } from '../api/trips'
|
||||
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let vehicleRequest = 0, driverRequest = 0, generation = 0
|
||||
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(() => props.detail.trip, trip => {
|
||||
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
|
||||
}, { immediate: true })
|
||||
function searchVehicles(keyword = '') {
|
||||
clearTimeout(vehicleTimer); const request = ++vehicleRequest
|
||||
vehicleTimer = setTimeout(async () => {
|
||||
vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
|
||||
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
|
||||
finally { if (request === vehicleRequest) vehicleLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
function searchDrivers(keyword = '') {
|
||||
clearTimeout(driverTimer); const request = ++driverRequest
|
||||
driverTimer = setTimeout(async () => {
|
||||
driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
|
||||
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
|
||||
finally { if (request === driverRequest) driverLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
|
||||
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
|
||||
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer) })
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<LoadErrorState v-if="error" title="分配未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
|
||||
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
|
||||
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.assignment-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.assignment-form :deep(.el-select){width:100%}</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
|
||||
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const booking: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null,
|
||||
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
|
||||
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
|
||||
LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] }))
|
||||
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
|
||||
const wrapper = mount('receipt'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled(); expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
|
||||
})
|
||||
it('retries the same incoming registration after an uncertain result without creating a new command', async () => {
|
||||
vi.mocked(api.startTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking, replayed: true })
|
||||
const wrapper = mount('incoming'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.startTripIncoming).mock.calls[0]![2]
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
|
||||
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
|
||||
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
|
||||
let generation = 0
|
||||
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
|
||||
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
|
||||
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
|
||||
async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
|
||||
if (current !== generation) return
|
||||
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [open.value, target.value], () => {
|
||||
generation++; busy.value = false; error.value = null; preview.value = null
|
||||
if (!open.value || !props.booking) return
|
||||
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
|
||||
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
if (pending.value?.target !== target.value) pending.value = null
|
||||
if (props.mode === 'receipt' || props.mode === 'view') void load()
|
||||
}, { immediate: true })
|
||||
async function save() {
|
||||
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
|
||||
const current = generation, booking = props.booking, mode = props.mode
|
||||
if (!pending.value) {
|
||||
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
|
||||
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
|
||||
else if (mode === 'bind') {
|
||||
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
|
||||
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
|
||||
} else {
|
||||
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
|
||||
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
|
||||
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
|
||||
}
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const body = pending.value.body
|
||||
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
|
||||
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
|
||||
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
|
||||
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="booking" v-loading="loading">
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
|
||||
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
|
||||
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
|
||||
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
|
||||
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
|
||||
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
|
||||
</template>
|
||||
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingPanel from './TripBookingPanel.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking } from '../bookingTypes'
|
||||
const access = vi.hoisted(() => ({ writable: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.writable }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
vi.mock('../api/tripBookings', () => ({ listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
|
||||
const row: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount() {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
|
||||
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElInput: field, ElOption: true,
|
||||
LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.writable = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
|
||||
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
|
||||
expect(api.confirmTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 4, recordVersion: 0, idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('changed')?.[0]).toEqual([5]); expect(api.listTripBookings).toHaveBeenCalledOnce()
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
|
||||
})
|
||||
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
|
||||
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
|
||||
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
|
||||
})
|
||||
it('does not expose booking writes to a read-only operator', async () => {
|
||||
access.writable = false; const wrapper = mount(); await flushPromises()
|
||||
for (const label of ['登记外接询价', '编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(button => button.text() === label)).toBe(false)
|
||||
})
|
||||
|
||||
it('opens reactive booking data for editing and read-only details without losing the original row', async () => {
|
||||
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
||||
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking } from '../api/tripBookings'
|
||||
import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
const props = defineProps<{ trip: FleetTrip }>()
|
||||
const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
|
||||
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
|
||||
const readOnly = ref(false)
|
||||
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
|
||||
const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null)
|
||||
const version = ref<number | null>(null), customerId = ref('')
|
||||
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive<TripBookingCargo>(blank())
|
||||
let generation = 0, searchSequence = 0
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const planned = computed(() => props.trip.status === 'PLANNED')
|
||||
const quotedAmount = computed(() => form.unitPrice == null ? null : form.priceBasis === 'ORDER' ? form.unitPrice : form.palletSpaces == null ? null : Math.round(form.unitPrice * form.palletSpaces * 100) / 100)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 440, fixed: 'left' },
|
||||
{ key: 'bookingNo', prop: 'bookingNo', label: '外接预订号', minWidth: 225 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '委托客户', minWidth: 170 },
|
||||
{ key: 'destinationLabel', prop: 'destinationLabel', label: '卸货站', minWidth: 140 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 130 },
|
||||
{ key: 'cargo', label: '承运货量', slotName: 'cargo', minWidth: 225 },
|
||||
{ key: 'amount', label: '约定运费', slotName: 'amount', minWidth: 160 },
|
||||
{ key: 'documents', label: '来货 / 排车', slotName: 'documents', minWidth: 230 },
|
||||
]
|
||||
async function load() {
|
||||
const current = ++generation; loading.value = true; error.value = null
|
||||
try { const value = await listTripBookings(props.trip.tripNo); if (current === generation) { records.value = value.records; version.value = value.tripRecordVersion
|
||||
if (pendingCommand.value) { const row = value.records.find(row => row.bookingNo === pendingCommand.value!.bookingNo); if (row && (row.status === 'CANCELLED' || pendingCommand.value.action === 'confirm' && row.status === 'CONFIRMED')) pendingCommand.value = null } } }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '外接预订加载失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function searchCustomers(keyword = '') {
|
||||
clearTimeout(searchTimer); const current = ++searchSequence
|
||||
searchTimer = setTimeout(async () => {
|
||||
customersLoading.value = true
|
||||
try { const rows = await listFleetCustomers(keyword.trim() || undefined); if (current === searchSequence) customers.value = rows }
|
||||
catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '客户目录加载失败') }
|
||||
finally { if (current === searchSequence) customersLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
function edit(row?: TripBooking, viewOnly = false) {
|
||||
readOnly.value = viewOnly
|
||||
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
|
||||
editing.value = row || null
|
||||
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo) }
|
||||
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()) }
|
||||
open.value = true
|
||||
}
|
||||
function accept(value: TripBookingResult) {
|
||||
version.value = value.tripRecordVersion
|
||||
const index = records.value.findIndex(row => row.bookingNo === value.booking.bookingNo)
|
||||
if (index < 0) records.value.push(value.booking); else records.value[index] = value.booking
|
||||
emit('changed', value.tripRecordVersion)
|
||||
}
|
||||
function failure(cause: unknown, fallback: string) {
|
||||
error.value = getApiErrorDetails(cause, fallback)
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || readOnly.value || version.value == null) return
|
||||
if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
let value: TripBookingResult
|
||||
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }) })
|
||||
else {
|
||||
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), idempotencyKey: crypto.randomUUID() }
|
||||
value = await createTripBooking(props.trip.tripNo, pendingCreate.value)
|
||||
}
|
||||
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
|
||||
} catch (cause) { if (current === generation) failure(cause, '外接登记未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
|
||||
if (busy.value || version.value == null) return
|
||||
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
|
||||
const current = generation
|
||||
if (!pendingCommand.value) {
|
||||
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm' }) }
|
||||
catch { return }
|
||||
if (current !== generation) return
|
||||
pendingCommand.value = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: version.value, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const value = await (action === 'confirm' ? confirmTripBooking : cancelTripBooking)(props.trip.tripNo, row.bookingNo, pendingCommand.value.body)
|
||||
if (current === generation) { accept(value); pendingCommand.value = null; ElMessage.success('外接预订状态已更新') }
|
||||
} catch (cause) { if (current === generation) failure(cause, '外接预订操作未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer) })
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<p>询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。</p>
|
||||
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="readOnly || busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
|
||||
<el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
|
||||
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargoName" placeholder="填写货物名称(选填)" /></el-form-item>
|
||||
<el-form-item label="箱数" required><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="预计箱数" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="报价方式"><el-select v-model="form.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
|
||||
<el-form-item :label="form.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.unitPrice" :min="0" :precision="2" placeholder="可先保存询价" /></el-form-item>
|
||||
<el-form-item label="币种"><el-select v-model="form.currency" filterable allow-create placeholder="选择或输入币种"><el-option v-for="currency in ['USD','CAD','EUR','GBP','CNY']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.contactName" placeholder="填写联系人(选填)" /></el-form-item>
|
||||
<el-form-item label="联系方式"><el-input v-model="form.contactPhone" placeholder="填写电话或其他联系方式(选填)" /></el-form-item>
|
||||
<el-form-item label="备注" class="booking-wide"><el-input v-model="form.remark" type="textarea" placeholder="其他交货说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
|
||||
</AppDialog>
|
||||
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.booking-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.booking-form :deep(.el-select),.booking-form :deep(.el-input-number){width:100%}.booking-wide{grid-column:1/-1}</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTripLoading, confirmTripLoading, getTripCapacityPlan, confirmTripCapacityPlan } from '../api/trips'
|
||||
import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean }>()
|
||||
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning] }>()
|
||||
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
|
||||
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let sequence = 0
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
|
||||
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 },
|
||||
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 },
|
||||
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 200 }])
|
||||
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
|
||||
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
|
||||
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
|
||||
function apply(value: TripLoading | TripCapacityPlanning) {
|
||||
info.value = value
|
||||
const pallets = new Map<string, PalletRow>()
|
||||
for (const cargo of value.cargo) {
|
||||
if (!cargo.warehousePalletId) continue
|
||||
const existing = pallets.get(cargo.warehousePalletId)
|
||||
if (existing) { if (cargo.destinationLabel && !existing.destinations.split(' / ').includes(cargo.destinationLabel)) existing.destinations += ` / ${cargo.destinationLabel}`; continue }
|
||||
const saved = ('capacityPlan' in value ? value.capacityPlan : value.confirmedLoad)?.pallets.find(row => row.warehousePalletId === cargo.warehousePalletId)
|
||||
const index = props.trip.stops.findIndex(stop => !!cargo.destinationKey && stop.destinationKey === cargo.destinationKey)
|
||||
pallets.set(cargo.warehousePalletId, { warehousePalletId: cargo.warehousePalletId, palletNo: cargo.palletNo || '—', destinations: cargo.destinationLabel || '—',
|
||||
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved?.deliveryIndex ?? (index > 0 ? index : null) })
|
||||
}
|
||||
rows.value = [...pallets.values()]
|
||||
}
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try { const result = await (props.planning ? getTripCapacityPlan(props.trip.tripNo) : getTripLoading(props.trip.tripNo)); if (current === sequence) apply(result) }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量加载失败' : '装载信息加载失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || !info.value) return
|
||||
if (!rows.value.length || rows.value.some(row => row.weightKg == null || row.volumeCbm == null || row.deliveryIndex == null)) { ElMessage.warning(props.planning ? '请填写每托预计重量、体积和卸货站' : '请填写每托实测重量、体积和卸货站'); return }
|
||||
const current = sequence, snapshot = info.value
|
||||
const pallets: TripMeasurement[] = rows.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces,
|
||||
weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! }))
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const body = { recordVersion: snapshot.recordVersion, manifestHash: snapshot.manifestHash, pallets, reason: props.planning ? '核对本车自有货计划载荷' : '核对实物托盘及实际装载' }
|
||||
if (props.planning) {
|
||||
const result = await confirmTripCapacityPlan(props.trip.tripNo, body)
|
||||
if (current === sequence) { apply(result); emit('planned', result); ElMessage.success('计划容量已核对') }
|
||||
} else {
|
||||
const result = await confirmTripLoading(props.trip.tripNo, body)
|
||||
if (current === sequence) { apply(result); emit('saved', result); ElMessage.success('实际装载已确认') }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量确认失败' : '装载确认失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
watch(() => [props.trip.tripNo, props.trip.recordVersion, props.planning], () => { if (info.value?.tripNo !== props.trip.tripNo || info.value.recordVersion !== props.trip.recordVersion || !!props.planning !== ('capacityPlan' in info.value)) void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<LoadErrorState v-if="error" :title="planning ? '计划容量操作未完成' : '装载操作未完成'" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="planning">先核对本车自有货的预计载荷,查看每段剩余托位、载重和容积。实际装载完成后仍需按实测值确认。</p>
|
||||
<p v-if="stalePlan" role="status">货物明细已变化,请重新核对计划容量。</p>
|
||||
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
|
||||
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
|
||||
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
|
||||
<DataTable :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
||||
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
|
||||
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
|
||||
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
|
||||
<template #stop="{ row }"><el-select v-model="row.deliveryIndex" :disabled="!editable || busy" placeholder="选择卸货站"><el-option v-for="(stop, index) in trip.stops.slice(1)" :key="index" :value="index + 1" :label="stop.name" /></el-select></template>
|
||||
<template #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="!!info?.blockReason || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
|
||||
</DataTable>
|
||||
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listDriverWarehouses } from '../api/drivers'
|
||||
import { createTrip, updateTripPlan } from '../api/trips'
|
||||
import type { DriverWarehouseOption } from '../driverTypes'
|
||||
import type { FleetTrip, TripDetail, TripDraft, TripStop } from '../tripTypes'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: FleetTrip | null }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const form = reactive({ warehouseId: '', plannedDepartureAt: '', expectedDriverEndAt: '', remark: '', reason: '' })
|
||||
const stops = ref<TripStop[]>([]), warehouses = ref<DriverWarehouseOption[]>([])
|
||||
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<TripDraft | null>(null)
|
||||
let generation = 0
|
||||
async function loadWarehouses() {
|
||||
const current = generation
|
||||
warehouseError.value = null
|
||||
try { const result = await listDriverWarehouses('OVERSEAS'); if (current === generation) warehouses.value = result }
|
||||
catch (cause) { if (current === generation) warehouseError.value = getApiErrorDetails(cause, '起运仓加载失败') }
|
||||
}
|
||||
watch([open, () => props.trip?.tripNo], ([visible]) => {
|
||||
generation++; saving.value = false
|
||||
if (!visible) return
|
||||
if (pending.value && !props.trip) return
|
||||
const trip = props.trip
|
||||
pending.value = null; error.value = null
|
||||
Object.assign(form, { warehouseId: trip?.warehouseId || '', plannedDepartureAt: trip?.plannedDepartureAt || new Date(Date.now() + 3600000).toISOString(),
|
||||
expectedDriverEndAt: trip?.expectedDriverEndAt || new Date(Date.now() + 86400000).toISOString(), remark: trip?.remark || '', reason: '' })
|
||||
stops.value = trip ? trip.stops.map(stop => ({ ...stop })) : [{ name: '起运仓' }, { name: '待确定卸货点' }]
|
||||
if (!trip) void loadWarehouses()
|
||||
})
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
if (!form.warehouseId || !form.plannedDepartureAt || !form.expectedDriverEndAt || stops.value.some(stop => !stop.name.trim())) {
|
||||
ElMessage.warning('请填写起运仓、计划时间和站点名称'); return
|
||||
}
|
||||
const current = generation, trip = props.trip
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
const plan = { plannedDepartureAt: new Date(form.plannedDepartureAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString(),
|
||||
stops: stops.value.map(stop => ({ ...stop, name: stop.name.trim() })), remark: form.remark.trim() || null }
|
||||
if (!trip) pending.value ||= { ...plan, warehouseId: form.warehouseId, idempotencyKey: crypto.randomUUID() }
|
||||
const result = trip ? await updateTripPlan(trip.tripNo, { ...plan, recordVersion: trip.recordVersion, reason: form.reason.trim() || '更新车次计划' })
|
||||
: await createTrip(pending.value!)
|
||||
if (current !== generation || !open.value) return
|
||||
pending.value = null; emit('saved', result); open.value = false; ElMessage.success('车次计划已保存')
|
||||
} catch (cause) {
|
||||
if (current !== generation) return
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null
|
||||
error.value = getApiErrorDetails(cause, '计划保存失败')
|
||||
} finally { if (current === generation) saving.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="trip ? '编辑车次计划' : '新建车次'" width="1080px" confirm-text="保存计划" :confirm-loading="saving" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="计划保存失败" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError && !trip" title="起运仓加载失败" :message="warehouseError.message" @retry="loadWarehouses" />
|
||||
<p v-if="pending">上次保存结果尚未确定,再次保存会核对原请求。</p>
|
||||
<el-form label-position="top" class="density-form trip-plan-form" :disabled="saving || !!pending" @submit.prevent="save">
|
||||
<el-form-item label="起运仓" required><el-input v-if="trip" :model-value="trip.warehouseName" readonly /><el-select v-else v-model="form.warehouseId" filterable placeholder="选择海外起运仓"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :value="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" /></el-select></el-form-item>
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.plannedDepartureAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
|
||||
<div class="full-width"><h3>路线站点</h3><p>先建立计划,关联货物后可在详情中匹配实际卸货站。</p>
|
||||
<div v-for="(stop, index) in stops" :key="index" class="trip-stop-row"><span>{{ index === 0 ? '起点' : `第 ${index} 站` }}</span><el-input v-model="stop.name" placeholder="站点名称" /><el-input v-model="stop.address" placeholder="地址(选填)" /><el-button :disabled="index === 0 || stops.length <= 2" @click="stops.splice(index, 1)">移除</el-button></div>
|
||||
<el-button @click="stops.push({ name: '' })">添加站点</el-button>
|
||||
</div>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="补充本次运输安排(选填)" /></el-form-item>
|
||||
<el-form-item v-if="trip" label="调整说明(选填)" class="full-width"><el-input v-model="form.reason" placeholder="如改期或调整路线(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.trip-plan-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
|
||||
.full-width{grid-column:1/-1}.trip-stop-row{display:grid;grid-template-columns:70px 1fr 2fr auto;gap:var(--admin-space-4);align-items:center;margin-bottom:var(--admin-space-4)}
|
||||
.trip-plan-form :deep(.el-select),.trip-plan-form :deep(.el-date-editor){width:100%}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import VehicleCareEditor from './VehicleCareEditor.vue'
|
||||
import VehicleCareTable from './VehicleCareTable.vue'
|
||||
import VehicleDetailView from '../pages/VehicleDetailView.vue'
|
||||
import * as api from '../api/vehicleCare'
|
||||
import { getVehicle } from '../api/overseasMasterData'
|
||||
import type { VehicleCareRecord } from '../vehicleCareTypes'
|
||||
const access = vi.hoisted(() => ({ write: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || access.write }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: '71' } }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('../api/vehicleCare', () => ({ listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn() }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ getVehicle: vi.fn() }))
|
||||
const row: VehicleCareRecord = { recordNo: 'VR-ONE', vehicleId: '71', vehiclePlate: 'VEH-A', recordVersion: 0, details: { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null }, files: [], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'operator', paymentStatus: null }
|
||||
const container = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
||||
const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.recordNo"><slot name="actions" :row="row"/><span data-test="status"><slot name="status" :row="row"/></span><span data-test="payment"><slot name="payment" :row="row"/></span><span data-test="cost"><slot name="cost" :row="row"/></span></div></div>' }
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' }
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(component: object, props: Record<string, unknown>): VueWrapper {
|
||||
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
|
||||
AdminPage: container, DetailPageLayout: container, FormSection: container, AppDialog: dialog, LoadErrorState: true, ApiErrorMeta: true,
|
||||
ElButton: { template: '<button><slot/></button>' }, ElForm: container, ElFormItem: container,
|
||||
ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true,
|
||||
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: true, DataTable: table, RowActions: true, VehicleCareTable: component === VehicleCareTable ? false : true, VehicleCareEditor: component === VehicleCareEditor ? false : true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('loads care only after the detail tab is selected', async () => {
|
||||
vi.mocked(getVehicle).mockResolvedValue({ id: '71', plateNumber: 'VEH-A', model: 'Truck', fuelConsumptionPer100Km: 10, mileageKm: 1000, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', recordVersion: 0 } as Awaited<ReturnType<typeof getVehicle>>)
|
||||
const wrapper = mount(VehicleDetailView, {}); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(0)
|
||||
expect(api.listVehicleCare).not.toHaveBeenCalled()
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'care'); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(2)
|
||||
})
|
||||
it('requests one page at a time, never preloads photos, and ignores the old vehicle response', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof api.listVehicleCare>>) => void
|
||||
vi.mocked(api.listVehicleCare).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' })
|
||||
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B' }); await flushPromises()
|
||||
expect(api.listVehicleCare).toHaveBeenCalledTimes(2)
|
||||
resolveOld({ records: [{ ...row, recordNo: 'OLD' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].recordNo).toBe('VR-ONE')
|
||||
expect(api.downloadVehicleCarePhoto).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retains the saved record and exact photo key after an upload failure', async () => {
|
||||
vi.mocked(api.createVehicleCare).mockResolvedValue(structuredClone(row))
|
||||
vi.mocked(api.uploadVehicleCarePhoto).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...row, recordVersion: 1 })
|
||||
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
||||
const file = new File(['photo'], 'meter.png', { type: 'image/png' }), picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [file] }); await picker.trigger('change')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('记录已保存')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createVehicleCare).toHaveBeenCalledTimes(1); expect(api.updateVehicleCare).not.toHaveBeenCalled()
|
||||
expect(api.uploadVehicleCarePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[0]).toEqual(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[1])
|
||||
})
|
||||
it('keeps read-only users out of create edit and upload controls', async () => {
|
||||
access.write = false
|
||||
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('添加加油记录')
|
||||
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions').every((action: { visible: boolean }) => !action.visible)).toBe(true)
|
||||
})
|
||||
it('reads the latest record only when editing instead of reusing an old page version', async () => {
|
||||
vi.mocked(api.getVehicleCare).mockResolvedValue({ ...row, recordVersion: 5 })
|
||||
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
|
||||
expect(api.getVehicleCare).not.toHaveBeenCalled()
|
||||
wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'edit'); await flushPromises()
|
||||
expect(api.getVehicleCare).toHaveBeenCalledExactlyOnceWith('71', 'VR-ONE')
|
||||
expect(wrapper.findComponent({ name: 'VehicleCareEditor' }).props('record').recordVersion).toBe(5)
|
||||
})
|
||||
it('allows correction after a definitive validation failure instead of locking the invalid draft', async () => {
|
||||
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new ApiResponseError('请填写加油量', 'A0400', 200)).mockResolvedValueOnce(structuredClone(row))
|
||||
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
||||
await wrapper.setProps({ modelValue: true }); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('正在核对原保存结果')
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createVehicleCare).toHaveBeenLastCalledWith('71', expect.any(String), expect.objectContaining({ fuelQuantity: 28.6 }))
|
||||
})
|
||||
|
||||
it('does not continue uploading vehicle photos after the editor is unmounted', async () => {
|
||||
let finish!: (value: VehicleCareRecord) => void
|
||||
vi.mocked(api.createVehicleCare).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [new File(['png'], 'fuel.png', { type: 'image/png' })] })
|
||||
await picker.trigger('change')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
wrapper.unmount(); finish(structuredClone(row)); await flushPromises()
|
||||
expect(api.uploadVehicleCarePhoto).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an uncertain create key scoped to its vehicle when the editor is reused', async () => {
|
||||
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new Error('unknown network outcome')).mockResolvedValueOnce({ ...row, vehicleId: '72', recordNo: 'VR-TWO' })
|
||||
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const oldKey = vi.mocked(api.createVehicleCare).mock.calls[0]![1]
|
||||
expect(wrapper.text()).toContain('正在核对原保存结果')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B', modelValue: true }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('正在核对原保存结果')
|
||||
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 12)
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createVehicleCare).toHaveBeenLastCalledWith('72', expect.any(String), expect.objectContaining({ fuelQuantity: 12 }))
|
||||
expect(vi.mocked(api.createVehicleCare).mock.calls[1]![1]).not.toBe(oldKey)
|
||||
})
|
||||
|
||||
it('saves repair project and place separately without submitting hidden mileage or fuel fields', async () => {
|
||||
vi.mocked(api.createVehicleCare).mockImplementation(async (vehicleId, _key, details) => ({ ...row, vehicleId, details, paymentStatus: 'UNLINKED' }))
|
||||
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR', record: null })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.get('input[placeholder="请输入具体项目"]').setValue('更换轮胎')
|
||||
await wrapper.get('input[placeholder="请输入实际地点"]').setValue('维修站')
|
||||
await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('轮胎损坏,现场维修')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createVehicleCare).toHaveBeenCalledWith('71', expect.any(String), expect.objectContaining({ recordType: 'REPAIR', odometer: null, distanceUnit: null, verificationStatus: null, fuelQuantity: null,
|
||||
incident: expect.objectContaining({ item: '更换轮胎', place: '维修站', description: '轮胎损坏,现场维修', reportedCost: null, currency: null }) }))
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders completed rescue and zero reported cost separately from payment and provides the approved update action', async () => {
|
||||
const rescue: VehicleCareRecord = { ...row, paymentStatus: 'UNLINKED', details: { ...row.details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
|
||||
incident: { item: '拖车', place: '现场', description: '处理完成', handler: '救援人员', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: 0, currency: 'USD' } } }
|
||||
vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [rescue], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'RESCUE' }); await flushPromises()
|
||||
expect(wrapper.get('[data-test="status"]').text()).toBe('已完成')
|
||||
expect(wrapper.get('[data-test="payment"]').text()).toBe('未关联财务')
|
||||
expect(wrapper.get('[data-test="cost"]').text()).toBe('USD 0.00')
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0].key).toBe('actions')
|
||||
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions')[0].label).toBe('更新处理')
|
||||
})
|
||||
|
||||
it('editing an exception does not mutate the displayed source until a command succeeds', async () => {
|
||||
const exception: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'EXCEPTION', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
|
||||
incident: { item: null, place: null, description: '原异常描述', handler: null, processingStatus: 'PENDING', exceptionType: 'TIMEOUT', reportedCost: null, currency: null } } }
|
||||
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'EXCEPTION', record: exception })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('尚未保存的修改')
|
||||
expect(exception.details.incident?.description).toBe('原异常描述')
|
||||
expect(api.updateVehicleCare).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto } from '../api/vehicleCare'
|
||||
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareDraft, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType; record: VehicleCareRecord | null }>()
|
||||
const emit = defineEmits<{ saved: [record: VehicleCareRecord] }>()
|
||||
const access = useAccessPreviewStore(), saving = ref(false), error = ref<ApiErrorDetails | null>(null), current = ref<VehicleCareRecord | null>(null)
|
||||
const reason = ref(''), input = ref<HTMLInputElement | null>(null)
|
||||
const form = reactive<VehicleCareDraft>({ recordType: 'MAINTENANCE', occurredAt: '', distanceUnit: 'KM', odometer: 0,
|
||||
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: 'PENDING', remark: null, incident: null })
|
||||
const selected = ref<Array<{ key: string; file: File }>>([])
|
||||
const pendingCreate = ref<{ key: string; details: VehicleCareDraft } | null>(null)
|
||||
let lastSaved = '', editSession = 0
|
||||
const identity = computed(() => JSON.stringify([props.vehicleId, props.recordType, props.record?.recordNo ?? null]))
|
||||
const canEdit = computed(() => current.value ? access.can(permission.vehicleRecordEdit) : access.can(permission.vehicleRecordCreate))
|
||||
const locked = computed(() => saving.value || !canEdit.value || (!!pendingCreate.value && !current.value))
|
||||
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
|
||||
const costRecord = computed(() => ['REPAIR', 'RESCUE'].includes(props.recordType))
|
||||
const distanceLabel = computed(() => form.distanceUnit ? distanceLabels[form.distanceUnit] : '—')
|
||||
watch([open, identity], ([value, target], [, previousTarget]) => {
|
||||
editSession++; saving.value = false
|
||||
if (target !== previousTarget) { pendingCreate.value = null; current.value = null }
|
||||
if (!value) return
|
||||
if (pendingCreate.value && !current.value) return
|
||||
current.value = props.record; error.value = null; selected.value = []; reason.value = ''; pendingCreate.value = null
|
||||
if (input.value) input.value.value = ''
|
||||
Object.assign(form, props.record ? { ...props.record.details, incident: props.record.details.incident ? { ...props.record.details.incident } : null }
|
||||
: { recordType: props.recordType, occurredAt: new Date().toISOString(), distanceUnit: meterRecord.value ? 'KM' : null, odometer: meterRecord.value ? 0 : null,
|
||||
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: props.recordType === 'FUEL' ? 'L' : null,
|
||||
verificationStatus: meterRecord.value ? 'PENDING' : null, remark: null, incident: meterRecord.value ? null
|
||||
: { description: '', item: null, place: null, handler: null, processingStatus: 'PENDING', exceptionType: null, reportedCost: null, currency: null } })
|
||||
lastSaved = props.record ? JSON.stringify(props.record.details) : ''
|
||||
})
|
||||
function selectFiles(event: Event) {
|
||||
const element = event.target as HTMLInputElement
|
||||
const files = Array.from(element.files || [])
|
||||
if (files.length + (current.value?.files.length || 0) > 20 || files.some(file => !['image/png', 'image/jpeg'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
|
||||
ElMessage.warning('每条记录最多 20 张非空 JPG / PNG,每张最大 10MiB'); element.value = ''; selected.value = []; return
|
||||
}
|
||||
selected.value = files.map(file => ({ key: crypto.randomUUID(), file }))
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
const details = pendingCreate.value?.details || { ...form, incident: form.incident ? { ...form.incident } : null }
|
||||
if (!details.occurredAt || !Number.isFinite(new Date(details.occurredAt).getTime()) || (meterRecord.value && details.odometer == null)) { ElMessage.warning('请填写实际发生时间及本类记录必填字段'); return }
|
||||
if (details.incident && details.incident.reportedCost == null) details.incident.currency = null
|
||||
const fingerprint = JSON.stringify(details)
|
||||
if ((!current.value || fingerprint !== lastSaved) && !canEdit.value) return
|
||||
if (selected.value.length && !access.can(permission.vehicleRecordUpload)) return
|
||||
saving.value = true; error.value = null
|
||||
const session = editSession, target = identity.value, vehicleId = props.vehicleId
|
||||
const isCurrent = () => session === editSession && open.value && identity.value === target
|
||||
let persisted = false
|
||||
try {
|
||||
if (!current.value) {
|
||||
pendingCreate.value ||= { key: crypto.randomUUID(), details: { ...details } }
|
||||
const created = await createVehicleCare(vehicleId, pendingCreate.value.key, pendingCreate.value.details)
|
||||
if (!isCurrent()) return
|
||||
current.value = created
|
||||
pendingCreate.value = null; lastSaved = fingerprint
|
||||
} else if (fingerprint !== lastSaved) {
|
||||
const updated = await updateVehicleCare(vehicleId, current.value.recordNo, current.value.recordVersion, reason.value.trim() || '更新车辆运维记录', details)
|
||||
if (!isCurrent()) return
|
||||
current.value = updated
|
||||
lastSaved = fingerprint
|
||||
}
|
||||
persisted = true
|
||||
while (selected.value.length && current.value) {
|
||||
const next = selected.value[0]!
|
||||
const uploaded = await uploadVehicleCarePhoto(vehicleId, current.value.recordNo, current.value.recordVersion, next.key, next.file)
|
||||
if (!isCurrent()) return
|
||||
current.value = uploaded
|
||||
selected.value.shift()
|
||||
}
|
||||
if (current.value) emit('saved', current.value)
|
||||
ElMessage.success('记录已保存'); open.value = false
|
||||
} catch (cause) {
|
||||
if (!isCurrent()) return
|
||||
if (persisted && current.value) emit('saved', current.value)
|
||||
const problem = getApiErrorDetails(cause, '记录保存失败')
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
|
||||
error.value = { ...problem, message: persisted ? `记录已保存,照片上传未完成:${problem.message}。重试会继续上传剩余照片。` : problem.message }
|
||||
} finally { if (isCurrent()) saving.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { editSession++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="`${current ? '编辑' : '新增'}${vehicleCareLabels[recordType]}`" :width="meterRecord ? '680px' : '620px'" confirm-text="保存记录" :confirm-loading="saving" :confirm-disabled="!canEdit && !selected.length" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="pendingCreate && !current" class="care-hint">正在核对原保存结果,请保留当前窗口并重试。</p>
|
||||
<el-form label-position="top" class="density-form care-form" :disabled="locked" @submit.prevent="save">
|
||||
<el-form-item label="车牌号"><el-input :model-value="plate" readonly /></el-form-item>
|
||||
<el-form-item label="发生时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item v-if="meterRecord" label="里程单位" required><el-select v-model="form.distanceUnit"><el-option label="公里(km)" value="KM" /><el-option label="英里(mi)" value="MI" /></el-select></el-form-item>
|
||||
<el-form-item v-if="meterRecord" :label="`记录时里程(${distanceLabel})`" required><el-input-number v-model="form.odometer" :min="0" :max="9999999999.999" :precision="3" placeholder="请输入记录时表盘里程" /></el-form-item>
|
||||
<template v-if="recordType === 'MAINTENANCE'">
|
||||
<el-form-item :label="`上次保养里程(${distanceLabel})`" required><el-input-number v-model="form.lastServiceOdometer" :min="0" :precision="3" placeholder="请输入上次保养里程" /></el-form-item>
|
||||
<el-form-item :label="`下次保养里程(${distanceLabel})`" required><el-input-number v-model="form.nextServiceOdometer" :min="0" :precision="3" placeholder="请输入下次计划保养里程" /></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="recordType === 'FUEL'">
|
||||
<el-form-item label="加油量" required><el-input-number v-model="form.fuelQuantity" :min="0.001" :precision="3" placeholder="请输入实际加油量" /></el-form-item>
|
||||
<el-form-item label="加油单位" required><el-select v-model="form.fuelUnit"><el-option v-for="(label, key) in fuelLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-if="form.incident">
|
||||
<el-form-item v-if="recordType === 'EXCEPTION'" label="异常类型" required><el-select v-model="form.incident.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, key) in exceptionLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
|
||||
<el-form-item label="处理人" :required="form.incident.processingStatus !== 'PENDING'"><el-input v-model="form.incident.handler" maxlength="128" placeholder="请输入实际处理人" /></el-form-item>
|
||||
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修项目' : '救援项目'" required><el-input v-model="form.incident.item" maxlength="256" placeholder="请输入具体项目" /></el-form-item>
|
||||
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修地点' : '救援地点'" required><el-input v-model="form.incident.place" maxlength="512" placeholder="请输入实际地点" /></el-form-item>
|
||||
<el-form-item label="事件描述" class="full-width" required><el-input v-model="form.incident.description" type="textarea" :rows="3" maxlength="2000" placeholder="请说明事件及实际处理情况" /></el-form-item>
|
||||
<el-form-item v-if="costRecord" label="费用(申报金额)"><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
|
||||
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
|
||||
<p v-if="costRecord" class="care-hint full-width">费用仅作运维记录,尚未关联财务付款。处理完成不会标记为已付款。</p>
|
||||
</template>
|
||||
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充本次保养或加油情况(选填)" /></el-form-item>
|
||||
<el-form-item v-if="current && canEdit" label="更正说明(选填)" class="full-width"><el-input v-model="reason" maxlength="1000" placeholder="需要补充时填写" /></el-form-item>
|
||||
</el-form>
|
||||
<label v-if="meterRecord && access.can(permission.vehicleRecordUpload)" class="file-picker">附件图片(JPG / PNG,每张最大 10MiB)<input ref="input" type="file" accept="image/png,image/jpeg" multiple :disabled="saving || !!pendingCreate" @change="selectFiles" /></label>
|
||||
<p v-if="meterRecord" class="care-hint">已保存 {{ current?.files.length || 0 }} 张,待上传 {{ selected.length }} 张。记录使用所选单位,不自动修改车辆公里数。</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.care-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.care-form :deep(.el-select), .care-form :deep(.el-input-number), .care-form :deep(.el-date-editor) { width: 100%; }
|
||||
.file-picker { display: grid; gap: var(--admin-space-3); }
|
||||
.care-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.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 { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { listVehicleCare, getVehicleCare, downloadVehicleCarePhoto } from '../api/vehicleCare'
|
||||
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareFile, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
|
||||
import VehicleCareEditor from './VehicleCareEditor.vue'
|
||||
|
||||
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<VehicleCareRecord[]>([]), total = ref(0), page = ref(1), size = ref(10), loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editing = ref<VehicleCareRecord | null>(null)
|
||||
const photoError = ref<ApiErrorDetails | null>(null), openingFile = ref('')
|
||||
const openingRecord = ref(false)
|
||||
const name = computed(() => vehicleCareLabels[props.recordType])
|
||||
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
|
||||
let request = 0, disposed = false
|
||||
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }]
|
||||
if (props.recordType === 'EXCEPTION') result.push({ key: 'exceptionType', label: '异常类型', minWidth: 135, slotName: 'exceptionType' })
|
||||
result.push({ key: 'date', label: '发生时间', minWidth: 165, slotName: 'date' })
|
||||
if (meterRecord.value) result.push(...(props.recordType === 'MAINTENANCE' ? [
|
||||
{ key: 'last', label: '上次保养', minWidth: 120, slotName: 'last' },
|
||||
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
|
||||
{ key: 'next', label: '下次保养', minWidth: 120, slotName: 'next' },
|
||||
] : [
|
||||
{ key: 'fuel', label: '加油量', minWidth: 120, slotName: 'fuel' },
|
||||
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
|
||||
]), { key: 'files', label: '图片', minWidth: 130, slotName: 'files' })
|
||||
else if (['REPAIR', 'RESCUE'].includes(props.recordType)) {
|
||||
const project = { key: 'item', prop: 'details.incident.item', label: props.recordType === 'REPAIR' ? '维修项目' : '救援项目', minWidth: 170 }
|
||||
const place = { key: 'place', prop: 'details.incident.place', label: props.recordType === 'REPAIR' ? '维修地点' : '救援地点', minWidth: 190 }
|
||||
result.push(...(props.recordType === 'REPAIR' ? [project, place] : [place, project]), { key: 'cost', label: '费用(申报金额)', minWidth: 160, slotName: 'cost' })
|
||||
} else result.push({ key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
|
||||
result.push({ key: 'status', label: meterRecord.value ? '核验状态' : '处理状态', width: 105, slotName: 'status' })
|
||||
if (!meterRecord.value) result.push({ key: 'handler', prop: 'details.incident.handler', label: '处理人', minWidth: 120 })
|
||||
if (['REPAIR', 'RESCUE'].includes(props.recordType)) result.push({ key: 'payment', label: '付款状态', minWidth: 130, slotName: 'payment' }, { key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
|
||||
result.push({ key: 'remark', prop: 'details.remark', label: '备注', minWidth: 150 })
|
||||
return result
|
||||
})
|
||||
async function load() {
|
||||
const id = ++request; loading.value = true; error.value = null
|
||||
try { const result = await listVehicleCare(props.vehicleId, props.recordType, page.value, size.value); if (id === request) { rows.value = result.records; total.value = result.total } }
|
||||
catch (cause) { if (id === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, `${name.value}加载失败`) } }
|
||||
finally { if (id === request) loading.value = false }
|
||||
}
|
||||
watch([() => props.vehicleId, () => props.recordType, page, size], load, { immediate: true })
|
||||
function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(permission.vehicleRecordEdit) ? (meterRecord.value || props.recordType === 'EXCEPTION' ? '编辑' : '更新处理') : '上传', disabled: openingRecord.value,
|
||||
visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) }] }
|
||||
async function edit(row: VehicleCareRecord | null) {
|
||||
if (openingRecord.value) return
|
||||
if (!row) { editing.value = null; editorOpen.value = true; return }
|
||||
const vehicleId = props.vehicleId
|
||||
openingRecord.value = true
|
||||
try { const current = await getVehicleCare(vehicleId, row.recordNo); if (!disposed && props.vehicleId === vehicleId) { editing.value = current; editorOpen.value = true } }
|
||||
catch (cause) { if (!disposed && props.vehicleId === vehicleId) error.value = getApiErrorDetails(cause, '记录详情加载失败') }
|
||||
finally { openingRecord.value = false }
|
||||
}
|
||||
function saved(row: VehicleCareRecord) {
|
||||
const index = rows.value.findIndex(value => value.recordNo === row.recordNo)
|
||||
if (index >= 0) rows.value[index] = row
|
||||
else if (page.value !== 1) page.value = 1
|
||||
else void load()
|
||||
}
|
||||
async function viewPhoto(row: VehicleCareRecord, file: VehicleCareFile) {
|
||||
if (openingFile.value) return
|
||||
const target = window.open('about:blank', '_blank')
|
||||
if (!target) { ElMessage.warning('请允许浏览器打开图片标签页'); return }
|
||||
target.opener = null; photoError.value = null; openingFile.value = file.fileId
|
||||
try {
|
||||
const blob = await downloadVehicleCarePhoto(props.vehicleId, row.recordNo, file.fileId)
|
||||
if (disposed || target.closed) { target.close(); return }
|
||||
const url = URL.createObjectURL(blob); target.location.href = url
|
||||
const preview = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(preview.timer); URL.revokeObjectURL(url); previews.delete(preview) } }, 1000) }
|
||||
previews.add(preview)
|
||||
} catch (cause) { target.close(); if (!disposed) photoError.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { openingFile.value = '' }
|
||||
}
|
||||
onBeforeUnmount(() => { request++; disposed = true; for (const item of previews) { clearInterval(item.timer); URL.revokeObjectURL(item.url) } previews.clear() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="care-records" :aria-label="name">
|
||||
<h3>{{ name }}</h3>
|
||||
<LoadErrorState v-if="error" :title="`${name}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="photoError" role="alert">{{ photoError.message }}<ApiErrorMeta :details="photoError" /></p>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleRecordCreate)" type="primary" @click="edit(null)"><Plus :size="14" />添加{{ name }}</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions()" @action="edit(row as VehicleCareRecord)" /></template>
|
||||
<template #date="{ row }">{{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}</template>
|
||||
<template #last="{ row }">{{ row.details.lastServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
||||
<template #current="{ row }">{{ row.details.odometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
||||
<template #next="{ row }">{{ row.details.nextServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
||||
<template #fuel="{ row }">{{ row.details.fuelQuantity }} {{ fuelLabels[row.details.fuelUnit as keyof typeof fuelLabels] }}</template>
|
||||
<template #exceptionType="{ row }">{{ exceptionLabels[row.details.incident?.exceptionType as keyof typeof exceptionLabels] || '—' }}</template>
|
||||
<template #cost="{ row }">{{ row.details.incident?.reportedCost == null ? '—' : `${row.details.incident.currency} ${row.details.incident.reportedCost.toFixed(2)}` }}</template>
|
||||
<template #payment="{ row }">{{ row.paymentStatus === 'UNLINKED' ? '未关联财务' : '—' }}</template>
|
||||
<template #files="{ row }"><el-button v-for="file in row.files" :key="file.fileId" link type="primary" :disabled="!!openingFile" @click="viewPhoto(row as VehicleCareRecord, file)">{{ file.fileName }}</el-button><span v-if="!row.files.length">暂无附件</span></template>
|
||||
<template #status="{ row }">{{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}</template>
|
||||
</DataTable>
|
||||
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.care-records { min-width: 0; }.care-records h3 { margin: 0 0 var(--admin-space-4); }</style>
|
||||
Reference in New Issue
Block a user