This commit is contained in:
2026-10-16 18:25:56 +08:00
parent fbaa48337d
commit e0726444cc
7 changed files with 90 additions and 41 deletions
@@ -2,14 +2,11 @@
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { getRelabel, downloadRelabelDocument, type RelabelDetail } from '../api/warehouseServices'
const props = defineProps<{ relabelNo: string; detail?: RelabelDetail | null }>()
const props = defineProps<{ relabelNo: string; detail?: RelabelDetail | null; inline?: boolean }>()
const loaded = ref<RelabelDetail | null>(null)
const loading = ref(false)
const error = ref('')
const busy = ref(false)
const previewUrl = ref('')
const previewOpen = ref(false)
const previewTitle = ref('')
let generation = 0
const current = computed(() => props.detail || loaded.value)
/** 各申请分别加载,失败只影响资料区,不阻断订单详情。 */
@@ -22,28 +19,26 @@ async function load() {
catch (cause) { if (ticket === generation) error.value = cause instanceof Error ? cause.message : '贴标资料加载失败' }
finally { if (ticket === generation) loading.value = false }
}
watch(() => [props.relabelNo, props.detail], () => { clearPreview(); void load() }, { immediate: true })
interface DocumentRow { key: string; kind: string; fileId: string; fileName: string; scope: string; itemId?: string; context?: string }
watch(() => [props.relabelNo, props.detail], () => { void load() }, { immediate: true })
interface DocumentRow { key: string; kind: string; fileId: string; fileName: string; scope: string; itemId?: string }
const documents = computed<DocumentRow[]>(() => {
const result: DocumentRow[] = []
const application = current.value?.application
if (!application) return result
// 旧总文件仅作为兼容回显,避免与已分类型的总文件重复展示。
const add = (kind: string, id: string | null | undefined, name: string | null | undefined, scope: string, itemId?: string, context?: string) => {
if (id) result.push({ key: scope + ':' + (itemId || '') + ':' + id, kind, fileId: id, fileName: name || '未命名文件', scope, itemId, context })
const add = (kind: string, id: string | null | undefined, name: string | null | undefined, scope: string, itemId?: string) => {
if (id) result.push({ key: scope + ':' + (itemId || '') + ':' + id, kind, fileId: id, fileName: name || '未命名文件', scope, itemId })
}
add('箱唛总文件', application.cartonTotalFileId, application.cartonTotalFileName, 'CARTON_TOTAL')
add('SKU 标签总文件', application.skuTotalFileId, application.skuTotalFileName, 'SKU_TOTAL')
add('箱唛贴标文件', application.cartonTotalFileId, application.cartonTotalFileName, 'CARTON_TOTAL')
add('SKU 贴标文件', application.skuTotalFileId, application.skuTotalFileName, 'SKU_TOTAL')
if (application.totalFileId !== application.cartonTotalFileId && application.totalFileId !== application.skuTotalFileId)
add('贴标总文件', application.totalFileId, application.totalFileName, 'TOTAL')
add('面单文件', application.shippingLabelFileId, application.shippingLabelFileName, 'SHIPPING')
for (const item of current.value?.items || []) add('明细文件', item.relabelFileId, item.relabelFileName, 'ITEM', String(item.id), [item.cartonMark, item.skuCode].filter(Boolean).join(' / '))
for (const item of current.value?.items || []) add('明细文件', item.relabelFileId, item.relabelFileName, 'ITEM', String(item.id))
return result
})
/** 切换申请及关闭预览时释放文件对象。 */
function clearPreview() { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = ''; previewOpen.value = false }
/** 使用带身份认证的下载接口,预览和保存共享同一归属校验。 */
async function openFile(key: string, download = false) {
/** 使用带身份认证的下载接口,保留文件归属校验和原始文件名。 */
async function downloadFile(key: string) {
const row = documents.value.find(document => document.key === key)
if (!row || busy.value) return
busy.value = true
@@ -51,32 +46,33 @@ async function openFile(key: string, download = false) {
try {
const blob = await downloadRelabelDocument(props.relabelNo, row.fileId, row.scope, row.itemId)
if (ticket !== generation) return
if (download) {
const url = URL.createObjectURL(blob); const link = document.createElement('a')
link.href = url; link.download = row.fileName; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000)
} else { clearPreview(); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = row.fileName; previewOpen.value = true }
const url = URL.createObjectURL(blob); const link = document.createElement('a')
link.href = url; link.download = row.fileName; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '文件读取失败,请重试') }
finally { busy.value = false }
}
onBeforeUnmount(() => { ++generation; clearPreview() })
onBeforeUnmount(() => { ++generation })
</script>
<template>
<section class="relabel-documents" aria-label="客户上传的贴标文件">
<h3>贴标文件<span v-if="relabelNo"> · {{ relabelNo }}</span></h3>
<section class="relabel-documents" :class="{ 'relabel-documents--inline': inline }" aria-label="客户上传的贴标文件">
<h3>贴标文件</h3>
<p v-if="loading">资料加载中…</p>
<p v-else-if="error" role="alert">{{ error }} <el-button link type="primary" @click="load">重试</el-button></p>
<div v-else-if="inline" class="relabel-document-links">
<div v-for="row in documents" :key="row.key" class="relabel-document-link"><span>{{ row.kind }}</span><el-button link type="primary" :disabled="busy" @click="downloadFile(row.key)">下载</el-button></div>
<span v-if="!documents.length">暂无上传的贴标文件</span>
</div>
<el-table v-else :data="documents" row-key="key" border empty-text="暂无上传的贴标文件">
<el-table-column prop="kind" label="文件类型" width="150" />
<el-table-column prop="context" label="对应箱唛 / SKU" min-width="180" />
<el-table-column prop="fileName" label="文件名称" min-width="240" />
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openFile(String(row.key))">查看</el-button><el-button link type="primary" :disabled="busy" @click="openFile(String(row.key), true)">下载</el-button></template></el-table-column>
<el-table-column prop="kind" label="文件类型" min-width="150" />
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="downloadFile(String(row.key))">下载</el-button></template></el-table-column>
</el-table>
<el-dialog v-model="previewOpen" :title="previewTitle" width="80%" append-to-body @closed="clearPreview"><iframe v-if="previewUrl" :src="previewUrl" :title="previewTitle" class="relabel-document-preview" /></el-dialog>
</section>
</template>
<style scoped>
.relabel-documents { margin: 16px 0; }
.relabel-documents h3 { margin: 0 0 12px; font-size: 14px; }
.relabel-documents h3 span { font-weight: 400; }
.relabel-document-preview { width: 100%; height: 70vh; border: 0; }
.relabel-documents--inline { display: flex; align-items: center; gap: 12px; margin: 0; min-width: 0; }
.relabel-documents--inline h3 { flex-shrink: 0; margin: 0; color: var(--muted); font-size: 12px; font-weight: 400; }
.relabel-document-links { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.relabel-document-link { display: flex; align-items: center; gap: 8px; }
</style>
@@ -0,0 +1,29 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { parse } from 'vue/compiler-sfc'
const source = readFileSync(new URL('./ReviewRejectionNotice.vue', import.meta.url), 'utf8')
const template = parse(source).descriptor.template.content
const render = (status, reason) => renderToString(createSSRApp({ template, props: ['status', 'reason'] }, { status, reason }))
test('rejected documents display the actual reason safely', async () => {
const html = await render('REJECTED', '箱数不符\n请核对 <SKU>')
assert.match(html, /驳回原因/)
assert.match(html, /箱数不符\n请核对 &lt;SKU&gt;/)
assert.match(html, /role="alert"/)
})
test('non-rejected documents do not show an old rejection reason', async () => {
for (const status of ['PENDING_REVIEW', 'APPROVED', 'COMPLETED', undefined]) {
assert.doesNotMatch(await render(status, '旧审核原因'), /旧审核原因|驳回原因/)
}
})
test('legacy rejection without a reason has an explicit fallback', async () => {
for (const reason of [undefined, '', ' ']) {
assert.match(await render('REJECTED', reason), /暂未记录驳回原因/)
}
})
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ status?: string; reason?: string }>()
</script>
<template>
<div v-if="status === 'REJECTED'" class="review-rejection-notice" role="alert">
<strong>驳回原因</strong>
<span>{{ reason?.trim() || '暂未记录驳回原因,请联系平台确认' }}</span>
</div>
</template>
<style scoped>
.review-rejection-notice{display:flex;align-items:flex-start;gap:12px;margin:12px 16px;padding:10px 12px;border:1px solid var(--el-color-danger-light-7);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:13px;line-height:1.7}
.review-rejection-notice strong{flex-shrink:0;font-weight:600}
.review-rejection-notice span{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere}
</style>