916 lines
31 KiB
Vue
916 lines
31 KiB
Vue
<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>
|