From 207c872dffba29736de5847fcff5055c918e97a6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 12:33:33 +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 --- api/pda-loading.js | 57 +++++++++++++++++++++++++++ pages/picking/picking.vue | 34 ++++++++++++++-- tests/pda-loading-api.test.mjs | 11 ++++++ tests/picking-evidence-stage.test.mjs | 8 +++- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/api/pda-loading.js b/api/pda-loading.js index 36c76a3..8b0a96f 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -251,6 +251,63 @@ export function getEvidence(inboundId, tenantId) { }) } +export function getEvidencePreviewUrl(inboundId, evidenceId, tenantId) { + if (!inboundId || !evidenceId) return '' + return `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/content/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}` +} + +export function downloadEvidencePreview(previewUrl) { + requireOnline('当前网络不可用,无法查看装柜照片') + if (!previewUrl) throw new Error('装柜照片地址尚未同步') + const baseUrl = session.apiBase || '/api/platform/v1' + let url = String(previewUrl) + if (!/^https?:\/\//i.test(url) && /^https?:\/\//i.test(baseUrl)) { + const origin = baseUrl.match(/^(https?:\/\/[^/]+)/i)?.[1] || '' + url = `${origin}${url.startsWith('/') ? '' : '/'}${url}` + } + const headers = {} + if (session.token) headers.Authorization = `Bearer ${session.token}` + if (session.requestId) headers['X-Request-Id'] = session.requestId + debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url }) + return new Promise((resolve, reject) => { + uni.downloadFile({ + url, + header: headers, + success(response) { + const responseHeaders = response.header || {} + const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || '' + const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || '' + const traceId = responseHeaders['X-Trace-Id'] || responseHeaders['x-trace-id'] || '' + debugLog('evidence.preview.response', { previewPath: String(previewUrl), statusCode: response.statusCode, contentType, hasTempFilePath: Boolean(response.tempFilePath), requestId, traceId }) + if (response.statusCode < 200 || response.statusCode >= 300 || !response.tempFilePath) { + const error = new Error('装柜照片下载失败') + error.statusCode = response.statusCode + error.requestId = requestId + error.traceId = traceId + reject(error) + return + } + if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) { + const error = new Error('服务端未返回有效照片') + error.statusCode = response.statusCode + error.contentType = contentType + error.requestId = requestId + error.traceId = traceId + reject(error) + return + } + resolve(response.tempFilePath) + }, + fail(error) { + debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.errMsg || '' }) + const next = new Error(error.errMsg || '装柜照片下载失败') + next.network = true + reject(next) + } + }) + }) +} + export function upsertEvidence(inboundId, payload, tenantId) { if (!EVIDENCE_TYPES.includes(payload.evidenceType)) throw new Error('不支持的装柜证据类型') return request({ diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index ff63bfb..de25308 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -344,7 +344,7 @@ import { navigateBackOr } from '@/utils/navigation.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' - import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingInventorySnapshot, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' + import { claimContainerPickingTask, completeLoading, completePicking, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' import { isOnline } from '@/utils/network.js' import { stableOperationId } from '@/utils/idempotency.js' @@ -795,7 +795,7 @@ this.pickScannedBox(result.item, value) }, evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) }, - evidencePreviewSource(photo) { return photo?.localPath || photo?.previewUrl || photo?.fileUrl || photo?.downloadUrl || photo?.url || '' }, + evidencePreviewSource(photo) { return photo?.localPath || photo?.fileUrl || photo?.downloadUrl || photo?.url || '' }, evidencePreviewCacheKey(fileId, identity = this.evidenceIdentity) { return `${this.evidenceDraftKey(identity)}|${String(fileId || '')}` }, cacheEvidencePreview(fileId, localPath, identity = this.evidenceIdentity) { if (!fileId || !localPath) return @@ -927,16 +927,44 @@ try { const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId) if (!this.isEvidenceContextActive(contextId, identity)) return + const remoteItems = this.evidenceItems(evidence).map((item) => ({ + ...item, + previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.inboundId, item.id, this.currentBox.tenantId) + })) + this.debugEvidence('list.loaded', { evidenceIdentity: identity, count: remoteItems.length, previewCount: remoteItems.filter((item) => item.previewUrl).length }) const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath)])) - this.evidenceFiles = this.evidenceItems(evidence).map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles) + this.evidenceFiles = remoteItems.map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles) const seal = this.evidenceEntry('SEAL') if (seal?.sealNo) this.sealNo = seal.sealNo + await this.loadEvidencePreviews(contextId, identity) + if (!this.isEvidenceContextActive(contextId, identity)) return await this.flushPendingEvidence() } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败') } }, + async loadEvidencePreviews(contextId, identity) { + const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type) + .filter((item) => !this.evidencePreviewSource(item) && item.previewUrl) + .map((item) => ({ type: slot.type, id: item.id, fileId: item.fileId, previewUrl: item.previewUrl }))) + const loaded = await Promise.all(pending.map(async (item) => { + try { + const localPath = await downloadEvidencePreview(item.previewUrl) + this.debugEvidence('preview.downloaded', { evidenceType: item.type, fileId: item.fileId, hasLocalPath: Boolean(localPath) }) + return { ...item, localPath } + } catch (error) { + this.debugEvidence('preview.download-error', { evidenceType: item.type, fileId: item.fileId, message: error?.message || '', statusCode: error?.statusCode || '', contentType: error?.contentType || '', requestId: error?.requestId || '', traceId: error?.traceId || '' }) + return null + } + })) + if (!this.isEvidenceContextActive(contextId, identity)) return + loaded.filter(Boolean).forEach((item) => this.cacheEvidencePreview(item.fileId, item.localPath, identity)) + this.evidenceFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, entries.map((entry) => { + const preview = loaded.find((item) => item && item.type === type && item.id === entry.id && item.fileId === entry.fileId) + return preview ? { ...entry, localPath: preview.localPath } : entry + })])) + }, async refreshTask() { if (!this.currentBox.boxNo) return this.persistSession() diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index d15b7bb..19493df 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -67,6 +67,17 @@ test('PDA evidence binding unwraps the nested loading action response before ren assert.match(page, /cacheEvidencePreview\(current\.fileId, current\.localPath/) }) +test('PDA downloads authenticated evidence previews returned by the backend', () => { + assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/) + assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/) + assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/) + assert.match(source, /uni\.downloadFile\(/) + assert.match(source, /headers\.Authorization = `Bearer \$\{session\.token\}`/) + assert.match(source, /response\.tempFilePath/) + assert.match(source, /evidence\.preview\.response/) + assert.match(source, /\^image\\\//) +}) + test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { assert.match(source, /\/first-leg\/pda-loading\/tasks\/pending/) assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index 0ea7fd6..e2d5526 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -74,10 +74,14 @@ test('refresh preserves local photos for the same evidence identity and flushes test('evidence photos render thumbnails and support full-screen preview', () => { assert.match(page, /class="evidence-thumbnail" :src="evidencePreviewSource\(photo\)"/) assert.match(page, /@tap="previewEvidencePhoto\(photo, slot\.type\)"/) - assert.match(page, /evidencePreviewSource\(photo\)[\s\S]*?localPath[\s\S]*?previewUrl[\s\S]*?fileUrl/) + assert.match(page, /evidencePreviewSource\(photo\)[\s\S]*?localPath[\s\S]*?fileUrl/) assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/) assert.match(page, /cacheEvidencePreview\(fileId, localPath/) assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/) + assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.inboundId, item\.id, this\.currentBox\.tenantId\)/) + assert.match(page, /['"]list\.loaded['"]/) + assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/) + assert.match(page, /['"]preview\.downloaded['"]/) assert.match(page, /已上传并保存/) }) @@ -90,7 +94,7 @@ test('switching cabinets invalidates old sync and evidence upload state', () => assert.match(selector, /this\.resetEvidenceState\(\)/) assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/) const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.equal((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length, 2) + assert.ok((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2) }) test('camera callbacks cannot append evidence after switching cabinets', () => {