Files
pda-overseas-uniapp/src/components/DispatchLoadingDocuments.vue
T

114 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="loading-documents">
<view class="heading">
<view class="heading-main"><text class="heading-title">装车照片</text><text class="heading-subtitle">每类 1 张,上传后自动保存,可更换</text></view>
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}</text>
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
</view>
<text v-if="snapshot?.stage === 'LOADING'" class="section-hint">可边装车边上传;确认装车完成且照片齐全后自动进入派送中。</text>
<text v-if="snapshot?.stage === 'AFTER_LOADING'" class="section-hint">上传并保存 5 张装车照片后,本车自动进入派送中。</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">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
</view>
<button v-if="editable" class="photo-upload" :aria-label="`${snapshot?.photos.some(item => item.slot === slot) ? '更换' : '上传'}${slotLabels[slot]}`" :disabled="busy" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</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">
<image v-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>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { session } from '@/stores/session.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
import { loadingPhotoSlots, 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: { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' }, snapshot: null, busy: 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 } },
mounted() { this.load() },
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
methods: {
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 (this.images[photo.fileId]) return
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
}))
},
async load() { await this.run(async () => { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; await this.previews() }) },
async choose(slot) {
if (this.busy || !this.editable) return
await this.run(async () => {
const chosen = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
const filePath = chosen.tempFilePaths?.[0]; if (!filePath) return
if (chosen.tempFiles?.[0]?.size > 10 * 1024 * 1024) throw new Error('请选择不超过10MiB的图片')
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId }); this.ensureScope()
this.snapshot = value; await this.previews()
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
return true
}, true)
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
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-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>