新增路线产品

This commit is contained in:
liuminghui
2026-11-06 13:39:26 +08:00
parent b4da248815
commit 6f342d424f
32 changed files with 1850 additions and 785 deletions
@@ -1,291 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="附加费配置"
width="1280px"
top="6vh"
append-to-body
destroy-on-close
@closed="reset"
>
<div v-loading="loading">
<p class="fee-tip">还没配置的产品共用一页,保存后按产品各写一份。已经配置过的产品单独一页,互不影响。</p>
<el-tabs v-if="tabs.length > 1" v-model="active">
<el-tab-pane v-for="tab in tabs" :key="tab.key" :label="tab.label" :name="tab.key" />
</el-tabs>
<div v-if="current" class="fee-bar">
<span>{{ currentHint }}</span>
<el-button type="primary" plain icon="Plus" @click="addRow">添加一行</el-button>
</div>
<el-table v-if="current" :data="current.rules" border size="small" max-height="460" empty-text="还没有附加费">
<el-table-column label="附加费名称" min-width="180">
<template #default="{ row }">
<el-select v-model="row.feeName" filterable clearable placeholder="选择附加费">
<el-option v-for="item in feeOptions(row)" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="启用" width="72" align="center">
<template #default="{ row }">
<el-switch v-model="row.enabled" active-value="1" inactive-value="0" />
</template>
</el-table-column>
<el-table-column label="触发类型" width="120">
<template #default="{ row }">
<el-select v-model="row.triggerType">
<el-option v-for="item in triggerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="最长边cm" width="108">
<template #default="{ row }"><el-input-number v-model="row.sideMaxCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="长+宽+高cm" width="116">
<template #default="{ row }"><el-input-number v-model="row.sumMaxCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="体积cm³" width="116">
<template #default="{ row }"><el-input-number v-model="row.volumeMaxCm3" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="最小长" width="96">
<template #default="{ row }"><el-input-number v-model="row.minLengthCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="最小宽" width="96">
<template #default="{ row }"><el-input-number v-model="row.minWidthCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="最小高" width="96">
<template #default="{ row }"><el-input-number v-model="row.minHeightCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="重量上限kg" width="112">
<template #default="{ row }"><el-input-number v-model="row.weightMaxKg" :min="0" :precision="3" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="命中方式" width="128">
<template #default="{ row }">
<el-select v-model="row.joinMode">
<el-option label="任一超出" value="ANY" />
<el-option label="全部超出" value="ALL" />
</el-select>
</template>
</el-table-column>
<el-table-column label="说明" min-width="150">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="500" placeholder="不参与判断" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="removeRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { listFeeRule, saveFeeRule } from '@/api/price/pay'
import { useDict } from '@/utils/dict'
const { proxy } = getCurrentInstance()
const { bms_surcharge_type } = useDict('bms_surcharge_type')
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const tabs = ref([])
const active = ref('')
const triggerOptions = [
{ label: '尺寸', value: 'SIZE' },
{ label: '体积', value: 'VOLUME' },
{ label: '重量', value: 'WEIGHT' },
{ label: '邮编', value: 'POSTCODE' },
{ label: '每票', value: 'ALWAYS' },
{ label: '退件', value: 'RETURN' }
]
const current = computed(() => tabs.value.find(tab => tab.key === active.value) || null)
const currentHint = computed(() => {
if (!current.value) return ''
if (current.value.productIds.length > 1) return `同时写入:${current.value.productName}`
return tabs.value.length === 1 ? current.value.productName : ''
})
function blankRule() {
return {
feeName: '',
enabled: '0',
triggerType: 'ALWAYS',
sideMaxCm: null,
sumMaxCm: null,
volumeMaxCm3: null,
minLengthCm: null,
minWidthCm: null,
minHeightCm: null,
weightMaxKg: null,
joinMode: 'ANY',
remark: ''
}
}
function copyRule(row) {
const next = blankRule()
next.feeName = row.feeName || ''
next.enabled = row.enabled === '1' ? '1' : '0'
next.triggerType = row.triggerType || 'ALWAYS'
next.sideMaxCm = toNumber(row.sideMaxCm)
next.sumMaxCm = toNumber(row.sumMaxCm)
next.volumeMaxCm3 = toNumber(row.volumeMaxCm3)
next.minLengthCm = toNumber(row.minLengthCm)
next.minWidthCm = toNumber(row.minWidthCm)
next.minHeightCm = toNumber(row.minHeightCm)
next.weightMaxKg = toNumber(row.weightMaxKg)
next.joinMode = row.joinMode || 'ANY'
next.remark = row.remark || ''
return next
}
function toNumber(value) {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
function feeOptions(row) {
const list = bms_surcharge_type.value || []
if (row.feeName && !list.some(item => item.value === row.feeName)) {
return [{ label: row.feeName, value: row.feeName }, ...list]
}
return list
}
function addRow() {
current.value?.rules.push(blankRule())
}
function removeRow(index) {
if (!current.value) return
current.value.rules.splice(index, 1)
if (!current.value.rules.length) current.value.rules.push(blankRule())
}
function rowTouched(row) {
return !!(row.feeName || row.remark || row.enabled === '1'
|| (row.triggerType && row.triggerType !== 'ALWAYS')
|| (row.joinMode && row.joinMode !== 'ANY')
|| [row.sideMaxCm, row.sumMaxCm, row.volumeMaxCm3, row.minLengthCm, row.minWidthCm, row.minHeightCm, row.weightMaxKg]
.some(value => value !== null && value !== undefined && value !== ''))
}
function keptRules(tab) {
const kept = []
const names = new Set()
for (const row of tab.rules) {
if (!rowTouched(row)) continue
const feeName = (row.feeName || '').trim()
if (!feeName) {
proxy.$modal.msgError(`${tab.productName || '产品'}有未选择名称的附加费`)
return null
}
if (names.has(feeName)) {
proxy.$modal.msgError(`${tab.productName || '产品'}的附加费「${feeName}」重复了`)
return null
}
names.add(feeName)
kept.push({ ...row, feeName, remark: (row.remark || '').trim() })
}
return kept
}
function save() {
const products = []
for (const tab of tabs.value) {
const rules = keptRules(tab)
if (!rules) return
for (const productId of tab.productIds) {
products.push({ productId, rules: rules.map(row => ({ ...row })) })
}
}
if (!products.length) return
saving.value = true
saveFeeRule(products).then(() => {
proxy.$modal.msgSuccess('附加费配置已保存')
visible.value = false
}).finally(() => {
saving.value = false
})
}
function reset() {
tabs.value = []
active.value = ''
loading.value = false
saving.value = false
}
function open(rows) {
const picked = (rows || []).filter(row => row && row.productId != null)
if (!picked.length) return
reset()
visible.value = true
loading.value = true
listFeeRule(picked.map(row => row.productId)).then(res => {
const groups = res.data || []
const unbound = groups.filter(group => !group.rules?.length)
const bound = groups.filter(group => group.rules?.length)
const next = []
if (unbound.length) {
const names = unbound.map(group => group.productName || `产品${group.productId}`)
next.push({
key: 'unbound',
label: '未配置',
productIds: unbound.map(group => group.productId),
productName: names.join('、'),
rules: [blankRule()]
})
}
const nameCount = bound.reduce((map, group) => {
const name = group.productName || ''
map.set(name, (map.get(name) || 0) + 1)
return map
}, new Map())
bound.forEach(group => {
const name = group.productName || `产品${group.productId}`
next.push({
key: `bound-${group.productId}`,
label: nameCount.get(group.productName || '') > 1 ? `${name}(${group.productId})` : name,
productIds: [group.productId],
productName: name,
rules: group.rules.map(copyRule)
})
})
tabs.value = next
active.value = next.length ? next[0].key : ''
}).catch(() => {
visible.value = false
}).finally(() => {
loading.value = false
})
}
defineExpose({ open })
</script>
<style scoped>
.fee-tip {
margin: 0 0 12px;
color: var(--el-text-color-secondary);
}
.fee-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -28,6 +28,18 @@
</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>
<div v-if="preview" v-loading="recognizing" class="preview-box">
<el-alert
:title="`${preview.sourceFile || ''} 归属 ${preview.supplierName || '-'} 生效 ${preview.effectiveDate || '-'} 共 ${products.length} 个产品`"
@@ -97,7 +109,7 @@
<script setup>
import { ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { previewPay, savePay } from '@/api/price/pay'
import { previewPay, payParseProgress, savePay } from '@/api/price/pay'
import { useDict } from '@/utils/dict'
import PayConfigDetail from './PayConfigDetail.vue'
@@ -116,17 +128,24 @@ const selected = ref([])
const tableRef = ref()
const activeKey = ref('')
const uploadKey = ref(0)
const parsePercent = ref(0)
const parseMessage = ref('')
const parseStatus = ref(undefined)
const products = computed(() => preview.value?.products || [])
const activeProduct = computed(() => products.value.find(item => item.rowKey === activeKey.value) || products.value[0])
const skippedText = computed(() => (preview.value?.skippedSheets || []).filter(Boolean).join('、'))
let parseSeq = 0
let progressTimer = null
function open() {
reset()
visible.value = true
}
function reset() {
parseSeq += 1
preview.value = null
selected.value = []
overwrite.value = false
@@ -135,6 +154,10 @@ function reset() {
activeKey.value = ''
recognizing.value = false
saving.value = false
parsePercent.value = 0
parseMessage.value = ''
parseStatus.value = undefined
stopProgress()
uploadKey.value += 1
}
@@ -145,9 +168,26 @@ async function onFile(uploadFile) {
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 previewPay(raw, dictValue.value)
const res = await previewPay(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 = '正在上传价卡…'
}
})
if (seq !== parseSeq) return
parsePercent.value = 100
parseMessage.value = '解析完成'
parseStatus.value = 'success'
const data = res.data || {}
data.products = (data.products || []).map((item, index) => ({
...item,
@@ -160,8 +200,48 @@ async function onFile(uploadFile) {
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
} catch (error) {
if (seq !== parseSeq) return
parseStatus.value = 'exception'
parseMessage.value = error?.message || '价卡解析失败'
} finally {
recognizing.value = false
if (seq === parseSeq) {
stopProgress()
recognizing.value = false
}
}
}
function createProgressId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID()
return `pay${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 payParseProgress(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 {
// 进度查询失败时继续等预览结果
}
}, 400)
}
function stopProgress() {
if (progressTimer) {
window.clearInterval(progressTimer)
progressTimer = null
}
}
@@ -262,6 +342,14 @@ defineExpose({ open })
.import-form {
margin-bottom: 8px;
}
.parse-progress {
margin-top: 14px;
}
.parse-progress-msg {
margin-top: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
}
.sheet-scroll {
display: flex;
gap: 8px;
@@ -13,9 +13,6 @@
<el-col :span="1.5">
<el-button type="primary" plain icon="Upload" @click="importRef.open()" v-hasPermi="['price:pay:import']">导入价卡</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="primary" plain icon="Setting" :disabled="!selection.length" @click="feeRuleRef.open(selection)" v-hasPermi="['price:pay:edit']">附加费配置</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="!selection.length" @click="handleDelete(ids)" v-hasPermi="['price:pay:remove']">删除</el-button>
</el-col>
@@ -63,7 +60,6 @@
</basic-table>
<pay-import-dialog ref="importRef" @saved="tableRef.refresh()" />
<pay-fee-rule-dialog ref="feeRuleRef" />
<el-drawer v-model="historyVisible" :title="historyTitle" size="980px" destroy-on-close>
<div class="history-bar">
@@ -154,12 +150,10 @@
import { listPay, getPay, delPay, updatePay, listPayTerm, updatePayTerm, changePayTermStatus, comparePay } from '@/api/price/pay'
import PayImportDialog from './components/PayImportDialog.vue'
import PayConfigDetail from './components/PayConfigDetail.vue'
import PayFeeRuleDialog from './components/PayFeeRuleDialog.vue'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const importRef = ref()
const feeRuleRef = ref()
const detailVisible = ref(false)
const detailLoading = ref(false)
const editing = ref(false)