feat(fleet): integrate trip management and fee coverage workflows
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
|
||||
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDriver, getDriverQualification, listDriverHistory, reviewDriver, submitDriver, uploadDriverLicense } from '../api/drivers'
|
||||
import { driverLicenseLabels, driverQualificationLabels, driverReviewLabels, type DriverDetail, type DriverHistory } from '../driverTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<DriverDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const editorOpen = ref(false), previewOpen = ref(false), previewFile = ref(''), reviewReason = ref(''), expectedEnd = ref<Date | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null), qualificationLoading = ref(false)
|
||||
const qualificationStale = ref(false)
|
||||
const historyOpen = ref(false), historyRows = ref<DriverHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0, historyRequest = 0, qualificationRequest = 0
|
||||
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const basic = computed(() => detail.value ? [
|
||||
['司机编号', detail.value.driverNo], ['司机姓名', detail.value.details.name], ['联系电话', detail.value.details.phone],
|
||||
['归属', detail.value.details.ownership === 'OWNED' ? '自有司机' : '外派司机'], ['区域', detail.value.details.region === 'OVERSEAS' ? '海外' : '国内'],
|
||||
['所属仓库', detail.value.warehouseName], ['可驾驶车型', detail.value.details.profile?.vehicleModels], ['语言 / 服务区域', detail.value.details.profile?.languagesAndArea],
|
||||
] : [])
|
||||
const credentials = computed(() => detail.value ? [
|
||||
['驾照类型', detail.value.details.licenseType], ['驾照编号', detail.value.details.licenseNumber], ['签发日期', detail.value.details.licenseIssuedOn],
|
||||
['驾照到期', detail.value.details.licenseExpiresOn], ['有效时区', detail.value.details.licenseZone], ['有效期状态', driverLicenseLabels[detail.value.licenseState]],
|
||||
['体检到期', detail.value.details.profile?.medicalExpiresOn], ['培训 / 安全到期', detail.value.details.profile?.trainingExpiresOn], ['保险到期', detail.value.details.profile?.insuranceExpiresOn],
|
||||
] : [])
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'historyActions' },
|
||||
{ key: 'occurredAt', label: '操作时间(UTC)', prop: 'occurredAt', minWidth: 180 },
|
||||
{ key: 'action', label: '动作', slotName: 'historyAction', minWidth: 130 },
|
||||
{ key: 'reason', label: '意见 / 原因', prop: 'reason', minWidth: 280 },
|
||||
{ key: 'reviewStatus', label: '当时审核状态', slotName: 'historyReview', minWidth: 120 },
|
||||
]
|
||||
const actionLabels: Record<DriverHistory['action'], string> = { CREATE: '创建档案', UPDATE: '编辑档案', ACTIVATE: '启用', DEACTIVATE: '停用', UPLOAD_LICENSE: '上传驾驶证', APPROVED: '审核通过', REJECTED: '审核打回', RESUBMIT: '重新提交' }
|
||||
async function load() {
|
||||
const current = ++request
|
||||
historyRequest++; qualificationRequest++; detail.value = null; error.value = null; loading.value = true
|
||||
historyOpen.value = false; historyRows.value = []; historyPage.value = 1; reviewReason.value = ''; expectedEnd.value = null
|
||||
previewOpen.value = false; editorOpen.value = false; qualificationLoading.value = false
|
||||
qualificationStale.value = false
|
||||
try { const result = await getDriver(String(route.params.driverId)); if (current === request) detail.value = result }
|
||||
catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '司机详情加载失败') }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!detail.value || !historyOpen.value) return
|
||||
const current = ++historyRequest
|
||||
historyLoading.value = true; historyError.value = null
|
||||
try {
|
||||
const result = await listDriverHistory(detail.value.id, historyPage.value, historySize.value)
|
||||
if (current === historyRequest) { historyRows.value = result.records; historyTotal.value = result.total }
|
||||
} catch (cause) { if (current === historyRequest) { historyRows.value = []; historyError.value = getApiErrorDetails(cause, '历史加载失败') } }
|
||||
finally { if (current === historyRequest) historyLoading.value = false }
|
||||
}
|
||||
function updated(value: DriverDetail) {
|
||||
if (value.id !== detail.value?.id) return
|
||||
qualificationRequest++; qualificationLoading.value = false; detail.value = value
|
||||
qualificationStale.value = expectedEnd.value !== null
|
||||
if (historyOpen.value) void loadHistory()
|
||||
}
|
||||
async function review(decision: 'APPROVED' | 'REJECTED') {
|
||||
if (!detail.value || busy.value) return
|
||||
if (decision === 'REJECTED' && !reviewReason.value.trim()) { ElMessage.warning('请填写打回原因'); return }
|
||||
busy.value = true
|
||||
try {
|
||||
updated(await reviewDriver(detail.value.id, detail.value.recordVersion, decision, reviewReason.value.trim() || null))
|
||||
reviewReason.value = ''; ElMessage.success(decision === 'APPROVED' ? '司机证照审核通过' : '司机证照已打回')
|
||||
} catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '审核失败')) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (!detail.value || busy.value) return
|
||||
busy.value = true
|
||||
try { updated(await submitDriver(detail.value.id, detail.value.recordVersion)); ElMessage.success('已重新提交审核') }
|
||||
catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '提交失败')) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function checkQualification() {
|
||||
if (!detail.value || !expectedEnd.value || qualificationLoading.value) return
|
||||
const current = ++qualificationRequest, id = detail.value.id
|
||||
qualificationLoading.value = true; qualificationStale.value = true
|
||||
try {
|
||||
const result = await getDriverQualification(id, expectedEnd.value.toISOString())
|
||||
if (current === qualificationRequest && detail.value?.id === id) { detail.value.qualification = result; qualificationStale.value = false }
|
||||
} catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '资格校验失败')) }
|
||||
finally { if (current === qualificationRequest) qualificationLoading.value = false }
|
||||
}
|
||||
function invalidateQualification() {
|
||||
qualificationRequest++; qualificationLoading.value = false
|
||||
qualificationStale.value = true
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0], row = detail.value
|
||||
input.value = ''
|
||||
if (!file || !row || busy.value) return
|
||||
if (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || file.size === 0 || file.size > 10485760) { ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); return }
|
||||
busy.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm('上传后驾驶证需要重新审核,确认继续?', '上传驾驶证', { customClass: 'admin-density-confirm', confirmButtonText: '上传', cancelButtonText: '取消' })
|
||||
updated(await uploadDriverLicense(row.id, row.recordVersion, file)); ElMessage.success('驾驶证已上传,等待审核')
|
||||
} catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '上传失败')) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
function showLicense(fileId: string) { previewFile.value = fileId; previewOpen.value = true }
|
||||
watch(() => route.params.driverId, load, { immediate: true })
|
||||
watch([historyPage, historySize], () => void loadHistory())
|
||||
watch(historyOpen, value => { if (value) void loadHistory() })
|
||||
onBeforeUnmount(() => { request++; historyRequest++; qualificationRequest++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density">
|
||||
<DetailPageLayout class="density-detail-layout surface-panel" aria-label="司机详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-drivers' })"><ArrowLeft :size="16" />返回司机管理</el-button><h1 class="density-detail-title">{{ detail?.details.name || '司机详情' }}</h1><StatusBadge v-if="detail" :status="detail.active ? '启用' : '停用'" /></div><el-button v-if="detail && access.can(permission.driverEdit)" :disabled="busy" @click="editorOpen = true">编辑档案</el-button></div></template>
|
||||
<LoadErrorState v-if="error" title="司机详情加载失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading">
|
||||
<template v-if="detail">
|
||||
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
|
||||
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
|
||||
<FormSection class="density-form-section" title="证照与派车状态">
|
||||
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
|
||||
<p v-if="detail.qualification.expiringSoon" class="driver-note">驾驶证将在 30 天内到期;预计任务结束前仍有效时允许派车,请及时更新。</p>
|
||||
<div v-if="access.can(permission.driverReview) && detail.reviewStatus === 'PENDING'" class="driver-review">
|
||||
<el-input v-model="reviewReason" type="textarea" :rows="2" maxlength="1000" aria-label="审核意见,打回时必填" placeholder="审核意见(打回必填)" :disabled="busy" />
|
||||
<div class="driver-document-actions"><el-button type="success" :loading="busy" @click="review('APPROVED')">审核通过</el-button><el-button type="danger" :loading="busy" @click="review('REJECTED')">打回</el-button></div>
|
||||
</div>
|
||||
<el-button v-if="access.can(permission.driverEdit) && detail.reviewStatus === 'REJECTED'" :loading="busy" @click="submit">重新提交审核</el-button>
|
||||
<div class="driver-qualification"><label>司机预计任务结束时间({{ browserZone }})<el-date-picker v-model="expectedEnd" type="datetime" placeholder="包含返程或转场的结束时间" @change="invalidateQualification" /></label><el-button :loading="qualificationLoading" :disabled="!expectedEnd || busy" @click="checkQualification">校验本次任务资格</el-button><strong :class="{ 'driver-warning': qualificationStale || !detail.qualification.canDispatch }">{{ qualificationStale ? '预计时间或档案已变更,请重新校验' : driverQualificationLabels[detail.qualification.reason] }}</strong></div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="操作与审核历史" :bordered="false">
|
||||
<el-button v-if="!historyOpen" @click="historyOpen = true">查看操作历史</el-button>
|
||||
<template v-else><LoadErrorState v-if="historyError" title="历史加载失败" :message="historyError.message" @retry="loadHistory" ><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
|
||||
<template #historyActions="{ row }"><el-button v-if="row.snapshot.licenseFileId" link type="primary" @click="showLicense(row.snapshot.licenseFileId)">查看当时驾驶证</el-button><span v-else>—</span></template>
|
||||
<template #historyAction="{ row }">{{ actionLabels[row.action as DriverHistory['action']] }}</template>
|
||||
<template #historyReview="{ row }">{{ driverReviewLabels[row.snapshot.reviewStatus as keyof typeof driverReviewLabels] }}</template>
|
||||
</DataTable></template>
|
||||
</FormSection>
|
||||
</template>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
<DriverEditorDialog v-model="editorOpen" :driver="detail" @saved="updated" />
|
||||
<DriverLicensePreview v-model="previewOpen" :driver-id="detail?.id || ''" :file-id="previewFile" :driver-name="detail?.details.name || ''" />
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wide-field { grid-column: span 4; }
|
||||
.driver-document-actions, .driver-qualification { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.driver-review { display: grid; gap: var(--admin-space-5); margin: var(--admin-space-6) 0; max-width: 900px; }
|
||||
.driver-qualification { margin-top: var(--admin-space-6); }
|
||||
.driver-qualification label { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||
.driver-note { color: var(--el-color-warning); }
|
||||
.driver-warning { color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/drivers'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
import DriversView from './DriversView.vue'
|
||||
import DriverDetailView from './DriverDetailView.vue'
|
||||
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
|
||||
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
|
||||
|
||||
const security = vi.hoisted(() => ({ allowWrite: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || security.allowWrite }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), useRoute: () => ({ params: { driverId: '9223372036854775701' } }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '暂停' }) } }))
|
||||
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn(), getDriver: vi.fn(), listDriverHistory: vi.fn(), listDriverWarehouses: vi.fn(), createDriver: vi.fn(), updateDriver: vi.fn(), changeDriverStatus: vi.fn(), reviewDriver: vi.fn(), submitDriver: vi.fn(), getDriverQualification: vi.fn(), uploadDriverLicense: vi.fn(), downloadDriverLicense: vi.fn() }))
|
||||
|
||||
const driver: DriverDetail = {
|
||||
id: '9223372036854775701', driverNo: 'DR-TEST', details: { name: '测试司机', phone: '123', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: { warehouseId: '92', vehicleModels: '53尺拖车', languagesAndArea: '中文 / 洛杉矶', medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } },
|
||||
active: true, reviewStatus: 'PENDING', licenseFileId: '9223372036854775801', recordVersion: 2, updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: false, expiringSoon: true, reason: 'PENDING_REVIEW', licenseExpiresOn: '2026-10-22' }, licenseState: 'EXPIRING', warehouseCode: 'WH-US', warehouseName: '洛杉矶仓',
|
||||
}
|
||||
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
const button = { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm', 'update:modelValue'], template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="confirm" @click="$emit(\'confirm\')">保存</button></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', 'total'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.id || row.recordVersion"><span>{{row.driverNo}}</span><slot name="actions" :row="row"/><slot name="file" :row="row"/></div></div>' }
|
||||
const stubs = { AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true, RowActions: true, StatusBadge: true, LoadErrorState: true, AppDialog: dialog, DriverEditorDialog: true, DriverLicensePreview: true, ElButton: button, ElForm: layout, ElFormItem: layout, ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElOption: true, ElDatePicker: { ...input, name: 'ElDatePicker' } }
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(component: object, props: Record<string, unknown> = {}): VueWrapper {
|
||||
const wrapper = shallowMount(component, { props, global: { stubs: { ...stubs, DriverEditorDialog: component === DriverEditorDialog ? false : true, DriverLicensePreview: component === DriverLicensePreview ? false : true }, directives: { loading: () => undefined } } })
|
||||
wrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
function findButton(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(value => value.text().includes(text)); expect(found).toBeDefined(); return found! }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); security.allowWrite = true
|
||||
vi.mocked(api.listDrivers).mockResolvedValue({ records: [structuredClone(driver)], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } })
|
||||
vi.mocked(api.getDriver).mockResolvedValue(structuredClone(driver))
|
||||
vi.mocked(api.listDriverWarehouses).mockResolvedValue([{ id: '92', code: 'WH-US', name: '洛杉矶仓', region: 'OVERSEAS' }])
|
||||
vi.mocked(api.listDriverHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn().mockReturnValue('blob:test-license'), revokeObjectURL: vi.fn() }))
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.unstubAllGlobals() })
|
||||
|
||||
describe('driver page workflows', () => {
|
||||
it('loads one paged request with server summary and does not request each driver or file', async () => {
|
||||
const wrapper = mount(DriversView)
|
||||
await flushPromises()
|
||||
expect(api.listDrivers).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('司机总数24')
|
||||
expect(api.getDriver).not.toHaveBeenCalled()
|
||||
expect(api.downloadDriverLicense).not.toHaveBeenCalled()
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(api.listDrivers).toHaveBeenCalledTimes(2)
|
||||
expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
|
||||
await findButton(wrapper, '即将到期').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.listDrivers).toHaveBeenCalledTimes(3)
|
||||
expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, licenseState: 'EXPIRING' }))
|
||||
})
|
||||
|
||||
it('shows a retryable error instead of stale summary after list failure', async () => {
|
||||
vi.mocked(api.listDrivers).mockRejectedValueOnce(new Error('服务不可用'))
|
||||
const wrapper = mount(DriversView)
|
||||
await flushPromises()
|
||||
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
||||
expect(error.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('司机总数—')
|
||||
error.vm.$emit('retry'); await flushPromises()
|
||||
expect(api.listDrivers).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not expose write actions to a read-only user', async () => {
|
||||
security.allowWrite = false
|
||||
const list = mount(DriversView), detail = mount(DriverDetailView)
|
||||
await flushPromises()
|
||||
expect(list.text()).not.toContain('新增司机')
|
||||
expect(detail.text()).not.toContain('审核通过')
|
||||
expect(detail.text()).not.toContain('上传 / 替换驾驶证')
|
||||
expect(detail.text()).not.toContain('编辑档案')
|
||||
})
|
||||
|
||||
it('loads detail once, requests history only on demand, and requires a rejection reason', async () => {
|
||||
const wrapper = mount(DriverDetailView)
|
||||
await flushPromises()
|
||||
expect(api.getDriver).toHaveBeenCalledTimes(1)
|
||||
expect(api.listDriverHistory).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('洛杉矶仓')
|
||||
expect(wrapper.text()).not.toContain(driver.id)
|
||||
await findButton(wrapper, '打回').trigger('click')
|
||||
expect(api.reviewDriver).not.toHaveBeenCalled()
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith('请填写打回原因')
|
||||
await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
|
||||
expect(api.listDriverHistory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('submits approval with current version and uses returned state without reloading detail', async () => {
|
||||
vi.mocked(api.reviewDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' })
|
||||
const wrapper = mount(DriverDetailView)
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '审核通过').trigger('click'); await flushPromises()
|
||||
expect(api.reviewDriver).toHaveBeenCalledWith(driver.id, 2, 'APPROVED', null)
|
||||
expect(api.getDriver).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('button').some(value => value.text() === '审核通过')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the editor and version after a failed write', async () => {
|
||||
vi.mocked(api.updateDriver).mockRejectedValue(new ApiResponseError('版本已变化,请刷新', 'A0409', 200, null, 'req-driver-conflict'))
|
||||
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.objectContaining({ name: driver.details.name }))
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('版本已变化')
|
||||
})
|
||||
|
||||
it('invalidates an old task preview after the planned end changes without inventing a new server qualification', async () => {
|
||||
vi.mocked(api.getDriverQualification).mockResolvedValue({ ...driver.qualification, canDispatch: true, reason: 'ELIGIBLE' })
|
||||
const wrapper = mount(DriverDetailView)
|
||||
await flushPromises()
|
||||
const picker = wrapper.findComponent({ name: 'ElDatePicker' })
|
||||
picker.vm.$emit('update:modelValue', new Date('2026-10-20T12:00:00Z'))
|
||||
picker.vm.$emit('change'); await flushPromises()
|
||||
await findButton(wrapper, '校验本次任务资格').trigger('click'); await flushPromises()
|
||||
expect(api.getDriverQualification).toHaveBeenCalledWith(driver.id, '2026-10-20T12:00:00.000Z')
|
||||
expect(wrapper.text()).toContain('本次任务可派车')
|
||||
picker.vm.$emit('update:modelValue', new Date('2026-11-20T12:00:00Z'))
|
||||
picker.vm.$emit('change'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('本次任务可派车')
|
||||
expect(wrapper.text()).toContain('请重新校验')
|
||||
expect(api.getDriverQualification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not create a second draft when attachment upload fails after saving', async () => {
|
||||
vi.mocked(api.createDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 0 })
|
||||
vi.mocked(api.uploadDriverLicense).mockRejectedValueOnce(new Error('文件服务不可用')).mockResolvedValueOnce({ ...structuredClone(driver), recordVersion: 1 })
|
||||
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver: null })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
|
||||
inputs[0]!.vm.$emit('update:modelValue', '新司机')
|
||||
inputs[1]!.vm.$emit('update:modelValue', '123')
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects.at(-1)!.vm.$emit('update:modelValue', 'UTC')
|
||||
const file = new File(['%PDF-test'], 'license.pdf', { type: 'application/pdf' })
|
||||
const 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(api.createDriver).toHaveBeenCalledTimes(1)
|
||||
expect(api.uploadDriverLicense).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('档案已保存')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createDriver).toHaveBeenCalledTimes(1)
|
||||
expect(api.updateDriver).not.toHaveBeenCalled()
|
||||
expect(api.uploadDriverLicense).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('keeps an old save outside the newly opened driver session and uploads only that driver file', async () => {
|
||||
let finishOld!: (value: DriverDetail) => void
|
||||
vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
|
||||
const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
|
||||
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.anything())
|
||||
await wrapper.setProps({ modelValue: false }); await flushPromises()
|
||||
await wrapper.setProps({ driver: other, modelValue: true }); await flushPromises()
|
||||
const file = new File(['%PDF-other'], 'other-driver.pdf', { type: 'application/pdf' })
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
|
||||
await picker.trigger('change')
|
||||
finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
|
||||
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
||||
expect(wrapper.findAllComponents({ name: 'ElInput' })[0]!.props('modelValue')).toBe('另一司机')
|
||||
vi.mocked(api.updateDriver).mockResolvedValue({ ...other, recordVersion: 3 })
|
||||
vi.mocked(api.uploadDriverLicense).mockResolvedValue({ ...other, recordVersion: 4 })
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDriver).toHaveBeenLastCalledWith(other.id, 2, expect.objectContaining({ name: '另一司机' }))
|
||||
expect(api.uploadDriverLicense).toHaveBeenCalledExactlyOnceWith(other.id, 3, file)
|
||||
expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(expect.objectContaining({ id: other.id }))
|
||||
})
|
||||
|
||||
it('does not continue a pending save with an upload after the editor is unmounted', async () => {
|
||||
let finishOld!: (value: DriverDetail) => void
|
||||
vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
|
||||
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'driver.pdf', { type: 'application/pdf' })] })
|
||||
await picker.trigger('change')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
wrapper.unmount()
|
||||
finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
|
||||
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
|
||||
expect(ElMessage.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases preview blobs when closed and ignores a download completed after close', async () => {
|
||||
vi.mocked(api.downloadDriverLicense).mockResolvedValueOnce(new Blob(['%PDF'], { type: 'application/pdf' }))
|
||||
const wrapper = mount(DriverLicensePreview, { modelValue: false, driverId: driver.id, fileId: driver.licenseFileId, driverName: driver.details.name })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.find('iframe').exists()).toBe(true)
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-license')
|
||||
let complete!: (value: Blob) => void
|
||||
vi.mocked(api.downloadDriverLicense).mockReturnValueOnce(new Promise(resolve => { complete = resolve }))
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
complete(new Blob(['%PDF'], { type: 'application/pdf' })); await flushPromises()
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
|
||||
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { changeDriverStatus, getDriver, listDrivers, type DriverQuery } from '../api/drivers'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { driverLicenseLabels, driverReviewLabels, type DriverDetail, type DriverLicenseState, type DriverSummary } from '../driverTypes'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const rows = ref<DriverDetail[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), summary = ref<DriverSummary | null>(null)
|
||||
const filters = ref<QueryModel>({ keyword: '', region: '', ownership: '', active: '', reviewStatus: '', licenseState: '' })
|
||||
let applied: Omit<DriverQuery, 'page' | 'size'> = {}, request = 0
|
||||
const editorOpen = ref(false), editing = ref<DriverDetail | null>(null), actionId = ref('')
|
||||
const previewOpen = ref(false), preview = ref<DriverDetail | null>(null)
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '司机关键词', placeholder: '姓名 / 手机 / 司机编号', width: 220 },
|
||||
{ key: 'region', type: 'select', label: '区域', width: 120, options: [{ label: '国内', value: 'DOMESTIC' }, { label: '海外', value: 'OVERSEAS' }] },
|
||||
{ key: 'ownership', type: 'select', label: '归属', width: 140, options: [{ label: '自有司机', value: 'OWNED' }, { label: '外派司机', value: 'DISPATCHED' }] },
|
||||
{ key: 'active', type: 'select', label: '状态', width: 120, options: [{ label: '启用', value: 'true' }, { label: '停用', value: 'false' }] },
|
||||
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 140, options: Object.entries(driverReviewLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'licenseState', type: 'select', label: '驾照有效期', width: 150, options: Object.entries(driverLicenseLabels).map(([value, label]) => ({ value, label })) },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 205, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'driverNo', prop: 'driverNo', label: '司机编号', minWidth: 205 },
|
||||
{ key: 'name', prop: 'details.name', label: '姓名', minWidth: 115 },
|
||||
{ key: 'ownership', label: '归属', width: 100, slotName: 'ownership' },
|
||||
{ key: 'phone', prop: 'details.phone', label: '联系电话', minWidth: 140 },
|
||||
{ key: 'vehicleModels', label: '可驾驶车型', minWidth: 150, slotName: 'vehicleModels' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '所属仓库', minWidth: 160 },
|
||||
{ key: 'license', label: '驾照类型 / 编号', minWidth: 180, slotName: 'license' },
|
||||
{ key: 'expires', label: '驾照到期', minWidth: 140, slotName: 'expires' },
|
||||
{ key: 'medical', label: '体检到期', width: 125, slotName: 'medical' },
|
||||
{ key: 'otherDocs', label: '培训 / 保险到期', minWidth: 185, slotName: 'otherDocs' },
|
||||
{ key: 'review', label: '审核状态', width: 100, slotName: 'review' },
|
||||
{ key: 'file', label: '驾驶证附件', width: 110, slotName: 'file' },
|
||||
{ key: 'active', label: '当前状态', width: 100, slotName: 'active' },
|
||||
]
|
||||
async function load() {
|
||||
const current = ++request
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listDrivers({ ...applied, page: page.value, size: pageSize.value })
|
||||
if (current === request) { rows.value = result.records; total.value = result.total; summary.value = result.summary }
|
||||
} catch (cause) {
|
||||
if (current === request) { rows.value = []; total.value = 0; summary.value = null; error.value = getApiErrorDetails(cause, '司机列表加载失败') }
|
||||
} finally { if (current === request) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
applied = { keyword: String(filters.value.keyword || '').trim() || undefined,
|
||||
region: (filters.value.region || undefined) as DriverQuery['region'], ownership: (filters.value.ownership || undefined) as DriverQuery['ownership'],
|
||||
active: filters.value.active === 'true' ? true : filters.value.active === 'false' ? false : undefined,
|
||||
reviewStatus: (filters.value.reviewStatus || undefined) as DriverQuery['reviewStatus'], licenseState: (filters.value.licenseState || undefined) as DriverQuery['licenseState'] }
|
||||
if (page.value !== 1) page.value = 1
|
||||
else void load()
|
||||
}
|
||||
function filterExpiry(state: DriverLicenseState) { filters.value.licenseState = state; search() }
|
||||
function actions(row: DriverDetail): RowActionItem[] {
|
||||
return [{ key: 'detail', label: '详情', priority: 3 },
|
||||
{ key: 'edit', label: '编辑', visible: access.can(permission.driverEdit), disabled: !!actionId.value, priority: 2 },
|
||||
{ key: 'status', label: row.active ? '停用' : '启用', visible: access.can(permission.driverStatus), disabled: !!actionId.value, tone: row.active ? 'danger' : 'success' }]
|
||||
}
|
||||
async function action(key: string, row: DriverDetail) {
|
||||
if (key === 'detail') { await router.push({ name: 'overseas-master-data-driver-detail', params: { driverId: row.id } }); return }
|
||||
if (actionId.value) return
|
||||
actionId.value = row.id
|
||||
try {
|
||||
if (key === 'edit') { editing.value = await getDriver(row.id); editorOpen.value = true }
|
||||
else {
|
||||
const result = await ElMessageBox.prompt(`请填写${row.active ? '停用' : '启用'}司机 ${row.details.name} 的原因`, row.active ? '停用司机' : '启用司机', {
|
||||
customClass: 'admin-density-confirm', inputValidator: value => !!value?.trim() || '请填写原因', confirmButtonText: '确认', cancelButtonText: '取消', inputType: 'textarea', closeOnClickModal: false,
|
||||
})
|
||||
await changeDriverStatus(row.id, row.recordVersion, !row.active, result.value.trim())
|
||||
ElMessage.success(row.active ? '司机已停用' : '司机已启用'); await load()
|
||||
}
|
||||
} catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '司机操作失败')) }
|
||||
finally { actionId.value = '' }
|
||||
}
|
||||
function create() { editing.value = null; editorOpen.value = true }
|
||||
function showLicense(row: DriverDetail) { preview.value = row; previewOpen.value = true }
|
||||
function saved(row: DriverDetail) {
|
||||
const index = rows.value.findIndex(item => item.id === row.id)
|
||||
if (index >= 0) rows.value[index] = row
|
||||
}
|
||||
watch(editorOpen, (value, previous) => { if (!value && previous) void load() })
|
||||
watch([page, pageSize], () => void load())
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { request++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density">
|
||||
<ListPageLayout>
|
||||
<div class="driver-summary" aria-label="当前筛选范围司机统计">
|
||||
<section class="surface-panel"><span>司机总数</span><strong>{{ summary?.total ?? '—' }}</strong><small>自有 {{ summary?.owned ?? '—' }} · 外派 {{ summary?.dispatched ?? '—' }}</small></section>
|
||||
<button class="surface-panel" type="button" @click="filterExpiry('EXPIRING')"><span>即将到期</span><strong>{{ summary?.expiringSoon ?? '—' }}</strong><small>30 天内到期 · 点击筛选</small></button>
|
||||
<button class="surface-panel" type="button" @click="filterExpiry('EXPIRED')"><span>驾照已过期</span><strong>{{ summary?.expired ?? '—' }}</strong><small>不可派车 · 点击筛选</small></button>
|
||||
</div>
|
||||
<section class="surface-panel"><QueryToolbar v-model="filters" :fields="fields" ariaLabel="司机查询" preference-key="overseas-drivers-query" :loading="loading" @search="search" /></section>
|
||||
<LoadErrorState v-if="error" title="司机列表加载失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="id" empty-text="暂无符合条件的司机">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.driverCreate)" type="primary" @click="create"><Plus :size="16" />新增司机</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as DriverDetail)" text-actions @action="action($event, row as DriverDetail)" /></template>
|
||||
<template #ownership="{ row }">{{ row.details.ownership === 'OWNED' ? '自有司机' : '外派司机' }}</template>
|
||||
<template #vehicleModels="{ row }">{{ row.details.profile?.vehicleModels || '—' }}</template>
|
||||
<template #license="{ row }">{{ row.details.licenseType || '—' }} / {{ row.details.licenseNumber || '—' }}</template>
|
||||
<template #expires="{ row }"><span>{{ row.details.licenseExpiresOn || '—' }}</span><div :class="{ 'expiry-warning': row.licenseState === 'EXPIRING', 'expiry-danger': row.licenseState === 'EXPIRED' }">{{ driverLicenseLabels[row.licenseState as DriverLicenseState] }}</div></template>
|
||||
<template #medical="{ row }">{{ row.details.profile?.medicalExpiresOn || '—' }}</template>
|
||||
<template #otherDocs="{ row }">{{ row.details.profile?.trainingExpiresOn || '—' }} / {{ row.details.profile?.insuranceExpiresOn || '—' }}</template>
|
||||
<template #review="{ row }"><StatusBadge :status="driverReviewLabels[row.reviewStatus as keyof typeof driverReviewLabels]" /></template>
|
||||
<template #file="{ row }"><el-button v-if="row.licenseFileId" link type="primary" @click="showLicense(row as DriverDetail)">查看附件</el-button><span v-else>待上传</span></template>
|
||||
<template #active="{ row }"><StatusBadge :status="row.active ? '启用' : '停用'" /></template>
|
||||
<template #pagination-left>共 {{ total }} 位司机 · 统计随筛选变化</template>
|
||||
</DataTable>
|
||||
</ListPageLayout>
|
||||
<DriverEditorDialog v-model="editorOpen" :driver="editing" @saved="saved" />
|
||||
<DriverLicensePreview v-model="previewOpen" :driver-id="preview?.id || ''" :file-id="preview?.licenseFileId || ''" :driver-name="preview?.details.name || ''" />
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.driver-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.driver-summary > * { display: grid; gap: var(--admin-space-3); padding: var(--admin-space-6); text-align: left; color: inherit; font: inherit; }
|
||||
.driver-summary button { cursor: pointer; }
|
||||
.driver-summary strong { font-size: 24px; }
|
||||
.driver-summary small { color: var(--cc-muted); }
|
||||
.expiry-warning { color: var(--el-color-warning); }
|
||||
.expiry-danger { color: var(--el-color-danger); }
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ vi.mock('../api/overseasMasterData', () => ({
|
||||
getExchangeRateSummary: vi.fn().mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedBy: null, updatedAt: null }),
|
||||
getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
|
||||
listVehicles: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
|
||||
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
|
||||
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
|
||||
createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -37,5 +37,5 @@ describe('overseas master data real page mounts', () => {
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST'); expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(11); expect(wrapper.get('.density-fields').text()).toContain('在役'); expect(wrapper.text()).not.toContain('IN_SERVICE'); expect(wrapper.text()).toContain('当前车辆主数据接口明确不包含派送履约记录') })
|
||||
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST'); expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(14); expect(wrapper.get('.density-fields').text()).toContain('在役'); expect(wrapper.text()).not.toContain('IN_SERVICE'); expect(wrapper.text()).toContain('派送记录请在海外仓的排车管理或派送管理中查看') })
|
||||
})
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('overseas master data real contract pages', () => {
|
||||
for (const label of ['自有车辆', '外派车辆', '司机 / 外派单位']) expect(vehicles).toContain(label)
|
||||
expect(vehicles).toContain("key:'region'")
|
||||
expect(vehicles).toContain("placeholder:'全部区域'")
|
||||
expect(vehicles).toContain('class="status-tabs density-tabs"')
|
||||
expect(vehicles).toContain("'status-tab',{active:ownership===tab[1]}")
|
||||
expect(vehicles).toContain('class="status-tabs density-status-tabs"')
|
||||
expect(vehicles).toContain("'status-tab',{'is-active':ownership===tab[1]}")
|
||||
expect(vehicles).not.toContain('<el-segmented')
|
||||
expect(vehicles).not.toContain('aria-label="车辆区域"')
|
||||
})
|
||||
@@ -76,7 +76,8 @@ describe('overseas master data real contract pages', () => {
|
||||
expect(detail).toContain('派送订单明细')
|
||||
expect(detail).toContain('派送更换记录')
|
||||
expect(detail).toContain('getVehicle(')
|
||||
expect(detail).toContain('明确不包含派送履约记录')
|
||||
expect(detail).toContain('派送记录请在海外仓的排车管理或派送管理中查看')
|
||||
expect(detail).toContain('车辆更换记录尚未接入')
|
||||
expect(detail).not.toContain('AppDrawer')
|
||||
expect(detail).not.toContain('const vehicle =')
|
||||
})
|
||||
@@ -85,7 +86,10 @@ describe('overseas master data real contract pages', () => {
|
||||
const matrix = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
|
||||
for (const id of ['gen-partner-warehouse', 'gen-overseas-quote', 'gen-exchange-rate', 'vehicle-detail']) expect(matrix).toContain(id)
|
||||
for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
|
||||
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
|
||||
expect(matrix).toContain('旧版原型未定义的车辆文件预览、审核/确认流程不自行补充')
|
||||
expect(matrix).toContain('新版车辆保养及加油接入矩阵')
|
||||
expect(matrix).toContain('modal-vehicle-ops-record')
|
||||
expect(matrix).toContain('target="_blank"')
|
||||
})
|
||||
|
||||
it('records the real service boundary and remaining unsupported operations', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const longId = '9223372036854775807'
|
||||
const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null }
|
||||
const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' }
|
||||
const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
|
||||
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
|
||||
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
|
||||
const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
|
||||
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/></div>' }
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
|
||||
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
|
||||
import TripBookingPanel from '../components/TripBookingPanel.vue'
|
||||
import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan,
|
||||
getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { driverQualificationLabels } from '../driverTypes'
|
||||
import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading, type TripHistory, type TripCompletionInspection,
|
||||
type TripCapacityPlanning, type TripComplete, type TripResourceRelease, type ReleasedVehicleStatus } from '../tripTypes'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
|
||||
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
|
||||
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
|
||||
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
|
||||
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('')
|
||||
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
|
||||
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
|
||||
let generation = 0, inspectionSequence = 0, historySequence = 0
|
||||
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
|
||||
const editable = computed(() => planned.value && access.can(permission.tripEdit))
|
||||
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
const bindingsColumns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 210 }, { key: 'packageCount', prop: 'packageCount', label: '计划箱数', width: 120 },
|
||||
{ key: 'review', label: '调度审核', slotName: 'review', minWidth: 210 },
|
||||
{ key: 'boundAt', label: '关联时间', slotName: 'boundAt', minWidth: 180 }]
|
||||
const outcomeColumns: DataTableColumn[] = [{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 220 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 180 }, { key: 'status', label: '状态', slotName: 'status', width: 115 },
|
||||
{ key: 'packages', prop: 'packages', label: '箱数', width: 110 }, { key: 'terminalAt', label: '完成时间', slotName: 'terminalAt', minWidth: 180 }]
|
||||
const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED: '已退回', RETURN_PENDING: '待退回', DELIVERING: '派送中', WAIT_DELIVERY: '待派送', CANCELLED: '已取消' }
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
|
||||
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
|
||||
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
|
||||
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
|
||||
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
|
||||
function accept(value: TripDetail) {
|
||||
if (value.trip.status === 'COMPLETED' && detail.value?.trip.status !== 'COMPLETED') actualEndAt.value = new Date().toISOString()
|
||||
detail.value = value; operationError.value = null; endLocation.value ||= value.trip.stops.at(-1)?.name || ''
|
||||
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
|
||||
}
|
||||
async function load() {
|
||||
const current = ++generation; inspectionSequence++; historySequence++
|
||||
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = 'overview'
|
||||
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
|
||||
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
|
||||
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
|
||||
if (busy.value) return false
|
||||
const current = generation; busy.value = action; operationError.value = null
|
||||
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
|
||||
catch (cause) {
|
||||
if (current === generation) {
|
||||
operationError.value = getApiErrorDetails(cause, '操作未完成')
|
||||
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { completionRequest.value = null; releaseRequest.value = null }
|
||||
}
|
||||
return false
|
||||
} finally { if (current === generation) busy.value = '' }
|
||||
}
|
||||
async function refreshLinked() {
|
||||
const number = detail.value?.trip.tripNo
|
||||
if (number) await run('linked', () => getTrip(number))
|
||||
}
|
||||
async function bind() {
|
||||
const trip = detail.value?.trip, number = dispatchNo.value.trim()
|
||||
if (!trip || !number) { ElMessage.warning('请输入排车单号'); return }
|
||||
if (await run('bind', () => bindTripDispatch(trip.tripNo, { recordVersion: trip.recordVersion, dispatchNo: number, reason: '关联本车发运货物' }))) dispatchNo.value = ''
|
||||
}
|
||||
async function unbind(number: string) {
|
||||
const trip = detail.value?.trip, current = generation
|
||||
if (!trip) return
|
||||
try { await ElMessageBox.confirm(`解除排车单 ${number} 与当前车次的关联?已开始拣货的单据需要先处理现场作业。`, '解除关联', { customClass: 'admin-density-confirm' }) }
|
||||
catch { return }
|
||||
if (current === generation) await run('unbind', () => unbindTripDispatch(trip.tripNo, { recordVersion: trip.recordVersion, dispatchNo: number, reason: '解除当前车次关联' }))
|
||||
}
|
||||
async function matchStops() {
|
||||
const trip = detail.value?.trip, current = generation
|
||||
if (!trip) return
|
||||
await run('stops', async () => {
|
||||
const loading = await getTripLoading(trip.tripNo)
|
||||
if (current !== generation) throw new Error('已离开当前车次')
|
||||
const destinations = new Map(loading.cargo.filter(row => row.destinationKey).map(row => [row.destinationKey!, { name: row.destinationLabel || '卸货点', destinationKey: row.destinationKey }]))
|
||||
if (!destinations.size) throw new Error('请先关联有明确目的地的排车单')
|
||||
return updateTripPlan(trip.tripNo, { recordVersion: loading.recordVersion, plannedDepartureAt: trip.plannedDepartureAt,
|
||||
expectedDriverEndAt: trip.expectedDriverEndAt, remark: trip.remark, stops: [trip.stops[0]!, ...destinations.values()], reason: '按关联货物匹配卸货站' })
|
||||
})
|
||||
}
|
||||
async function cancel() {
|
||||
const trip = detail.value?.trip, current = generation
|
||||
if (!trip) return
|
||||
try { await ElMessageBox.confirm(`取消车次 ${trip.tripNo} 并释放车辆、司机?`, '取消车次', { customClass: 'admin-density-confirm' }) }
|
||||
catch { return }
|
||||
if (current === generation) await run('cancel', () => cancelTrip(trip.tripNo, { recordVersion: trip.recordVersion, reason: '取消未执行的车次计划' }))
|
||||
}
|
||||
function capacityPlanned(value: TripCapacityPlanning) {
|
||||
if (detail.value?.trip.tripNo === value.tripNo) detail.value.trip.recordVersion = value.recordVersion
|
||||
}
|
||||
function loaded(value: TripLoading) {
|
||||
if (detail.value?.trip.tripNo !== value.tripNo) return
|
||||
detail.value.trip.recordVersion = value.recordVersion
|
||||
detail.value.loadingReview = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
|
||||
}
|
||||
async function review(decision: 'APPROVED' | 'REJECTED', number?: string) {
|
||||
const trip = detail.value?.trip, current = generation
|
||||
if (!trip || busy.value) return
|
||||
let reason: string | null = null
|
||||
if (decision === 'REJECTED') {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('请简要说明需要调整的内容', number ? `打回调度审核 · ${number}` : '打回理货审核', {
|
||||
customClass: 'admin-density-confirm', inputPlaceholder: '填写打回原因', inputValidator: value => !!value?.trim() || '请填写打回原因',
|
||||
})
|
||||
reason = result.value.trim()
|
||||
} catch { return }
|
||||
}
|
||||
if (current !== generation) return
|
||||
const body = { recordVersion: trip.recordVersion, decision, reason }
|
||||
await run('review', () => number ? reviewTripDispatch(trip.tripNo, number, body) : reviewTripLoading(trip.tripNo, body))
|
||||
}
|
||||
async function inspect() {
|
||||
const trip = detail.value?.trip
|
||||
if (!trip || trip.status !== 'IN_TRANSIT') return
|
||||
const current = ++inspectionSequence; inspectionLoading.value = true; operationError.value = null
|
||||
try { const result = await getTripCompletion(trip.tripNo); if (current === inspectionSequence) inspection.value = result }
|
||||
catch (cause) { if (current === inspectionSequence) operationError.value = getApiErrorDetails(cause, '结案检查失败') }
|
||||
finally { if (current === inspectionSequence) inspectionLoading.value = false }
|
||||
}
|
||||
async function complete() {
|
||||
const trip = detail.value?.trip, current = generation
|
||||
if (!trip || !inspection.value?.canComplete) return
|
||||
try { await ElMessageBox.confirm('确认本车货物均已签收或完成异常处置?货物结案后,还需分别结束车辆和司机任务。', '货物结案', { customClass: 'admin-density-confirm' }) }
|
||||
catch { return }
|
||||
if (current !== generation) return
|
||||
completionRequest.value ||= { recordVersion: inspection.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: '核对全部货物终态后结案' }
|
||||
if (await run('complete', () => completeTrip(trip.tripNo, completionRequest.value!))) { completionRequest.value = null; inspection.value = null }
|
||||
}
|
||||
async function release() {
|
||||
const trip = detail.value?.trip, current = generation
|
||||
if (!trip) return
|
||||
if (!actualEndAt.value || !endLocation.value.trim()) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
|
||||
try { await ElMessageBox.confirm(`确认${resourceKind.value === 'VEHICLE' ? '车辆' : '司机'}已结束本次任务,可以安排下一车次?`, '结束资源任务', { customClass: 'admin-density-confirm' }) }
|
||||
catch { return }
|
||||
if (current !== generation) return
|
||||
releaseRequest.value ||= { recordVersion: trip.recordVersion, resourceKind: resourceKind.value, actualEndAt: new Date(actualEndAt.value).toISOString(),
|
||||
endLocation: endLocation.value.trim(), vehicleStatus: resourceKind.value === 'VEHICLE' ? vehicleStatus.value : null, idempotencyKey: crypto.randomUUID(), reason: '确认资源实际任务结束' }
|
||||
if (await run('release', () => releaseTripResource(trip.tripNo, releaseRequest.value!))) { releaseRequest.value = null; resourceKind.value = detail.value?.trip.vehicleRelease ? 'DRIVER' : 'VEHICLE' }
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!detail.value) return
|
||||
const current = ++historySequence; historyLoading.value = true; operationError.value = null
|
||||
try { const result = await listTripHistory(detail.value.trip.tripNo, historyPage.value, 20); if (current === historySequence) { history.value = result.records; historyTotal.value = result.total } }
|
||||
catch (cause) { if (current === historySequence) operationError.value = getApiErrorDetails(cause, '历史读取失败') }
|
||||
finally { if (current === historySequence) historyLoading.value = false }
|
||||
}
|
||||
watch(() => route.params.tripNo, load, { immediate: true })
|
||||
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
|
||||
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
|
||||
<div v-loading="loading"><template v-if="detail">
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
|
||||
</FormSection>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><el-input v-model="dispatchNo" placeholder="输入待关联的排车单号" :disabled="!!busy" @keyup.enter="bind" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,关联排车单并匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</template>
|
||||
<FormSection v-if="activeTab === 'bookings'" class="density-form-section" title="本车外接预订"><TripBookingPanel :key="detail.trip.tripNo" :trip="detail.trip" @changed="detail.trip.recordVersion = $event" @linked="refreshLinked" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
|
||||
<template v-if="activeTab === 'completion'">
|
||||
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
|
||||
<el-form v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" label-position="top" class="density-form release-form" :disabled="!!busy || !!releaseRequest" @submit.prevent="release">
|
||||
<el-form-item label="结束任务的资源"><el-select v-model="resourceKind"><el-option label="车辆" value="VEHICLE" :disabled="!!detail.trip.vehicleRelease" /><el-option label="司机" value="DRIVER" :disabled="!!detail.trip.driverRelease" /></el-select></el-form-item>
|
||||
<el-form-item label="实际任务结束时间"><el-date-picker v-model="actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点"><el-input v-model="endLocation" placeholder="请输入实际任务结束地点" /></el-form-item>
|
||||
<el-form-item v-if="resourceKind === 'VEHICLE'" label="车辆结束状态"><el-select v-model="vehicleStatus"><el-option label="在役可调度" value="IN_SERVICE" /><el-option label="维修" value="MAINTENANCE" /><el-option label="停用" value="INACTIVE" /><el-option label="已归还" value="RETURNED" /></el-select></el-form-item>
|
||||
</el-form><el-button v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" type="primary" :loading="busy === 'release'" :disabled="!!busy" @click="release">{{ releaseRequest ? '重试原结束请求' : '确认结束资源任务' }}</el-button>
|
||||
</FormSection>
|
||||
</template>
|
||||
<FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></FormSection>
|
||||
<TripPlanDialog v-model="editorOpen" :trip="detail.trip" @saved="accept" />
|
||||
</template></div>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
<style scoped>.trip-inline{display:flex;gap:var(--admin-space-4);align-items:center}.trip-inline :deep(.el-input){max-width:340px}.release-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6);margin-top:var(--admin-space-6)}.release-form :deep(.el-select),.release-form :deep(.el-date-editor){width:100%}</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import TripsView from './TripsView.vue'
|
||||
import TripDetailView from './TripDetailView.vue'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
|
||||
import * as api from '../api/trips'
|
||||
import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
|
||||
|
||||
const state = vi.hoisted(() => ({ writable: true, push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' } }), useRouter: () => ({ push: state.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || state.writable }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '请核对预约时间' }) } }))
|
||||
vi.mock('../api/drivers', () => ({ listDriverWarehouses: vi.fn().mockResolvedValue([{ id: '92', name: '海外仓', code: 'WH-US' }]) }))
|
||||
vi.mock('../api/trips', () => ({ listTrips: vi.fn(), getTrip: vi.fn(), createTrip: vi.fn(), updateTripPlan: vi.fn(), assignTrip: vi.fn(),
|
||||
bindTripDispatch: vi.fn(), unbindTripDispatch: vi.fn(), cancelTrip: vi.fn(), getTripLoading: vi.fn(), confirmTripLoading: vi.fn(),
|
||||
getTripCompletion: vi.fn(), completeTrip: vi.fn(), releaseTripResource: vi.fn(), listTripHistory: vi.fn(), reviewTripDispatch: vi.fn(), reviewTripLoading: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn() }))
|
||||
const trip: FleetTrip = { tripNo: 'TR-TEST', status: 'PLANNED', warehouseId: '92', warehouseName: '海外仓', warehouseCode: 'WH-US', vehicleId: '71',
|
||||
vehiclePlate: 'TEST-TRUCK', driverId: '81', driverName: '测试司机', driverPhone: '123', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
|
||||
plannedDepartureAt: '2026-10-03T00:00:00Z', expectedDriverEndAt: '2026-10-04T00:00:00Z', departedAt: null, completedAt: null,
|
||||
stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, recordVersion: 3, updatedAt: '2026-10-02T00:00:00Z', vehicleRelease: null, driverRelease: null }
|
||||
const reviewState = { status: 'PENDING' as const, reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
|
||||
const detail: TripDetail = { trip, driverQualification: null, dispatches: [], loadingReview: reviewState }
|
||||
const layout = { 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 dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存计划</button></div>' }
|
||||
const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="(row,index) in data" :key="index"><slot name="actions" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' }
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount(component: object, props: Record<string, unknown> = {}): VueWrapper {
|
||||
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
|
||||
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true,
|
||||
ApiErrorMeta: true, LoadErrorState: true, AppDialog: dialog, TripPlanDialog: component === TripPlanDialog ? false : true,
|
||||
TripAssignmentPanel: true, TripLoadingPanel: component === TripLoadingPanel ? false : true,
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }, ElForm: layout, ElFormItem: layout,
|
||||
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,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); state.writable = true
|
||||
vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
|
||||
vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
})
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
|
||||
it('loads one trip page and opens a real detail route without fetching each row', async () => {
|
||||
const wrapper = mount(TripsView); await flushPromises()
|
||||
expect(api.listTrips).toHaveBeenCalledTimes(1); expect(api.getTrip).not.toHaveBeenCalled()
|
||||
await button(wrapper, '详情').trigger('click')
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(api.listTrips).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
|
||||
})
|
||||
|
||||
it('keeps read-only trip detail free of write actions and reads history only when opened', async () => {
|
||||
state.writable = false
|
||||
const wrapper = mount(TripDetailView); await flushPromises()
|
||||
expect(api.getTrip).toHaveBeenCalledTimes(1); expect(api.listTripHistory).not.toHaveBeenCalled(); expect(api.getTripLoading).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('编辑计划'); expect(wrapper.findComponent({ name: 'TripAssignmentPanel' }).exists()).toBe(false)
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
||||
expect(api.listTripHistory).toHaveBeenCalledExactlyOnceWith('TR-TEST', 1, 20)
|
||||
})
|
||||
|
||||
it('keeps the exact create command for an uncertain retry and accepts optional notes', async () => {
|
||||
vi.mocked(api.createTrip).mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(structuredClone(detail))
|
||||
const wrapper = mount(TripPlanDialog, { modelValue: false, trip: null })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '92'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.createTrip).mock.calls[0]![0]
|
||||
expect(first.idempotencyKey).toBeTruthy(); expect(first.remark).toBeNull()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createTrip).toHaveBeenNthCalledWith(2, first)
|
||||
expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(detail)
|
||||
})
|
||||
|
||||
it('counts a shared physical pallet once and submits only operator-entered measurements', async () => {
|
||||
const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
|
||||
const loading: TripLoading = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo, { ...cargo, dispatchNo: 'DP-B', customerId: 'B', dispatchItemId: '101' }], manifestHash: 'a'.repeat(64), confirmedLoad: null, blockReason: null }
|
||||
vi.mocked(api.getTripLoading).mockResolvedValue(loading)
|
||||
vi.mocked(api.confirmTripLoading).mockResolvedValue({ ...loading, recordVersion: 4 })
|
||||
const wrapper = mount(TripLoadingPanel, { trip, editable: true }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toHaveLength(1)
|
||||
await button(wrapper, '确认实际装载').trigger('click'); expect(api.confirmTripLoading).not.toHaveBeenCalled(); expect(ElMessage.warning).toHaveBeenCalled()
|
||||
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
|
||||
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
|
||||
await button(wrapper, '确认实际装载').trigger('click'); await flushPromises()
|
||||
expect(api.confirmTripLoading).toHaveBeenCalledExactlyOnceWith('TR-TEST', expect.objectContaining({ recordVersion: 3, manifestHash: loading.manifestHash,
|
||||
pallets: [{ warehousePalletId: '901', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] }))
|
||||
})
|
||||
|
||||
it('submits a specific dispatch review with optional approval notes and a reason when rejecting', async () => {
|
||||
const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
|
||||
vi.mocked(api.getTrip).mockResolvedValue(pending)
|
||||
vi.mocked(api.reviewTripDispatch).mockResolvedValue({ ...pending, trip: { ...trip, recordVersion: 4 } })
|
||||
const wrapper = mount(TripDetailView); await flushPromises()
|
||||
await button(wrapper, '审核通过').trigger('click'); await flushPromises()
|
||||
expect(api.reviewTripDispatch).toHaveBeenCalledWith('TR-TEST', 'DP-A', { recordVersion: 3, decision: 'APPROVED', reason: null })
|
||||
await button(wrapper, '打回').trigger('click'); await flushPromises()
|
||||
expect(ElMessageBox.prompt).toHaveBeenCalled()
|
||||
expect(api.reviewTripDispatch).toHaveBeenLastCalledWith('TR-TEST', 'DP-A', { recordVersion: 4, decision: 'REJECTED', reason: '请核对预约时间' })
|
||||
})
|
||||
|
||||
it('clears displayed loading approval after new actual load and uses the returned version for review', async () => {
|
||||
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), loadingReview: { ...reviewState, status: 'APPROVED' } })
|
||||
vi.mocked(api.reviewTripLoading).mockResolvedValue(structuredClone(detail))
|
||||
const wrapper = mount(TripDetailView); await flushPromises()
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'loading'); await flushPromises()
|
||||
expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeDefined()
|
||||
wrapper.findComponent({ name: 'TripLoadingPanel' }).vm.$emit('saved', { tripNo: trip.tripNo, recordVersion: 7 }); await flushPromises()
|
||||
expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '理货审核通过').trigger('click'); await flushPromises()
|
||||
expect(api.reviewTripLoading).toHaveBeenCalledWith('TR-TEST', { recordVersion: 7, decision: 'APPROVED', reason: null })
|
||||
})
|
||||
|
||||
it('lets an operator plan capacity before picking without confirming actual loading', async () => {
|
||||
const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
|
||||
const basis = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo], manifestHash: 'a'.repeat(64), capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
|
||||
vi.mocked(api.getTripCapacityPlan).mockResolvedValue(basis)
|
||||
vi.mocked(api.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 4, planValid: true })
|
||||
const wrapper = mount(TripLoadingPanel, { trip, editable: true, planning: true }); await flushPromises()
|
||||
expect(api.getTripLoading).not.toHaveBeenCalled()
|
||||
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
|
||||
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
|
||||
await button(wrapper, '确认计划容量').trigger('click'); await flushPromises()
|
||||
expect(api.confirmTripCapacityPlan).toHaveBeenCalledOnce(); expect(api.confirmTripLoading).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('planned')?.[0]?.[0]).toMatchObject({ recordVersion: 4 })
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refreshes trip members and loading review after external dispatch binding without leaving the current tab', async () => {
|
||||
const wrapper = mount(TripDetailView); await flushPromises()
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'bookings'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'TripBookingPanel' }).vm.$emit('linked'); await flushPromises()
|
||||
expect(api.getTrip).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings')
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTrips, type TripQuery } from '../api/trips'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词', placeholder: '车次号 / 车牌 / 司机', width: 250 },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', prop: 'tripNo', label: '车次号', minWidth: 215 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', prop: 'driverName', label: '司机', minWidth: 110 },
|
||||
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
|
||||
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try { const result = await listTrips({ ...applied, page: page.value, size: size.value }); if (current === sequence) { rows.value = result.records; total.value = result.total } }
|
||||
catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '车次加载失败') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, status: (filters.value.status || undefined) as TripQuery['status'] }
|
||||
if (page.value !== 1) page.value = 1; else void load()
|
||||
}
|
||||
function view(tripNo: string) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo } }) }
|
||||
function created(detail: TripDetail) { view(detail.trip.tripNo) }
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout>
|
||||
<LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" @search="search" @reset="search" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
|
||||
<template #actions="{ row }"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></template>
|
||||
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
|
||||
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
|
||||
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>
|
||||
</DataTable>
|
||||
<TripPlanDialog v-model="editorOpen" :trip="null" @saved="created" />
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import VehicleCareTable from '../components/VehicleCareTable.vue'
|
||||
import type { VehicleCareType } from '../vehicleCareTypes'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { vehicleStatusLabels } from '../presentation'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
@@ -8,8 +10,12 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'; import FormSection from '@/shared/components/FormSection.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { getVehicle } from '../api/overseasMasterData'; import type { VehicleDetail } from '../types'
|
||||
const route=useRoute(),router=useRouter(),detail=ref<VehicleDetail|null>(null),loading=ref(false),error=ref<ApiErrorDetails|null>(null)
|
||||
async function load(){loading.value=true;error.value=null;try{detail.value=await getVehicle(String(route.params.vehicleId||''))}catch(e){detail.value=null;error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{loading.value=false}}
|
||||
function back(){void router.push({name:'overseas-master-data-vehicles'})} onMounted(load)
|
||||
const activeTab = ref('overview')
|
||||
let request = 0
|
||||
async function load(){const current=++request;loading.value=true;error.value=null;detail.value=null;try{const result=await getVehicle(String(route.params.vehicleId||''));if(current===request)detail.value=result}catch(e){if(current===request)error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{if(current===request)loading.value=false}}
|
||||
function back(){void router.push({name:'overseas-master-data-vehicles'})}
|
||||
watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true })
|
||||
onBeforeUnmount(() => { request++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density" ><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><p class="contract-note">当前车辆主数据接口明确不包含派送履约记录。</p></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><p class="contract-note">当前车辆主数据接口明确不包含车辆更换历史。</p></FormSection></section></DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.detail-grid .wide-field { grid-column: span 2; }.contract-note{margin:0;padding:var(--admin-space-6);color:var(--cc-muted);text-align:center}</style>
|
||||
<template><AdminPage class="admin-density" ><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><p class="contract-note">派送记录请在海外仓的排车管理或派送管理中查看。</p></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><p class="contract-note">车辆更换记录尚未接入。</p></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.care-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-6)}.detail-grid .wide-field { grid-column: span 2; }.contract-note{margin:0;padding:var(--admin-space-6);color:var(--cc-muted);text-align:center}</style>
|
||||
|
||||
@@ -12,23 +12,23 @@ import { masterDataErrorMessage } from '../apiError'
|
||||
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
|
||||
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
|
||||
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:Object.entries(vehicleStatusLabels).map(([value,label])=>({label,value}))}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
|
||||
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0})
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'maxPalletSpaces',prop:'maxPalletSpaces',label:'最大托位',minWidth:100,align:'right'},{key:'maxLoadKg',prop:'maxLoadKg',label:'最大载重(kg)',minWidth:130,align:'right'},{key:'maxVolumeCbm',prop:'maxVolumeCbm',label:'可用容积(m³)',minWidth:130,align:'right'},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
|
||||
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0,maxPalletSpaces:null as number|null,maxLoadKg:null as number|null,maxVolumeCbm:null as number|null})
|
||||
function params(){return{page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,ownership:ownership.value,region:(filters.value.region||undefined) as VehicleRegion|undefined,status:(filters.value.status||undefined) as VehicleStatus|undefined}}
|
||||
async function load(){loading.value=true;error.value=null;try{const r=await listVehicles(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'车辆加载失败')}finally{loading.value=false}}
|
||||
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;page.value=1;void load()}
|
||||
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0})}
|
||||
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;search()}
|
||||
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0,maxPalletSpaces:null,maxLoadKg:null,maxVolumeCbm:null})}
|
||||
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
|
||||
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
|
||||
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}const capacity=[form.maxPalletSpaces,form.maxLoadKg,form.maxVolumeCbm];if(capacity.some(v=>v!=null)&&capacity.some(v=>v==null||v<=0)){ElMessage.warning('托位、载重和容积须同时填写正数,或全部留空');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null,maxPalletSpaces:form.maxPalletSpaces??null,maxLoadKg:form.maxLoadKg??null,maxVolumeCbm:form.maxVolumeCbm??null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
|
||||
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
|
||||
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
|
||||
<template #toolbar-start><h1>车辆列表</h1></template>
|
||||
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{active:ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
|
||||
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><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-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
||||
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}</style>
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(1080px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><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-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="最大标准托位"><el-input-number v-model="form.maxPalletSpaces" :min="1" :max="10000" :precision="0" placeholder="请输入实际托位" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="form.maxLoadKg" :min="0.001" :precision="3" placeholder="请输入核定载重" /></el-form-item><el-form-item label="可用容积(m³)"><el-input-number v-model="form.maxVolumeCbm" :min="0.001" :precision="3" placeholder="请输入实际容积" /></el-form-item><p class="capacity-hint">三项容量须同时确认;未知时可全部留空,确认后才能安排出车。</p><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
||||
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}.capacity-hint{grid-column:1/-1;margin:0;color:var(--cc-muted)}</style>
|
||||
|
||||
Reference in New Issue
Block a user