Files
admin-web/src/modules/inbounds/review/MeasurementReviewPanel.vue
T
2026-08-18 11:33:01 +08:00

221 lines
11 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, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Pencil, RotateCcw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
const props = defineProps<{
customerId?: string
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const history = ref<MeasurementReviewRecord[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const historyError = ref<ApiErrorDetails | null>(null)
const correctionMode = ref(false)
const requestSerial = ref(0)
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '—'
}
function resultLabel(value: string | null | undefined) {
if (value === 'APPROVED') return '审核通过'
if (value === 'REJECTED') return '审核驳回'
return '未知结果'
}
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return details.message.includes('版本已变化') || details.message.includes('并发修改')
}
async function loadReview() {
const serial = ++requestSerial.value
loadError.value = null
loading.value = true
try {
const result = await getMeasurementReviewDetail(customerId.value, props.inboundId)
if (serial === requestSerial.value) detail.value = result
} catch (error) {
if (serial === requestSerial.value) {
detail.value = null
loadError.value = getApiErrorDetails(error, '测量审核数据加载失败')
}
} finally {
if (serial === requestSerial.value) loading.value = false
}
}
async function loadHistory() {
historyError.value = null
historyLoading.value = true
try {
history.value = (await listMeasurementReviewHistory(customerId.value, props.inboundId)).records
} catch (error) {
history.value = []
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
} finally {
historyLoading.value = false
}
}
async function reload() {
await Promise.all([loadReview(), loadHistory()])
}
async function handleWeightChanged() {
await loadReview()
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
'确认测量审核通过',
{ confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
}
actionLoading.value = 'approve'
try {
const result = await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
})
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
correctionMode.value = false
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
<div class="review-toolbar__main">
<div class="review-toolbar__status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<span v-if="isPending">通过后进入待分柜</span>
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
<span v-else>当前记录不可审核</span>
</div>
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</div>
<p v-if="detail" class="review-toolbar__context">客户:{{ detail.companyName || '客户未命名' }} · 渠道:{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="reload">重新加载</el-button>
</el-alert>
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
/>
<el-collapse class="review-history">
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
<ol v-else class="history-list">
<li v-for="item in history" :key="item.id">
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }}</span></div>
</li>
</ol>
</el-collapse-item>
</el-collapse>
</template>
</section>
</template>
<style scoped>
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
.review-toolbar { position: sticky; z-index: 4; top: 0; display: grid; gap: 10px; margin: -20px -24px 0; padding: 14px 24px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; box-shadow: 0 4px 12px rgb(16 24 40 / 6%); }
.review-toolbar__main { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 14px; }
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
.review-toolbar__status > span { color: var(--cc-muted); font-size: 12px; }
.review-toolbar__actions { justify-content: flex-end; }
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
.review-toolbar__context { margin: 0; padding-top: 10px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }
.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }
.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }
.history-marker--success { border-color: #67c23a; }
.history-marker--danger { border-color: #f56c6c; }
.history-content { min-width: 0; }
.history-content > div { display: flex; justify-content: space-between; gap: 12px; }
.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }
.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.review-toolbar { margin: -14px -14px 0; padding: 12px 14px; }
.review-toolbar__main { align-items: stretch; flex-direction: column; }
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
}
@media (max-width: 360px) {
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
}
</style>