Files
BMS/bms-front/src/views/price/label/components/LabelImportPanel.vue
T

353 lines
13 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>
<div class="label-import-panel">
<el-form label-width="96px" class="import-form">
<el-form-item label="支付类型">
<el-tag type="success">应收 AR</el-tag>
<span class="form-tip">打单套餐固定应收,不显示建价选项,直接 Excel 导入</span>
</el-form-item>
<el-form-item label="产品">
<el-select v-model="dictValue" clearable filterable placeholder="不选则按工作表名称匹配全部字典产品" style="width: 420px">
<el-option v-for="item in payment_product_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<el-upload
:key="uploadKey"
drag
accept=".xlsx"
:auto-upload="false"
:show-file-list="false"
:disabled="recognizing"
@change="onFile"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="el-upload__text">将 xlsx 价卡拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="upload-tip">
{{ autoFillOnParse
? '解析成功后自动回填到创建页产品价卡列表;打单保存与应付独立,弹窗内不展示表格。'
: '模板以威狮 B2C 末端派送价卡为准。选一个产品就只读对应工作表。' }}
</div>
</template>
</el-upload>
<div v-if="recognizing" class="parse-progress">
<el-progress :percentage="parsePercent" :stroke-width="16" :status="parseStatus" striped striped-flow :duration="12" />
<div class="parse-progress-msg">{{ parseMessage }}</div>
</div>
<!-- 创建页 autoFillOnParse:不展示弹窗预览表,结果回填页面;详情等入口保留预览勾选 -->
<div v-if="preview && !autoFillOnParse" v-loading="recognizing" class="preview-box">
<el-alert
:title="`${preview.sourceFile || ''} 归属 ${preview.supplierName || '-'} 生效 ${preview.effectiveDate || '-'} 共 ${products.length} 个产品`"
type="info"
:closable="false"
show-icon
/>
<div class="preview-bar">
<el-switch v-model="overwrite" active-text="本套餐已存在时另存为新版本" />
<span class="preview-hint">仅本打单套餐维度;与应付公共价卡互不影响。开启后同产品编号不变,旧价留在历史 term。</span>
</div>
<el-table
ref="tableRef"
:data="products"
border
size="small"
max-height="420"
row-key="rowKey"
@selection-change="onSelect"
>
<el-table-column type="selection" width="48" :selectable="canSelect" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
<el-table-column prop="relatedSheet" label="关联分区表" min-width="120" show-overflow-tooltip />
<el-table-column prop="weightCount" label="重量段" width="80" align="right" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="right" />
<el-table-column label="识别" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.parseStatus)" size="small">{{ statusText(row.parseStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="库中" width="90">
<template #default="{ row }">
<el-tag v-if="row.duplicated" type="warning" size="small">已存在</el-tag>
<span v-else>新记录</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="View" @click="openDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="skippedText" class="upload-tip">未匹配字典、已跳过:{{ skippedText }}</div>
</div>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail :product="detailProduct" :editable="false" :bill-select="true" />
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import { previewLabel, labelParseProgress, saveLabelImport } from '@/api/price/label'
import { useDict } from '@/utils/dict'
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
const props = defineProps({
/** 仅创建页开启:解析成功后立刻抛出勾选结果,其它入口保持预览后手动保存 */
autoFillOnParse: { type: Boolean, default: false },
/** 详情再导入时传入,用于「本套餐已存在」判断;新建不传 */
priceSetId: { type: [Number, String], default: null }
})
const emit = defineEmits(['parsed'])
const { proxy } = getCurrentInstance()
const { payment_product_type } = useDict('payment_product_type')
const recognizing = ref(false)
const overwrite = ref(false)
const dictValue = ref('')
const preview = ref(null)
const selected = ref([])
const tableRef = ref()
const uploadKey = ref(0)
const parsePercent = ref(0)
const parseMessage = ref('')
const parseStatus = ref(undefined)
const active = ref(true)
const detailVisible = ref(false)
const detailProduct = ref(null)
const products = computed(() => preview.value?.products || [])
const skippedText = computed(() => (preview.value?.skippedSheets || []).filter(Boolean).join('、'))
const selectedCount = computed(() => selected.value.length)
const detailTitle = computed(() => {
if (!detailProduct.value) return '产品明细'
return detailProduct.value.productName || detailProduct.value.sheetName || '产品明细'
})
let parseSeq = 0
let progressTimer = null
function reset() {
parseSeq += 1
preview.value = null
selected.value = []
overwrite.value = false
dictValue.value = ''
recognizing.value = false
parsePercent.value = 0
parseMessage.value = ''
parseStatus.value = undefined
detailVisible.value = false
detailProduct.value = null
stopProgress()
uploadKey.value += 1
}
function openDetail(row) {
detailProduct.value = row || null
detailVisible.value = true
}
async function onFile(uploadFile) {
const raw = uploadFile?.raw
if (!raw) return
if (!raw.name.toLowerCase().endsWith('.xlsx')) {
proxy.$modal.msgError('请上传xlsx价卡')
return
}
const seq = ++parseSeq
const progressId = createProgressId()
recognizing.value = true
parsePercent.value = 0
parseMessage.value = '正在上传价卡…'
parseStatus.value = undefined
startProgress(seq, progressId)
try {
const res = await previewLabel(raw, dictValue.value, progressId, event => {
if (seq !== parseSeq || !event?.total) return
const uploaded = Math.min(8, Math.round(event.loaded * 8 / event.total))
if (uploaded > parsePercent.value) {
parsePercent.value = uploaded
parseMessage.value = '正在上传价卡…'
}
}, props.priceSetId)
if (seq !== parseSeq) return
parsePercent.value = 100
parseMessage.value = '解析完成'
parseStatus.value = 'success'
const data = res.data || {}
data.products = (data.products || []).map((item, index) => ({
...item,
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}`,
// 解析后默认不计费,勾选后才入 pay_surcharge
surcharges: (item.surcharges || []).map(fee => ({ ...fee, needBill: fee?.needBill === true }))
}))
preview.value = data
const picked = data.products.filter(row => canSelect(row))
selected.value = picked
// 详情等入口:预览表勾选;创建页不渲染预览表,直接回填页面
if (!props.autoFillOnParse) {
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
} else if (picked.length) {
emit('parsed', getSelectionPayload())
} else {
proxy.$modal.msgWarning(
props.priceSetId
? '没有可导入的产品,请调整产品筛选或开启「本套餐已存在时另存为新版本」后重试'
: '没有可导入的产品,请调整产品筛选或检查价卡工作表是否匹配字典'
)
}
} catch (error) {
if (seq !== parseSeq) return
parseStatus.value = 'exception'
parseMessage.value = error?.message || '价卡解析失败'
} finally {
if (seq === parseSeq) {
stopProgress()
recognizing.value = false
}
}
}
function createProgressId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID()
return `lbl${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`
}
function startProgress(seq, progressId) {
stopProgress()
progressTimer = window.setInterval(async () => {
if (seq !== parseSeq) return
try {
const res = await labelParseProgress(progressId)
if (seq !== parseSeq) return
const data = res.data || {}
if (!data.status || data.status === 'PENDING') return
const server = Math.min(99, Number(data.percent) || 0)
if (server >= parsePercent.value) {
parsePercent.value = server
if (data.message) parseMessage.value = data.message
}
if (data.status === 'FAIL') parseStatus.value = 'exception'
} catch {
// ignore
}
}, 400)
}
function stopProgress() {
if (progressTimer) {
window.clearInterval(progressTimer)
progressTimer = null
}
}
function canSelect(row) {
if (row.parseStatus === 'MISSING') return false
return overwrite.value || !row.duplicated
}
watch(overwrite, async () => {
if (!active.value || !preview.value) return
const picked = products.value.filter(row => canSelect(row))
selected.value = picked
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
})
function onSelect(rows) {
if (!active.value) return
selected.value = rows || []
}
function statusType(status) {
if (status === 'OK') return 'success'
if (status === 'EMPTY') return 'warning'
return 'danger'
}
function statusText(status) {
if (status === 'OK') return '已识别'
if (status === 'EMPTY') return '未识别到配置'
if (status === 'MISSING') return '缺少工作表'
return status || '-'
}
function validateReady() {
if (!selected.value.length) {
proxy.$modal.msgError('请先上传价卡并勾选要保存的产品')
return false
}
return true
}
/** 弹窗确认时回传勾选结果(创建页暂存,点完成再落库) */
function getSelectionPayload() {
return {
overwrite: overwrite.value,
sourceFile: preview.value?.sourceFile || '',
supplierName: preview.value?.supplierName || '',
effectiveDate: preview.value?.effectiveDate || '',
products: selected.value.map(row => ({ ...row, productType: 'AR' }))
}
}
/** 按现有导入逻辑保存;basic 由创建页传入名称/备注 */
async function submit(payload = {}) {
if (!validateReady()) {
throw new Error('NO_SELECTION')
}
const productsPayload = (payload.products && payload.products.length)
? payload.products
: selected.value.map(row => ({ ...row, productType: 'AR' }))
const filteredProducts = productsPayload.map(product => ({
...product,
productType: product.productType || 'AR',
surcharges: (product.surcharges || []).filter(fee => fee && fee.needBill === true)
}))
const res = await saveLabelImport({
priceSetId: payload.priceSetId || null,
priceSetName: String(payload.priceSetName || '').trim(),
priceSetNote: payload.priceSetNote || '',
overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value,
products: filteredProducts
})
const data = res.data || {}
return {
message: data.message || res.msg || '保存成功',
priceSetId: data.priceSetId || payload.priceSetId
}
}
onBeforeUnmount(() => {
active.value = false
stopProgress()
})
defineExpose({ reset, validateReady, submit, getSelectionPayload, selectedCount })
</script>
<style scoped>
.import-form { margin-bottom: 8px; }
.form-tip { margin-left: 12px; color: #909399; font-size: 12px; }
.upload-icon { font-size: 48px; color: #c0c4cc; }
.upload-tip { color: #909399; font-size: 12px; margin-top: 6px; }
.parse-progress { margin: 12px 0; }
.parse-progress-msg { margin-top: 6px; color: #606266; font-size: 13px; }
.preview-box { margin-top: 12px; }
.preview-bar { display: flex; align-items: center; gap: 12px; margin: 10px 0; }
.preview-hint { color: #909399; font-size: 12px; }
</style>