From e433f5086b08891354ceefd2af00235350d0ce7d Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 3 Nov 2026 16:52:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/cross-border/PROTOTYPE_MATRIX.md | 6 ++ .../cross-border/api/businessOrders.ts | 12 +++- .../components/BusinessOrdersView.vue | 4 ++ .../components/TransportPhotoGallery.vue | 56 +++++++++++++++++ .../cross-border/transportPhotos.test.mjs | 60 +++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/modules/cross-border/components/TransportPhotoGallery.vue create mode 100644 src/modules/cross-border/transportPhotos.test.mjs diff --git a/src/modules/cross-border/PROTOTYPE_MATRIX.md b/src/modules/cross-border/PROTOTYPE_MATRIX.md index 42d8ecc..015c786 100644 --- a/src/modules/cross-border/PROTOTYPE_MATRIX.md +++ b/src/modules/cross-border/PROTOTYPE_MATRIX.md @@ -1,5 +1,11 @@ # 本土卡车字段调整核对 +## 2026-11-03 运输照片回显 + +| 页面 | 承载与依据 | 验收 | +|---|---|---| +| 美本土散板、本土卡车详情 | 按本轮用户截图及文字要求,在订单信息下显示取货照片、派送照片;缩略图横向换行、点击放大,不显示图片名称,不提供客户上传或删除操作 | 定向测试及类型检查通过;待人工截图确认 | + ## 2026-11-02 用户截图问题修复 | 页面 | 操作及承载 | 截图依据与调整 | 验收 | diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index 8c6240c..d848f18 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -1,4 +1,4 @@ -import { apiRequest } from '@/api/http' +import { apiRequest, apiBlobRequest } from '@/api/http' export interface PageResult { records: T[] @@ -150,6 +150,16 @@ export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: stri return apiRequest({ 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({ 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 export function createTransport(kind: 'looseboard', payload: TransportCreate): Promise export function createTransport(kind: CrossBorderBusinessKind, payload: TransportCreate | TruckCreate) { diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index 3092bfa..f40d267 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -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([]) const detail = ref(null) +const photoRefresh = ref(0) const fleetArrangement = computed(() => detail.value ? (detail.value as TransportDetail).summary.fleetArrangement : null) const formRef = ref() const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }) @@ -284,6 +286,7 @@ async function loadDetail() { error.value = '' try { detail.value = await getBusinessOrder(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
{{ field[0] }}
{{ text(field[1]) }}
+

承运安排

diff --git a/src/modules/cross-border/components/TransportPhotoGallery.vue b/src/modules/cross-border/components/TransportPhotoGallery.vue new file mode 100644 index 0000000..90f7f83 --- /dev/null +++ b/src/modules/cross-border/components/TransportPhotoGallery.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/modules/cross-border/transportPhotos.test.mjs b/src/modules/cross-border/transportPhotos.test.mjs new file mode 100644 index 0000000..de5d3a7 --- /dev/null +++ b/src/modules/cross-border/transportPhotos.test.mjs @@ -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/) +})