Files
client-web/src/modules/cross-border/components/ProcurementDecisionDialog.vue
T
2026-10-30 11:20:03 +08:00

98 lines
7.4 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.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { ElMessage } from 'element-plus'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
import { confirmProcurement, rejectProcurement, getProcurementWorkflow, getProcurementFile, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
import { procurementRequestKey } from '../procurementSubmission'
import { dateTime } from '../procurementPresentation'
const props = defineProps<{ order: ProcurementSummary; decision: 'confirm' | 'reject' }>()
const emit = defineEmits<{ close: []; changed: [] }>()
const auth = useAuthStore()
const owner = auth.token ? accessTokenIdentity(auth.token) : null
const current = () => active && !!owner && owner === (auth.token ? accessTokenIdentity(auth.token) : null)
const workflow = shallowRef<ProcurementWorkflow | null>(null)
const loading = ref(false), saving = ref(false), error = ref(''), photoError = ref(''), reason = ref('')
const photos = ref<Record<string, string>>({})
const isReject = computed(() => props.decision === 'reject')
const canSubmit = computed(() => !loading.value && workflow.value?.status === 'WAIT_CUSTOMER_CONFIRM' && auth.hasPermission('merchant:cross-border:procurement:create'))
let active = true, generation = 0, attempt: { fingerprint: string; key: string } | null = null
function clearPhotos() { Object.values(photos.value).forEach(url => URL.revokeObjectURL(url)); photos.value = {} }
async function load() {
if (loading.value || saving.value || !current()) return
const sequence = ++generation
loading.value = true; error.value = ''; photoError.value = ''; workflow.value = null; clearPhotos()
try {
const result = await getProcurementWorkflow(props.order.orderNo)
if (!current() || sequence !== generation) return
workflow.value = result
if (result.status !== 'WAIT_CUSTOMER_CONFIRM') { error.value = '订单状态已更新,当前不能确认或驳回,请返回列表刷新'; return }
const ids = [...new Set(result.receipts.flatMap(receipt => receipt.fileIds))]
await Promise.all(ids.map(async id => {
try {
const blob = await getProcurementFile(props.order.orderNo, id)
if (!current() || sequence !== generation) return
if (!/^image\/(png|jpeg|gif|webp|bmp)$/i.test(blob.type)) throw new Error('附件不是可预览的收货照片')
photos.value[id] = URL.createObjectURL(blob)
} catch { if (current() && sequence === generation) photoError.value = '部分收货照片加载失败,请重新加载后核对' }
}))
} catch (cause) { if (current() && sequence === generation) error.value = cause instanceof Error ? cause.message : '采购结果加载失败' }
finally { if (current() && sequence === generation) loading.value = false }
}
function fileName(id: string) { return workflow.value?.files?.find(file => file.fileId === id)?.fileName || '收货照片' }
async function submitDecision() {
if (saving.value || !current() || !canSubmit.value || !!photoError.value || !workflow.value) return
if (isReject.value && (!reason.value.trim() || reason.value.trim().length > 1000)) { error.value = '请填写驳回原因,最多1000字'; return }
const version = workflow.value.version, orderNo = props.order.orderNo
const fingerprint = JSON.stringify([owner, orderNo, version, props.decision, isReject.value ? reason.value.trim() : ''])
if (attempt?.fingerprint !== fingerprint) attempt = { fingerprint, key: procurementRequestKey() }
saving.value = true; error.value = ''
try {
const data = { version, idempotencyKey: attempt.key }
if (isReject.value) await rejectProcurement(orderNo, { ...data, reason: reason.value.trim() })
else await confirmProcurement(orderNo, data)
if (!current()) return
ElMessage.success(isReject.value ? '已驳回,等待平台重新上传照片' : '采购结果已确认')
emit('changed')
} catch (cause) { if (current()) error.value = cause instanceof Error ? cause.message : '操作失败,请重试' }
finally { saving.value = false }
}
onBeforeUnmount(() => { active = false; generation++; clearPhotos() })
void load()
</script>
<template>
<el-dialog :model-value="true" :title="isReject ? '驳回采购结果' : '确认采购结果'" width="800px" append-to-body :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving" @close="!saving && emit('close')">
<div v-loading="loading" class="decision-content">
<dl class="decision-quantities"><div><dt>采购单号</dt><dd>{{ order.orderNo }}</dd></div><div><dt>商品名称</dt><dd>{{ order.productName }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }} 件</dd></div><div><dt>累计收货数量</dt><dd>{{ workflow?.receivedQuantity ?? '—' }} 件</dd></div></dl>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-alert v-if="photoError" :title="photoError" type="error" :closable="false" />
<el-button v-if="error || photoError" :disabled="saving || loading" @click="load">重新加载</el-button>
<div class="decision-batches">
<section v-for="(receipt, index) in workflow?.receipts || []" :key="receipt.id" class="decision-batch">
<h3>第 {{ index + 1 }} 批:{{ receipt.quantity }} 件 <small>{{ dateTime(receipt.receivedAt) }}</small></h3>
<div class="decision-photos"><el-image v-for="id in receipt.fileIds" :key="id" :src="photos[id]" :alt="fileName(id)" :preview-src-list="Object.values(photos)" :initial-index="Object.keys(photos).indexOf(id)" fit="contain" preview-teleported><template #error><span>{{ fileName(id) }}:{{ loading ? '加载中' : '照片未加载' }}</span></template></el-image></div>
</section>
</div>
<p v-if="workflow && !workflow.receipts.length">暂无收货照片</p>
<el-form v-if="isReject" label-position="top" :disabled="saving"><el-form-item label="驳回原因" required><el-input v-model="reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请填写采购结果不符合要求的原因" /></el-form-item></el-form>
<p v-else>请核对采购数量和收货照片,确认后平台将安排出库。</p>
</div>
<template #footer><el-button :disabled="saving" @click="emit('close')">取消</el-button><el-button :type="isReject ? 'danger' : 'primary'" :loading="saving" :disabled="!canSubmit || !!photoError" @click="submitDecision">{{ isReject ? '提交驳回' : '确认采购结果' }}</el-button></template>
</el-dialog>
</template>
<style scoped>
.decision-content { min-height: 160px; }
.decision-quantities { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0 0 16px; }
.decision-quantities > div { display: flex; gap: 12px; min-width: 0; }
dt { color: var(--muted); } dd { margin: 0; overflow-wrap: anywhere; }
.decision-batches { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
.decision-batch { min-width: 0; }
h3 { margin: 0 0 8px; color: var(--navy); font-size: 14px; } small { display: block; margin-top: 4px; color: var(--muted); font-weight: 400; }
.decision-photos { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; gap: 8px; overflow-x: auto; padding: 2px 0 6px; }
.decision-photos :deep(.el-image) { flex: 0 0 112px; width: 112px; height: 84px; border: 1px solid var(--border); border-radius: 4px; }
.decision-photos span { padding: 12px; overflow-wrap: anywhere; }
</style>