优化
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 本土卡车字段调整核对
|
||||
|
||||
## 2026-11-03 运输照片回显
|
||||
|
||||
| 页面 | 承载与依据 | 验收 |
|
||||
|---|---|---|
|
||||
| 美本土散板、本土卡车详情 | 按本轮用户截图及文字要求,在订单信息下显示取货照片、派送照片;缩略图横向换行、点击放大,不显示图片名称,不提供客户上传或删除操作 | 定向测试及类型检查通过;待人工截图确认 |
|
||||
|
||||
## 2026-11-02 用户截图问题修复
|
||||
|
||||
| 页面 | 操作及承载 | 截图依据与调整 | 验收 |
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in New Issue
Block a user