159 lines
13 KiB
Vue
159 lines
13 KiB
Vue
<template>
|
||
<view class="loading-documents">
|
||
<view class="heading">
|
||
<view class="heading-main"><text class="heading-title">装车照片</text></view>
|
||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
|
||
<button class="heading-refresh" :disabled="busy" @tap="refresh">刷新</button>
|
||
</view>
|
||
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
|
||
<text class="section-hint">司机驾驶证、车牌照片在理货审核通过后自动带入,可补拍或更换;PDF 驾驶证可点击查看。</text>
|
||
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
|
||
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
|
||
<view class="photo-list">
|
||
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
|
||
<view class="photo-card-header">
|
||
<view class="photo-card-main">
|
||
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
|
||
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ photosFor(slot).length }}/5 张已保存</text></view>
|
||
</view>
|
||
<button v-if="editable" class="photo-upload" :aria-label="`上传${slotLabels[slot]}`" :disabled="busy || photosFor(slot).length >= limit" @tap="choose(slot)">{{ photosFor(slot).length >= limit ? '已满 5 张' : '+ 上传图片' }}</button>
|
||
</view>
|
||
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
|
||
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
|
||
<button v-if="photo.contentType === 'application/pdf'" class="photo-read-retry" :disabled="busy" @tap="viewPdf(photo)">查看驾驶证 PDF</button>
|
||
<image v-else-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
|
||
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
|
||
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
|
||
<button v-if="editable" class="photo-replace" :disabled="busy" :aria-label="`更换${slotLabels[slot]}图片`" @tap="choose(slot, photo.fileId)">更换</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
<script>
|
||
import { session } from '@/stores/session.js'
|
||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||
import { loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
|
||
|
||
export default {
|
||
props: { dispatchNo: { type: String, required: true } },
|
||
emits: ['saved', 'busy-change'],
|
||
data() { return { slots: loadingPhotoSlots, slotLabels: loadingPhotoLabels, limit: loadingPhotoLimit, snapshot: null, busy: false, needsRefresh: false, error: null, images: {}, active: true, revision: 0,
|
||
scope: `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`, number: this.dispatchNo } },
|
||
computed: { editable() { return this.snapshot?.editable === true && !this.needsRefresh }, completedCategories() { return this.slots.filter(slot => this.photosFor(slot).length > 0).length } },
|
||
mounted() { this.load() },
|
||
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
|
||
methods: {
|
||
photosFor(slot) { return (this.snapshot?.photos || []).filter(photo => photo.slot === slot) },
|
||
ensureScope() { if (!this.active || this.number !== this.dispatchNo || this.scope !== `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`) throw new Error('作业身份已变化,请重新进入任务') },
|
||
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
|
||
async previews() {
|
||
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
|
||
if (photo.contentType === 'application/pdf') return
|
||
if (this.images[photo.fileId]) return
|
||
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
|
||
}))
|
||
},
|
||
async refreshFacts() {
|
||
this.needsRefresh = true
|
||
const value = await getLoadingDocuments(this.number); this.ensureScope()
|
||
this.snapshot = value; this.needsRefresh = false; await this.previews()
|
||
},
|
||
async load() { await this.run(() => this.refreshFacts()) },
|
||
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
|
||
async choose(slot, replaceFileId = null) {
|
||
if (this.busy || !this.editable) return
|
||
const reviewRound = this.snapshot.reviewRound
|
||
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
|
||
if (capacity <= 0) return
|
||
let saved = 0, reconciled = false
|
||
await this.run(async () => {
|
||
const chosen = await uni.chooseImage({ count: capacity, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
|
||
const paths = chosen.tempFilePaths || []; if (!paths.length) return
|
||
if (paths.length > capacity) throw new Error(`本次最多可选择${capacity}张图片`)
|
||
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
|
||
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
|
||
for (const filePath of paths) {
|
||
const uploadKey = this.key()
|
||
const photo = await uploadLoadingPhoto(this.number, slot, filePath, uploadKey, reviewRound); this.ensureScope()
|
||
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
||
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
|
||
if (latest.reviewRound !== reviewRound) { reconciled = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
|
||
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
|
||
try {
|
||
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
|
||
this.snapshot = value
|
||
} catch (cause) {
|
||
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
|
||
try {
|
||
await this.refreshFacts()
|
||
reconciled = this.snapshot.recordVersion !== latest.recordVersion || this.snapshot.dispatchRecordVersion !== latest.dispatchRecordVersion || this.snapshot.stage !== latest.stage
|
||
} catch { /* 保留原错误与刷新入口。 */ }
|
||
throw cause
|
||
}
|
||
saved++; await this.previews()
|
||
}
|
||
uni.showToast({ title: this.snapshot.stage === 'DELIVERING' && this.snapshot.complete ? '资料齐全,已进入派送中' : `已保存${saved}张照片`, icon: 'none' })
|
||
})
|
||
if ((saved || reconciled) && this.active) this.$emit('saved')
|
||
},
|
||
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
|
||
async viewPdf(photo) {
|
||
await this.run(async () => {
|
||
const browser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||
const popup = browser ? window.open('', '_blank') : null
|
||
if (browser && !popup) throw new Error('请允许打开驾驶证预览窗口')
|
||
if (popup) popup.opener = null
|
||
try {
|
||
const path = await readLoadingPhoto(this.number, photo); this.ensureScope()
|
||
if (popup) popup.location.href = path
|
||
else await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
|
||
} catch (cause) { if (popup) popup.close(); throw cause }
|
||
})
|
||
},
|
||
async run(work, notify = false) {
|
||
if (this.busy) return
|
||
const revision = ++this.revision
|
||
this.busy = true; this.$emit('busy-change', true); this.error = null; let saved = false
|
||
try { this.ensureScope(); saved = await work() === true }
|
||
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) { this.error = toDispatchError(cause, '装车资料操作失败,请重新操作') } }
|
||
finally { if (this.active) { this.busy = false; this.$emit('busy-change', false) } }
|
||
if (saved && notify && this.active) this.$emit('saved')
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style scoped>
|
||
.loading-documents { padding: 24rpx; margin-top: 20rpx; border: 2rpx solid #d9e5f4; border-radius: 20rpx; color: #24364d; font-size: 24rpx; background: #fff; }
|
||
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
|
||
.heading-main { flex: 1; min-width: 0; }
|
||
.heading-title { display: block; font-size: 30rpx; font-weight: 700; }
|
||
.heading-subtitle, .section-hint { display: block; margin-top: 8rpx; color: #64748b; font-size: 22rpx; line-height: 34rpx; }
|
||
.heading-progress { flex-shrink: 0; padding: 8rpx 12rpx; border-radius: 40rpx; color: #087443; font-size: 20rpx; line-height: 30rpx; background: #e8f8ef; }
|
||
.heading-refresh { flex-shrink: 0; min-width: 100rpx; min-height: 80rpx; margin: 0; padding: 0 18rpx; border: 2rpx solid #9db7d9; border-radius: 14rpx; color: #174d91; font-size: 24rpx; line-height: 80rpx; background: #fff; }
|
||
.photo-list { margin-top: 20rpx; }
|
||
.photo-card { margin-bottom: 16rpx; padding: 14rpx 18rpx; border: 2rpx solid #d7e1ed; border-radius: 18rpx; background: #f8fafc; }
|
||
.photo-card:last-child { margin-bottom: 0; }
|
||
.photo-card.filled { border-color: #a7d6ba; background: #f4fbf6; }
|
||
.photo-card-header { display: flex; min-height: 88rpx; align-items: center; justify-content: space-between; gap: 20rpx; }
|
||
.photo-card-main { display: flex; min-width: 0; align-items: center; gap: 18rpx; }
|
||
.photo-number { display: flex; width: 56rpx; height: 56rpx; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #47617f; font-size: 22rpx; font-weight: 700; background: #e8eef6; }
|
||
.filled .photo-number { color: #fff; background: #16a34a; }
|
||
.photo-card-title { display: flex; min-width: 0; flex-direction: column; gap: 2rpx; }
|
||
.photo-label { display: block; color: #24364d; font-size: 24rpx; font-weight: 700; }
|
||
.photo-status { color: #64748b; font-size: 20rpx; line-height: 30rpx; }
|
||
.filled .photo-status { color: #087443; }
|
||
.photo-upload { flex-shrink: 0; min-width: 152rpx; min-height: 84rpx; margin: 0; padding: 0 18rpx; border: 2rpx solid #9db7d9; border-radius: 14rpx; color: #174d91; font-size: 24rpx; line-height: 84rpx; background: #fff; }
|
||
.photo-preview-list { display: flex; flex-wrap: wrap; gap: 16rpx; margin-top: 18rpx; }
|
||
.photo-preview { box-sizing: border-box; width: calc(33.333% - 12rpx); min-width: 128rpx; padding: 8rpx; border: 2rpx solid #dbe5ef; border-radius: 16rpx; background: #fff; }
|
||
.photo-thumbnail { display: block; width: 100%; height: 136rpx; border-radius: 12rpx; background: #e7edf5; }
|
||
.photo-preview-caption { display: block; overflow: hidden; margin-top: 8rpx; color: #64748b; font-size: 18rpx; line-height: 28rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||
.photo-replace { margin: 8rpx 0 0; padding: 0; color: #174d91; font-size: 22rpx; line-height: 64rpx; background: #eef5ff; }
|
||
.photo-read-retry { display: flex; box-sizing: border-box; width: 100%; min-height: 136rpx; margin: 0; padding: 12rpx; align-items: center; justify-content: center; border: 0; border-radius: 12rpx; color: #174d91; font-size: 20rpx; line-height: 30rpx; background: #e7edf5; }
|
||
.error { margin-top: 16rpx; padding: 12rpx 16rpx; border-radius: 14rpx; color: #b91c1c; background: #fff1f0; }
|
||
.error text { display: block; font-size: 22rpx; line-height: 34rpx; overflow-wrap: anywhere; }
|
||
.heading-refresh::after, .photo-upload::after, .photo-read-retry::after, .recover-button::after { border: 0; }
|
||
.loading-documents button[disabled] { opacity: .55; }
|
||
</style>
|