This commit is contained in:
2026-11-03 16:52:02 +08:00
parent 95fb17c5aa
commit e433f5086b
5 changed files with 137 additions and 1 deletions
@@ -1,5 +1,11 @@
# 本土卡车字段调整核对
## 2026-11-03 运输照片回显
| 页面 | 承载与依据 | 验收 |
|---|---|---|
| 美本土散板、本土卡车详情 | 按本轮用户截图及文字要求,在订单信息下显示取货照片、派送照片;缩略图横向换行、点击放大,不显示图片名称,不提供客户上传或删除操作 | 定向测试及类型检查通过;待人工截图确认 |
## 2026-11-02 用户截图问题修复
| 页面 | 操作及承载 | 截图依据与调整 | 验收 |
+11 -1
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '@/api/http'
import { apiRequest, apiBlobRequest } from '@/api/http'
export interface PageResult<T> {
records: T[]
@@ -150,6 +150,16 @@ export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: stri
return apiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
}
export interface TransportPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface TransportPhotos { pickupPhotos: TransportPhoto[]; arrivalPhotos: TransportPhoto[] }
export type TransportPhotoPhase = 'PICKUP' | 'ARRIVAL'
export function getTransportPhotos(kind: CrossBorderBusinessKind, orderNo: string) {
return apiRequest<TransportPhotos>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}/transport-photos` })
}
export function readTransportPhoto(kind: CrossBorderBusinessKind, orderNo: string, phase: TransportPhotoPhase, fileId: string) {
return apiBlobRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}/transport-photos/${phase}/${encodeURIComponent(fileId)}/content` })
}
export function createTransport(kind: 'trucking', payload: TruckCreate): Promise<TransportDetail>
export function createTransport(kind: 'looseboard', payload: TransportCreate): Promise<TransportDetail>
export function createTransport(kind: CrossBorderBusinessKind, payload: TransportCreate | TruckCreate) {
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import TransportDemandForm from './TransportDemandForm.vue'
import OrderFundingStatus from './OrderFundingStatus.vue'
import TransportPhotoGallery from './TransportPhotoGallery.vue'
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
@@ -83,6 +84,7 @@ const submitKey = ref('')
const error = ref('')
const rows = ref<BusinessRow[]>([])
const detail = ref<BusinessDetail | null>(null)
const photoRefresh = ref(0)
const fleetArrangement = computed(() => detail.value ? (detail.value as TransportDetail).summary.fleetArrangement : null)
const formRef = ref<FormInstance>()
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' })
@@ -284,6 +286,7 @@ async function loadDetail() {
error.value = ''
try {
detail.value = await getBusinessOrder<TransportDetail>(props.kind, detailOrderNo.value)
photoRefresh.value++
mode.value = 'detail'
} catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) }
finally { loading.value = false }
@@ -355,6 +358,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
</dl>
</section>
<TransportPhotoGallery v-if="!error && detail" :kind="kind" :order-no="detail.summary.orderNo" :refresh-key="photoRefresh" />
<section v-if="!error && fleetArrangement" class="business-detail-section" aria-label="承运安排">
<h3>承运安排</h3>
<dl class="detail-grid arrangement-grid">
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { getTransportPhotos, readTransportPhoto, type CrossBorderBusinessKind, type TransportPhotos, type TransportPhotoPhase } from '../api/businessOrders'
const props = defineProps<{ kind: CrossBorderBusinessKind; orderNo: string; refreshKey: number }>()
const photos = ref<TransportPhotos | null>(null), urls = ref<Record<string, string>>({}), loading = ref(false), error = ref('')
const groups: { phase: TransportPhotoPhase; key: keyof TransportPhotos; label: string }[] = [
{ phase: 'PICKUP', key: 'pickupPhotos', label: '取货照片' }, { phase: 'ARRIVAL', key: 'arrivalPhotos', label: '派送照片' },
]
const previewUrls = computed(() => groups.flatMap(group => (photos.value?.[group.key] || []).filter(file => file.contentType.startsWith('image/')).map(file => urls.value[`${group.phase}:${file.fileId}`]).filter((url): url is string => Boolean(url))))
let sequence = 0
function clear() { Object.values(urls.value).forEach(url => URL.revokeObjectURL(url)); urls.value = {} }
async function load() {
const current = ++sequence, kind = props.kind, number = props.orderNo
loading.value = true; error.value = ''; photos.value = null; clear()
try {
const value = await getTransportPhotos(kind, number)
if (current !== sequence) return
photos.value = value
await Promise.all(groups.flatMap(group => value[group.key].map(async file => {
try {
const blob = await readTransportPhoto(kind, number, group.phase, file.fileId)
if (current === sequence) urls.value[`${group.phase}:${file.fileId}`] = URL.createObjectURL(blob)
} catch { if (current === sequence) error.value = '部分照片加载失败,请重试' }
})))
} catch (cause) { if (current === sequence) error.value = cause instanceof Error ? cause.message : '运输照片加载失败' }
finally { if (current === sequence) loading.value = false }
}
watch(() => [props.kind, props.orderNo, props.refreshKey], () => void load(), { immediate: true })
onBeforeUnmount(() => { sequence++; clear() })
</script>
<template>
<section class="transport-photos" aria-label="运输照片" v-loading="loading">
<div v-if="error" class="photo-error" role="alert">{{ error }} <el-button link type="primary" :disabled="loading" @click="load">重试</el-button></div>
<section v-for="group in groups" :key="group.phase" class="photo-group">
<h3>{{ group.label }}</h3>
<div class="photo-row">
<template v-for="file in photos?.[group.key] || []" :key="file.fileId">
<el-image v-if="file.contentType.startsWith('image/')" :src="urls[group.phase + ':' + file.fileId]" :alt="group.label" fit="contain" :preview-src-list="previewUrls" :initial-index="previewUrls.indexOf(urls[group.phase + ':' + file.fileId] || '')" preview-teleported><template #error><span>{{ loading ? '加载中' : '未加载' }}</span></template></el-image>
<a v-else :href="urls[group.phase + ':' + file.fileId]" target="_blank" rel="noopener">查看凭证</a>
</template>
<span v-if="!loading && !photos?.[group.key].length" class="photo-empty">—</span>
</div>
</section>
</section>
</template>
<style scoped>
.transport-photos { margin-top: 20px; }
.photo-group + .photo-group { margin-top: 16px; }
.photo-group h3 { margin: 0 0 10px; color: var(--navy); font-size: 15px; }
.photo-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.photo-row :deep(.el-image) { width: 112px; height: 84px; border: 1px solid var(--border); border-radius: 4px; }
.photo-row :deep(.el-image__error) { font-size: 12px; }
.photo-empty { color: var(--muted); }
.photo-error { margin-bottom: 10px; color: var(--el-color-danger); }
</style>
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript } from 'vue/compiler-sfc'
const source = readFileSync(new URL('./components/TransportPhotoGallery.vue', import.meta.url), 'utf8')
const { descriptor } = parse(source)
const code = ts.transpileModule(compileScript(descriptor, { id: 'transport-photos' }).content, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
function harness(kind, overrides = {}) {
const props = vue.reactive({ kind, orderNo: 'ORDER/1', refreshKey: 0 }), calls = [], disposers = [], created = [], revoked = []
const photos = { pickupPhotos: [{ fileId: '71', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 5 }], arrivalPhotos: [{ fileId: '72', fileName: 'arrival.png', contentType: 'image/png', sizeBytes: 6 }] }
const api = { getTransportPhotos: async (...args) => { calls.push(['list', ...args]); return photos }, readTransportPhoto: async (...args) => { calls.push(['file', ...args]); return {} }, ...overrides }
const context = { exports: {}, Error, URL: { createObjectURL: () => { const url = `blob:${created.length}`; created.push(url); return url }, revokeObjectURL: url => revoked.push(url) }, require(name) {
if (name === 'vue') return { ...vue, onBeforeUnmount: callback => disposers.push(callback) }
if (name === '../api/businessOrders') return api
throw Error(name)
} }
runInNewContext(code, context)
const state = context.exports.default.setup(props, { expose() {} })
return { state, props, calls, created, revoked, photos, dispose: () => disposers.forEach(fn => fn()) }
}
for (const kind of ['looseboard', 'trucking']) test(`${kind} loads both photo nodes and refreshes with URL cleanup`, async () => {
const h = harness(kind); await flush()
assert.deepEqual(h.calls, [['list', kind, 'ORDER/1'], ['file', kind, 'ORDER/1', 'PICKUP', '71'], ['file', kind, 'ORDER/1', 'ARRIVAL', '72']])
assert.equal(h.state.previewUrls.value.length, 2)
h.props.refreshKey++; await flush()
assert.equal(h.revoked.length, 2)
assert.equal(h.state.previewUrls.value.length, 2)
h.dispose(); assert.equal(h.revoked.length, 4)
})
test('failed photo reads preserve the saved lists and allow retry', async () => {
let failed = true
const h = harness('looseboard', { readTransportPhoto: async () => { if (failed) throw Error('offline'); return {} } }); await flush()
assert.match(h.state.error.value, /加载失败/); assert.equal(h.state.photos.value.pickupPhotos.length, 1)
failed = false; await h.state.load(); await flush()
assert.equal(h.state.error.value, ''); assert.equal(h.state.previewUrls.value.length, 2); h.dispose()
})
test('responses after unmount do not allocate preview URLs', async () => {
let finish
const response = new Promise(resolve => { finish = resolve })
const h = harness('trucking', { readTransportPhoto: () => response }); await flush()
h.dispose(); finish({}); await flush(); assert.equal(h.created.length, 0)
})
test('merchant photo API uses order and phase routes with authenticated blob requests', async () => {
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
const compiled = ts.transpileModule(api, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const calls = [], context = { exports: {}, require: () => ({ apiRequest: request => calls.push(request), apiBlobRequest: request => calls.push(request) }) }
runInNewContext(compiled, context)
for (const [kind, route] of [['looseboard', 'local-looseboard-orders'], ['trucking', 'us-truck-orders']]) {
context.exports.getTransportPhotos(kind, 'ORDER/1')
assert.equal(calls.at(-1).url, `/merchant/v1/last-leg/${route}/ORDER%2F1/transport-photos`)
context.exports.readTransportPhoto(kind, 'ORDER/1', 'ARRIVAL', '72')
assert.equal(calls.at(-1).url, `/merchant/v1/last-leg/${route}/ORDER%2F1/transport-photos/ARRIVAL/72/content`)
}
assert.match(source, /preview-teleported/)
assert.match(source, /flex-wrap: wrap/)
})