This commit is contained in:
2026-08-27 14:30:26 +08:00
parent c704032d66
commit a5a803046b
11 changed files with 178 additions and 66 deletions
+4 -6
View File
@@ -1,6 +1,9 @@
import { session } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
import { apiDownload, apiRequest, apiUpload } from '@/api/http.js'
import { resolveEvidencePreviewUrl } from '@/utils/api-url.js'
export { resolveEvidencePreviewUrl } from '@/utils/api-url.js'
const EVIDENCE_TYPES = Object.freeze([
'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'
@@ -221,12 +224,7 @@ export function getEvidencePreviewUrl(inboundId, evidenceId, 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 url = resolveEvidencePreviewUrl(previewUrl, session.apiBase || '/api/platform/v1')
debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url })
return apiDownload({ url }).then((response) => {
const responseHeaders = response.header || {}
+29 -31
View File
@@ -114,7 +114,7 @@
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
</view>
<view v-else-if="!completedBoxes[currentBoxIndex]" class="pick-item-actions processed-actions">
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
</view>
</view>
@@ -124,15 +124,18 @@
<view v-if="activeStage === 'evidence'" class="loading-evidence-section">
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">空仓、半仓、满仓、锁仓及带锁码照</text></view>
<view><text class="section-title">装柜证据</text><text class="section-subtitle">每类至少 1 张,拍照后自动上传,可继续追加</text></view>
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
<view v-for="slot in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
<view v-for="(slot, slotIndex) in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
<view class="evidence-card-header">
<view class="evidence-card-title">
<text class="evidence-label">{{ slot.label }}</text>
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? evidenceEntries(slot.type).length + ' 张' : '待拍摄' }}</text>
<view class="evidence-card-main">
<text class="evidence-step" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? '✓' : slotIndex + 1 }}</text>
<view class="evidence-card-title">
<text class="evidence-label">{{ slot.label }}</text>
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
</view>
</view>
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
</view>
@@ -144,7 +147,6 @@
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-void-btn" @tap="voidEvidencePhoto(photo)">作废</button>
</view>
</view>
<text v-else class="evidence-empty-hint">现场拍摄后自动上传,可继续追加</text>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
{{ evidenceUploadState(slot.type) }}
</text>
@@ -308,6 +310,10 @@
<PopupHeader title="拣货完成" @close="closeOverlay" />
<view class="complete-icon">✓</view>
<text class="complete-desc">集装箱号 {{ currentBox.currentContainerNo }} · 柜次号 {{ currentBox.cabinetNo }} · 订单 {{ currentBox.orderNo }}</text>
<view class="complete-evidence-status">
<text>装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类</text>
<text>{{ sealNo ? '封签号已填写' : '封签号未填写' }}</text>
</view>
<view class="complete-stats">
<view class="complete-stat">
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
@@ -328,20 +334,11 @@
</view>
<view class="complete-btns">
<button class="complete-btn-back" @tap="closeOverlay">返回查看</button>
<button class="complete-btn-next" @tap="nextBox">{{ nextBoxButtonText }}</button>
<button class="complete-btn-next" @tap="continueToEvidence">{{ evidenceNextButtonText }}</button>
</view>
</view>
</uni-popup>
<uni-popup ref="allCompletePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('allComplete', $event)">
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="全部柜次已完成" @tap.stop>
<PopupHeader title="全部柜次已完成" @close="closeOverlay" />
<view class="complete-icon">✓</view>
<text class="complete-desc">本班次 {{ boxes.length }} 个柜次任务已全部处理</text>
<button class="all-complete-btn" @tap="leavePicking">返回工作台</button>
</view>
</uni-popup>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
</view>
</template>
@@ -488,8 +485,13 @@
if (this.summary.pending > 0) return '提交时需选择原因'
return `已处理 ${this.summary.processed}/${this.summary.total}`
},
nextBoxButtonText() {
return this.completedCount === this.boxes.length ? '查看全部完成' : '下一箱'
evidenceCapturedTypeCount() {
return this.evidenceSlots.filter((slot) => this.evidenceEntries(slot.type).length > 0).length
},
evidenceNextButtonText() {
return this.evidenceCapturedTypeCount === this.evidenceSlots.length && Boolean(String(this.sealNo || '').trim())
? '查看装柜取证'
: '去拍照'
},
visibleBoxList() {
return this.boxes
@@ -513,17 +515,17 @@
},
onLoad(options = {}) {
if (!requireSession()) return
this.currentUser = session.currentUser
if (options.taskId) {
// A local completion marker belongs to the previously opened task. The
// canonical server detail decides whether this task is complete.
this.completedBoxes = {}
this.boxes = [{
boxNo: String(options.currentContainerNo || options.cabinetNo || '同步中'), cabinetNo: options.cabinetNo ? String(options.cabinetNo) : null, currentContainerNo: options.currentContainerNo ? String(options.currentContainerNo) : null, orderNo: String(options.orderNo || options.inboundCode || ''),
boxNo: '同步中', cabinetNo: null, currentContainerNo: null, orderNo: '',
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
inboundId: options.inboundId ? String(options.inboundId) : null,
inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion),
inboundId: null, inboundVersion: null,
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
tenantId: options.tenantId ? String(options.tenantId) : null, items: []
tenantId: null, items: []
}]
this.currentBoxIndex = 0
this.remoteTaskId = options.taskId ? String(options.taskId) : null
@@ -531,6 +533,7 @@
},
async onShow() {
if (!requireSession()) return
this.currentUser = session.currentUser
this.startHardwareScanner()
if (this.cameraPicking) {
this.debugEvidence('choose.resume', { evidenceIdentity: this.evidenceIdentity })
@@ -1645,15 +1648,10 @@
}
this.openOverlay('complete')
},
nextBox() {
continueToEvidence() {
this.closeOverlay(false)
const nextIndex = this.boxes.findIndex((box, index) => !this.completedBoxes[index])
if (nextIndex < 0) {
this.openOverlay('allComplete')
return
}
this.selectBox(nextIndex)
this.showToast('切换至下一箱', 'success')
this.activeStage = 'evidence'
this.scrollToTop()
},
resetOverlayState() {
this.activeOverlay = ''
+1 -1
View File
@@ -74,7 +74,7 @@
},
enterTask(task) {
if (!this.isTaskReady(task)) { this.showToast('任务尚未满足后端进入条件', 'error'); return }
uni.navigateTo({ url: `/pages/picking/picking?cabinetNo=${encodeURIComponent(task.cabinetNo)}&currentContainerNo=${encodeURIComponent(task.currentContainerNo)}&warehouseId=${encodeURIComponent(task.warehouseId)}&taskId=${encodeURIComponent(task.taskId)}&orderNo=${encodeURIComponent(task.displayNo || '')}` })
uni.navigateTo({ url: `/pages/picking/picking?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` })
},
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) }
}
+36 -24
View File
@@ -592,9 +592,10 @@
.pick-item-actions {
display: grid;
grid-template-columns: 1.15fr 1fr 0.78fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
width: 100%;
margin-top: 14px;
}
.processed-actions {
@@ -602,18 +603,30 @@
}
.pick-btn {
display: flex;
box-sizing: border-box;
width: 100%;
height: 48px;
padding: 0 6px;
border-radius: 7px;
min-width: 0;
margin: 0;
padding: 0 8px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
line-height: 48px;
font-weight: 700;
line-height: 18px;
white-space: nowrap;
transition: transform 120ms ease-out, box-shadow 120ms ease-out, background-color 120ms ease-out;
}
.pick-btn-confirm { color: #ffffff; background: $success; }
.pick-btn-partial { border: 1px solid #75a5ef; color: #4a7dff; background: #eef5ff; }
.pick-btn-skip { color: $skip; background: #eef0f3; }
.pick-btn-undo { border: 1px solid #d8dde4; color: $text-secondary; background: #ffffff; }
.pick-btn::after { border: 0; }
.pick-btn:active { transform: translateY(1px); box-shadow: none; }
.pick-btn-confirm { border-color: #0d8f84; color: #ffffff; background: $success; box-shadow: 0 3px 8px rgba(13, 148, 136, 0.22); }
.pick-btn-partial { border-color: #75a5ef; color: #2467d4; background: #eef5ff; box-shadow: 0 2px 6px rgba(74, 125, 255, 0.12); }
.pick-btn-skip { border-color: #d8dee7; color: #536174; background: #f5f7fa; }
.pick-btn-undo { border-color: #efb7b7; color: #b42323; background: #fff5f5; box-shadow: 0 2px 6px rgba(180, 35, 35, 0.08); }
.empty-state {
display: flex;
@@ -1096,8 +1109,6 @@
.incomplete-reason { box-sizing: border-box; min-height: 48px; padding: 10px 12px; border: 1px solid $border; border-radius: 8px; color: $text; font-size: 13px; line-height: 20px; text-align: left; background: #ffffff; }
.incomplete-reason.selected { border-color: $danger; color: #a51d1d; background: $danger-light; }
.danger-confirm { background: $danger; }
.all-complete-btn { width: 100%; height: 48px; margin-top: 20px; border-radius: 9px; color: #ffffff; font-size: 14px; font-weight: 700; line-height: 48px; background: $primary; }
.complete-modal {
box-sizing: border-box;
width: calc(100vw - 32px);
@@ -1130,6 +1141,7 @@
.complete-title { font-size: 20px; font-weight: 700; }
.complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; }
.complete-evidence-status { display: flex; margin-top: 14px; padding: 10px 12px; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; color: #285d9e; font-size: 12px; font-weight: 600; background: #edf4ff; }
.complete-stats {
display: flex;
@@ -1157,8 +1169,8 @@
.red { color: $danger; }
.loading-evidence-section {
margin: 8px 12px 92px;
padding: 14px;
margin: 8px 12px 32px;
padding: 12px;
border: 1px solid #d8e4f4;
border-radius: 10px;
background: $card;
@@ -1171,28 +1183,30 @@
.sync-state.ready { color: $success; background: #e8f8ef; }
.sync-state.error { color: $danger; background: #fff0f0; }
.sync-state.loading { color: #8a5a00; background: #fff7df; }
.evidence-grid { display: flex; flex-direction: column; margin-top: 12px; }
.evidence-card { margin-bottom: 12px; padding: 10px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; }
.evidence-grid { display: flex; flex-direction: column; margin-top: 10px; }
.evidence-card { margin-bottom: 8px; padding: 7px 9px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; }
.evidence-card:last-child { margin-bottom: 0; }
.evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; }
.evidence-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.evidence-card-title { display: flex; min-width: 0; align-items: center; gap: 7px; }
.evidence-card-header { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 10px; }
.evidence-card-main { display: flex; min-width: 0; align-items: center; gap: 9px; }
.evidence-step { display: flex; width: 28px; height: 28px; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #47617f; font-size: 11px; font-weight: 700; background: #e8eef6; }
.evidence-step.complete { color: #ffffff; background: $success; }
.evidence-card-title { display: flex; min-width: 0; flex-direction: column; gap: 1px; }
.evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; }
.evidence-count { padding: 2px 6px; border-radius: 99px; color: $text-secondary; font-size: 10px; line-height: 16px; background: #e9eef5; }
.evidence-count.complete { color: #087443; background: #dff4e7; }
.evidence-count { color: $text-secondary; font-size: 10px; line-height: 15px; }
.evidence-count.complete { color: #087443; }
.evidence-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 9px; }
.evidence-photo-item { position: relative; min-width: 0; padding: 4px; border: 1px solid #dbe5ef; border-radius: 8px; background: #ffffff; }
.evidence-thumbnail { display: flex; width: 100%; height: 68px; align-items: center; justify-content: center; border-radius: 6px; color: $text-secondary; font-size: 9px; line-height: 13px; text-align: center; background: #e7edf5; }
.evidence-thumbnail-placeholder { box-sizing: border-box; padding: 6px; }
.evidence-file { display: block; overflow: hidden; margin-top: 4px; padding: 0 2px; color: $text-secondary; font-size: 9px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; }
.evidence-void-btn { position: absolute; top: 8px; right: 8px; min-width: 40px; height: 40px; padding: 0 7px; border: 0; border-radius: 20px; color: #ffffff; font-size: 10px; line-height: 40px; background: rgba(185, 28, 28, 0.9); }
.evidence-empty-hint { display: block; margin-top: 7px; color: $text-secondary; font-size: 10px; line-height: 15px; }
.evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; }
.evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; }
.evidence-upload-state.failed { color: $danger; }
.evidence-upload-state.succeeded { color: $success; }
.evidence-btn { min-height: 40px; margin-top: 8px; padding: 0 9px; border: 1px solid #9db7d9; border-radius: 7px; color: #174d91; font-size: 12px; line-height: 40px; background: #ffffff; }
.evidence-add-btn { flex-shrink: 0; min-width: 78px; min-height: 40px; margin-top: 0; line-height: 40px; }
.evidence-add-btn { flex-shrink: 0; min-width: 76px; min-height: 42px; margin-top: 0; line-height: 42px; }
.evidence-retry-btn { min-height: 40px; margin-top: 6px; padding: 0 9px; border: 1px solid $danger; border-radius: 7px; color: $danger; font-size: 12px; line-height: 40px; background: #fff5f4; }
.seal-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.seal-input { flex: 1; height: 42px; box-sizing: border-box; padding: 0 10px; border: 1px solid $border; border-radius: 7px; font-size: 12px; background: #f8fafc; }
@@ -1238,8 +1252,6 @@
.header-title { font-size: 18px; }
.box-select-btn { padding: 0 10px; font-size: 12px; }
.pick-item { padding-right: 10px; padding-left: 10px; }
.pick-item-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pick-btn-confirm { grid-column: 1 / -1; }
.processed-actions { grid-template-columns: 1fr; }
.evidence-photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.submit-section { gap: 8px; width: calc(100% - 12px); padding-right: 8px; padding-left: 8px; }
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { resolveEvidencePreviewUrl } from '../utils/api-url.js'
const previewPath = '/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300'
test('evidence preview uses the H5 gateway proxy prefix after reopening a task', () => {
assert.equal(
resolveEvidencePreviewUrl(previewPath, '/gateway-api/platform/v1'),
'/gateway-api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300'
)
})
test('evidence preview uses the configured absolute gateway origin on a PDA', () => {
assert.equal(
resolveEvidencePreviewUrl(previewPath, 'https://test.example.com/api/platform/v1'),
'https://test.example.com/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300'
)
})
test('absolute evidence preview URLs remain unchanged', () => {
const absoluteUrl = 'https://files.example.com/evidence/200.png'
assert.equal(resolveEvidencePreviewUrl(absoluteUrl, '/gateway-api/platform/v1'), absoluteUrl)
})
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('empty evidence slots stay compact and completed slots expand with photos', async () => {
const [page, styles] = await Promise.all([
read('../pages/picking/picking.vue'),
read('../styles/pda.scss')
])
assert.match(page, /每类至少 1 张,拍照后自动上传,可继续追加/)
assert.match(page, /v-for="\(slot, slotIndex\) in evidenceSlots"/)
assert.match(page, /class="evidence-step"[\s\S]*?slotIndex \+ 1/)
assert.match(page, /evidenceEntries\(slot\.type\)\.length \? '✓'/)
assert.doesNotMatch(page, /class="evidence-empty-hint"/)
assert.match(styles, /\.evidence-card-header \{[^}]*min-height: 44px/)
assert.match(styles, /\.evidence-add-btn \{[^}]*min-height: 42px/)
assert.match(styles, /\.evidence-photo-grid \{[^}]*grid-template-columns/)
})
+9 -4
View File
@@ -125,7 +125,7 @@ test('non-picking operations remain complete static task pages', async () => {
test('picking page keeps scan, draft and risky transition contracts', async () => {
const [page, scanner] = await Promise.all([read('../pages/picking/picking.vue'), read('../utils/scan-broadcast.js')])
for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '下一箱', '最近扫码']) assert.match(page, new RegExp(text))
for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '去拍照', '最近扫码']) assert.match(page, new RegExp(text))
assert.match(page, /saveCurrentBoxDraft\(\)[\s\S]*?this\.boxDrafts =/)
assert.match(page, /loadBoxState\(index\)[\s\S]*?const draft = this\.boxDrafts\[index\]/)
assert.match(page, /requestBoxSwitch\(index\)[\s\S]*?this\.openOverlay\('switchBoxConfirm'\)/)
@@ -213,10 +213,10 @@ test('business dialogs use uni-ui popup components consistently', async () => {
assert.match(profile, /<uni-popup ref="logoutPopup" type="center"[\s\S]*?:is-mask-click="true"[\s\S]*?@change="handleLogoutChange"/)
assert.match(profile, /<PopupHeader title="确认退出登录?"[\s\S]*?@close="closeLogout"/)
assert.match(profile, /class="logout-dialog-confirm"/)
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete', 'allComplete']) {
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete']) {
assert.match(picking, new RegExp(`<uni-popup ref="${name}Popup"[^>]*:is-mask-click="true"[^>]*@change="handleOverlayChange\\('${name}', \\$event\\)"`))
}
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 7)
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 6)
assert.match(picking, /openOverlay\(name\)[\s\S]*?this\.\$refs\[`\$\{name\}Popup`\]/)
assert.match(picking, /closeOverlay\(\)[\s\S]*?popup\.close\(\)/)
assert.match(picking, /handleOverlayChange\(name, event\)[\s\S]*?!event\.show[\s\S]*?this\.resetOverlayState\(\)/)
@@ -274,12 +274,17 @@ test('shared visual contracts preserve accessible PDA targets', async () => {
assert.match(app, /@import '@\/styles\/pda\.scss'/)
assert.doesNotMatch(styles, /<\/?style\b/)
for (const className of ['pick-btn', 'partial-quick-btn']) assert.match(styles, new RegExp(`\\.${className}\\s*\\{[\\s\\S]*?height:\\s*48px;`))
assert.match(styles, /\.pick-item-actions\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);[^}]*width:\s*100%;/)
assert.match(styles, /\.pick-btn\s*\{[^}]*width:\s*100%;[^}]*margin:\s*0;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;/)
assert.match(styles, /\.pick-btn::after\s*\{[^}]*border:\s*0;/)
assert.match(styles, /\.processed-actions\s*\{[^}]*grid-template-columns:\s*1fr;/)
assert.match(styles, /\.pick-btn-undo\s*\{[^}]*border-color:\s*#efb7b7;[^}]*color:\s*#b42323;[^}]*background:\s*#fff5f5;/)
assert.doesNotMatch(styles, /\.scan-btn\s*\{/)
assert.match(styles, /\.modal-cancel-btn,[\s\S]*?height:\s*48px;/)
assert.match(styles, /\.modal-sheet\s*\{[\s\S]*?padding:\s*20px 18px 18px;/)
assert.doesNotMatch(styles, /\.modal-sheet\s*\{[\s\S]*?padding:[^;]*env\(/)
assert.match(styles, /\.submit-decision-modal\s*\{[^}]*padding:\s*18px 16px 16px;/)
assert.match(styles, /\.evidence-card\s*\{[^}]*margin-bottom:\s*12px;/)
assert.match(styles, /\.evidence-card\s*\{[^}]*margin-bottom:\s*8px;/)
assert.match(styles, /\.evidence-card:last-child\s*\{[^}]*margin-bottom:\s*0;/)
assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0 0 12px;/)
assert.match(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/)
+2
View File
@@ -89,6 +89,8 @@ test('PDA downloads authenticated evidence previews returned by the backend', ()
assert.match(source, /response\.tempFilePath/)
assert.match(source, /evidence\.preview\.response/)
assert.match(source, /\^image\\\//)
assert.match(source, /resolveEvidencePreviewUrl/)
assert.match(source, /const url = resolveEvidencePreviewUrl\(previewUrl, session\.apiBase/)
})
test('PDA cabinet picking adapter claims online and submits one atomic batch', () => {
+9
View File
@@ -21,6 +21,15 @@ test('a server task never inherits a completed marker from the previously opened
assert.match(page, /submitStatusHint\(\)[\s\S]*?if \(this\.pickingCompleted\) return '拣货已完成,请继续装柜取证'/)
const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.doesNotMatch(submit, /completedBoxes/)
assert.match(page, /v-else-if="pickingOperationReady" class="pick-item-actions processed-actions"/)
assert.doesNotMatch(page, /v-else-if="!(?:pickingCompleted|completedBoxes\[currentBoxIndex\])"/)
})
test('picking completion leads to current cabinet evidence instead of an all-complete summary', () => {
assert.match(page, /装柜照片已拍 \{\{ evidenceCapturedTypeCount \}\}\/\{\{ evidenceSlots\.length \}\} 类/)
assert.match(page, /evidenceNextButtonText\(\)[\s\S]*?'查看装柜取证'[\s\S]*?'去拍照'/)
assert.match(page, /continueToEvidence\(\)[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?this\.scrollToTop\(\)/)
assert.doesNotMatch(page, /查看全部完成|allCompletePopup|nextBox\(\)/)
})
test('loading evidence is not requested before picking completion', () => {
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('direct H5 refresh restores the page user before rendering picking content', async () => {
const page = await read('../pages/picking/picking.vue')
assert.match(page, /<view v-if="currentUser" class="page-shell">/)
assert.match(page, /onLoad\(options = \{\}\) \{[\s\S]*?if \(!requireSession\(\)\) return[\s\S]*?this\.currentUser = session\.currentUser/)
assert.match(page, /async onShow\(\) \{[\s\S]*?if \(!requireSession\(\)\) return[\s\S]*?this\.currentUser = session\.currentUser/)
})
test('picking route carries only stable task and warehouse identity', async () => {
const [tasks, page] = await Promise.all([
read('../pages/tasks/tasks.vue'),
read('../pages/picking/picking.vue')
])
const navigation = tasks.match(/uni\.navigateTo\(\{ url: `\/pages\/picking\/picking\?[^`]+` \}\)/)?.[0] || ''
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\n\t\t\},\n\t\t\tasync onShow/)?.[0] || ''
assert.match(navigation, /\?taskId=\$\{encodeURIComponent\(task\.taskId\)\}&warehouseId=\$\{encodeURIComponent\(task\.warehouseId\)\}/)
assert.doesNotMatch(navigation, /cabinetNo|currentContainerNo|orderNo|inboundId|tenantId/)
assert.match(onLoad, /taskId: String\(options\.taskId\)/)
assert.match(onLoad, /warehouseId: options\.warehouseId \? String\(options\.warehouseId\) : null/)
assert.doesNotMatch(onLoad, /options\.(cabinetNo|currentContainerNo|orderNo|inboundId|inboundVersion|tenantId)/)
})
+15
View File
@@ -0,0 +1,15 @@
export function resolveEvidencePreviewUrl(previewUrl, baseUrl) {
const rawUrl = String(previewUrl || '')
const normalizedBase = String(baseUrl || '').replace(/\/$/, '')
if (!rawUrl || /^https?:\/\//i.test(rawUrl)) return rawUrl
if (/^https?:\/\//i.test(normalizedBase)) {
const origin = normalizedBase.match(/^(https?:\/\/[^/]+)/i)?.[1] || ''
return `${origin}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
}
const platformPrefix = '/api/platform/v1'
if (rawUrl === platformPrefix || rawUrl.startsWith(`${platformPrefix}/`)) {
return `${normalizedBase}${rawUrl.slice(platformPrefix.length)}`
}
if (rawUrl.startsWith('/')) return rawUrl
return `${normalizedBase}/${rawUrl}`
}