Merge origin/release/test and combine transport photo workflows

This commit is contained in:
2026-10-27 17:45:43 +08:00
73 changed files with 1914 additions and 541 deletions
@@ -44,3 +44,5 @@
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
- 车辆详情与司机详情通过 `ResourcePhotoPanel.vue` 维护常用照片,接口 `api/resourcePhotos.ts`/`fleet-resource-photos-v1.yaml`;车辆沿vehicle:edit、司机沿driver:upload,读取沿各自list。照片版本独立于档案字段版本,替换保留历史且不改变驾驶证审核;排车缺照片时由服务端复制为当趟证据。只在详情加载元数据、点击预览读取图片,不逐行请求。
@@ -1,5 +1,9 @@
# 海外通用资料原型核对矩阵
## 档案照片复用(2026-10-26)
依据用户确认优化重复上传:车辆概览和司机详情增加常用照片维护,沿既有详情分区与620px居中预览;元数据按详情加载,原图点击才读。车辆编辑权限与司机上传权限分别控制替换。排车选档案后自动带入并保存当趟副本;沿用Admin详情、提示及表单规范。自动行为验证与类型检查单独报告,1920×1080、100%截图待人工确认。
## 散板需求只读展示(2026-10-21)
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
@@ -367,3 +371,9 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
## 装车资料多图(2026-10-26)
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
const license: OutsourcedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
await expect(getOutsourcedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
})
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
@@ -44,7 +44,7 @@ export async function saveOutsourcedBusinessOperations(no: string, body: Outsour
}
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
@@ -68,7 +68,7 @@ export async function getOutsourcedBusinessPhotoContent(no: string, photo: Outso
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/owned-business'
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('owned business HTTP contract', () => {
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
const license: OwnedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(getOwnedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
await expect(getOwnedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await expect(getOwnedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
})
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
@@ -38,7 +38,7 @@ export async function saveOwnedBusinessOperations(no: string, body: OwnedBusines
return value
}
export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) {
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OwnedBusinessPhoto>, ApiEnvelope<OwnedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
@@ -60,7 +60,7 @@ export async function getOwnedBusinessPhotoContent(no: string, photo: OwnedBusin
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -0,0 +1,15 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto } from './resourcePhotos'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
it('preserves long identifiers and versions and submits the correct resource scope', async () => {
const id = '9223372036854775701', fileId = '9223372036854775702', version = '9007199254740993'
const wire = { code: '200', data: { recordVersion: version, photo: { fileId, fileName: 'photo.png', contentType: 'image/png', sizeBytes: '3' } } }
vi.mocked(http.get).mockResolvedValue(wire); vi.mocked(http.post).mockResolvedValue(wire)
expect(await getResourcePhoto('VEHICLE', id)).toMatchObject({ recordVersion: version, photo: { fileId, sizeBytes: 3 } })
await uploadResourcePhoto('VEHICLE', id, version, new File(['png'], 'photo.png', { type: 'image/png' }))
expect(http.post).toHaveBeenCalledWith(`/platform/v1/last-leg/fleet/resource-photos/VEHICLE/${id}`, expect.any(FormData))
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('recordVersion')).toBe(version)
vi.mocked(http.get).mockResolvedValue(new Blob(['{"code":"A0403"}'], { type: 'application/json' }))
await expect(downloadResourcePhoto('VEHICLE', id, fileId)).rejects.toThrow('读取失败')
})
@@ -0,0 +1,22 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ResourcePhotoKind = 'VEHICLE' | 'DRIVER'
export interface ResourcePhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface ResourcePhotoDetail { recordVersion: string; photo: ResourcePhoto | null }
const path = (kind: ResourcePhotoKind, id: string) => `/platform/v1/last-leg/fleet/resource-photos/${kind}/${encodeURIComponent(id)}`
type Wire = { recordVersion: string | number; photo: (Omit<ResourcePhoto, 'sizeBytes'> & { sizeBytes: number | string }) | null }
function normalize(value: Wire): ResourcePhotoDetail {
return { recordVersion: String(value.recordVersion), photo: value.photo ? { ...value.photo, sizeBytes: Number(value.photo.sizeBytes) } : null }
}
export async function getResourcePhoto(kind: ResourcePhotoKind, id: string) {
return normalize(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id))))
}
export async function uploadResourcePhoto(kind: ResourcePhotoKind, id: string, version: string, file: File) {
const body = new FormData(); body.append('recordVersion', version); body.append('file', file)
return normalize(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id), body)))
}
export async function downloadResourcePhoto(kind: ResourcePhotoKind, id: string, fileId: string) {
const blob = await http.get<Blob, Blob>(`${path(kind, id)}/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size || blob.size > 10 * 1024 * 1024) throw new Error('档案照片读取失败,请重新核对')
return blob
}
@@ -24,7 +24,7 @@ describe('OutsourcedBusiness presentation boundaries', () => {
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
@@ -37,12 +37,15 @@ describe('OutsourcedBusiness presentation boundaries', () => {
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(done).toHaveBeenCalledTimes(1)
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
@@ -19,7 +19,7 @@ function close(done: () => void) {
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
<slot name="state" />
<div class="outsourced-business-photo-grid">
@@ -57,8 +57,21 @@ describe('outsourced business per-slot photo save',()=>{
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
await wrapper.findAll('button').find(button=>button.text()==='查看资料')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
})
})
it('renders all saved images and replaces only the chosen file', async () => {
const detail=outsourcedBusinessFixture()
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
await flushPromises()
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
const input=wrapper.findAll('input[type="file"]')[1]!
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
await input.trigger('change');await flushPromises()
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
wrapper.unmount()
})
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
const uploadable = computed(() => access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
let actor = '', number = '', generation = 0
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function savedPhotos(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
@@ -30,25 +30,26 @@ watch(open, value => {
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OutsourcedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
async function preview(slot: OutsourcedBusinessPhotoSlot, fileId: string) {
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
reading.value = fileId; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
busy.value = true; error.value = null
try {
ensureScope()
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getOutsourcedBusiness(number); ensureScope()
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
emit('saved', result); ElMessage.success('照片已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
finally { busy.value = false }
@@ -60,10 +61,14 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
<a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
<label v-if="uploadable" class="outsourced-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
</div>
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
@@ -24,7 +24,7 @@ describe('OwnedBusiness presentation boundaries', () => {
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证', '起始油耗表盘', '结束油耗表盘'])
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字', '起始油耗表盘', '结束油耗表盘'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
@@ -37,12 +37,15 @@ describe('OwnedBusiness presentation boundaries', () => {
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(done).toHaveBeenCalledTimes(1)
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
@@ -10,13 +10,13 @@ const props = defineProps<{
}>()
const open = defineModel<boolean>({ default: false })
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
// These are prototype presentation slots; the public file categories are not yet frozen.
// 展示槽位沿用既有引用,标签与排车装车资料一致。
const loadingPhotos = [
{ slot: 'loading-scene', label: '装车现场' },
{ slot: 'loading-list', label: '装车清单' },
{ slot: 'pre-departure-check', label: '出车前检查' },
{ slot: 'driver-license', label: '驾驶证附件' },
{ slot: 'delivery-receipt', label: '签收凭证' },
{ slot: 'loading-scene', label: '司机驾驶证' },
{ slot: 'loading-list', label: '车牌照片' },
{ slot: 'pre-departure-check', label: '装车完成' },
{ slot: 'driver-license', label: '封车照片' },
{ slot: 'delivery-receipt', label: 'BOL司机签字' },
]
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
function close(done: () => void) {
@@ -28,7 +28,7 @@ function close(done: () => void) {
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,油耗每类1张;支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
<slot name="state" />
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
@@ -50,3 +50,16 @@ describe('owned business per-slot photo save',()=>{
wrapper.unmount()
})
})
it('renders all saved images and replaces only the chosen file', async () => {
const detail=ownedBusinessFixture()
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
await flushPromises()
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
const input=wrapper.findAll('input[type="file"]')[1]!
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
await input.trigger('change');await flushPromises()
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
wrapper.unmount()
})
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
const uploadable = computed(() => access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
let actor = '', number = '', generation = 0
function savedPhoto(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function savedPhotos(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
@@ -30,25 +30,26 @@ watch(open, value => {
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OwnedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
async function preview(slot: OwnedBusinessPhotoSlot, fileId: string) {
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
reading.value = fileId; error.value = null
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
busy.value = true; error.value = null
try {
ensureScope()
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getOwnedBusiness(number); ensureScope()
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
emit('saved', result); ElMessage.success('照片已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
finally { busy.value = false }
@@ -60,10 +61,14 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
<OwnedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
<a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
<label v-if="uploadable" class="owned-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
</div>
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template>
</OwnedBusinessPhotosDialog>
</template>
@@ -0,0 +1,54 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import ResourcePhotoPanel from './ResourcePhotoPanel.vue'
import * as api from '../api/resourcePhotos'
const security = vi.hoisted(() => ({ writable: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => security.writable }) }))
vi.mock('../api/resourcePhotos', () => ({ getResourcePhoto: vi.fn(), uploadResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() }))
const photo = { fileId: '81', fileName: 'driver.png', contentType: 'image/png', sizeBytes: 3 }
const options = { props: { kind: 'DRIVER' as const, resourceId: '7' }, global: { directives: { loading: () => undefined }, stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
} } }
beforeEach(() => {
vi.resetAllMocks(); security.writable = true
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo })
URL.createObjectURL = vi.fn().mockReturnValue('blob:photo'); URL.revokeObjectURL = vi.fn()
})
it('keeps reading independent from upload permission and downloads only on preview', async () => {
security.writable = false
vi.mocked(api.downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
expect(wrapper.text()).not.toContain('上传 / 替换')
expect(api.downloadResourcePhoto).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click'); await flushPromises()
expect(api.downloadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '81')
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo')
})
it('submits the independent photo version and releases busy state after failure', async () => {
vi.mocked(api.uploadResourcePhoto).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409'))
.mockResolvedValueOnce({ recordVersion: '3', photo })
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
const input = wrapper.get('input[type="file"]'), file = new File(['png'], 'driver.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change'); await flushPromises()
expect(api.uploadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '2', file)
expect(wrapper.text()).toContain('版本冲突')
await input.trigger('change'); await flushPromises()
expect(api.uploadResourcePhoto).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('does not apply an upload result to a different resource', async () => {
let finish!: (value: api.ResourcePhotoDetail) => void
vi.mocked(api.uploadResourcePhoto).mockImplementation(() => new Promise(resolve => { finish = resolve }))
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['png'], 'driver.png', { type: 'image/png' })] })
await input.trigger('change')
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
await wrapper.setProps({ resourceId: '8' }); await flushPromises()
finish({ recordVersion: '3', photo }); await flushPromises()
expect(wrapper.text()).toContain('尚未上传司机照片')
wrapper.unmount()
})
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind, type ResourcePhotoDetail } from '../api/resourcePhotos'
import { overseasMasterDataPermissions as permission } from '../permissions'
const props = defineProps<{ kind: ResourcePhotoKind; resourceId: string }>()
const access = useAccessPreviewStore(), detail = ref<ResourcePhotoDetail | null>(null), loading = ref(false), busy = ref(false)
const error = ref<ApiErrorDetails | null>(null), input = ref<HTMLInputElement | null>(null), previewOpen = ref(false), url = ref('')
const label = computed(() => props.kind === 'VEHICLE' ? '车辆图片' : '司机照片')
const editable = computed(() => access.can(props.kind === 'VEHICLE' ? permission.vehicleEdit : permission.driverUpload))
let generation = 0
function closePreview() { previewOpen.value = false; if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
async function load() {
const current = ++generation; closePreview(); detail.value = null; loading.value = true; error.value = null; busy.value = false
try { const result = await getResourcePhoto(props.kind, props.resourceId); if (current === generation) detail.value = result }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '档案照片加载失败') }
finally { if (current === generation) loading.value = false }
}
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
if (!file || !detail.value || busy.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择非空 JPG 或 PNG,最大 10MiB'); return }
const current = generation; busy.value = true; error.value = null
try {
const saved = await uploadResourcePhoto(props.kind, props.resourceId, detail.value.recordVersion, file)
if (current !== generation) return
detail.value = saved; closePreview(); ElMessage.success(`${label.value}已保存,新排车会自动带入`)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片保存未完成,请重新读取后重试') }
finally { if (current === generation) busy.value = false }
}
async function preview() {
if (!detail.value?.photo || busy.value) return
const current = generation; busy.value = true; error.value = null
try {
const blob = await downloadResourcePhoto(props.kind, props.resourceId, detail.value.photo.fileId)
if (current !== generation) return
closePreview(); url.value = URL.createObjectURL(blob); previewOpen.value = true
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片预览失败') }
finally { if (current === generation) busy.value = false }
}
watch(() => [props.kind, props.resourceId], load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview() })
</script>
<template>
<section v-loading="loading" :aria-label="label">
<LoadErrorState v-if="error" title="档案照片操作未完成" :message="error.message" compact @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div class="density-actions">
<el-button v-if="detail?.photo" :disabled="busy" @click="preview">查看{{ label }}</el-button><span v-else-if="detail">尚未上传{{ label }}</span>
<el-button v-if="editable" :disabled="!detail || busy" :loading="busy" @click="input?.click()">上传 / 替换{{ label }}</el-button>
<input ref="input" hidden type="file" accept="image/jpeg,image/png" :aria-label="'上传' + label" @change="upload">
</div>
<p class="density-dialog-note">排车选择此档案后自动带入照片;替换不影响已保存的排车资料。JPG、PNG,最大 10MiB。</p>
<AppDialog :model-value="previewOpen" class="admin-density-dialog" :title="label" width="620px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><img v-if="url" class="resource-photo-preview" :src="url" :alt="label"></AppDialog>
</section>
</template>
<style scoped>.resource-photo-preview { display: block; width: 100%; max-height: 60vh; object-fit: contain; }</style>
@@ -18,3 +18,6 @@ export { normalizeTripDetail } from './api/trips'
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
export { getResourcePhoto, downloadResourcePhoto } from './api/resourcePhotos'
export type { ResourcePhotoKind } from './api/resourcePhotos'
@@ -5,7 +5,7 @@ export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PR
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
@@ -21,16 +21,16 @@ export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: n
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' },
]
export function outsourcedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
@@ -5,7 +5,7 @@ export type OwnedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEP
export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] }
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OwnedBusinessRow {
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
@@ -22,16 +22,16 @@ export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] }
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
]
export function ownedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
@@ -153,6 +154,7 @@ onBeforeUnmount(() => { detailActive = false; resetDetailRequests() })
<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="司机照片"><ResourcePhotoPanel kind="DRIVER" :resource-id="detail.id" /></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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onBeforeUnmount, ref, watch } from 'vue'
@@ -19,5 +20,5 @@ 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 admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" 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="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></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>
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" 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 v-if="detail" class="density-form-section" title="车辆图片"><ResourcePhotoPanel kind="VEHICLE" :resource-id="detail.id" /></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></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; }</style>