128 lines
10 KiB
Vue
128 lines
10 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
|
|
import { useAuthStore } from '@/core/auth/auth'
|
||
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
|
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||
|
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
|
|
import OwnedBusinessPhotosDialog from './OwnedBusinessPhotosDialog.vue'
|
||
|
|
import { ownedBusinessPhotoRoles, ownedBusinessVersion, type OwnedBusinessDetail, type OwnedBusinessPhoto, type OwnedBusinessPhotoSlot } from '../ownedBusinessTypes'
|
||
|
|
import { getOwnedBusiness, getOwnedBusinessPhotoContent, uploadOwnedBusinessPhoto } from '../api/ownedBusiness'
|
||
|
|
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||
|
|
import { acceptOwnedBusinessResult, newOwnedBusinessPending, ownedBusinessPendingKey, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, type OwnedBusinessPending } from './OwnedBusinessCommandRecovery'
|
||
|
|
|
||
|
|
const open = defineModel<boolean>({ default: false })
|
||
|
|
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
|
||
|
|
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
|
||
|
|
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OwnedBusinessPending | null>(null), prepared = ref<OwnedBusinessPhoto | null>(null), corrupted = ref(false)
|
||
|
|
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
|
||
|
|
const uploadable = computed(() => !corrupted.value && access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||
|
|
useUnsavedWorkRegistration({ key: `owned-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '自有业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.ownedBusinessUpload) })
|
||
|
|
let actor = '', number = '', generation = 0
|
||
|
|
function savedPhoto(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||
|
|
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||
|
|
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||
|
|
function persist() { persistOwnedBusinessPending(actor, number, 'photos', pending.value) }
|
||
|
|
function persistPrepared() {
|
||
|
|
const key = ownedBusinessPendingKey(actor, number, 'prepared-photo')
|
||
|
|
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
|
||
|
|
}
|
||
|
|
watch(busy, value => emit('busy', value))
|
||
|
|
watch(open, value => {
|
||
|
|
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
|
||
|
|
if (!value || !props.detail) return
|
||
|
|
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
|
||
|
|
try {
|
||
|
|
pending.value = restoreOwnedBusinessPending(actor, number, 'photos')
|
||
|
|
const raw = sessionStorage.getItem(ownedBusinessPendingKey(actor, number, 'prepared-photo'))
|
||
|
|
prepared.value = raw ? JSON.parse(raw) as OwnedBusinessPhoto : null
|
||
|
|
if (prepared.value && (!prepared.value.fileId || !ownedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
|
||
|
|
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
|
||
|
|
}, { immediate: true })
|
||
|
|
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||
|
|
onBeforeUnmount(() => { generation++; release() })
|
||
|
|
async function preview(slot: OwnedBusinessPhotoSlot) {
|
||
|
|
const photo = savedPhoto(slot), current = generation
|
||
|
|
if (!photo || reading.value) return
|
||
|
|
reading.value = slot; error.value = null
|
||
|
|
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||
|
|
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||
|
|
finally { if (current === generation) reading.value = null }
|
||
|
|
}
|
||
|
|
async function bindPrepared() {
|
||
|
|
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
|
||
|
|
if (!pending.value) {
|
||
|
|
const body = { recordVersion: ownedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
|
||
|
|
pending.value = newOwnedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
|
||
|
|
}
|
||
|
|
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
|
||
|
|
if (!result.detail) throw new Error('照片绑定尚未确认')
|
||
|
|
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
|
||
|
|
}
|
||
|
|
async function recover() {
|
||
|
|
if (busy.value || !uploadable.value) return
|
||
|
|
busy.value = true; error.value = null
|
||
|
|
try {
|
||
|
|
ensureScope()
|
||
|
|
if (pending.value?.action === 'UPLOAD_PHOTO') {
|
||
|
|
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
|
||
|
|
if (!result.photo) throw new Error('原上传文件未确认')
|
||
|
|
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
|
||
|
|
}
|
||
|
|
await bindPrepared()
|
||
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
|
||
|
|
finally { busy.value = false }
|
||
|
|
}
|
||
|
|
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
|
||
|
|
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||
|
|
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
|
||
|
|
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||
|
|
busy.value = true; error.value = null
|
||
|
|
try {
|
||
|
|
ensureScope(); pending.value = newOwnedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
|
||
|
|
const uploaded = await uploadOwnedBusinessPhoto(number, slot, file, pending.value.key)
|
||
|
|
ensureScope(); acceptOwnedBusinessResult(pending.value, uploaded); persist()
|
||
|
|
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
|
||
|
|
if (!result.photo) throw new Error('真实上传文件未确认')
|
||
|
|
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
|
||
|
|
} catch (cause) {
|
||
|
|
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
|
||
|
|
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
|
||
|
|
} finally { busy.value = false }
|
||
|
|
}
|
||
|
|
async function discardRejected() {
|
||
|
|
if (busy.value || !pending.value?.rejected) return
|
||
|
|
busy.value = true
|
||
|
|
try { const latest = await getOwnedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
|
||
|
|
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
|
||
|
|
finally { busy.value = false }
|
||
|
|
}
|
||
|
|
async function beforeClose(done: () => void) {
|
||
|
|
if (busy.value) return
|
||
|
|
if (pending.value || prepared.value) {
|
||
|
|
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
|
||
|
|
}
|
||
|
|
done()
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<OwnedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||
|
|
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||
|
|
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||
|
|
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-business-photo" />
|
||
|
|
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||
|
|
<p v-else>暂无已保存照片</p>
|
||
|
|
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
|
||
|
|
</template>
|
||
|
|
</OwnedBusinessPhotosDialog>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.owned-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
|
||
|
|
.owned-business-upload { display: block; margin-top: var(--admin-space-3); }
|
||
|
|
.owned-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
|
||
|
|
</style>
|