Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
liuminghui
2026-11-13 11:57:40 +08:00
37 changed files with 11647 additions and 160 deletions
+84 -3
View File
@@ -69,15 +69,76 @@ export function labelParseProgress(progressId) {
})
}
/** 提交异步保存,返回 { taskId } */
export function saveLabelImport(data) {
return request({
url: '/price/label/importSave',
method: 'post',
data,
timeout: 10 * 60 * 1000
headers: { repeatSubmit: false },
timeout: 60 * 1000
})
}
export function labelSaveProgress(taskId) {
return request({
url: '/price/label/importSave/task/' + taskId,
method: 'get',
headers: { repeatSubmit: false }
})
}
function pickLabelSaveTaskId(res) {
if (!res) return ''
const d = res.data
if (d && typeof d === 'object' && d.taskId) return String(d.taskId)
if (typeof d === 'string' && d.trim()) return d.trim()
if (res.taskId) return String(res.taskId)
if (typeof res.msg === 'string') {
const m = res.msg.trim()
if (/^[a-f0-9]{32}$/i.test(m)) return m
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) {
return m.replace(/-/g, '')
}
}
return ''
}
/** 提交并轮询直到成功/失败;onProgress({ percent, message, status }) */
export async function saveLabelImportAndWait(data, onProgress) {
const res = await saveLabelImport(data)
const taskId = pickLabelSaveTaskId(res)
if (!taskId) {
throw new Error('未返回保存任务编号')
}
const started = Date.now()
const timeoutMs = 10 * 60 * 1000
while (Date.now() - started < timeoutMs) {
const prog = await labelSaveProgress(taskId)
const task = (prog && prog.data) || prog || {}
if (typeof onProgress === 'function') {
onProgress({
percent: Number(task.percent) || 0,
message: task.message || '',
status: task.status || 'PENDING',
priceSetId: task.priceSetId,
error: task.error
})
}
if (task.status === 'SUCCESS') {
return {
message: task.message || '保存成功',
priceSetId: task.priceSetId
}
}
if (task.status === 'FAIL') {
throw new Error(task.error || task.message || '保存失败')
}
await new Promise(r => setTimeout(r, 400))
}
throw new Error('保存超时,请稍后在列表中查看')
}
export function listLabelVersions(priceSetId) {
return request({
url: '/price/label/' + priceSetId + '/versions',
@@ -147,11 +208,11 @@ export function delLabelCustomer(ids) {
})
}
export function getLabelProduct(productId, termId) {
export function getLabelProduct(productId, versionId) {
return request({
url: '/price/label/product/' + productId,
method: 'get',
params: termId ? { termId } : undefined
params: versionId ? { versionId } : undefined
})
}
@@ -166,6 +227,26 @@ export function updateLabelProduct(data) {
})
}
/** 仅保存当前重量段行 */
export function updateLabelWeightBand(data) {
return request({
url: '/price/label/product/band',
method: 'put',
data,
headers: { repeatSubmit: false }
})
}
/** 仅保存当前附加费行 */
export function updateLabelSurcharge(data) {
return request({
url: '/price/label/product/surcharge',
method: 'put',
data,
headers: { repeatSubmit: false }
})
}
export function removeLabelProducts(priceSetId, productIds) {
return request({
url: '/price/label/' + priceSetId + '/products/' + productIds,
@@ -0,0 +1,906 @@
<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 prop="zoneCode" label="分区编码" width="110" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.zoneCode" :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 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)" :model-value="feeBillFlag(row)" size="small" @change="value => setFeeBill(row, value)">
<el-option label="需要计费" value="BILL" />
<el-option label="暂不计费" value="MANUAL" />
</el-select>
<span v-else>{{ feeBillFlag(row) === 'BILL' ? '需要计费' : '暂不计费' }}</span>
</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 feeBillFlag(row) {
return row?.calcRule && row.calcRule !== 'MANUAL' ? 'BILL' : 'MANUAL'
}
function setFeeBill(row, value) {
if (value === 'MANUAL') {
row.calcRule = 'MANUAL'
return
}
if (!row.calcRule || row.calcRule === 'MANUAL') row.calcRule = 'ALWAYS'
}
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', 'zoneCode', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
}
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 'ALWAYS'
const allowed = ['ALWAYS', 'POSTCODE', 'ZONE', 'EXTRA_KG', 'WEIGHT', 'SIZE', 'RETURN', 'MANUAL']
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>
@@ -13,9 +13,13 @@
:price-set-id="priceSetId"
@parsed="onParsed"
/>
<div v-if="saving" class="save-progress">
<el-progress :percentage="savePercent" :stroke-width="14" :status="saveStatus" striped striped-flow :duration="12" />
<div class="save-progress-msg">{{ saveMessage }}</div>
</div>
<template #footer>
<el-button @click="visible = false">{{ autoFillOnParse ? '关闭' : '取消' }}</el-button>
<el-button v-if="!autoFillOnParse" type="primary" :loading="saving" @click="save">保存</el-button>
<el-button :disabled="saving" @click="visible = false">{{ autoFillOnParse ? '关闭' : '取消' }}</el-button>
<el-button v-if="!autoFillOnParse" type="primary" :loading="saving" :disabled="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
@@ -38,6 +42,9 @@ const { proxy } = getCurrentInstance()
const visible = ref(false)
const saving = ref(false)
const savePercent = ref(0)
const saveMessage = ref('')
const saveStatus = ref(undefined)
const panelRef = ref()
const autoFilled = ref(false)
@@ -50,6 +57,9 @@ function open() {
function onClosed() {
panelRef.value?.reset()
saving.value = false
savePercent.value = 0
saveMessage.value = ''
saveStatus.value = undefined
autoFilled.value = false
}
@@ -76,12 +86,30 @@ async function save() {
return
}
saving.value = true
savePercent.value = 1
saveStatus.value = undefined
saveMessage.value = '正在提交保存任务…'
try {
const result = await panelRef.value.submit({ priceSetId: props.priceSetId })
const result = await panelRef.value.submit({
priceSetId: props.priceSetId,
onProgress: prog => {
const pct = Math.min(99, Number(prog.percent) || 0)
if (pct >= savePercent.value) savePercent.value = pct
if (prog.message) saveMessage.value = prog.message
if (prog.status === 'FAIL') saveStatus.value = 'exception'
}
})
savePercent.value = 100
saveStatus.value = 'success'
saveMessage.value = result.message || '保存成功'
visible.value = false
ElMessageBox.close()
proxy.$modal.msgSuccess(result.message || '保存成功')
emit('saved', { priceSetId: result.priceSetId || props.priceSetId })
} catch (e) {
saveStatus.value = 'exception'
saveMessage.value = e?.message || '保存失败'
proxy.$modal.msgError(e?.message || '保存失败')
} finally {
saving.value = false
}
@@ -89,3 +117,8 @@ async function save() {
defineExpose({ open })
</script>
<style scoped>
.save-progress { margin: 8px 0 0; padding: 8px 4px 0; }
.save-progress-msg { margin-top: 6px; color: #606266; font-size: 13px; }
</style>
@@ -5,11 +5,6 @@
<el-tag type="success">应收 AR</el-tag>
<span class="form-tip">打单套餐固定应收,不显示建价选项,直接 Excel 导入</span>
</el-form-item>
<el-form-item label="产品">
<el-select v-model="dictValue" clearable filterable placeholder="不选则按工作表名称匹配全部字典产品" style="width: 420px">
<el-option v-for="item in payment_product_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<el-upload
@@ -27,7 +22,7 @@
<div class="upload-tip">
{{ autoFillOnParse
? '解析成功后自动回填到创建页产品价卡列表;打单保存与应付独立,弹窗内不展示表格。'
: '模板以威狮 B2C 末端派送价卡为准。选一个产品就只读对应工作表。' }}
: '模板以威狮 B2C 末端派送价卡为准;按工作表名称匹配字典产品。' }}
</div>
</template>
</el-upload>
@@ -47,7 +42,7 @@
/>
<div class="preview-bar">
<el-switch v-model="overwrite" active-text="本套餐已存在时另存为新版本" />
<span class="preview-hint">仅本打单套餐维度;与应付公共价卡互不影响。开启后同产品编号不变,旧价留在历史 term。</span>
<span class="preview-hint">仅本打单套餐维度;与应付公共价卡互不影响。开启后同产品编号不变,旧价留在历史版本。</span>
</div>
<el-table
ref="tableRef"
@@ -60,22 +55,10 @@
>
<el-table-column type="selection" width="48" :selectable="canSelect" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="chargeType" label="费用类型" min-width="110" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
<el-table-column prop="relatedSheet" label="关联分区表" min-width="120" show-overflow-tooltip />
<el-table-column prop="weightCount" label="重量段" width="80" align="right" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="right" />
<el-table-column label="识别" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.parseStatus)" size="small">{{ statusText(row.parseStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="库中" width="90">
<template #default="{ row }">
<el-tag v-if="row.duplicated" type="warning" size="small">已存在</el-tag>
<span v-else>新记录</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="View" @click="openDetail(row)">查看</el-button>
@@ -87,7 +70,7 @@
</div>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail :product="detailProduct" :editable="false" />
<label-config-detail :product="detailProduct" :editable="false" />
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
@@ -97,9 +80,8 @@
<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import { previewLabel, labelParseProgress, saveLabelImport } from '@/api/price/label'
import { useDict } from '@/utils/dict'
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
import { previewLabel, labelParseProgress, saveLabelImportAndWait } from '@/api/price/label'
import LabelConfigDetail from './LabelConfigDetail.vue'
const props = defineProps({
/** 仅创建页开启:解析成功后立刻抛出勾选结果,其它入口保持预览后手动保存 */
@@ -109,11 +91,8 @@ const props = defineProps({
})
const emit = defineEmits(['parsed'])
const { proxy } = getCurrentInstance()
const { payment_product_type } = useDict('payment_product_type')
const recognizing = ref(false)
const overwrite = ref(false)
const dictValue = ref('')
const preview = ref(null)
const selected = ref([])
const tableRef = ref()
@@ -141,7 +120,6 @@ function reset() {
preview.value = null
selected.value = []
overwrite.value = false
dictValue.value = ''
recognizing.value = false
parsePercent.value = 0
parseMessage.value = ''
@@ -172,7 +150,7 @@ async function onFile(uploadFile) {
parseStatus.value = undefined
startProgress(seq, progressId)
try {
const res = await previewLabel(raw, dictValue.value, progressId, event => {
const res = await previewLabel(raw, '', progressId, event => {
if (seq !== parseSeq || !event?.total) return
const uploaded = Math.min(8, Math.round(event.loaded * 8 / event.total))
if (uploaded > parsePercent.value) {
@@ -202,8 +180,8 @@ async function onFile(uploadFile) {
} else {
proxy.$modal.msgWarning(
props.priceSetId
? '没有可导入的产品,请调整产品筛选或开启「本套餐已存在时另存为新版本」后重试'
: '没有可导入的产品,请调整产品筛选或检查价卡工作表是否匹配字典'
? '没有可导入的产品,请开启「本套餐已存在时另存为新版本」后重试'
: '没有可导入的产品,请检查价卡工作表是否匹配字典'
)
}
} catch (error) {
@@ -314,16 +292,15 @@ async function submit(payload = {}) {
...product,
productType: product.productType || 'AR'
}))
const res = await saveLabelImport({
const data = await saveLabelImportAndWait({
priceSetId: payload.priceSetId || null,
priceSetName: String(payload.priceSetName || '').trim(),
priceSetNote: payload.priceSetNote || '',
overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value,
products: productsForSave
})
const data = res.data || {}
}, payload.onProgress)
return {
message: data.message || res.msg || '保存成功',
message: data.message || '保存成功',
priceSetId: data.priceSetId || payload.priceSetId
}
}
@@ -15,12 +15,13 @@
:data="filteredRows"
border
height="calc(100vh - 220px)"
row-key="termId"
row-key="versionId"
>
<el-table-column prop="productId" :label="colId" width="90" align="center" />
<el-table-column prop="productName" :label="colName" min-width="150" show-overflow-tooltip />
<el-table-column prop="chargeType" :label="colChargeType" min-width="110" show-overflow-tooltip />
<el-table-column :label="colTerm" min-width="180" align="center">
<template #default="{ row }">{{ termText(row) }}</template>
<template #default="{ row }">{{ versionText(row) }}</template>
</el-table-column>
<!-- <el-table-column :label="colStatus" width="90" align="center">
<template #default="{ row }">
@@ -41,23 +42,13 @@
</el-table>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail
<label-config-detail
v-loading="detailLoading"
:product="detail"
:editable="canEditDetail"
:saving-row-key="savingRowKey"
@save-row="saveDetailRow"
/>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button
v-if="canEditDetail"
type="primary"
:loading="detailSaving"
v-hasPermi="['price:label:edit']"
@click="saveDetail"
>保存明细</el-button>
</template>
</el-drawer>
<el-drawer
@@ -139,12 +130,14 @@ import {
listLabelProducts,
getLabelProduct,
updateLabelProduct,
updateLabelWeightBand,
updateLabelSurcharge,
listLabelProductChannels,
replaceLabelProductChannels
} from '@/api/price/label'
import { checkPermi } from '@/utils/permission'
import { listChannelOptions } from '@/api/masterdata/serviceChannel'
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
import LabelConfigDetail from './LabelConfigDetail.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -180,6 +173,7 @@ const phSearch = '产品名称 / 绑定渠道'
const colId = '产品编号'
const colName = '产品名称'
const colTerm = '有效期'
const colChargeType = '费用类型'
const colStatus = '状态'
const colWeight = '重量段'
const colSc = '附加费'
@@ -232,9 +226,9 @@ function formatHour(val) {
return text
}
function termText(row) {
const start = formatHour(row.termStart || row.effectiveDate || '')
const end = row.termEnd ? formatHour(row.termEnd) : '不限'
function versionText(row) {
const start = formatHour(row.versionStart || row.effectiveDate || '')
const end = row.versionEnd ? formatHour(row.versionEnd) : '不限'
if (!start) return '未设置'
return start + ' ~ ' + end
}
@@ -279,7 +273,11 @@ async function load() {
function cleanMoney(product) {
if (!product) return
;(product.weightBands || []).forEach(row => {
if (row && row.price === '') row.price = null
if (!row) return
if (row.price === '') row.price = null
if (row.extraPrice === '') row.extraPrice = null
if (row.weightMin === '') row.weightMin = null
if (row.weightMax === '') row.weightMax = null
})
;(product.surcharges || []).forEach(row => {
if (row && row.amount === '') row.amount = null
@@ -292,26 +290,13 @@ async function persistDetail() {
}
cleanMoney(detail.value)
await updateLabelProduct(detail.value)
const termId = detail.value.termId
const res = await getLabelProduct(detail.value.productId, termId)
const versionId = detail.value.versionId || detail.value.termId
const res = await getLabelProduct(detail.value.productId, versionId)
detail.value = res.data || detail.value
await load()
emit('changed')
}
async function saveDetail() {
if (!detail.value || detailSaving.value || savingRowKey.value) return
detailSaving.value = true
try {
await persistDetail()
proxy.$modal.msgSuccess('明细已保存')
} catch (e) {
// request 拦截器已提示
} finally {
detailSaving.value = false
}
}
async function saveDetailRow(payload) {
if (!detail.value || savingRowKey.value || detailSaving.value) {
payload?.done?.(false)
@@ -320,7 +305,27 @@ async function saveDetailRow(payload) {
savingRowKey.value = payload?.rowKey || 'row'
let ok = false
try {
await persistDetail()
const row = payload?.row
if (payload?.kind === 'band') {
if (!row?.bandId) throw new Error('NO_BAND')
if (row.price === '') row.price = null
if (row.extraPrice === '') row.extraPrice = null
await updateLabelWeightBand({
bandId: row.bandId,
feeType: row.feeType,
chargeMode: row.chargeMode,
calcParam: row.calcParam,
postcodeParam: row.postcodeParam,
price: row.price,
extraPrice: row.extraPrice
})
} else if (payload?.kind === 'fee') {
if (!row?.surchargeId) throw new Error('NO_FEE')
if (row.amount === '') row.amount = null
await updateLabelSurcharge(row)
} else {
await persistDetail()
}
ok = true
proxy.$modal.msgSuccess('该行已保存')
} catch (e) {
@@ -338,7 +343,7 @@ async function openProduct(row) {
savingRowKey.value = ''
detail.value = null
try {
const res = await getLabelProduct(row.productId, row.termId)
const res = await getLabelProduct(row.productId, row.versionId || row.termId)
detail.value = res.data || null
} finally {
detailLoading.value = false
@@ -59,6 +59,13 @@
@click="$emit('edit', row)"
v-hasPermi="['price:label:edit']"
>编辑</el-button>
<el-button
v-if="canActivate(row)"
link
type="success"
@click="$emit('activate', row)"
v-hasPermi="['price:label:edit']"
>启动</el-button>
<el-button
v-if="canReschedule(row)"
link
@@ -83,7 +90,7 @@
const props = defineProps({
versions: { type: Array, default: () => [] }
})
defineEmits(['edit', 'reschedule', 'remove', 'open-details', 'create-version'])
defineEmits(['edit', 'activate', 'reschedule', 'remove', 'open-details', 'create-version'])
const statusTab = ref('all')
@@ -133,9 +140,18 @@ function canEdit(row) {
return !!row
}
/** 仅待生效可改生效时间 */
function hasEffectiveTime(row) {
return !!(row && row.effectiveTime)
}
/** 待生效且未设生效时间:启动(启动时设置) */
function canActivate(row) {
return row && String(row.status) === '0' && !hasEffectiveTime(row)
}
/** 待生效且已设生效时间:仅修改时间 */
function canReschedule(row) {
return row && String(row.status) === '0'
return row && String(row.status) === '0' && hasEffectiveTime(row)
}
/** 仅待生效可删除整版 */
+53 -25
View File
@@ -58,22 +58,10 @@
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="chargeType" label="费用类型" min-width="110" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
<el-table-column prop="relatedSheet" label="关联分区表" min-width="120" show-overflow-tooltip />
<el-table-column prop="weightCount" label="重量段" width="80" align="right" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="right" />
<el-table-column label="识别" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.parseStatus)" size="small">{{ statusText(row.parseStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="库中" width="90">
<template #default="{ row }">
<el-tag v-if="row.duplicated" type="warning" size="small">已存在</el-tag>
<span v-else>新记录</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="Edit" @click="openDetail(row)">编辑</el-button>
@@ -84,9 +72,21 @@
</el-card>
</div>
<div v-if="saving" class="save-progress">
<el-progress
:percentage="savePercent"
:stroke-width="16"
:status="saveStatus"
striped
striped-flow
:duration="12"
/>
<div class="save-progress-msg">{{ saveMessage }}</div>
</div>
<div class="wizard-actions">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="saving" @click="finish">完成</el-button>
<el-button :disabled="saving" @click="goBack">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="saving" @click="finish">完成</el-button>
</div>
<label-import-dialog
@@ -97,7 +97,7 @@
/>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body @closed="onDetailClosed">
<pay-config-detail
<label-config-detail
:product="detailProduct"
:editable="true"
:saving-row-key="savingRowKey"
@@ -112,14 +112,17 @@ import { ElMessageBox } from 'element-plus'
import StepBasicInfo from '../set/components/create/StepBasicInfo.vue'
import { createEmptyBasicInfo } from '../set/utils/priceSet'
import LabelImportDialog from './components/LabelImportDialog.vue'
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
import { getLabelPackage, getLabelProduct, listLabelProducts, saveLabelImport } from '@/api/price/label'
import LabelConfigDetail from './components/LabelConfigDetail.vue'
import { getLabelPackage, getLabelProduct, listLabelProducts, saveLabelImportAndWait } from '@/api/price/label'
const { proxy } = getCurrentInstance()
const route = useRoute()
const basicRef = ref()
const importRef = ref()
const saving = ref(false)
const savePercent = ref(0)
const saveMessage = ref('')
const saveStatus = ref(undefined)
const loadingProducts = ref(false)
const form = reactive({
basic: createEmptyBasicInfo()
@@ -196,14 +199,14 @@ async function loadEditProducts() {
}
const details = await Promise.all(list.map(async item => {
try {
const detailRes = await getLabelProduct(item.productId, item.termId)
const detailRes = await getLabelProduct(item.productId, item.versionId || item.termId)
const detail = detailRes.data || item
const bands = detail.weightBands || []
const fees = detail.surcharges || []
const extras = detail.extraLines || []
return {
...detail,
rowKey: `${detail.sheetName || ''}-${detail.productName || ''}-${detail.termId || detail.productId || ''}`,
rowKey: `${detail.sheetName || ''}-${detail.productName || ''}-${detail.versionId || detail.termId || detail.productId || ''}`,
weightCount: bands.length || detail.weightCount || 0,
surchargeCount: fees.length || detail.surchargeCount || 0,
extraCount: extras.length || detail.extraCount || 0,
@@ -213,7 +216,7 @@ async function loadEditProducts() {
} catch {
return {
...item,
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${item.termId || item.productId || ''}`,
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${item.versionId || item.termId || item.productId || ''}`,
parseStatus: 'OK',
duplicated: true
}
@@ -384,13 +387,16 @@ async function finish() {
? `将覆盖版本 V${versionNo.value} 的 ${productRows.value.length} 个产品,确认继续?`
: newVersionMode.value
? `将为套餐「${form.basic.priceSetName || ''}」新建待生效版本并保存 ${productRows.value.length} 个产品,确认继续?`
: `将创建打单套餐并保存 ${productRows.value.length} 个应收产品,确认继续?`
: `将创建打单套餐并保存 ${productRows.value.length} 个产品,确认继续?`
try {
await proxy.$modal.confirm(tip)
} catch {
return
}
saving.value = true
savePercent.value = 1
saveStatus.value = undefined
saveMessage.value = '正在提交保存任务…'
try {
const payload = {
overwrite: editVersionMode.value ? true : importMeta.overwrite,
@@ -405,10 +411,17 @@ async function finish() {
payload.priceSetName = form.basic.priceSetName
payload.priceSetNote = form.basic.priceSetNote
}
const res = await saveLabelImport(payload)
const data = res.data || {}
const data = await saveLabelImportAndWait(payload, prog => {
const pct = Math.min(99, Number(prog.percent) || 0)
if (pct >= savePercent.value) savePercent.value = pct
if (prog.message) saveMessage.value = prog.message
if (prog.status === 'FAIL') saveStatus.value = 'exception'
})
savePercent.value = 100
saveStatus.value = 'success'
saveMessage.value = data.message || '保存成功'
ElMessageBox.close()
proxy.$modal.msgSuccess(data.message || res.msg || '保存成功')
proxy.$modal.msgSuccess(data.message || '保存成功')
const id = data.priceSetId || priceSetId.value
if (id) {
const obj = { path: '/customer/labelDetail', query: { priceSetId: id } }
@@ -416,6 +429,10 @@ async function finish() {
} else {
goBack()
}
} catch (e) {
saveStatus.value = 'exception'
saveMessage.value = e?.message || '保存失败'
proxy.$modal.msgError(e?.message || '保存失败')
} finally {
saving.value = false
}
@@ -458,4 +475,15 @@ watch([priceSetId, versionNo, () => route.query.editVersion, () => route.query.n
gap: 12px;
padding: 8px 0 24px;
}
.save-progress {
margin: 8px 0 4px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 6px;
}
.save-progress-msg {
margin-top: 8px;
color: #606266;
font-size: 13px;
}
</style>
+110 -6
View File
@@ -7,6 +7,28 @@
<el-tag size="small" type="warning" effect="plain">打单 / LABEL</el-tag>
<el-tag v-if="setInfo.status" size="small" :type="setStatusTag" effect="plain" class="status-tag">{{ setStatusText }}</el-tag>
</template>
<template #extra>
<el-button
v-if="canStartPackage"
type="success"
v-hasPermi="['price:label:edit']"
@click="startPackage"
>启动套餐</el-button>
<el-button
v-if="setInfo.status === 'E'"
type="danger"
plain
v-hasPermi="['price:label:edit']"
@click="onDisablePackage"
>停用</el-button>
<el-button
v-if="setInfo.status === 'S'"
type="success"
plain
v-hasPermi="['price:label:edit']"
@click="onEnablePackage"
>启用</el-button>
</template>
</el-page-header>
<set-customer-table :set="setInfo" @refresh="loadHeader" />
@@ -14,6 +36,7 @@
<label-version-table
:versions="versions"
@edit="openEditVersion"
@activate="openActivate"
@reschedule="openReschedule"
@remove="removeVersion"
@open-details="openVersionDetails"
@@ -29,7 +52,7 @@
<el-dialog
v-model="activateVisible"
title="修改生效时间"
:title="activateDialogTitle"
width="460px"
append-to-body
@closed="onActivateClosed"
@@ -48,11 +71,11 @@
style="width: 100%"
/>
</el-form-item>
<div class="activate-hint">仅待生效版本可修改;精确到小时。未到生效时间仍为待生效,到时自动按生效处理。</div>
<div class="activate-hint">{{ activateDialogHint }}</div>
</el-form>
<template #footer>
<el-button @click="activateVisible = false">取消</el-button>
<el-button type="primary" :loading="activateSubmitting" @click="submitActivate">确认修改</el-button>
<el-button type="primary" :loading="activateSubmitting" @click="submitActivate">{{ activateConfirmText }}</el-button>
</template>
</el-dialog>
</div>
@@ -64,6 +87,8 @@ import {
listLabelVersions,
activateLabelVersion,
deleteLabelVersion,
enableLabelPackage,
disableLabelPackage,
listLabelCustomer,
addLabelCustomer,
batchAddLabelCustomer,
@@ -135,14 +160,66 @@ const setStatusTag = computed(() => {
return map[setInfo.status] || 'info'
})
/** 待启动:有待生效且未设生效时间的版本 */
const startableVersion = computed(() =>
(versions.value || []).find(v => v && String(v.status) === '0' && !v.effectiveTime)
)
const canStartPackage = computed(() =>
setInfo.status !== 'E' && setInfo.status !== 'S' && !!startableVersion.value
)
function startPackage() {
const row = startableVersion.value
if (!row) {
proxy.$modal.msgWarning('没有可启动的版本,请先导入价卡')
return
}
openActivate(row)
}
function onDisablePackage() {
if (!priceSetId.value) return
const name = setInfo.priceSetName || ''
proxy.$modal.confirm('停用后该打单套餐不再参与计费命中,确认停用【' + name + '】?').then(() => {
return disableLabelPackage(priceSetId.value)
}).then(() => {
proxy.$modal.msgSuccess('已停用')
reloadAll()
}).catch(() => {})
}
function onEnablePackage() {
if (!priceSetId.value) return
const name = setInfo.priceSetName || ''
proxy.$modal.confirm('启用后该打单套餐将重新参与计费命中,确认启用【' + name + '】?').then(() => {
return enableLabelPackage(priceSetId.value)
}).then(() => {
proxy.$modal.msgSuccess('已启用')
reloadAll()
}).catch(() => {})
}
const activateVisible = ref(false)
const activateSubmitting = ref(false)
const activateFormRef = ref()
/** activate=启动(首次设生效时间);reschedule=修改已设时间 */
const activateMode = ref('activate')
const activateForm = reactive({
versionNo: null,
versionLabel: '',
effectiveTime: undefined
})
const activateDialogTitle = computed(() =>
activateMode.value === 'reschedule' ? '修改生效时间' : '启动版本'
)
const activateConfirmText = computed(() =>
activateMode.value === 'reschedule' ? '确认修改' : '确认启动'
)
const activateDialogHint = computed(() =>
activateMode.value === 'reschedule'
? '仅待生效版本可修改;精确到小时。未到生效时间仍为待生效,到时自动按生效处理。'
: '启动时设置生效时间;精确到小时。若晚于当前则为待生效,到时自动生效并使套餐进入生效状态。'
)
const activateRules = {
effectiveTime: [{ required: true, message: '请选择生效时间', trigger: 'change' }]
}
@@ -223,18 +300,41 @@ function toHourValue(val) {
return undefined
}
function openReschedule(row) {
function formatNowHour() {
const d = new Date()
const pad = n => String(n).padStart(2, '0')
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':00:00'
}
function openActivateDialog(row, mode) {
activateMode.value = mode
activateForm.versionNo = row.versionNo
activateForm.versionLabel = row.versionLabel || ('V' + row.versionNo)
activateForm.effectiveTime = row.effectiveTime ? toHourValue(row.effectiveTime) : undefined
if (mode === 'reschedule' && row.effectiveTime) {
activateForm.effectiveTime = toHourValue(row.effectiveTime)
} else {
activateForm.effectiveTime = formatNowHour()
}
activateVisible.value = true
}
/** 首次启动:设置生效时间 */
function openActivate(row) {
openActivateDialog(row, 'activate')
}
/** 已设生效时间后修改 */
function openReschedule(row) {
openActivateDialog(row, 'reschedule')
}
function onActivateClosed() {
activateForm.versionNo = null
activateForm.versionLabel = ''
activateForm.effectiveTime = undefined
activateSubmitting.value = false
activateMode.value = 'activate'
}
function submitActivate() {
@@ -245,7 +345,8 @@ function submitActivate() {
effectiveTime: activateForm.effectiveTime
}).then(res => {
const data = res.data || {}
proxy.$modal.msgSuccess(data.message || res.msg || '操作成功')
const fallback = activateMode.value === 'reschedule' ? '生效时间已修改' : '版本已启动'
proxy.$modal.msgSuccess(data.message || res.msg || fallback)
activateVisible.value = false
reloadAll()
}).finally(() => {
@@ -262,6 +363,9 @@ watch(priceSetId, async (id) => {
<style scoped>
.page-header { margin-bottom: 16px; }
.page-header :deep(.el-page-header__extra) {
margin-left: auto;
}
.title { font-weight: 600; margin-right: 8px; }
.code-tag { margin-right: 8px; }
.status-tag { margin-left: 8px; }
@@ -95,7 +95,6 @@ const columns = [
{ label: '套餐编码', prop: 'priceSetCode', width: 110 },
{ label: '套餐名称', prop: 'priceSetName', minWidth: 160 },
{ label: '类型', prop: 'setType', width: 140 },
{ label: '产品数', prop: 'productCount', width: 90, align: 'center' },
{ label: '说明', prop: 'priceSetNote', minWidth: 140 },
{ label: '状态', prop: 'status', width: 90 },
{ label: '操作', prop: 'action', width: 200, showInToolbar: false }