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

916 lines
31 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="label-detail">
<section v-for="pane in panes" :key="pane.key" class="label-product">
<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="label-filter" />
<el-table
:data="displayBands(pane)"
border
size="small"
empty-text="没有重量段配置"
max-height="calc(100vh - 220px)"
:span-method="arg => bandSpanMethod(pane, arg)"
>
<el-table-column label="国家" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ pairText(row.countryName, row.countryCode) }}</span>
</template>
</el-table-column>
<el-table-column label="分区" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ pairText(row.zoneName, row.zoneCode) }}</span>
</template>
</el-table-column>
<el-table-column prop="weightLabel" label="重量段" min-width="110" show-overflow-tooltip>
<template #default="{ row }"><span>{{ row.weightLabel || '-' }}</span></template>
</el-table-column>
<el-table-column label="费用类型" width="120">
<template #default="{ row }">
<el-select v-if="isRowEditing(row)" v-model="row.feeType" size="small" style="width: 100%">
<el-option label="运费" value="FREIGHT" />
<el-option label="处理费" value="REGISTRATION" />
<el-option label="偏远费" value="REMOTE" />
</el-select>
<span v-else>{{ feeTypeText(row.feeType) }}</span>
</template>
</el-table-column>
<el-table-column label="计费条件" width="130">
<template #default="{ row }">
<el-select v-if="isRowEditing(row)" v-model="row.chargeMode" size="small" style="width: 100%" clearable>
<el-option v-for="d in lbl_charge_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<span v-else>{{ dictLabel(lbl_charge_mode, row.chargeMode) || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="金额" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="isRowEditing(row)" class="label-cell-inline">
<el-input v-model="row.price" size="small" placeholder="金额" class="label-price-input" />
<span class="label-unit-text">{{ row.priceUnit || '-' }}</span>
</div>
<span v-else>{{ moneyUnit(row.price, row.priceUnit) }}</span>
</template>
</el-table-column>
<el-table-column label="续重" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="isRowEditing(row)" class="label-cell-inline">
<el-input v-model="row.extraPrice" size="small" placeholder="续重价" class="label-price-input" />
<span class="label-unit-text">{{ row.extraPriceUnit || '-' }}</span>
</div>
<span v-else>{{ moneyUnit(row.extraPrice, row.extraPriceUnit) }}</span>
</template>
</el-table-column>
<el-table-column prop="calcParam" label="条件参数" min-width="100" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.calcParam" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="postcodeParam" label="邮编" min-width="110" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.postcodeParam" :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-tab-pane>
<el-tab-pane :label="feeTabLabel(pane)" name="fee">
<div class="label-edit-bar">
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、价格、币种、计费条件或费用解释" class="label-filter" @input="pane.feePageNo = 1" />
<el-button v-if="editable" size="small" type="primary" plain @click="openFeePicker(pane)">添加附加费</el-button>
<el-button
v-if="editable"
size="small"
type="danger"
plain
:disabled="!feeCheckedCount(pane)"
@click="removeCheckedFees(pane)"
>批量删除</el-button>
</div>
<el-table :data="pageFees(pane)" border size="small" empty-text="没有附加费">
<el-table-column v-if="editable" width="48" align="center" fixed="left">
<template #header>
<el-checkbox
:model-value="isAllFilteredFeesChecked(pane)"
:indeterminate="isFeeCheckIndeterminate(pane)"
@update:model-value="v => setFilteredFeesChecked(pane, v)"
/>
</template>
<template #default="{ row }">
<el-checkbox :model-value="row._checked === true" @update:model-value="v => { row._checked = !!v }" />
</template>
</el-table-column>
<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 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="计费条件" width="140">
<template #default="{ row }">
<el-select
v-if="isRowEditing(row)"
:model-value="feeChargeModeOf(row)"
size="small"
style="width: 100%"
clearable
@update:model-value="value => setFeeChargeMode(row, value)"
>
<el-option v-for="d in feeChargeModeOptions(row)" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<span v-else>{{ dictLabel(lbl_charge_mode, feeChargeModeOf(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 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="label-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="label-hint">{{ postcodeHint(pane) }}</p>
<el-input v-model="pane.postKeyword" clearable placeholder="筛选邮编、州、城市或分区" class="label-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="label-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>
<pay-surcharge-picker-dialog
v-model="feePickerVisible"
:exclude-sc-ids="feePickerExcludeIds"
@confirm="onFeePickerConfirm"
/>
</div>
</template>
<script setup>
// 打单价卡明细:feeType / chargeMode(=calc_rule) / calcParam / extraPrice。
import { h, onMounted, watch } from 'vue'
import { ElInput } from 'element-plus'
import { listSurchargeOptions } from '@/api/price/surcharge'
import PaySurchargePickerDialog from '@/views/price/basic/pay/components/PaySurchargePickerDialog.vue'
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, lbl_charge_mode } = useDict('bms_currency', 'lbl_charge_mode')
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 || '-')
}
}
function normalizeFeeChargeMode(rule) {
const v = String(rule || '').trim().toUpperCase()
if (!v || v === 'MANUAL') return ''
if (v === 'ALWAYS' || v === 'FLAT' || v === 'ZONE' || v === 'WEIGHT' || v === 'SIZE' || v === 'RETURN') return 'ITEM'
if (v === 'FIRST_EXTRA') return 'EXTRA_KG'
return v
}
function feeChargeModeOf(row) {
return normalizeFeeChargeMode(row?.calcRule)
}
function feeChargeModeOptions(row) {
const list = (lbl_charge_mode.value || []).map(d => ({ label: d.label || d.value, value: d.value }))
const cur = feeChargeModeOf(row)
if (cur && !list.some(d => String(d.value) === String(cur))) {
list.unshift({ label: cur, value: cur })
}
return list
}
function setFeeChargeMode(row, value) {
const mode = String(value || '').trim().toUpperCase()
row.calcRule = mode || 'MANUAL'
}
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, ['countryName', 'countryCode', 'zoneCode', 'zoneName', 'postcodeParam', 'weightLabel', 'feeType', 'chargeMode', 'chargeName', 'calcParam', 'priceUnit', 'extraPriceUnit'])
}
const FEE_TYPE_ORDER = { FREIGHT: 1, REGISTRATION: 2, REMOTE: 3 }
const bandViewCache = new WeakMap()
function bandCountryKey(row) {
return pairText(row?.countryName, row?.countryCode)
}
function bandZoneKey(row) {
return bandCountryKey(row) + '|' + pairText(row?.zoneName, row?.zoneCode)
}
function bandWeightKey(row) {
return bandZoneKey(row) + '|' + String(row?.weightLabel || '-').trim()
}
function feeTypeOrder(row) {
return FEE_TYPE_ORDER[String(row?.feeType || '').toUpperCase()] || 9
}
function bandViewSignature(pane) {
const bands = pane?.item?.weightBands || []
const keyword = pane?.bandKeyword || ''
let hash = bands.length + ':' + keyword
for (let i = 0; i < bands.length; i++) {
const row = bands[i]
hash += '|' + (row?.bandId ?? i) + ':' + (row?.feeType || '') + ':' + (row?.countryCode || row?.countryName || '') + ':' + (row?.zoneCode || row?.zoneName || '') + ':' + (row?.weightLabel || '')
}
return hash
}
/** 缓存排序与 rowspan,避免每次渲染重排导致卡顿 */
function weightSortValue(row) {
const min = Number(row?.weightMin)
if (Number.isFinite(min)) return min
const label = String(row?.weightLabel || '')
const m = label.match(/(\d+(?:\.\d+)?)/)
return m ? Number(m[1]) : Number.POSITIVE_INFINITY
}
function buildBandSpans(rows, keyOf) {
const spans = new Array(rows.length).fill(1)
let i = 0
while (i < rows.length) {
let j = i + 1
const key = keyOf(rows[i])
while (j < rows.length && keyOf(rows[j]) === key) j++
spans[i] = j - i
for (let k = i + 1; k < j; k++) spans[k] = 0
i = j
}
return spans
}
function ensureBandView(pane) {
const sig = bandViewSignature(pane)
const hit = bandViewCache.get(pane)
if (hit && hit.sig === sig) return hit
const rows = filteredBands(pane).slice()
rows.sort((a, b) => {
const c = bandCountryKey(a).localeCompare(bandCountryKey(b), 'zh')
if (c) return c
const z = pairText(a?.zoneName, a?.zoneCode).localeCompare(pairText(b?.zoneName, b?.zoneCode), 'zh')
if (z) return z
const w = weightSortValue(a) - weightSortValue(b)
if (w) return w
const wl = String(a?.weightLabel || '').localeCompare(String(b?.weightLabel || ''), 'zh')
if (wl) return wl
const f = feeTypeOrder(a) - feeTypeOrder(b)
if (f) return f
return Number(a?.bandId || 0) - Number(b?.bandId || 0)
})
const view = {
sig,
rows,
countrySpans: buildBandSpans(rows, bandCountryKey),
zoneSpans: buildBandSpans(rows, bandZoneKey),
weightSpans: buildBandSpans(rows, bandWeightKey)
}
bandViewCache.set(pane, view)
return view
}
function displayBands(pane) {
return ensureBandView(pane).rows
}
function spanOf(spans, rowIndex) {
const rowspan = spans[rowIndex] || 0
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
}
function bandSpanMethod(pane, { columnIndex, rowIndex }) {
const view = ensureBandView(pane)
if (columnIndex === 0) return spanOf(view.countrySpans, rowIndex)
if (columnIndex === 1) return spanOf(view.zoneSpans, rowIndex)
if (columnIndex === 2) return spanOf(view.weightSpans, rowIndex)
return { rowspan: 1, colspan: 1 }
}
function invalidateBandView(pane) {
if (pane) bandViewCache.delete(pane)
}
function filteredFees(pane) {
return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam'])
}
function feeTabLabel(pane) {
const fees = pane.item.surcharges || []
return '附加费(' + fees.length + ')'
}
function feeCheckedCount(pane) {
return (pane?.item?.surcharges || []).filter(fee => fee && fee._checked === true).length
}
function isAllFilteredFeesChecked(pane) {
const rows = filteredFees(pane)
return rows.length > 0 && rows.every(fee => fee && fee._checked === true)
}
function isFeeCheckIndeterminate(pane) {
const rows = filteredFees(pane)
if (!rows.length) return false
const n = rows.filter(fee => fee && fee._checked === true).length
return n > 0 && n < rows.length
}
function setFilteredFeesChecked(pane, value) {
for (const fee of filteredFees(pane)) {
if (fee) fee._checked = !!value
}
}
async function removeCheckedFees(pane) {
const list = pane?.item?.surcharges
if (!list?.length) return
const checked = list.filter(fee => fee && fee._checked === true)
if (!checked.length) {
proxy.$modal.msgWarning('请先勾选要删除的附加费')
return
}
try {
await proxy.$modal.confirm(`确认删除所选 ${checked.length} 项附加费?`)
} catch {
return
}
const remain = list.filter(fee => !(fee && fee._checked === true))
pane.item.surcharges = remain
const maxPage = Math.max(1, Math.ceil(remain.length / pageSize) || 1)
if (pane.feePageNo > maxPage) pane.feePageNo = maxPage
proxy.$modal.msgSuccess(`已删除 ${checked.length} 项`)
}
const { proxy } = getCurrentInstance()
const feePickerVisible = ref(false)
const feePickerPane = ref(null)
const feePickerExcludeIds = computed(() => {
const fees = feePickerPane.value?.item?.surcharges || []
return fees.map(fee => fee?.scId).filter(id => id != null)
})
function openFeePicker(pane) {
feePickerPane.value = pane
feePickerVisible.value = true
}
function toCalcRule(chargeType) {
const t = String(chargeType || '').trim().toUpperCase()
if (!t || t === 'RATE') return 'MANUAL'
if (t === 'FIXED') return 'ITEM'
const allowed = ['ITEM', 'PER_KG', 'ALWAYS', 'POSTCODE', 'ZONE', 'EXTRA_KG', 'WEIGHT', 'SIZE', 'RETURN', 'MANUAL']
if (t === 'ALWAYS') return 'ITEM'
return allowed.includes(t) ? t : 'MANUAL'
}
function configToFeeRow(cfg, product) {
const currency = String(cfg?.currency || product?.currency || 'EUR').trim().toUpperCase() || 'EUR'
const amount = cfg?.chargeValue
const isPercent = String(cfg?.chargeType || '').toUpperCase() === 'RATE'
return {
feeName: String(cfg?.scName || '').trim(),
scId: cfg?.scId,
zoneName: String(cfg?.blockTitle || cfg?.countryCode || '').trim(),
chargeText: '',
amount: amount == null ? '' : amount,
currency,
priceUnit: isPercent ? '%' : (currency + '/ITEM'),
feeDesc: String(cfg?.scNote || '').trim(),
calcRule: toCalcRule(cfg?.chargeType),
calcParam: ''
}
}
function onFeePickerConfirm(list) {
const pane = feePickerPane.value
if (!pane?.item) return
if (!pane.item.surcharges) pane.item.surcharges = []
const existed = new Set(
(pane.item.surcharges || []).map(fee => fee?.scId).filter(id => id != null).map(id => String(id))
)
let added = 0
let skipped = 0
for (const cfg of list || []) {
if (cfg?.scId != null && existed.has(String(cfg.scId))) {
skipped++
continue
}
const row = configToFeeRow(cfg, pane.item)
if (!row.feeName) continue
pane.item.surcharges.push(row)
if (row.scId != null) existed.add(String(row.scId))
added++
}
if (added > 0) {
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
pane.tab = 'fee'
}
if (added && skipped) {
proxy.$modal.msgSuccess(`已添加 ${added} 项,跳过重复 ${skipped} 项`)
} else if (added) {
proxy.$modal.msgSuccess(`已添加 ${added} 项附加费`)
} else {
proxy.$modal.msgWarning('没有可添加的附加费(可能已全部存在)')
}
}
function removeRow(list, row) {
const index = list.indexOf(row)
if (index >= 0) list.splice(index, 1)
for (const pane of panes.value) {
if (pane?.item?.weightBands === list || pane?.item?.surcharges === list) invalidateBandView(pane)
}
}
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 feeTypeText(type) {
const map = {
FREIGHT: '运费',
REGISTRATION: '处理费',
REMOTE: '偏远费'
}
return map[String(type || '')] || (type || '-')
}
function pairText(name, code) {
const a = String(name ?? '').trim()
const b = String(code ?? '').trim()
if (!a) return b || '-'
if (!b || a === b) return a
const compact = a.replace(/\s/g, '').toUpperCase()
const codeU = b.toUpperCase()
// 名称已含码(如 奥地利(AT))不再拼
if (compact.includes('(' + codeU + ')') || compact.includes('/' + codeU) || compact.endsWith(codeU)) return a
return `${a} (${b})`
}
function dictLabel(options, value) {
const v = String(value ?? '').trim()
if (!v) return ''
const hit = (options || []).find(d => String(d.value) === v)
return hit ? (hit.label || v) : v
}
function moneyUnit(amount, unit) {
const a = money(amount)
const u = String(unit ?? '').trim()
if (a === '-' && !u) return '-'
if (a === '-') return u || '-'
return u ? `${a} ${u}` : a
}
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>
.label-product + .label-product {
margin-top: 18px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
.label-filter {
width: 320px;
}
.label-edit-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.label-hint {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.label-page {
margin-top: 8px;
justify-content: flex-end;
}
.label-cond-hint {
line-height: 1.5;
color: var(--el-text-color-regular);
}
.limit-tag {
margin-right: 6px;
}
.label-cell-pair {
display: flex;
flex-direction: column;
gap: 4px;
}
.label-cell-inline {
display: flex;
align-items: center;
gap: 6px;
}
.label-price-input {
flex: 1;
min-width: 0;
}
.label-unit-text {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.4;
padding: 0 2px;
white-space: nowrap;
}
</style>