353 lines
13 KiB
Vue
353 lines
13 KiB
Vue
<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>
|