Files
BMS/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue
T
2026-11-10 16:21:07 +08:00

667 lines
25 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 v-if="panes.length" class="pay-detail">
<section v-for="pane in panes" :key="pane.key" class="pay-product">
<div class="pay-product-title">{{ pane.item.productName || '未命名产品' }}</div>
<el-descriptions :column="3" border size="small" class="pay-summary">
<el-descriptions-item label="价卡归属">{{ pane.item.supplierName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ pane.item.effectiveDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="国家/口岸">{{ pane.item.countryScope || '-' }}</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ pane.item.chargeType || '-' }}</el-descriptions-item>
<el-descriptions-item label="每公斤加价">{{ pane.item.extraPerKg || '-' }}</el-descriptions-item>
<el-descriptions-item label="服务类型">{{ pane.item.serviceType || '-' }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ pane.item.classification || '-' }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ pane.item.currency || '-' }}</el-descriptions-item>
<el-descriptions-item label="报价工作表">{{ pane.item.sheetName || '-' }}</el-descriptions-item>
<el-descriptions-item label="关联分区表">{{ pane.item.relatedSheet || '-' }}</el-descriptions-item>
<el-descriptions-item label="识别结果">{{ statusText(pane.item.parseStatus) }}</el-descriptions-item>
<el-descriptions-item label="重量限制">{{ pane.item.weightLimit || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新内容" :span="2">{{ pane.item.updateContent || '-' }}</el-descriptions-item>
<el-descriptions-item label="目录备注" :span="3">{{ pane.item.catalogRemark || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="pane.item.remark" label="备注" :span="3">{{ pane.item.remark }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="pane.tab">
<el-tab-pane :label="`重量段(${(pane.item.weightBands || []).length})`" name="band">
<el-input v-model="pane.bandKeyword" clearable placeholder="筛选国家、分区、重量段、费用项" class="pay-filter" @input="pane.bandPageNo = 1" />
<el-table :data="pageBands(pane)" border size="small" empty-text="没有重量段或起重配置">
<el-table-column prop="sectionName" label="区块" min-width="110" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.sectionName" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="countryName" label="国家" min-width="120" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.countryName" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="countryCode" label="代码" width="80" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.countryCode" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="zoneName" label="分区" min-width="140" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.zoneName" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="weightLabel" label="重量段" min-width="120" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.weightLabel" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column label="下限KG" width="90" align="right">
<template #default="{ row }">{{ money(row.weightMin) }}</template>
</el-table-column>
<el-table-column label="上限KG" width="90" align="right">
<template #default="{ row }">{{ money(row.weightMax) }}</template>
</el-table-column>
<el-table-column prop="chargeName" label="费用项" min-width="120" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.chargeName" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column label="单价" width="110" align="right">
<template #default="{ row }">
<el-input v-if="isRowEditing(row)" v-model="row.price" size="small" />
<span v-else>{{ money(row.price) }}</span>
</template>
</el-table-column>
<el-table-column prop="priceUnit" label="单位" width="110" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.priceUnit" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="!isRowEditing(row)" link type="primary" :disabled="rowBusy" @click="startEdit(row)">修改</el-button>
<el-button v-else link type="primary" :disabled="rowBusy" @click="saveBandRow(pane, row)">保存</el-button>
<el-button link type="danger" :disabled="rowBusy" @click="removeRow(pane.item.weightBands, row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="filteredBands(pane).length > pageSize"
class="pay-page"
layout="total, prev, pager, next"
:total="filteredBands(pane).length"
:page-size="pageSize"
v-model:current-page="pane.bandPageNo"
/>
</el-tab-pane>
<el-tab-pane :label="`附加费(${(pane.item.surcharges || []).length})`" name="fee">
<div class="pay-edit-bar">
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、地区、价格、货币或费用解释" class="pay-filter" @input="pane.feePageNo = 1" />
<el-button v-if="editable" size="small" type="primary" plain @click="addFee(pane)">添加附加费</el-button>
</div>
<el-table :data="pageFees(pane)" border size="small" empty-text="没有附加费">
<el-table-column prop="feeName" label="附加费用" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="isRowEditing(row)"
v-model="row.feeName"
filterable
clearable
size="small"
style="width: 100%"
:placeholder="'请选择附加费'"
@change="name => onFeeNameChange(row, name)"
>
<el-option
v-for="item in feeNameOptions(row)"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<span v-else>{{ row.feeName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="zoneName" label="地区" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.zoneName" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column label="价格" width="110" align="right">
<template #default="{ row }">
<el-input v-if="isRowEditing(row)" v-model="row.amount" size="small" />
<span v-else>{{ money(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="币种" width="110" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="isRowEditing(row)"
:model-value="feeCurrencyOf(row)"
filterable
allow-create
default-first-option
clearable
size="small"
style="width: 100%"
:placeholder="'币种'"
@update:model-value="value => setFeeCurrency(row, value)"
>
<el-option
v-for="item in currencyOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span v-else>{{ feeCurrencyOf(row) || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="单位" width="110" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="isRowEditing(row)"
:model-value="feeUnitOf(row)"
filterable
allow-create
default-first-option
clearable
size="small"
style="width: 100%"
:placeholder="'单位'"
@update:model-value="value => setFeeUnit(row, value)"
>
<el-option
v-for="item in feeUnitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<span v-else>{{ feeUnitOf(row) || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="收费标准" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<cell-text v-if="isRowEditing(row) && (!row.chargeText || feeNote(row))" v-model="row.chargeText" :edit="true" />
<span v-else>{{ feeNote(row) || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="feeDesc" label="费用解释" min-width="240" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.feeDesc" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="calcRule" label="计费条件" width="140">
<template #default="{ row }">
<el-select v-if="isRowEditing(row)" v-model="row.calcRule" size="small" placeholder="人工确认">
<el-option v-for="item in calcRules" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span v-else>{{ calcRuleLabel(row.calcRule) }}</span>
</template>
</el-table-column>
<el-table-column prop="calcParam" label="条件参数" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="!isRowEditing(row)">{{ feeCondition(row) }}</span>
<template v-else>
<div v-if="conditionHint(row)" class="pay-cond-hint">{{ conditionHint(row) }}</div>
<cell-text
v-if="showCalcParamEditor(row)"
v-model="row.calcParam"
:edit="true"
/>
</template>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="!isRowEditing(row)" link type="primary" :disabled="rowBusy" @click="startEdit(row)">修改</el-button>
<el-button v-else link type="primary" :disabled="rowBusy" @click="saveFeeRow(pane, row)">保存</el-button>
<el-button link type="danger" :disabled="rowBusy" @click="removeRow(pane.item.surcharges, row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="filteredFees(pane).length > pageSize"
class="pay-page"
layout="total, prev, pager, next"
:total="filteredFees(pane).length"
:page-size="pageSize"
v-model:current-page="pane.feePageNo"
/>
</el-tab-pane>
<el-tab-pane v-if="postcodeLines(pane).length" :label="`邮编分区(${postcodeLines(pane).length})`" name="postcode">
<p class="pay-hint">{{ postcodeHint(pane) }}</p>
<el-input v-model="pane.postKeyword" clearable placeholder="筛选邮编、州、城市或分区" class="pay-filter" @input="pane.postPageNo = 1" />
<el-table :data="pagePostcodes(pane)" border size="small" empty-text="没有邮编">
<el-table-column v-for="(title, index) in postcodeTitles(pane)" :key="title" :label="title" min-width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.parts[index] || '-' }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="filteredPostcodes(pane).length > pageSize"
class="pay-page"
layout="total, prev, pager, next"
:total="filteredPostcodes(pane).length"
:page-size="pageSize"
v-model:current-page="pane.postPageNo"
/>
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup>
// 重量段和附加费。传入 products 时一次展示全部产品。
import { h, onMounted, watch } from 'vue'
import { ElInput } from 'element-plus'
import { listSurchargeOptions } from '@/api/price/surcharge'
const props = defineProps({
product: {
type: Object,
default: null
},
products: {
type: Array,
default: () => []
},
editable: {
type: Boolean,
default: false
},
savingRowKey: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['save-row'])
const editingKeys = ref(new Set())
watch(() => props.product, () => {
editingKeys.value = new Set()
})
function rowKeyOf(row) {
if (!row) return ''
if (row.surchargeId != null) return 'fee-' + row.surchargeId
if (row.bandId != null) return 'band-' + row.bandId
if (row.__rowKey) return row.__rowKey
row.__rowKey = 'tmp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
return row.__rowKey
}
function isRowEditing(row) {
return editingKeys.value.has(rowKeyOf(row))
}
function startEdit(row) {
const next = new Set(editingKeys.value)
next.add(rowKeyOf(row))
editingKeys.value = next
}
function stopEdit(row) {
const next = new Set(editingKeys.value)
next.delete(rowKeyOf(row))
editingKeys.value = next
}
const localSavingKey = ref('')
const rowBusy = computed(() => !!(props.savingRowKey || localSavingKey.value))
function finishSave(row, ok) {
localSavingKey.value = ''
if (ok !== false) stopEdit(row)
}
function runSave(kind, pane, row) {
if (localSavingKey.value || props.savingRowKey) return
const rowKey = rowKeyOf(row)
localSavingKey.value = rowKey
let settled = false
const done = (ok) => {
if (settled) return
settled = true
finishSave(row, ok)
}
try {
emit('save-row', { kind, product: pane.item, row, rowKey, done })
} catch (e) {
done(false)
return
}
// Parent must call done(); hard stop UI lock if it does not.
window.setTimeout(() => {
if (!settled) done(false)
}, 8000)
}
function saveBandRow(pane, row) {
runSave('band', pane, row)
}
function saveFeeRow(pane, row) {
runSave('fee', pane, row)
}
const surchargeOptions = ref([])
const { bms_currency } = useDict('bms_currency')
const currencyOptions = computed(() => (bms_currency.value || []).map(item => ({
label: item.label || item.value,
value: item.value
})))
const feeUnitOptions = ['ITEM', 'KG', 'PCS', 'PARCEL', '%']
function parseFeeUnit(priceUnit) {
const text = String(priceUnit || '').trim()
if (!text) return ''
if (text === '%') return '%'
const slash = text.lastIndexOf('/')
if (slash >= 0) {
return text.slice(slash + 1).trim().toUpperCase()
}
if (/^[A-Za-z]{3}$/.test(text)) return ''
return text.toUpperCase()
}
function feeCurrencyOf(row) {
const currency = String(row?.currency || '').trim()
if (currency) return currency.toUpperCase()
const text = String(row?.priceUnit || '').trim()
const match = text.match(/^([A-Za-z]{3})(?:\/|$)/)
return match ? match[1].toUpperCase() : ''
}
function feeUnitOf(row) {
return parseFeeUnit(row?.priceUnit)
}
function syncFeePriceUnit(row, unitOverride) {
const currency = feeCurrencyOf(row)
const unit = unitOverride != null ? String(unitOverride || '').trim().toUpperCase() : feeUnitOf(row)
if (unit === '%') {
row.priceUnit = '%'
return
}
if (currency && unit) {
row.priceUnit = currency + '/' + unit
return
}
if (currency) {
row.priceUnit = currency
return
}
row.priceUnit = unit || ''
}
function setFeeCurrency(row, value) {
const currency = String(value || '').trim().toUpperCase()
row.currency = currency
syncFeePriceUnit(row)
}
function setFeeUnit(row, value) {
syncFeePriceUnit(row, value)
}
function loadSurchargeOptions() {
return listSurchargeOptions({ ssCode: 'on' }).then(res => {
surchargeOptions.value = res.data || []
}).catch(() => {
surchargeOptions.value = []
})
}
function feeNameOptions(row) {
const names = []
const seen = new Set()
for (const item of surchargeOptions.value || []) {
const name = String(item?.scName || '').trim()
if (!name || seen.has(name)) continue
seen.add(name)
names.push(name)
}
const current = String(row?.feeName || '').trim()
if (current && !seen.has(current)) {
names.unshift(current)
}
return names
}
function onFeeNameChange(row, name) {
const matched = (surchargeOptions.value || []).find(item => String(item?.scName || '').trim() === String(name || '').trim())
if (!matched) {
row.scId = undefined
return
}
row.scId = matched.scId
const currency = matched.currency || feeCurrencyOf(row)
if (currency) {
setFeeCurrency(row, currency)
}
if (!feeUnitOf(row)) {
setFeeUnit(row, 'ITEM')
}
}
onMounted(() => {
loadSurchargeOptions()
})
const CellText = {
props: { modelValue: { default: '' }, edit: Boolean },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => props.edit
? h(ElInput, {
modelValue: props.modelValue ?? '',
size: 'small',
'onUpdate:modelValue': value => emit('update:modelValue', value)
})
: h('span', props.modelValue || '-')
}
}
const calcRules = [
{ value: 'ALWAYS', label: '每票都收' },
{ value: 'POSTCODE', label: '命中邮编' },
{ value: 'ZONE', label: '命中分区' },
{ value: 'EXTRA_KG', label: '超重续收' },
{ value: 'WEIGHT', label: '超重' },
{ value: 'SIZE', label: '超尺寸' },
{ value: 'RETURN', label: '退件' },
{ value: 'MANUAL', label: '人工确认' }
]
function calcRuleLabel(value) {
return calcRules.find(item => item.value === value)?.label || '人工确认'
}
function conditionHint(row) {
const parts = []
const hasMin = row.weightMin != null && row.weightMin !== ''
const hasMax = row.weightMax != null && row.weightMax !== ''
if (hasMin || hasMax) {
parts.push(`${hasMin ? row.weightMin : ''}-${hasMax ? row.weightMax : ''}kg`)
}
if (row.postcodeParam) {
parts.push(`邮编 ${row.postcodeParam}`)
}
return parts.join(';')
}
function hasCalcParam(row) {
const text = String(row?.calcParam ?? '').trim()
if (!text) return false
return text !== String(row?.postcodeParam ?? '').trim()
}
function showCalcParamEditor(row) {
return hasCalcParam(row) || !conditionHint(row)
}
function feeCondition(row) {
const parts = []
const hint = conditionHint(row)
if (hint) parts.push(hint)
if (hasCalcParam(row)) parts.push(String(row.calcParam).trim())
return parts.join(';') || '-'
}
const pageSize = 20
const panes = ref([])
const source = computed(() => (props.products?.length ? props.products : (props.product ? [props.product] : [])))
watch(source, (list) => {
panes.value = list.map((item, index) => ({
key: item.productId || item.rowKey || `${item.sheetName || ''}-${item.productName || ''}-${index}`,
item,
tab: 'band',
bandKeyword: '',
feeKeyword: '',
postKeyword: '',
bandPageNo: 1,
feePageNo: 1,
postPageNo: 1
}))
}, { immediate: true })
function filteredBands(pane) {
return filterRows(pane.item.weightBands || [], pane.bandKeyword, ['sectionName', 'countryName', 'countryCode', 'zoneName', 'weightLabel', 'chargeName', 'priceUnit'])
}
function filteredFees(pane) {
return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
}
function addFee(pane) {
if (!pane.item.surcharges) pane.item.surcharges = []
const currency = String(pane.item.currency || 'EUR').trim().toUpperCase() || 'EUR'
const row = {
feeName: '',
scId: undefined,
zoneName: '',
chargeText: '',
amount: '',
priceUnit: currency + '/ITEM',
currency,
feeDesc: '',
calcRule: 'MANUAL',
calcParam: ''
}
pane.item.surcharges.push(row)
startEdit(row)
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
}
function removeRow(list, row) {
const index = list.indexOf(row)
if (index >= 0) list.splice(index, 1)
}
function pageBands(pane) {
return pageOf(filteredBands(pane), pane.bandPageNo)
}
function pageFees(pane) {
return pageOf(filteredFees(pane), pane.feePageNo)
}
function postcodeLines(pane) {
const related = pane.item.relatedSheet || ''
if (!related.includes('邮编')) return []
return (pane.item.extraLines || []).filter(line => line.lineType === '邮编')
}
function postcodeTitles(pane) {
const sheet = `${pane.item.sheetName || ''}${pane.item.productName || ''}`
if (sheet.includes('DE-iMile')) return ['国家', '产品', '邮编', '状态']
return ['州/地区', '城市', '地区代码', '邮编', '分区']
}
function postcodeHint(pane) {
const sheet = `${pane.item.sheetName || ''}${pane.item.productName || ''}`
if (sheet.includes('DE-iMile')) {
return '德国这块没有州和城市。状态为 Active 的邮编属于价卡里的德国邮编清单,走同一套重量价格。'
}
return '用邮编对应州和城市。分区已换成价卡上的列:city(dense) 为 City,province(no dense) 为 Province,rural(remote) 为 Rural。'
}
function filteredPostcodes(pane) {
const text = (pane.postKeyword || '').trim().toLowerCase()
const rows = postcodeLines(pane).map(line => ({ parts: String(line.lineText || '').split(' | '), raw: line.lineText || '' }))
if (!text) return rows
return rows.filter(row => row.raw.toLowerCase().includes(text))
}
function pagePostcodes(pane) {
return pageOf(filteredPostcodes(pane), pane.postPageNo)
}
function filterRows(rows, keyword, fields) {
const text = (keyword || '').trim().toLowerCase()
if (!text) return rows
return rows.filter(row => fields.some(field => String(row?.[field] ?? '').toLowerCase().includes(text)))
}
function pageOf(rows, pageNo) {
const start = (pageNo - 1) * pageSize
return rows.slice(start, start + pageSize)
}
function money(value) {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
return Number.isNaN(num) ? value : String(num)
}
/** 价格和货币已经拆开时,收费标准只留说明,不再重复「0.19 EUR/ITEM」。 */
function feeNote(row) {
const text = (row.chargeText || '').trim()
if (!text || row.amount === null || row.amount === undefined || row.amount === '') return text
const amount = Number(row.amount)
if (!Number.isFinite(amount)) return text
const match = text.match(/^\s*(\d+(?:\.\d+)?)\s*(?:EUR|GBP|USD|RMB|CNY|欧元|英镑|人民币|美元)?\s*(?:\/\s*[A-Za-z\u4e00-\u9fa5]+)?\s*([\s\S]*)$/i)
if (!match || Number(match[1]) !== amount) return text
const rest = (match[2] || '').trim()
if (!rest || rest === '%' || rest === (row.priceUnit || '') || /^[。..、,,]+$/.test(rest)) return ''
return rest
}
function periodLabel(row) {
if (row.effectiveStart || row.effectiveEnd) {
return `${row.effectiveStart || '…'} 至 ${row.effectiveEnd || '…'}`
}
return row.periodText || row.chargeTiming || '-'
}
function statusText(status) {
return { OK: '已识别', EMPTY: '未识别到配置', MISSING: '缺少工作表' }[status] || status || '-'
}
</script>
<style scoped>
.pay-product + .pay-product {
margin-top: 18px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
.pay-product-title {
margin-bottom: 8px;
font-size: 15px;
font-weight: 600;
}
.pay-summary {
margin-bottom: 12px;
}
.pay-filter {
width: 320px;
}
.pay-edit-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.pay-hint {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.pay-page {
margin-top: 8px;
justify-content: flex-end;
}
.pay-cond-hint {
line-height: 1.5;
color: var(--el-text-color-regular);
}
.limit-tag {
margin-right: 6px;
}
</style>