修改价格套餐样式结构
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="sc-cell">
|
||||
<div v-if="!list.length" class="muted">{{ emptyText }}</div>
|
||||
<el-popover
|
||||
v-else-if="popover"
|
||||
placement="top"
|
||||
:width="width"
|
||||
trigger="hover"
|
||||
>
|
||||
<template #default>
|
||||
<div class="sc-pop-list">
|
||||
<div v-for="s in list" :key="s.relId || s.scId" class="sc-pop-item">
|
||||
<div class="sc-pop-name">{{ s.scName }}</div>
|
||||
<div class="sc-pop-meta">
|
||||
{{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
|
||||
· {{ surchargeScopeLabel(s) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #reference>
|
||||
<div class="sc-tags">
|
||||
<el-tag
|
||||
v-for="s in list"
|
||||
:key="s.relId || s.scId"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>{{ surchargeTagLabel(s) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<div v-else class="sc-tags">
|
||||
<el-tag
|
||||
v-for="s in list"
|
||||
:key="s.relId || s.scId"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>{{ surchargeTagLabel(s) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="SurchargeTags">
|
||||
import { formatChargeValue, surchargeScopeLabel, surchargeTagLabel } from '../utils/priceSet'
|
||||
|
||||
defineProps({
|
||||
/** 附加费列表(refData.surchargesOf(row) 的结果) */
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
/** 是否用悬浮气泡展示明细 */
|
||||
popover: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/** 无附加费时的占位文案 */
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '无'
|
||||
},
|
||||
/** 气泡宽度 */
|
||||
width: {
|
||||
type: Number,
|
||||
default: 400
|
||||
}
|
||||
})
|
||||
|
||||
const { bms_charge_type } = useDict('bms_charge_type')
|
||||
|
||||
function chargeTypeLabel(val) {
|
||||
const found = (bms_charge_type.value || []).find(c => c.value === val)
|
||||
return found ? found.label : (val || '-')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.sc-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sc-pop-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sc-pop-item {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sc-pop-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sc-pop-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="step-card">
|
||||
<template #header><span>第 1 步 · 基本信息</span></template>
|
||||
<el-form ref="formRef" :model="basic" :rules="rules" label-width="120px" class="max-form">
|
||||
<el-form-item label="套餐名称" prop="priceSetName">
|
||||
<el-input v-model="basic.priceSetName" maxlength="50" placeholder="如:金牌电商专属套餐" />
|
||||
<div class="form-hint">显示给客户的套餐对外名称</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="计价类型" prop="setType">
|
||||
<div class="type-row">
|
||||
<div
|
||||
class="type-card"
|
||||
:class="{ selected: basic.setType === 'FIXED' }"
|
||||
@click="basic.setType = 'FIXED'"
|
||||
>
|
||||
<div class="t-title">FIXED 固定价</div>
|
||||
<div class="t-desc">可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费</div>
|
||||
</div>
|
||||
<div
|
||||
class="type-card"
|
||||
:class="{ selected: basic.setType === 'DISCOUNT' }"
|
||||
@click="basic.setType = 'DISCOUNT'"
|
||||
>
|
||||
<div class="t-title">DISCOUNT 折扣</div>
|
||||
<div class="t-desc">按默认等级价统一乘折扣,可选产品范围</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="默认折扣" prop="defaultDiscount">
|
||||
<el-input-number
|
||||
v-model="basic.defaultDiscount"
|
||||
:min="1"
|
||||
:max="99"
|
||||
:disabled="basic.setType !== 'DISCOUNT'"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit">%</span>
|
||||
<el-tag v-if="basic.setType !== 'DISCOUNT'" type="warning" size="small" style="margin-left: 8px">仅 DISCOUNT 模式生效</el-tag>
|
||||
<div class="form-hint">范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="生效日期" prop="effectiveDate">
|
||||
<el-date-picker v-model="basic.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="生效日期" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="失效日期" prop="expireDate">
|
||||
<el-date-picker v-model="basic.expireDate" type="date" value-format="YYYY-MM-DD" placeholder="空表示长期有效" />
|
||||
<div class="form-hint">空表示长期有效</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="说明备注" prop="priceSetNote">
|
||||
<el-input v-model="basic.priceSetNote" type="textarea" :rows="3" maxlength="200" placeholder="套餐业务说明,显示给客户与运营" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup name="StepBasicInfo">
|
||||
/**
|
||||
* 第 1 步 · 基本信息
|
||||
* 直接读写父级传入的 basic 对象(同一引用),校验规则由本组件维护。
|
||||
*/
|
||||
const basic = defineModel({ type: Object, required: true })
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
const rules = {
|
||||
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }],
|
||||
setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }],
|
||||
effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
|
||||
}
|
||||
|
||||
/** 供父级在下一步 / 保存前调用 */
|
||||
function validate() {
|
||||
return new Promise(resolve => {
|
||||
if (!formRef.value) return resolve(false)
|
||||
formRef.value.validate(valid => resolve(!!valid))
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.max-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
.type-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.type-card {
|
||||
flex: 1;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-card.selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.t-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.t-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="step-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>第 3 步 · 绑定客户<span class="sub">可选,可后续补充</span></span>
|
||||
<el-button type="primary" size="small" icon="Plus" @click="addRow">添加客户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="套餐创建后处于草稿状态,可暂不绑定客户。一个客户只能绑定一个套餐。"
|
||||
/>
|
||||
|
||||
<el-table :data="customers" border empty-text="暂未绑定客户">
|
||||
<el-table-column label="客户" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<customer-select
|
||||
v-model="row.customerCode"
|
||||
value-key="customerCode"
|
||||
placeholder="请选择客户"
|
||||
@change="(code, item) => onCustomerChange(row, code, item)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="优先级" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.bindPriority" :min="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生效日期" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.prBegindate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失效日期" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.prEnddate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="customers.splice($index, 1)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup name="StepCustomers">
|
||||
/**
|
||||
* 第 3 步 · 绑定客户
|
||||
* customers 为父级 form.customers 的同一数组引用;新行默认继承套餐生效/失效日期。
|
||||
*/
|
||||
import { createEmptyCustomerRow } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
/** 客户绑定行数组(父级 form.customers) */
|
||||
customers: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/** 套餐生效日期,用于新行默认值 */
|
||||
effectiveDate: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
/** 套餐失效日期,用于新行默认值 */
|
||||
expireDate: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
function addRow() {
|
||||
props.customers.push(createEmptyCustomerRow({
|
||||
effectiveDate: props.effectiveDate,
|
||||
expireDate: props.expireDate,
|
||||
seq: props.customers.length + 1
|
||||
}))
|
||||
}
|
||||
|
||||
/** 下拉选中客户后回填代码 / 名称 / ID */
|
||||
function onCustomerChange(row, code, item) {
|
||||
row.customerCode = code || undefined
|
||||
row.customerName = item ? item.customerName : ''
|
||||
row.customerId = item ? item.customerId : undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sub {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mb12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="step-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>
|
||||
第 2 步 · 套餐明细
|
||||
<span class="sub">当前:{{ setTypeLabel(setType) }}模式</span>
|
||||
</span>
|
||||
<el-button type="primary" size="small" icon="Plus" @click="addRow">添加明细行</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="isFixedType(setType)"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×重量段覆盖固定价。附加费来自产品关联,只读展示。"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="DISCOUNT 模式:可不填明细(全部产品适用默认折扣),或按产品限定折扣范围。"
|
||||
/>
|
||||
|
||||
<el-table :data="details" border empty-text="暂无明细,点击添加明细行">
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.productCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择产品"
|
||||
style="width: 100%"
|
||||
@change="onProductChange(row, setType)"
|
||||
>
|
||||
<el-option v-for="p in refData.productOptions.value" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="国家" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row._countryKey"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择国家"
|
||||
style="width: 100%"
|
||||
:disabled="!row.productCode"
|
||||
@change="onCountryChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in refData.countryOptionsOf(row)"
|
||||
:key="c.key"
|
||||
:label="c.label"
|
||||
:value="c.key"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="重量段" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.weightLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择重量段"
|
||||
style="width: 100%"
|
||||
:disabled="!row._countryKey"
|
||||
@change="onWeightChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="w in refData.weightOptionsOf(row)"
|
||||
:key="w.value"
|
||||
:label="w.label"
|
||||
:value="w.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="运费单价(RMB/KG)" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.fvValue" :min="0" :precision="4" :step="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="挂号费(RMB/票)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.fvRegistrationFee" :min="0" :precision="2" :step="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isDiscountType(setType)" label="产品折扣%" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.discountValue" :min="1" :max="99" :precision="2" controls-position="right" style="width: 100%" placeholder="空=默认折扣" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="附加费" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="!row.productCode" class="muted">-</div>
|
||||
<surcharge-tags v-else :list="surchargesOf(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="取值方式" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.roundMode" placeholder="取值方式" style="width: 100%">
|
||||
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="details.splice($index, 1)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup name="StepDetails">
|
||||
/**
|
||||
* 第 2 步 · 套餐明细
|
||||
* details 为父级 form.details 的同一数组引用,增删行直接原地操作。
|
||||
*/
|
||||
import SurchargeTags from '../SurchargeTags.vue'
|
||||
import { useDetailCascade } from '../../composables/usePriceSetRefData'
|
||||
import { createEmptyDetailRow, isDiscountType, isFixedType, setTypeLabel } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
/** 明细行数组(父级 form.details) */
|
||||
details: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/** 计价类型 FIXED / DISCOUNT */
|
||||
setType: {
|
||||
type: String,
|
||||
default: 'FIXED'
|
||||
},
|
||||
/** 页面共享的参考数据(usePriceSetRefData 返回值) */
|
||||
refData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const { bms_round_mode } = useDict('bms_round_mode')
|
||||
|
||||
/** 参考数据来自父页面,缓存与请求去重全页面共享 */
|
||||
const { productOptions, surchargesOf, countryOptionsOf, weightOptionsOf } = props.refData
|
||||
const { onProductChange, onCountryChange, onWeightChange } = useDetailCascade(props.refData)
|
||||
|
||||
function addRow() {
|
||||
props.details.push(createEmptyDetailRow())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sub {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mb12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<set-section-card title="基本信息" tone="blue">
|
||||
<div class="info-grid">
|
||||
<div v-for="item in items" :key="item.label" class="info-item">
|
||||
<div class="info-label">{{ item.label }}</div>
|
||||
<div class="info-value" :class="{ 'is-muted': item.muted }">
|
||||
<el-tag v-if="item.tag" :type="item.tag" size="small" effect="light">{{ item.value }}</el-tag>
|
||||
<span v-else>{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note-block">
|
||||
<div class="info-label">说明</div>
|
||||
<div class="note-text">{{ set.priceSetNote || '—' }}</div>
|
||||
</div>
|
||||
</set-section-card>
|
||||
</template>
|
||||
|
||||
<script setup name="SetBasicInfoCard">
|
||||
/**
|
||||
* 套餐详情 · 基本信息卡片(纯展示)
|
||||
*/
|
||||
import SetSectionCard from './SetSectionCard.vue'
|
||||
import {
|
||||
formatDateRange,
|
||||
isDiscountType,
|
||||
setStatusLabel,
|
||||
setStatusTagType,
|
||||
setTypeLabel
|
||||
} from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
/** 套餐详情对象 */
|
||||
set: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
const s = props.set
|
||||
const discount = isDiscountType(s.setType)
|
||||
const updater = s.updateBy ? `(${s.updateBy})` : ''
|
||||
return [
|
||||
{ label: '套餐ID', value: s.priceSetCode || '-' },
|
||||
{ label: '套餐名称', value: s.priceSetName || '-' },
|
||||
{ label: '计价类型', value: setTypeLabel(s.setType) },
|
||||
{
|
||||
label: '默认折扣',
|
||||
value: discount ? `${s.defaultDiscount}%` : '不适用(FIXED)',
|
||||
muted: !discount
|
||||
},
|
||||
{ label: '状态', value: setStatusLabel(s), tag: setStatusTagType(s) },
|
||||
{ label: '有效期', value: formatDateRange(s.effectiveDate, s.expireDate) },
|
||||
{ label: '创建人', value: s.createBy || '-' },
|
||||
{ label: '创建时间', value: s.createTime || '-' },
|
||||
{ label: '最后更新', value: (s.updateTime || '-') + ' ' + updater }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
column-gap: 24px;
|
||||
}
|
||||
.info-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.info-value {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.info-value.is-muted {
|
||||
color: #a8abb2;
|
||||
}
|
||||
.note-block {
|
||||
padding: 12px 0 2px;
|
||||
}
|
||||
.note-text {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="560px"
|
||||
append-to-body
|
||||
class="ps-dialog"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
|
||||
<el-form-item label="客户" prop="customerCode">
|
||||
<customer-select
|
||||
v-model="form.customerCode"
|
||||
value-key="customerCode"
|
||||
placeholder="请选择客户"
|
||||
@change="onCustomerSelectChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="客户信息">
|
||||
<div class="ps-readonly" :class="{ 'ps-readonly--empty': !form.customerCode }">
|
||||
<span class="ps-readonly__code">{{ form.customerCode || '未选择' }}</span>
|
||||
<span class="ps-readonly__name">{{ form.customerName || '选择客户后自动带出' }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先级" prop="bindPriority">
|
||||
<el-input-number
|
||||
v-model="form.bindPriority"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="ps-hint">数字越小优先级越高,1 为最高;同一客户命中多个套餐时按此排序</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="生效日期" prop="prBegindate">
|
||||
<el-date-picker
|
||||
v-model="form.prBegindate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="失效日期" prop="prEnddate">
|
||||
<el-date-picker
|
||||
v-model="form.prEnddate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="长期有效"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="ps-hint ps-hint--block">{{ bindRangeHint }}</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup name="SetCustomerDialog">
|
||||
/**
|
||||
* 套餐绑定客户 · 新增 / 编辑弹窗
|
||||
* 由表格组件通过 ref.open(row) 唤起,保存成功后 emit('success') 通知页面刷新。
|
||||
* 外观统一走 styles/dialog.scss 的 .ps-dialog(见该文件顶部说明)。
|
||||
*/
|
||||
import { createEmptyCustomerRow } from '../../utils/priceSet'
|
||||
import { addSetCustomer, updateSetCustomer } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
/** 所属套餐ID */
|
||||
priceSetId: {
|
||||
type: [Number, String],
|
||||
default: undefined
|
||||
},
|
||||
/** 新行默认生效日期(套餐生效日期) */
|
||||
defaultBegindate: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
/** 新行默认失效日期(套餐失效日期) */
|
||||
defaultEnddate: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const visible = ref(false)
|
||||
const title = ref('')
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const form = ref({})
|
||||
|
||||
const rules = {
|
||||
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }],
|
||||
prBegindate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
|
||||
}
|
||||
|
||||
/** 有效期一句话说明,替掉原来两行零散提示 */
|
||||
const bindRangeHint = computed(() => {
|
||||
const begin = form.value.prBegindate
|
||||
const end = form.value.prEnddate
|
||||
if (!begin) return '生效日期为必填,失效日期留空表示长期有效。'
|
||||
return end
|
||||
? `该客户在 ${begin} 至 ${end} 期间的询价将命中本套餐;失效日期留空表示长期有效。`
|
||||
: `该客户自 ${begin} 起长期命中本套餐;如需限制期限,请填写失效日期。`
|
||||
})
|
||||
|
||||
/** 打开弹窗:row 为空表示新增 */
|
||||
function open(row) {
|
||||
form.value = row
|
||||
? { ...row }
|
||||
: {
|
||||
...createEmptyCustomerRow({
|
||||
effectiveDate: props.defaultBegindate,
|
||||
expireDate: props.defaultEnddate
|
||||
}),
|
||||
priceSetId: props.priceSetId
|
||||
}
|
||||
title.value = row ? '编辑客户绑定' : '添加客户'
|
||||
visible.value = true
|
||||
nextTick(() => formRef.value && formRef.value.clearValidate())
|
||||
}
|
||||
|
||||
/** 下拉选中客户后回填代码 / 名称 / ID */
|
||||
function onCustomerSelectChange(code, item) {
|
||||
form.value.customerCode = code || undefined
|
||||
form.value.customerName = item ? item.customerName : ''
|
||||
form.value.customerId = item ? item.customerId : undefined
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const payload = { ...form.value, priceSetId: props.priceSetId }
|
||||
submitting.value = true
|
||||
const req = payload.customerPricesetId ? updateSetCustomer(payload) : addSetCustomer(payload)
|
||||
req.then(() => {
|
||||
proxy.$modal.msgSuccess('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
}).finally(() => {
|
||||
submitting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<!-- 非 scoped:弹窗被 append-to-body 传送到 body,样式需全局生效,命名空间见 styles/dialog.scss -->
|
||||
<style lang="scss" src="../../styles/dialog.scss"></style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<set-section-card
|
||||
title="绑定客户"
|
||||
tone="green"
|
||||
:count="customers.length"
|
||||
sub="一个客户只能绑定一个套餐"
|
||||
>
|
||||
<template #action>
|
||||
<el-button
|
||||
v-if="editable"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@click="dialogRef.open()"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>添加客户</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="customers" border class="customer-table" empty-text="暂未绑定客户">
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="c-code">{{ row.customerCode }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="优先级" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.bindPriority === 1" size="small" type="success" effect="light">最高</el-tag>
|
||||
<span v-else class="priority">{{ row.bindPriority }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" min-width="220">
|
||||
<template #default="{ row }">{{ formatDateRange(row.prBegindate, row.prEnddate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">解绑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-hint">解绑后该客户可绑定其他套餐;生效中的套餐修改绑定会即时影响计费命中。</div>
|
||||
|
||||
<set-customer-dialog
|
||||
ref="dialogRef"
|
||||
:price-set-id="set.priceSetId"
|
||||
:default-begindate="set.effectiveDate"
|
||||
:default-enddate="set.expireDate"
|
||||
@success="emit('refresh')"
|
||||
/>
|
||||
</set-section-card>
|
||||
</template>
|
||||
|
||||
<script setup name="SetCustomerTable">
|
||||
/**
|
||||
* 套餐详情 · 绑定客户表格
|
||||
* 自带新增 / 编辑弹窗与解绑,数据变更后 emit('refresh') 由页面重新拉取详情。
|
||||
*/
|
||||
import SetSectionCard from './SetSectionCard.vue'
|
||||
import SetCustomerDialog from './SetCustomerDialog.vue'
|
||||
import { formatDateRange, isChildEditable } from '../../utils/priceSet'
|
||||
import { delSetCustomer } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
/** 套餐详情对象 */
|
||||
set: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const dialogRef = ref()
|
||||
|
||||
const customers = computed(() => props.set.customers || [])
|
||||
const editable = computed(() => isChildEditable(props.set.status))
|
||||
|
||||
function remove(row) {
|
||||
proxy.$modal.confirm('确认解绑该客户?').then(() => delSetCustomer(row.customerPricesetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已解绑')
|
||||
emit('refresh')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.customer-table {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.customer-table :deep(th.el-table__cell) {
|
||||
background: #fafbfc;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
.c-code {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.priority {
|
||||
color: #303133;
|
||||
}
|
||||
.table-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="680px"
|
||||
append-to-body
|
||||
class="ps-dialog"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
|
||||
<el-form-item label="产品" prop="productCode">
|
||||
<el-select
|
||||
v-model="form.productCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择产品"
|
||||
style="width: 100%"
|
||||
@change="onProductChange(form, setType)"
|
||||
>
|
||||
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="isFixedType(setType)">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="国家" prop="countryCode">
|
||||
<el-select
|
||||
v-model="form._countryKey"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择国家"
|
||||
style="width: 100%"
|
||||
:disabled="!form.productCode"
|
||||
@change="onCountryChange(form)"
|
||||
>
|
||||
<el-option v-for="c in countryOptions" :key="c.key" :label="c.label" :value="c.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="重量段" prop="weightLabel">
|
||||
<el-select
|
||||
v-model="form.weightLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择重量段"
|
||||
style="width: 100%"
|
||||
:disabled="!form._countryKey"
|
||||
@change="onWeightChange(form)"
|
||||
>
|
||||
<el-option v-for="w in weightOptions" :key="w.value" :label="w.label" :value="w.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="运费单价" prop="fvValue">
|
||||
<div class="ps-field">
|
||||
<el-input-number
|
||||
v-model="form.fvValue"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
controls-position="right"
|
||||
class="ps-field__input"
|
||||
/>
|
||||
<span class="ps-field__unit">RMB/KG</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="挂号费" prop="fvRegistrationFee">
|
||||
<div class="ps-field">
|
||||
<el-input-number
|
||||
v-model="form.fvRegistrationFee"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
class="ps-field__input"
|
||||
/>
|
||||
<span class="ps-field__unit">RMB/票</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="取值方式" prop="roundMode">
|
||||
<el-select v-model="form.roundMode" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-row v-else :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品折扣" prop="discountValue">
|
||||
<div class="ps-field">
|
||||
<el-input-number
|
||||
v-model="form.discountValue"
|
||||
:min="1"
|
||||
:max="99"
|
||||
controls-position="right"
|
||||
class="ps-field__input"
|
||||
/>
|
||||
<span class="ps-field__unit">%</span>
|
||||
</div>
|
||||
<div class="ps-hint">留空表示使用套餐默认折扣</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="取值方式" prop="roundMode">
|
||||
<el-select v-model="form.roundMode" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item v-if="form.productCode" label="附加费">
|
||||
<div class="ps-subbox">
|
||||
<div class="ps-subbox__head">
|
||||
产品匹配附加费
|
||||
<span class="ps-subbox__count">{{ surcharges.length }}</span>
|
||||
</div>
|
||||
<surcharge-tags
|
||||
:popover="false"
|
||||
empty-text="该产品暂无匹配附加费"
|
||||
:list="surcharges"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup name="SetDetailDialog">
|
||||
/**
|
||||
* 套餐明细 · 新增 / 编辑弹窗
|
||||
* 由表格组件通过 ref.open(row) 唤起,保存成功后 emit('success') 通知页面刷新。
|
||||
* 外观统一走 styles/dialog.scss 的 .ps-dialog(见该文件顶部说明)。
|
||||
*/
|
||||
import SurchargeTags from '../SurchargeTags.vue'
|
||||
import { useDetailCascade } from '../../composables/usePriceSetRefData'
|
||||
import { createEmptyDetailRow, isFixedType, toEditableDetailRow } from '../../utils/priceSet'
|
||||
import { addSetDetail, updateSetDetail } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
/** 所属套餐ID */
|
||||
priceSetId: {
|
||||
type: [Number, String],
|
||||
default: undefined
|
||||
},
|
||||
/** 计价类型 FIXED / DISCOUNT */
|
||||
setType: {
|
||||
type: String,
|
||||
default: 'FIXED'
|
||||
},
|
||||
/** 页面共享的参考数据(usePriceSetRefData 返回值) */
|
||||
refData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_round_mode } = useDict('bms_round_mode')
|
||||
|
||||
const { productOptions, surchargesOf, countryOptionsOf, weightOptionsOf } = props.refData
|
||||
const { onProductChange, onCountryChange, onWeightChange } = useDetailCascade(props.refData)
|
||||
|
||||
const visible = ref(false)
|
||||
const title = ref('')
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const form = ref({})
|
||||
|
||||
const rules = {
|
||||
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }],
|
||||
roundMode: [{ required: true, message: '请选择取值方式', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const surcharges = computed(() => surchargesOf(form.value))
|
||||
const countryOptions = computed(() => countryOptionsOf(form.value))
|
||||
const weightOptions = computed(() => weightOptionsOf(form.value))
|
||||
|
||||
/** 打开弹窗:row 为空表示新增 */
|
||||
function open(row) {
|
||||
form.value = row
|
||||
? toEditableDetailRow(row)
|
||||
: { ...createEmptyDetailRow(), priceSetId: props.priceSetId }
|
||||
title.value = row ? '编辑明细' : '添加明细'
|
||||
visible.value = true
|
||||
nextTick(() => formRef.value && formRef.value.clearValidate())
|
||||
if (!form.value.productCode) return
|
||||
props.refData.ensureSurcharges(form.value.productCode)
|
||||
if (isFixedType(props.setType)) props.refData.ensureCountries(form.value.productCode)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const payload = { ...form.value, priceSetId: props.priceSetId }
|
||||
delete payload._countryKey
|
||||
submitting.value = true
|
||||
const req = payload.psdId ? updateSetDetail(payload) : addSetDetail(payload)
|
||||
req.then(() => {
|
||||
proxy.$modal.msgSuccess('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
}).finally(() => {
|
||||
submitting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<!-- 非 scoped:弹窗被 append-to-body 传送到 body,样式需全局生效,命名空间见 styles/dialog.scss -->
|
||||
<style lang="scss" src="../../styles/dialog.scss"></style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="set-header">
|
||||
<div class="hd-main">
|
||||
<div class="hd-line">
|
||||
<h2 class="hd-name">{{ set.priceSetName || '套餐详情' }}</h2>
|
||||
<span v-if="set.priceSetCode" class="hd-code">{{ set.priceSetCode }}</span>
|
||||
</div>
|
||||
<div class="hd-line hd-meta">
|
||||
<el-tag :type="statusTagType" size="small" effect="dark">{{ statusText }}</el-tag>
|
||||
<el-tag :type="isFixedType(set.setType) ? 'warning' : 'primary'" size="small" effect="plain">
|
||||
{{ setTypeLabel(set.setType) }}
|
||||
</el-tag>
|
||||
<span class="hd-range">
|
||||
<el-icon class="hd-icon"><Calendar /></el-icon>
|
||||
{{ dateRange }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hd-actions">
|
||||
<el-button @click="emit('back')">返回列表</el-button>
|
||||
<template v-if="editableStatus">
|
||||
<el-button type="primary" @click="emit('edit')" v-hasPermi="['price:set:edit']">继续配置</el-button>
|
||||
<el-button type="primary" plain @click="emit('submit')" v-hasPermi="['price:set:audit']">提交审核</el-button>
|
||||
</template>
|
||||
<template v-else-if="set.status === 'A'">
|
||||
<el-button type="success" @click="emit('approve')" v-hasPermi="['price:set:audit']">审批生效</el-button>
|
||||
<el-button type="warning" plain @click="emit('reject')" v-hasPermi="['price:set:audit']">驳回</el-button>
|
||||
</template>
|
||||
<el-button
|
||||
v-else-if="set.status === 'E' && !set.expired"
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('disable')"
|
||||
v-hasPermi="['price:set:audit']"
|
||||
>停用</el-button>
|
||||
<el-button v-else-if="set.status === 'S'" type="primary" plain @click="emit('copy')" v-hasPermi="['price:set:add']">复用新建</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="SetDetailHeader">
|
||||
/**
|
||||
* 套餐详情 · 页头
|
||||
* 只负责展示套餐标识 / 状态与派发操作事件,接口调用与确认框由页面处理。
|
||||
*/
|
||||
import { SET_STATUS, formatDateRange, isFixedType, setStatusLabel, setStatusTagType, setTypeLabel } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
/** 套餐详情对象 */
|
||||
set: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'edit', 'submit', 'approve', 'reject', 'disable', 'copy'])
|
||||
|
||||
const statusText = computed(() => setStatusLabel(props.set))
|
||||
const statusTagType = computed(() => setStatusTagType(props.set))
|
||||
const dateRange = computed(() => formatDateRange(props.set.effectiveDate, props.set.expireDate))
|
||||
const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].includes(props.set.status))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.set-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-left: 3px solid #1890ff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hd-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.hd-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hd-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hd-code {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid #e9e9eb;
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.hd-meta {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hd-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.hd-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.hd-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<set-section-card
|
||||
title="套餐明细"
|
||||
tone="blue"
|
||||
:count="details.length"
|
||||
:sub="subTitle"
|
||||
>
|
||||
<template #action>
|
||||
<el-button
|
||||
v-if="editable"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@click="dialogRef.open()"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>添加明细</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="details" border class="detail-table" empty-text="暂无明细,点击右上角「添加明细」">
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-product">
|
||||
<span class="p-code">{{ row.productCode }}</span>
|
||||
<span class="p-name">{{ row.productName || '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template v-if="isFixedType(set.setType)">
|
||||
<el-table-column label="国家" min-width="130">
|
||||
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重量段" prop="weightLabel" min-width="130" />
|
||||
<el-table-column label="运费单价" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="hl">{{ row.fvValue }}</span>
|
||||
<span class="unit"> RMB/KG</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号费" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="reg">{{ row.fvRegistrationFee }}</span>
|
||||
<span class="unit"> RMB/票</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<el-table-column v-else label="产品折扣" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.discountValue == null" size="small" type="info" effect="plain">默认折扣</el-tag>
|
||||
<span v-else class="pct">{{ row.discountValue }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="附加费" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<surcharge-tags :list="surchargesOf(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="取值方式" min-width="110" align="center">
|
||||
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-hint">{{ hint }}</div>
|
||||
|
||||
<set-detail-dialog
|
||||
ref="dialogRef"
|
||||
:price-set-id="set.priceSetId"
|
||||
:set-type="set.setType"
|
||||
:ref-data="refData"
|
||||
@success="emit('refresh')"
|
||||
/>
|
||||
</set-section-card>
|
||||
</template>
|
||||
|
||||
<script setup name="SetDetailTable">
|
||||
/**
|
||||
* 套餐详情 · 明细表格
|
||||
* 自带新增 / 编辑弹窗与删除,数据变更后 emit('refresh') 由页面重新拉取详情。
|
||||
*/
|
||||
import SetSectionCard from './SetSectionCard.vue'
|
||||
import SetDetailDialog from './SetDetailDialog.vue'
|
||||
import SurchargeTags from '../SurchargeTags.vue'
|
||||
import { isChildEditable, isFixedType } from '../../utils/priceSet'
|
||||
import { delSetDetail } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
/** 套餐详情对象 */
|
||||
set: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
/** 页面共享的参考数据(usePriceSetRefData 返回值) */
|
||||
refData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_round_mode } = useDict('bms_round_mode')
|
||||
|
||||
const { surchargesOf } = props.refData
|
||||
|
||||
const dialogRef = ref()
|
||||
|
||||
const details = computed(() => props.set.details || [])
|
||||
const editable = computed(() => isChildEditable(props.set.status))
|
||||
|
||||
const subTitle = computed(() => isFixedType(props.set.setType)
|
||||
? '按 产品 × 国家 × 重量段 覆盖固定价'
|
||||
: '按产品限定折扣,留空走套餐默认折扣')
|
||||
|
||||
const hint = computed(() => isFixedType(props.set.setType)
|
||||
? '运费单价单位 RMB/KG,挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
|
||||
: '未配置明细时全部在售产品均适用套餐默认折扣。')
|
||||
|
||||
function roundModeLabel(mode) {
|
||||
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
|
||||
return hit ? hit.label : (mode || '四舍五入')
|
||||
}
|
||||
|
||||
function remove(row) {
|
||||
proxy.$modal.confirm('确认删除该明细?').then(() => delSetDetail(row.psdId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已删除')
|
||||
emit('refresh')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-table {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.detail-table :deep(th.el-table__cell) {
|
||||
background: #fafbfc;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-product {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.p-code {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #f4f4f5;
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.p-name {
|
||||
color: #303133;
|
||||
}
|
||||
.hl {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.reg {
|
||||
color: #d48806;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pct {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.table-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="sec-card">
|
||||
<template #header>
|
||||
<div class="sec-head">
|
||||
<div class="sec-title">
|
||||
<i class="sec-bar" :class="'is-' + tone" />
|
||||
<span class="sec-text">{{ title }}</span>
|
||||
<el-tag v-if="count !== null" size="small" type="info" effect="plain" round class="sec-count">
|
||||
{{ count }}
|
||||
</el-tag>
|
||||
<span v-if="sub" class="sec-sub">{{ sub }}</span>
|
||||
</div>
|
||||
<div class="sec-extra">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup name="SetSectionCard">
|
||||
/**
|
||||
* 套餐详情 · 通用分区卡片
|
||||
* 统一标题样式(色条 + 标题 + 数量徽标)与右上角操作区。
|
||||
*/
|
||||
defineProps({
|
||||
/** 分区标题 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 数量徽标,传 null 不显示 */
|
||||
count: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
/** 标题右侧补充说明 */
|
||||
sub: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 色条色调 */
|
||||
tone: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
validator: v => ['blue', 'green', 'orange'].includes(v)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sec-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.sec-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sec-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #1890ff;
|
||||
flex: none;
|
||||
}
|
||||
.sec-bar.is-green {
|
||||
background: #52c41a;
|
||||
}
|
||||
.sec-bar.is-orange {
|
||||
background: #fa8c16;
|
||||
}
|
||||
.sec-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.sec-count {
|
||||
flex: none;
|
||||
}
|
||||
.sec-sub {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: #909399;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sec-extra {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 价格套餐 · 参考数据与明细级联
|
||||
*
|
||||
* 页面内共享一份(在父页面调用一次,作为 prop 传给各步骤组件),
|
||||
* 避免产品 / 产品国家 / 附加费 被重复请求。
|
||||
*/
|
||||
import { listProduct, listProductCountries } from '@/api/price/product'
|
||||
import { listSurchargeByProduct } from '@/api/price/surcharge'
|
||||
import {
|
||||
buildCountryOptions,
|
||||
buildWeightOptions,
|
||||
countryKeyOf,
|
||||
formatWeightLabel,
|
||||
matchCountry,
|
||||
toEditableDetailRow
|
||||
} from '../utils/priceSet'
|
||||
|
||||
/**
|
||||
* 产品 / 产品国家 / 附加费 参考数据(带缓存)
|
||||
*/
|
||||
export function usePriceSetRefData() {
|
||||
/** 产品下拉选项 */
|
||||
const productOptions = ref([])
|
||||
/** productCode -> bms_product_country 行 */
|
||||
const countryCache = reactive({})
|
||||
/** productCode -> 有效附加费关联 */
|
||||
const surchargeCache = reactive({})
|
||||
/** productCode -> 进行中的请求 */
|
||||
const surchargePending = {}
|
||||
|
||||
const productMap = computed(() => new Map(productOptions.value.map(p => [p.value, p])))
|
||||
|
||||
/** 加载可选产品(在售) */
|
||||
function loadProducts() {
|
||||
return listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
productOptions.value = (res.rows || []).map(p => ({
|
||||
value: p.productCode,
|
||||
label: `${p.productCode} ${p.productName || ''}`.trim(),
|
||||
name: p.productName
|
||||
}))
|
||||
return productOptions.value
|
||||
})
|
||||
}
|
||||
|
||||
/** 产品名称 */
|
||||
function productNameOf(productCode) {
|
||||
const hit = productMap.value.get(productCode)
|
||||
return hit ? hit.name : productCode
|
||||
}
|
||||
|
||||
/** 确保产品的国家×重量段配置已加载(过滤已失效配置) */
|
||||
function ensureCountries(productCode) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
|
||||
return listProductCountries(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => !item.expireDate)
|
||||
countryCache[productCode] = rows
|
||||
return rows
|
||||
})
|
||||
}
|
||||
|
||||
/** 确保产品的有效附加费已加载(剔除 OF 燃油,结果缓存 + 请求去重) */
|
||||
function ensureSurcharges(productCode) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (surchargeCache[productCode]) return Promise.resolve(surchargeCache[productCode])
|
||||
if (surchargePending[productCode]) return surchargePending[productCode]
|
||||
surchargePending[productCode] = listSurchargeByProduct(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
|
||||
surchargeCache[productCode] = rows
|
||||
return rows
|
||||
}).catch(() => {
|
||||
surchargeCache[productCode] = []
|
||||
return []
|
||||
}).finally(() => {
|
||||
delete surchargePending[productCode]
|
||||
})
|
||||
return surchargePending[productCode]
|
||||
}
|
||||
|
||||
/** 明细行命中的附加费(按产品 + 国家 + 重量段过滤) */
|
||||
function surchargesOf(row) {
|
||||
if (!row || !row.productCode) return []
|
||||
const all = surchargeCache[row.productCode] || []
|
||||
return all.filter(s => {
|
||||
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
|
||||
return false
|
||||
}
|
||||
if (s.detailId == null) return true
|
||||
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/** 明细行可选国家 */
|
||||
function countryOptionsOf(row) {
|
||||
return buildCountryOptions(countryCache[row?.productCode] || [])
|
||||
}
|
||||
|
||||
/** 明细行可选重量段 */
|
||||
function weightOptionsOf(row) {
|
||||
return buildWeightOptions(countryCache[row?.productCode] || [], row || {})
|
||||
}
|
||||
|
||||
return {
|
||||
productOptions,
|
||||
loadProducts,
|
||||
productNameOf,
|
||||
ensureCountries,
|
||||
ensureSurcharges,
|
||||
surchargesOf,
|
||||
countryOptionsOf,
|
||||
weightOptionsOf
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细行级联:产品 → 国家 → 重量段,并带出默认运费 / 挂号费
|
||||
*
|
||||
* @param {Object} refData usePriceSetRefData() 的返回值(页面内共享同一份)
|
||||
*/
|
||||
export function useDetailCascade(refData) {
|
||||
/** 切换产品后清空下游字段 */
|
||||
function resetRow(row) {
|
||||
row.countryCode = undefined
|
||||
row.countryName = undefined
|
||||
row._countryKey = undefined
|
||||
row.weightLabel = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
}
|
||||
|
||||
/** 选择产品:回填名称、清空级联、预取附加费与国家配置 */
|
||||
function onProductChange(row, setType) {
|
||||
row.productName = row.productCode ? refData.productNameOf(row.productCode) : undefined
|
||||
resetRow(row)
|
||||
if (!row.productCode) return
|
||||
refData.ensureSurcharges(row.productCode)
|
||||
if (setType === 'FIXED') refData.ensureCountries(row.productCode)
|
||||
}
|
||||
|
||||
/** 选择国家:回填国家码 / 名称,清空重量段与价格 */
|
||||
function onCountryChange(row) {
|
||||
const opt = refData.countryOptionsOf(row).find(c => c.key === row._countryKey)
|
||||
row.countryCode = opt ? opt.code : undefined
|
||||
row.countryName = opt ? opt.name : undefined
|
||||
row.weightLabel = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
}
|
||||
|
||||
/** 选择重量段:绑定产品国家明细,并带出默认运费 / 挂号费 */
|
||||
function onWeightChange(row) {
|
||||
const opt = refData.weightOptionsOf(row).find(w => w.value === row.weightLabel)
|
||||
if (!opt) {
|
||||
row.productCountryId = undefined
|
||||
return
|
||||
}
|
||||
row.productCountryId = opt.detailId
|
||||
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
|
||||
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
|
||||
}
|
||||
|
||||
/** 回显历史明细行:补齐字段,并把旧重量段文案归一化为 min≤W≤max */
|
||||
function hydrateRow(d, setType) {
|
||||
const row = toEditableDetailRow(d)
|
||||
if (!row.productCode) return row
|
||||
refData.ensureSurcharges(row.productCode)
|
||||
if (setType !== 'FIXED') return row
|
||||
refData.ensureCountries(row.productCode).then(rows => {
|
||||
if (!row.weightLabel) return
|
||||
const hit = rows.find(item => {
|
||||
if (!matchCountry(row, item)) return false
|
||||
return formatWeightLabel(item.weightMin, item.weightMax) === row.weightLabel
|
||||
|| (row.weightLabel.includes(String(item.weightMin)) && row.weightLabel.includes(String(item.weightMax)))
|
||||
})
|
||||
if (!hit) return
|
||||
row.weightLabel = formatWeightLabel(hit.weightMin, hit.weightMax)
|
||||
row._countryKey = countryKeyOf(hit)
|
||||
row.productCountryId = hit.detailId
|
||||
if (!row.countryCode) row.countryCode = hit.countryCode
|
||||
if (!row.countryName) row.countryName = hit.countryName
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
return { resetRow, onProductChange, onCountryChange, onWeightChange, hydrateRow }
|
||||
}
|
||||
@@ -7,255 +7,23 @@
|
||||
</el-steps>
|
||||
|
||||
<!-- 第 1 步 -->
|
||||
<el-card v-show="active === 0" shadow="never" class="step-card">
|
||||
<template #header><span>第 1 步 · 基本信息</span></template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="max-form">
|
||||
<el-form-item label="套餐名称" prop="priceSetName">
|
||||
<el-input v-model="form.priceSetName" maxlength="50" placeholder="如:金牌电商专属套餐" />
|
||||
<div class="form-hint">显示给客户的套餐对外名称</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="计价类型" prop="setType">
|
||||
<div class="type-row">
|
||||
<div
|
||||
class="type-card"
|
||||
:class="{ selected: form.setType === 'FIXED' }"
|
||||
@click="form.setType = 'FIXED'"
|
||||
>
|
||||
<div class="t-title">FIXED 固定价</div>
|
||||
<div class="t-desc">可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费</div>
|
||||
</div>
|
||||
<div
|
||||
class="type-card"
|
||||
:class="{ selected: form.setType === 'DISCOUNT' }"
|
||||
@click="form.setType = 'DISCOUNT'"
|
||||
>
|
||||
<div class="t-title">DISCOUNT 折扣</div>
|
||||
<div class="t-desc">按默认等级价统一乘折扣,可选产品范围</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认折扣" prop="defaultDiscount">
|
||||
<el-input-number
|
||||
v-model="form.defaultDiscount"
|
||||
:min="1"
|
||||
:max="99"
|
||||
:disabled="form.setType !== 'DISCOUNT'"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit">%</span>
|
||||
<el-tag v-if="form.setType !== 'DISCOUNT'" type="warning" size="small" style="margin-left: 8px">仅 DISCOUNT 模式生效</el-tag>
|
||||
<div class="form-hint">范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效日期" prop="effectiveDate">
|
||||
<el-date-picker v-model="form.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="生效日期" />
|
||||
</el-form-item>
|
||||
<el-form-item label="失效日期" prop="expireDate">
|
||||
<el-date-picker v-model="form.expireDate" type="date" value-format="YYYY-MM-DD" placeholder="空表示长期有效" />
|
||||
<div class="form-hint">空表示长期有效</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="说明备注" prop="priceSetNote">
|
||||
<el-input v-model="form.priceSetNote" type="textarea" :rows="3" maxlength="200" placeholder="套餐业务说明,显示给客户与运营" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<step-basic-info v-show="active === 0" ref="basicRef" v-model="form.basic" />
|
||||
|
||||
<!-- 第 2 步 -->
|
||||
<el-card v-show="active === 1" shadow="never" class="step-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>
|
||||
第 2 步 · 套餐明细
|
||||
<span class="sub">当前:{{ form.setType === 'FIXED' ? 'FIXED 固定价' : 'DISCOUNT 折扣' }}模式</span>
|
||||
</span>
|
||||
<el-button type="primary" size="small" icon="Plus" @click="addDetailRow">添加明细行</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
v-if="form.setType === 'FIXED'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×重量段覆盖固定价。附加费来自产品关联,只读展示。"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="DISCOUNT 模式:可不填明细(全部产品适用默认折扣),或按产品限定折扣范围。"
|
||||
/>
|
||||
<el-table :data="form.details" border empty-text="暂无明细,点击添加明细行">
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.productCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择产品"
|
||||
style="width: 100%"
|
||||
@change="onProductChange(row)"
|
||||
>
|
||||
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="form.setType === 'FIXED'" label="国家" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row._countryKey"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择国家"
|
||||
style="width: 100%"
|
||||
:disabled="!row.productCode"
|
||||
@change="onCountryChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countryOptionsOf(row)"
|
||||
:key="c.key"
|
||||
:label="c.label"
|
||||
:value="c.key"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="form.setType === 'FIXED'" label="重量段" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.weightLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择重量段"
|
||||
style="width: 100%"
|
||||
:disabled="!row._countryKey"
|
||||
@change="onWeightChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="w in weightOptionsOf(row)"
|
||||
:key="w.value"
|
||||
:label="w.label"
|
||||
:value="w.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="form.setType === 'FIXED'" label="运费单价(RMB/KG)" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.fvValue" :min="0" :precision="4" :step="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="form.setType === 'FIXED'" label="挂号费(RMB/票)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.fvRegistrationFee" :min="0" :precision="2" :step="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="form.setType === 'DISCOUNT'" label="产品折扣%" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.discountValue" :min="1" :max="99" :precision="2" controls-position="right" style="width: 100%" placeholder="空=默认折扣" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="附加费" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="!row.productCode" class="muted">-</div>
|
||||
<el-popover
|
||||
v-else-if="surchargesOf(row).length"
|
||||
placement="top"
|
||||
:width="400"
|
||||
trigger="hover"
|
||||
>
|
||||
<template #default>
|
||||
<div class="sc-pop-list">
|
||||
<div v-for="s in surchargesOf(row)" :key="s.relId || s.scId" class="sc-pop-item">
|
||||
<div class="sc-pop-name">{{ s.scName }}</div>
|
||||
<div class="sc-pop-meta">
|
||||
{{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
|
||||
· {{ surchargeScopeLabel(s) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #reference>
|
||||
<div class="sc-tags">
|
||||
<el-tag
|
||||
v-for="s in surchargesOf(row)"
|
||||
:key="s.relId || s.scId"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>{{ s.scName }} {{ formatChargeValue(s) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<span v-else class="muted">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="取值方式" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.roundMode" placeholder="取值方式" style="width: 100%">
|
||||
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="form.details.splice($index, 1)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<step-details
|
||||
v-show="active === 1"
|
||||
:details="form.details"
|
||||
:set-type="form.basic.setType"
|
||||
:ref-data="refData"
|
||||
/>
|
||||
|
||||
<!-- 第 3 步 -->
|
||||
<el-card v-show="active === 2" shadow="never" class="step-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>第 3 步 · 绑定客户<span class="sub">可选,可后续补充</span></span>
|
||||
<el-button type="primary" size="small" icon="Plus" @click="addCustomerRow">添加客户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="套餐创建后处于草稿状态,可暂不绑定客户。一个客户只能绑定一个套餐。"
|
||||
/>
|
||||
<el-table :data="form.customers" border empty-text="暂未绑定客户">
|
||||
<el-table-column label="客户" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<customer-select
|
||||
v-model="row.customerCode"
|
||||
value-key="customerCode"
|
||||
placeholder="请选择客户"
|
||||
@change="(code, item) => onBindCustomerChange(row, code, item)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="优先级" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.bindPriority" :min="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生效日期" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.prBegindate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失效日期" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.prEnddate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="form.customers.splice($index, 1)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<step-customers
|
||||
v-show="active === 2"
|
||||
:customers="form.customers"
|
||||
:effective-date="form.basic.effectiveDate"
|
||||
:expire-date="form.basic.expireDate"
|
||||
/>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<el-button @click="goBack">取消</el-button>
|
||||
@@ -268,346 +36,68 @@
|
||||
</template>
|
||||
|
||||
<script setup name="PriceSetCreate">
|
||||
/**
|
||||
* 新建 / 编辑套餐 · 流程编排
|
||||
*
|
||||
* 三步表单已拆分到 components/,页面只负责:
|
||||
* 步骤切换、保存前校验、payload 组装、路由返回。
|
||||
* 参考数据(产品 / 国家 / 附加费)由 composables 提供,整页共享一份缓存。
|
||||
*/
|
||||
import StepBasicInfo from './components/create/StepBasicInfo.vue'
|
||||
import StepDetails from './components/create/StepDetails.vue'
|
||||
import StepCustomers from './components/create/StepCustomers.vue'
|
||||
import { usePriceSetRefData, useDetailCascade } from './composables/usePriceSetRefData'
|
||||
import {
|
||||
createEmptyBasicInfo,
|
||||
createEmptyDetailRow,
|
||||
toCustomerPayload,
|
||||
toDetailPayload
|
||||
} from './utils/priceSet'
|
||||
import { getPriceSet, savePriceSet } from '@/api/price/set'
|
||||
import { listProduct, listProductCountries } from '@/api/price/product'
|
||||
import { listSurchargeByProduct } from '@/api/price/surcharge'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
|
||||
const route = useRoute()
|
||||
|
||||
const refData = usePriceSetRefData()
|
||||
const cascade = useDetailCascade(refData)
|
||||
|
||||
const active = ref(0)
|
||||
const saving = ref(false)
|
||||
const formRef = ref()
|
||||
const productOptions = ref([])
|
||||
/** productCode -> bms_product_country rows */
|
||||
const countryCache = reactive({})
|
||||
/** productCode -> 有效附加费关联 */
|
||||
const surchargeCache = reactive({})
|
||||
const surchargePending = {}
|
||||
const basicRef = ref()
|
||||
|
||||
/** 整个套餐表单:基本信息 / 明细 / 绑定客户 */
|
||||
const form = reactive({
|
||||
priceSetId: undefined,
|
||||
priceSetName: '',
|
||||
setType: 'FIXED',
|
||||
defaultDiscount: 88,
|
||||
effectiveDate: undefined,
|
||||
expireDate: undefined,
|
||||
priceSetNote: '',
|
||||
basic: createEmptyBasicInfo(),
|
||||
details: [],
|
||||
customers: []
|
||||
})
|
||||
|
||||
const rules = {
|
||||
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }],
|
||||
setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }],
|
||||
effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function trimNum(v) {
|
||||
if (v == null || v === '') return ''
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return String(v)
|
||||
return String(parseFloat(n.toFixed(6)))
|
||||
}
|
||||
|
||||
function formatWeightLabel(min, max) {
|
||||
return `${trimNum(min)}≤W≤${trimNum(max)}`
|
||||
}
|
||||
|
||||
function countryKeyOf(row) {
|
||||
if (!row) return ''
|
||||
if (row.countryCode) return row.countryCode
|
||||
if (row.countryName) return 'N:' + row.countryName
|
||||
return ''
|
||||
}
|
||||
|
||||
function matchCountry(detail, item) {
|
||||
if (detail.countryCode && item.countryCode) {
|
||||
return detail.countryCode === item.countryCode
|
||||
}
|
||||
return detail.countryName && item.countryName === detail.countryName
|
||||
}
|
||||
|
||||
function loadProducts() {
|
||||
return listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
const rows = res.rows || []
|
||||
productOptions.value = rows.map(p => ({
|
||||
value: p.productCode,
|
||||
label: `${p.productCode} ${p.productName || ''}`.trim(),
|
||||
name: p.productName
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function ensureCountries(productCode) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
|
||||
return listProductCountries(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => !item.expireDate)
|
||||
countryCache[productCode] = rows
|
||||
return rows
|
||||
})
|
||||
}
|
||||
|
||||
function ensureSurcharges(productCode) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (surchargeCache[productCode]) return Promise.resolve(surchargeCache[productCode])
|
||||
if (surchargePending[productCode]) return surchargePending[productCode]
|
||||
surchargePending[productCode] = listSurchargeByProduct(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
|
||||
surchargeCache[productCode] = rows
|
||||
return rows
|
||||
}).catch(() => {
|
||||
surchargeCache[productCode] = []
|
||||
return []
|
||||
}).finally(() => {
|
||||
delete surchargePending[productCode]
|
||||
})
|
||||
return surchargePending[productCode]
|
||||
}
|
||||
|
||||
function surchargesOf(row) {
|
||||
if (!row || !row.productCode) return []
|
||||
const all = surchargeCache[row.productCode] || []
|
||||
return all.filter(s => {
|
||||
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
|
||||
return false
|
||||
}
|
||||
if (s.detailId == null) return true
|
||||
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function chargeTypeLabel(val) {
|
||||
const found = (bms_charge_type.value || []).find(c => c.value === val)
|
||||
return found ? found.label : (val || '-')
|
||||
}
|
||||
|
||||
function formatChargeValue(row) {
|
||||
if (row.chargeValue == null) return '-'
|
||||
const v = Number(row.chargeValue)
|
||||
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
|
||||
return v + ' ' + (row.currency || 'CNY')
|
||||
}
|
||||
|
||||
function surchargeScopeLabel(s) {
|
||||
if (s.detailId == null) return '产品级'
|
||||
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
|
||||
return `${s.countryName || '-'} · ${w}`
|
||||
}
|
||||
|
||||
function countryOptionsOf(row) {
|
||||
const rows = countryCache[row.productCode] || []
|
||||
const map = new Map()
|
||||
rows.forEach(item => {
|
||||
const key = countryKeyOf(item)
|
||||
if (!key || map.has(key)) return
|
||||
map.set(key, {
|
||||
key,
|
||||
code: item.countryCode || undefined,
|
||||
name: item.countryName,
|
||||
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function weightOptionsOf(row) {
|
||||
const rows = (countryCache[row.productCode] || []).filter(item => matchCountry(row, item))
|
||||
const map = new Map()
|
||||
rows.forEach(item => {
|
||||
if (item.weightMin == null && item.weightMax == null) return
|
||||
const value = formatWeightLabel(item.weightMin, item.weightMax)
|
||||
if (map.has(value)) return
|
||||
map.set(value, {
|
||||
value,
|
||||
label: value,
|
||||
detailId: item.detailId,
|
||||
freightPrice: item.freightPrice,
|
||||
registrationFee: item.registrationFee
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function emptyDetail() {
|
||||
return {
|
||||
productCode: undefined,
|
||||
productName: undefined,
|
||||
countryCode: undefined,
|
||||
countryName: undefined,
|
||||
_countryKey: undefined,
|
||||
weightLabel: undefined,
|
||||
productCountryId: undefined,
|
||||
fvValue: undefined,
|
||||
fvRegistrationFee: undefined,
|
||||
discountValue: undefined,
|
||||
roundMode: 'HALF_UP'
|
||||
}
|
||||
}
|
||||
|
||||
function emptyCustomer() {
|
||||
return {
|
||||
customerId: undefined,
|
||||
customerCode: undefined,
|
||||
customerName: '',
|
||||
bindPriority: form.customers.length + 1,
|
||||
prBegindate: form.effectiveDate,
|
||||
prEnddate: form.expireDate
|
||||
}
|
||||
}
|
||||
|
||||
function addDetailRow() {
|
||||
form.details.push(emptyDetail())
|
||||
}
|
||||
|
||||
function addCustomerRow() {
|
||||
form.customers.push(emptyCustomer())
|
||||
}
|
||||
|
||||
function onBindCustomerChange(row, code, item) {
|
||||
console.log('onBindCustomerChange', row, code, item)
|
||||
row.customerCode = code || undefined
|
||||
row.customerName = item ? item.customerName : ''
|
||||
row.customerId = item ? item.customerId : undefined
|
||||
}
|
||||
|
||||
function clearCascadeAfterProduct(row) {
|
||||
row.countryCode = undefined
|
||||
row.countryName = undefined
|
||||
row._countryKey = undefined
|
||||
row.weightLabel = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
}
|
||||
|
||||
function onProductChange(row) {
|
||||
const found = productOptions.value.find(p => p.value === row.productCode)
|
||||
row.productName = found ? found.name : row.productCode
|
||||
clearCascadeAfterProduct(row)
|
||||
if (!row.productCode) return
|
||||
ensureSurcharges(row.productCode)
|
||||
if (form.setType === 'FIXED') ensureCountries(row.productCode)
|
||||
}
|
||||
|
||||
function onCountryChange(row) {
|
||||
const opt = countryOptionsOf(row).find(c => c.key === row._countryKey)
|
||||
row.countryCode = opt ? opt.code : undefined
|
||||
row.countryName = opt ? opt.name : undefined
|
||||
row.weightLabel = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
}
|
||||
|
||||
function onWeightChange(row) {
|
||||
const opt = weightOptionsOf(row).find(w => w.value === row.weightLabel)
|
||||
if (!opt) {
|
||||
row.productCountryId = undefined
|
||||
return
|
||||
}
|
||||
row.productCountryId = opt.detailId
|
||||
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
|
||||
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
|
||||
}
|
||||
|
||||
function hydrateDetailRow(d) {
|
||||
const row = {
|
||||
...d,
|
||||
roundMode: d.roundMode || 'HALF_UP',
|
||||
_countryKey: countryKeyOf(d)
|
||||
}
|
||||
// normalize historical weight labels to min≤W≤max when possible
|
||||
if (row.productCode) ensureSurcharges(row.productCode)
|
||||
if (row.productCode && form.setType === 'FIXED') {
|
||||
ensureCountries(row.productCode).then(rows => {
|
||||
if (!row.weightLabel) return
|
||||
const hit = rows.find(item => {
|
||||
if (!matchCountry(row, item)) return false
|
||||
return formatWeightLabel(item.weightMin, item.weightMax) === row.weightLabel
|
||||
|| row.weightLabel.includes(String(item.weightMin)) && row.weightLabel.includes(String(item.weightMax))
|
||||
})
|
||||
if (hit) {
|
||||
row.weightLabel = formatWeightLabel(hit.weightMin, hit.weightMax)
|
||||
row._countryKey = countryKeyOf(hit)
|
||||
row.productCountryId = hit.detailId
|
||||
if (!row.countryCode) row.countryCode = hit.countryCode
|
||||
if (!row.countryName) row.countryName = hit.countryName
|
||||
}
|
||||
})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function buildPayload(submitFlag) {
|
||||
const details = (form.details || []).filter(d => d.productCode).map(d => ({
|
||||
productCode: d.productCode,
|
||||
productName: d.productName,
|
||||
countryCode: d.countryCode || undefined,
|
||||
countryName: d.countryName || undefined,
|
||||
weightLabel: d.weightLabel || undefined,
|
||||
productCountryId: d.productCountryId || undefined,
|
||||
fvValue: d.fvValue,
|
||||
fvRegistrationFee: d.fvRegistrationFee,
|
||||
discountValue: d.discountValue,
|
||||
roundMode: d.roundMode || 'HALF_UP'
|
||||
}))
|
||||
const customers = (form.customers || [])
|
||||
.filter(c => c.customerCode)
|
||||
.map(c => ({
|
||||
...c,
|
||||
customerCode: c.customerCode
|
||||
}))
|
||||
console.log('details', details)
|
||||
|
||||
return {
|
||||
priceSetId: form.priceSetId,
|
||||
priceSetName: form.priceSetName,
|
||||
setType: form.setType,
|
||||
defaultDiscount: form.setType === 'DISCOUNT' ? form.defaultDiscount : undefined,
|
||||
effectiveDate: form.effectiveDate,
|
||||
expireDate: form.expireDate || undefined,
|
||||
priceSetNote: form.priceSetNote,
|
||||
details,
|
||||
customers,
|
||||
submitFlag: submitFlag ? 'Y' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
/** 下一步:仅第 1 步需要校验 */
|
||||
async function nextStep() {
|
||||
if (active.value === 0) {
|
||||
formRef.value.validate(valid => {
|
||||
if (valid) active.value++
|
||||
})
|
||||
return
|
||||
const valid = await basicRef.value.validate()
|
||||
if (!valid) return
|
||||
}
|
||||
active.value++
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
formRef.value.validate(valid => {
|
||||
if (!valid) {
|
||||
active.value = 0
|
||||
return
|
||||
}
|
||||
doSave(false)
|
||||
})
|
||||
save(false)
|
||||
}
|
||||
|
||||
function saveAndSubmit() {
|
||||
formRef.value.validate(valid => {
|
||||
if (!valid) {
|
||||
active.value = 0
|
||||
return
|
||||
}
|
||||
doSave(true)
|
||||
})
|
||||
save(true)
|
||||
}
|
||||
|
||||
function doSave(submit) {
|
||||
const ids = (form.customers || []).filter(c => c.customerId).map(c => String(c.customerId))
|
||||
/** 校验 + 保存;校验失败回到第 1 步 */
|
||||
async function save(submit) {
|
||||
const valid = await basicRef.value.validate()
|
||||
if (!valid) {
|
||||
active.value = 0
|
||||
return
|
||||
}
|
||||
const ids = form.customers.filter(c => c.customerId).map(c => String(c.customerId))
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
proxy.$modal.msgError('该客户已绑定套餐,一个客户只能绑定一个套餐')
|
||||
return
|
||||
@@ -617,11 +107,30 @@ function doSave(submit) {
|
||||
form.priceSetId = res.data
|
||||
proxy.$modal.msgSuccess(submit ? '已保存并提交审核' : '草稿已保存,套餐ID=' + res.data)
|
||||
goBack()
|
||||
}).catch(() => {
|
||||
// 错误提示由请求拦截器统一处理
|
||||
}).finally(() => {
|
||||
saving.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 表单 -> 后端 payload */
|
||||
function buildPayload(submitFlag) {
|
||||
const { basic } = form
|
||||
return {
|
||||
priceSetId: form.priceSetId,
|
||||
priceSetName: basic.priceSetName,
|
||||
setType: basic.setType,
|
||||
defaultDiscount: basic.setType === 'DISCOUNT' ? basic.defaultDiscount : undefined,
|
||||
effectiveDate: basic.effectiveDate,
|
||||
expireDate: basic.expireDate || undefined,
|
||||
priceSetNote: basic.priceSetNote,
|
||||
details: form.details.filter(d => d.productCode).map(toDetailPayload),
|
||||
customers: form.customers.filter(c => c.customerCode).map(toCustomerPayload),
|
||||
submitFlag: submitFlag ? 'Y' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const obj = { path: '/customer/set' }
|
||||
if (proxy.$tab) {
|
||||
@@ -631,28 +140,31 @@ function goBack() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑 / 复用新建:回显已有套餐 */
|
||||
function loadExisting(id) {
|
||||
getPriceSet(id).then(res => {
|
||||
const data = res.data || {}
|
||||
form.priceSetId = data.priceSetId
|
||||
form.priceSetName = data.priceSetName
|
||||
form.setType = data.setType || 'FIXED'
|
||||
form.defaultDiscount = data.defaultDiscount != null ? Number(data.defaultDiscount) : 88
|
||||
form.effectiveDate = data.effectiveDate
|
||||
form.expireDate = data.expireDate
|
||||
form.priceSetNote = data.priceSetNote
|
||||
form.details = (data.details || []).map(d => hydrateDetailRow(d))
|
||||
Object.assign(form.basic, {
|
||||
priceSetName: data.priceSetName,
|
||||
setType: data.setType || 'FIXED',
|
||||
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
|
||||
effectiveDate: data.effectiveDate,
|
||||
expireDate: data.expireDate,
|
||||
priceSetNote: data.priceSetNote
|
||||
})
|
||||
form.details = (data.details || []).map(d => cascade.hydrateRow(d, form.basic.setType))
|
||||
form.customers = (data.customers || []).map(c => ({ ...c }))
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts().finally(() => {
|
||||
refData.loadProducts().finally(() => {
|
||||
const id = route.query.priceSetId
|
||||
if (id) {
|
||||
loadExisting(id)
|
||||
} else {
|
||||
addDetailRow()
|
||||
form.details.push(createEmptyDetailRow())
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -668,84 +180,8 @@ onMounted(() => {
|
||||
.step-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sub {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
.max-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
.type-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.type-card {
|
||||
flex: 1;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-card.selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.t-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.t-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mb12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.wizard-actions {
|
||||
text-align: center;
|
||||
padding: 8px 0 24px;
|
||||
}
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.sc-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sc-pop-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sc-pop-item {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sc-pop-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sc-pop-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,503 +1,50 @@
|
||||
<template>
|
||||
<div class="app-container" v-loading="loading">
|
||||
<div class="detail-header">
|
||||
<h2>{{ set.priceSetCode || '-' }} · {{ set.priceSetName || '套餐详情' }}</h2>
|
||||
<div class="header-actions">
|
||||
<el-tag :type="statusTag" size="large">{{ statusLabel }}</el-tag>
|
||||
<el-tag :type="set.setType === 'FIXED' ? 'warning' : 'primary'" size="large" effect="plain">
|
||||
{{ set.setType === 'FIXED' ? 'FIXED 固定价' : 'DISCOUNT 折扣' }}
|
||||
</el-tag>
|
||||
<el-button @click="goList">返回列表</el-button>
|
||||
<el-button v-if="set.status === 'D' || set.status === 'R'" type="primary" @click="goEdit" v-hasPermi="['price:set:edit']">继续配置</el-button>
|
||||
<el-button v-if="set.status === 'D' || set.status === 'R'" type="primary" @click="onSubmit" v-hasPermi="['price:set:audit']">提交审核</el-button>
|
||||
<el-button v-if="set.status === 'A'" type="success" @click="onApprove" v-hasPermi="['price:set:audit']">审批生效</el-button>
|
||||
<el-button v-if="set.status === 'A'" type="warning" @click="onReject" v-hasPermi="['price:set:audit']">驳回</el-button>
|
||||
<el-button v-if="set.status === 'E' && !set.expired" type="danger" @click="onDisable" v-hasPermi="['price:set:audit']">停用</el-button>
|
||||
<el-button v-if="set.status === 'S'" @click="onCopy" v-hasPermi="['price:set:add']">复用新建</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<set-detail-header
|
||||
:set="set"
|
||||
@back="goList"
|
||||
@edit="goEdit"
|
||||
@submit="onSubmit"
|
||||
@approve="onApprove"
|
||||
@reject="onReject"
|
||||
@disable="onDisable"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>基本信息</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="套餐ID">{{ set.priceSetCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="套餐名称">{{ set.priceSetName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计价类型">
|
||||
{{ set.setType === 'FIXED' ? 'FIXED 固定价' : 'DISCOUNT 折扣' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="默认折扣">
|
||||
<span v-if="set.setType === 'DISCOUNT'">{{ set.defaultDiscount }}%</span>
|
||||
<el-tag v-else type="info" size="small">不适用(FIXED)</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效日期">
|
||||
{{ formatDate(set.effectiveDate) }} ~ {{ set.expireDate ? formatDate(set.expireDate) : '长期' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">{{ set.createBy || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ set.createTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后更新" :span="2">{{ set.updateTime || '-' }} {{ set.updateBy ? '(' + set.updateBy + ')' : '' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="说明" :span="2">{{ set.priceSetNote || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<set-basic-info-card :set="set" />
|
||||
|
||||
<el-card shadow="never" class="mt16">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>套餐明细</span>
|
||||
<el-button
|
||||
v-if="childEditable"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@click="openDetailDialog()"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>添加明细</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="set.details || []" border empty-text="暂无明细">
|
||||
<el-table-column label="产品" min-width="130">
|
||||
<template #default="{ row }">{{ row.productCode }} {{ row.productName || '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="set.setType === 'FIXED'" label="国家" min-width="110">
|
||||
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="set.setType === 'FIXED'" label="重量段" prop="weightLabel" min-width="120" />
|
||||
<el-table-column v-if="set.setType === 'FIXED'" label="运费单价" min-width="120">
|
||||
<template #default="{ row }"><span class="hl">{{ row.fvValue }} RMB/KG</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="set.setType === 'FIXED'" label="挂号费" min-width="120">
|
||||
<template #default="{ row }"><span class="reg">{{ row.fvRegistrationFee }} RMB/票</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="set.setType === 'DISCOUNT'" label="产品折扣" min-width="110">
|
||||
<template #default="{ row }">{{ row.discountValue != null ? row.discountValue + '%' : '默认折扣' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="附加费" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-popover
|
||||
v-if="surchargesOf(row).length"
|
||||
placement="top"
|
||||
:width="400"
|
||||
trigger="hover"
|
||||
>
|
||||
<template #default>
|
||||
<div class="sc-pop-list">
|
||||
<div v-for="s in surchargesOf(row)" :key="s.relId || s.scId" class="sc-pop-item">
|
||||
<div class="sc-pop-name">{{ s.scName }}</div>
|
||||
<div class="sc-pop-meta">
|
||||
{{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
|
||||
· {{ surchargeScopeLabel(s) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #reference>
|
||||
<div class="sc-tags">
|
||||
<el-tag
|
||||
v-for="s in surchargesOf(row)"
|
||||
:key="s.relId || s.scId"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>{{ s.scName }} {{ formatChargeValue(s) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<span v-else class="muted">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="取值方式" min-width="110" align="center">
|
||||
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="关联 pr_id" prop="prId" width="110">
|
||||
<template #default="{ row }">{{ row.prId || '-' }}</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column v-if="childEditable" label="操作" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetailDialog(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeDetail(row)" v-hasPermi="['price:set:edit']">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<set-detail-table :set="set" :ref-data="refData" @refresh="load" />
|
||||
|
||||
<el-card shadow="never" class="mt16">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>绑定客户</span>
|
||||
<el-button
|
||||
v-if="childEditable"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@click="openCustomerDialog()"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>添加客户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="set.customers || []" border empty-text="暂未绑定客户">
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="140" />
|
||||
<el-table-column label="优先级" width="110">
|
||||
<template #default="{ row }">{{ row.bindPriority }}{{ row.bindPriority === 1 ? '(最高)' : '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生效日期" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.prBegindate) }} ~ {{ row.prEnddate ? formatDate(row.prEnddate) : '长期' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="childEditable" label="操作" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openCustomerDialog(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeCustomer(row)" v-hasPermi="['price:set:edit']">解绑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="rel-hint">一个客户只能绑定一个套餐;解绑后可绑定其他套餐。</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="detailTitle" v-model="detailOpen" width="560px" append-to-body>
|
||||
<el-form ref="detailFormRef" :model="detailForm" :rules="detailRules" label-width="120px">
|
||||
<el-form-item label="产品" prop="productCode">
|
||||
<el-select
|
||||
v-model="detailForm.productCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择产品"
|
||||
style="width: 100%"
|
||||
@change="onDetailProductChange"
|
||||
>
|
||||
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="set.setType === 'FIXED'">
|
||||
<el-form-item label="国家" prop="countryCode">
|
||||
<el-select
|
||||
v-model="detailForm._countryKey"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择国家"
|
||||
style="width: 100%"
|
||||
:disabled="!detailForm.productCode"
|
||||
@change="onDetailCountryChange"
|
||||
>
|
||||
<el-option v-for="c in detailCountryOptions" :key="c.key" :label="c.label" :value="c.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="重量段" prop="weightLabel">
|
||||
<el-select
|
||||
v-model="detailForm.weightLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择重量段"
|
||||
style="width: 100%"
|
||||
:disabled="!detailForm._countryKey"
|
||||
@change="onDetailWeightChange"
|
||||
>
|
||||
<el-option v-for="w in detailWeightOptions" :key="w.value" :label="w.label" :value="w.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费单价" prop="fvValue">
|
||||
<el-input-number v-model="detailForm.fvValue" :min="0" :precision="4" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂号费" prop="fvRegistrationFee">
|
||||
<el-input-number v-model="detailForm.fvRegistrationFee" :min="0" :precision="2" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else label="产品折扣%" prop="discountValue">
|
||||
<el-input-number v-model="detailForm.discountValue" :min="1" :max="99" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="取值方式" prop="roundMode">
|
||||
<el-select v-model="detailForm.roundMode" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="detailForm.productCode" label="附加费">
|
||||
<div v-if="detailSurcharges.length" class="sc-tags">
|
||||
<el-tag
|
||||
v-for="s in detailSurcharges"
|
||||
:key="s.relId || s.scId"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>{{ s.scName }} {{ formatChargeValue(s) }}</el-tag>
|
||||
</div>
|
||||
<span v-else class="muted">该产品暂无匹配附加费</span>
|
||||
</el-form-item>
|
||||
<!-- <el-form-item label="关联 pr_id" prop="prId">
|
||||
<el-input v-model="detailForm.prId" placeholder="可选" />
|
||||
</el-form-item> -->
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="submitDetail">确 定</el-button>
|
||||
<el-button @click="detailOpen = false">取 消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :title="customerTitle" v-model="customerOpen" width="520px" append-to-body>
|
||||
<el-form ref="customerFormRef" :model="customerForm" :rules="customerRules" label-width="110px">
|
||||
<el-form-item label="客户" prop="customerCode">
|
||||
<customer-select
|
||||
v-model="customerForm.customerCode"
|
||||
value-key="customerCode"
|
||||
placeholder="请选择客户"
|
||||
@change="onCustomerSelectChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户代码">
|
||||
<el-input v-model="customerForm.customerCode" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户名称">
|
||||
<el-input v-model="customerForm.customerName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级" prop="bindPriority">
|
||||
<el-input-number v-model="customerForm.bindPriority" :min="1" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="生效日期" prop="prBegindate">
|
||||
<el-date-picker v-model="customerForm.prBegindate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="失效日期" prop="prEnddate">
|
||||
<el-date-picker v-model="customerForm.prEnddate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="submitCustomer">确 定</el-button>
|
||||
<el-button @click="customerOpen = false">取 消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<set-customer-table :set="set" @refresh="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="PriceSetDetail">
|
||||
/**
|
||||
* 套餐详情 · 页面编排
|
||||
*
|
||||
* 展示区拆到 components/:页头、基本信息、明细表(含明细弹窗)、客户表(含客户弹窗);
|
||||
* 本页只负责:加载详情、状态流转操作、向子组件下发共享参考数据。
|
||||
*/
|
||||
import SetDetailHeader from './components/detail/SetDetailHeader.vue'
|
||||
import SetBasicInfoCard from './components/detail/SetBasicInfoCard.vue'
|
||||
import SetDetailTable from './components/detail/SetDetailTable.vue'
|
||||
import SetCustomerTable from './components/detail/SetCustomerTable.vue'
|
||||
import { usePriceSetRefData } from './composables/usePriceSetRefData'
|
||||
import {
|
||||
getPriceSet, submitPriceSet, approvePriceSet, rejectPriceSet, disablePriceSet, copyPriceSet,
|
||||
addSetDetail, updateSetDetail, delSetDetail,
|
||||
addSetCustomer, updateSetCustomer, delSetCustomer
|
||||
getPriceSet, submitPriceSet, approvePriceSet, rejectPriceSet, disablePriceSet, copyPriceSet
|
||||
} from '@/api/price/set'
|
||||
import { listProduct, listProductCountries } from '@/api/price/product'
|
||||
import { listSurchargeByProduct } from '@/api/price/surcharge'
|
||||
import { parseTime } from '@/utils/ruoyi'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
|
||||
const route = useRoute()
|
||||
|
||||
/** 产品 / 国家 / 附加费 参考数据与明细弹窗级联共用,与新建页同一套实现 */
|
||||
const refData = usePriceSetRefData()
|
||||
|
||||
const loading = ref(false)
|
||||
const set = ref({})
|
||||
const productOptions = ref([])
|
||||
const countryCache = reactive({})
|
||||
const surchargeCache = reactive({})
|
||||
const surchargePending = {}
|
||||
|
||||
const detailOpen = ref(false)
|
||||
const detailTitle = ref('')
|
||||
const detailFormRef = ref()
|
||||
const detailForm = ref({})
|
||||
const detailRules = {
|
||||
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }],
|
||||
roundMode: [{ required: true, message: '请选择取值方式', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const customerOpen = ref(false)
|
||||
const customerTitle = ref('')
|
||||
const customerFormRef = ref()
|
||||
const customerForm = ref({})
|
||||
const customerRules = {
|
||||
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }],
|
||||
prBegindate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function trimNum(v) {
|
||||
if (v == null || v === '') return ''
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return String(v)
|
||||
return String(parseFloat(n.toFixed(6)))
|
||||
}
|
||||
|
||||
function formatWeightLabel(min, max) {
|
||||
return `${trimNum(min)}≤W≤${trimNum(max)}`
|
||||
}
|
||||
|
||||
function countryKeyOf(row) {
|
||||
if (!row) return ''
|
||||
if (row.countryCode) return row.countryCode
|
||||
if (row.countryName) return 'N:' + row.countryName
|
||||
return ''
|
||||
}
|
||||
|
||||
function matchCountry(detail, item) {
|
||||
if (detail.countryCode && item.countryCode) return detail.countryCode === item.countryCode
|
||||
return detail.countryName && item.countryName === detail.countryName
|
||||
}
|
||||
|
||||
function ensureCountries(productCode) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
|
||||
return listProductCountries(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => !item.expireDate)
|
||||
countryCache[productCode] = rows
|
||||
return rows
|
||||
})
|
||||
}
|
||||
|
||||
function ensureSurcharges(productCode) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (surchargeCache[productCode]) return Promise.resolve(surchargeCache[productCode])
|
||||
if (surchargePending[productCode]) return surchargePending[productCode]
|
||||
surchargePending[productCode] = listSurchargeByProduct(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
|
||||
surchargeCache[productCode] = rows
|
||||
return rows
|
||||
}).catch(() => {
|
||||
surchargeCache[productCode] = []
|
||||
return []
|
||||
}).finally(() => {
|
||||
delete surchargePending[productCode]
|
||||
})
|
||||
return surchargePending[productCode]
|
||||
}
|
||||
|
||||
function surchargesOf(row) {
|
||||
if (!row || !row.productCode) return []
|
||||
const all = surchargeCache[row.productCode] || []
|
||||
return all.filter(s => {
|
||||
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
|
||||
return false
|
||||
}
|
||||
if (s.detailId == null) return true
|
||||
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function chargeTypeLabel(val) {
|
||||
const found = (bms_charge_type.value || []).find(c => c.value === val)
|
||||
return found ? found.label : (val || '-')
|
||||
}
|
||||
|
||||
function formatChargeValue(row) {
|
||||
if (row.chargeValue == null) return '-'
|
||||
const v = Number(row.chargeValue)
|
||||
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
|
||||
return v + ' ' + (row.currency || 'CNY')
|
||||
}
|
||||
|
||||
function surchargeScopeLabel(s) {
|
||||
if (s.detailId == null) return '产品级'
|
||||
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
|
||||
return `${s.countryName || '-'} · ${w}`
|
||||
}
|
||||
|
||||
const detailSurcharges = computed(() => surchargesOf(detailForm.value))
|
||||
|
||||
const detailCountryOptions = computed(() => {
|
||||
const rows = countryCache[detailForm.value.productCode] || []
|
||||
const map = new Map()
|
||||
rows.forEach(item => {
|
||||
const key = countryKeyOf(item)
|
||||
if (!key || map.has(key)) return
|
||||
map.set(key, {
|
||||
key,
|
||||
code: item.countryCode || undefined,
|
||||
name: item.countryName,
|
||||
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
const detailWeightOptions = computed(() => {
|
||||
const form = detailForm.value
|
||||
const rows = (countryCache[form.productCode] || []).filter(item => matchCountry(form, item))
|
||||
const map = new Map()
|
||||
rows.forEach(item => {
|
||||
if (item.weightMin == null && item.weightMax == null) return
|
||||
const value = formatWeightLabel(item.weightMin, item.weightMax)
|
||||
if (map.has(value)) return
|
||||
map.set(value, {
|
||||
value,
|
||||
label: value,
|
||||
detailId: item.detailId,
|
||||
freightPrice: item.freightPrice,
|
||||
registrationFee: item.registrationFee
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
function loadProducts() {
|
||||
listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
productOptions.value = (res.rows || []).map(p => ({
|
||||
value: p.productCode,
|
||||
label: `${p.productCode} ${p.productName || ''}`.trim(),
|
||||
name: p.productName
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function onDetailProductChange() {
|
||||
const found = productOptions.value.find(p => p.value === detailForm.value.productCode)
|
||||
detailForm.value.productName = found ? found.name : detailForm.value.productCode
|
||||
detailForm.value.countryCode = undefined
|
||||
detailForm.value.countryName = undefined
|
||||
detailForm.value._countryKey = undefined
|
||||
detailForm.value.weightLabel = undefined
|
||||
detailForm.value.productCountryId = undefined
|
||||
detailForm.value.fvValue = undefined
|
||||
detailForm.value.fvRegistrationFee = undefined
|
||||
if (!detailForm.value.productCode) return
|
||||
ensureSurcharges(detailForm.value.productCode)
|
||||
if (set.value.setType === 'FIXED') {
|
||||
ensureCountries(detailForm.value.productCode)
|
||||
}
|
||||
}
|
||||
|
||||
function onDetailCountryChange() {
|
||||
const opt = detailCountryOptions.value.find(c => c.key === detailForm.value._countryKey)
|
||||
detailForm.value.countryCode = opt ? opt.code : undefined
|
||||
detailForm.value.countryName = opt ? opt.name : undefined
|
||||
detailForm.value.weightLabel = undefined
|
||||
detailForm.value.productCountryId = undefined
|
||||
detailForm.value.fvValue = undefined
|
||||
detailForm.value.fvRegistrationFee = undefined
|
||||
}
|
||||
|
||||
function onDetailWeightChange() {
|
||||
const opt = detailWeightOptions.value.find(w => w.value === detailForm.value.weightLabel)
|
||||
if (!opt) {
|
||||
detailForm.value.productCountryId = undefined
|
||||
return
|
||||
}
|
||||
detailForm.value.productCountryId = opt.detailId
|
||||
if (opt.freightPrice != null) detailForm.value.fvValue = Number(opt.freightPrice)
|
||||
if (opt.registrationFee != null) detailForm.value.fvRegistrationFee = Number(opt.registrationFee)
|
||||
}
|
||||
|
||||
const childEditable = computed(() => ['D', 'R', 'E'].includes(set.value.status))
|
||||
|
||||
function roundModeLabel(mode) {
|
||||
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
|
||||
return hit ? hit.label : (mode || '四舍五入')
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (set.value.expired) return '已过期'
|
||||
const map = { D: '草稿', A: '审核中', E: '生效中', R: '驳回', S: '已停用' }
|
||||
return map[set.value.status] || set.value.status || '-'
|
||||
})
|
||||
|
||||
const statusTag = computed(() => {
|
||||
if (set.value.expired) return 'danger'
|
||||
const map = { D: 'warning', A: 'primary', E: 'success', R: 'danger', S: 'info' }
|
||||
return map[set.value.status] || 'info'
|
||||
})
|
||||
|
||||
function formatDate(val) {
|
||||
return val ? parseTime(val, '{y}-{m}-{d}') : ''
|
||||
}
|
||||
|
||||
/** 拉取套餐详情,并预取明细涉及产品的附加费 */
|
||||
function load() {
|
||||
const id = route.query.priceSetId
|
||||
if (!id) return
|
||||
@@ -505,12 +52,44 @@ function load() {
|
||||
getPriceSet(id).then(res => {
|
||||
set.value = res.data || {}
|
||||
const codes = [...new Set((set.value.details || []).map(d => d.productCode).filter(Boolean))]
|
||||
codes.forEach(code => ensureSurcharges(code))
|
||||
codes.forEach(code => refData.ensureSurcharges(code))
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 二次确认 -> 调接口 -> 提示 -> 刷新(或跳转) */
|
||||
function confirmAction(text, request, successText, done) {
|
||||
proxy.$modal.confirm(text).then(() => request()).then(() => {
|
||||
proxy.$modal.msgSuccess(successText)
|
||||
done ? done() : load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
confirmAction('确认提交审核?', () => submitPriceSet(set.value.priceSetId), '已提交审核')
|
||||
}
|
||||
|
||||
function onApprove() {
|
||||
confirmAction('确认审批生效?', () => approvePriceSet(set.value.priceSetId), '套餐已生效')
|
||||
}
|
||||
|
||||
function onReject() {
|
||||
confirmAction('确认驳回该套餐?', () => rejectPriceSet(set.value.priceSetId), '已驳回')
|
||||
}
|
||||
|
||||
function onDisable() {
|
||||
confirmAction('停用后不再参与计费命中,确认停用?', () => disablePriceSet(set.value.priceSetId), '已停用')
|
||||
}
|
||||
|
||||
/** 复用新建:复制为草稿后跳转到新建页 */
|
||||
function onCopy() {
|
||||
copyPriceSet(set.value.priceSetId).then(res => {
|
||||
proxy.$modal.msgSuccess('已复制为草稿')
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: res.data } })
|
||||
})
|
||||
}
|
||||
|
||||
function goList() {
|
||||
const obj = { path: '/customer/set' }
|
||||
if (proxy.$tab) {
|
||||
@@ -524,207 +103,8 @@ function goEdit() {
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: set.value.priceSetId } })
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
proxy.$modal.confirm('确认提交审核?').then(() => submitPriceSet(set.value.priceSetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已提交审核')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onApprove() {
|
||||
proxy.$modal.confirm('确认审批生效?').then(() => approvePriceSet(set.value.priceSetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('套餐已生效')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onReject() {
|
||||
proxy.$modal.confirm('确认驳回该套餐?').then(() => rejectPriceSet(set.value.priceSetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已驳回')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onDisable() {
|
||||
proxy.$modal.confirm('停用后不再参与计费命中,确认停用?').then(() => disablePriceSet(set.value.priceSetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已停用')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onCopy() {
|
||||
copyPriceSet(set.value.priceSetId).then(res => {
|
||||
proxy.$modal.msgSuccess('已复制为草稿')
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: res.data } })
|
||||
})
|
||||
}
|
||||
|
||||
function openDetailDialog(row) {
|
||||
detailForm.value = row ? {
|
||||
...row,
|
||||
roundMode: row.roundMode || 'HALF_UP',
|
||||
_countryKey: countryKeyOf(row)
|
||||
} : {
|
||||
priceSetId: set.value.priceSetId,
|
||||
productCode: undefined,
|
||||
productName: undefined,
|
||||
countryCode: undefined,
|
||||
countryName: undefined,
|
||||
_countryKey: undefined,
|
||||
weightLabel: undefined,
|
||||
productCountryId: undefined,
|
||||
fvValue: undefined,
|
||||
fvRegistrationFee: undefined,
|
||||
discountValue: undefined,
|
||||
roundMode: 'HALF_UP'
|
||||
}
|
||||
detailTitle.value = row ? '编辑明细' : '添加明细'
|
||||
detailOpen.value = true
|
||||
if (!detailForm.value.productCode) return
|
||||
ensureSurcharges(detailForm.value.productCode)
|
||||
if (set.value.setType === 'FIXED') {
|
||||
ensureCountries(detailForm.value.productCode)
|
||||
}
|
||||
}
|
||||
|
||||
function submitDetail() {
|
||||
proxy.$refs['detailFormRef'].validate(valid => {
|
||||
if (!valid) return
|
||||
const payload = {
|
||||
...detailForm.value,
|
||||
priceSetId: set.value.priceSetId
|
||||
}
|
||||
delete payload._countryKey
|
||||
const req = payload.psdId ? updateSetDetail(payload) : addSetDetail(payload)
|
||||
req.then(() => {
|
||||
proxy.$modal.msgSuccess('保存成功')
|
||||
detailOpen.value = false
|
||||
load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function removeDetail(row) {
|
||||
proxy.$modal.confirm('确认删除该明细?').then(() => delSetDetail(row.psdId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已删除')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onCustomerSelectChange(code, item) {
|
||||
customerForm.value.customerCode = code || undefined
|
||||
customerForm.value.customerName = item ? item.customerName : ''
|
||||
customerForm.value.customerId = item ? item.customerId : undefined
|
||||
}
|
||||
|
||||
function openCustomerDialog(row) {
|
||||
customerForm.value = row ? { ...row } : {
|
||||
priceSetId: set.value.priceSetId,
|
||||
customerId: undefined,
|
||||
customerCode: undefined,
|
||||
customerName: '',
|
||||
bindPriority: 1,
|
||||
prBegindate: set.value.effectiveDate,
|
||||
prEnddate: set.value.expireDate
|
||||
}
|
||||
customerTitle.value = row ? '编辑客户绑定' : '添加客户'
|
||||
customerOpen.value = true
|
||||
}
|
||||
|
||||
function submitCustomer() {
|
||||
proxy.$refs['customerFormRef'].validate(valid => {
|
||||
if (!valid) return
|
||||
const payload = {
|
||||
...customerForm.value,
|
||||
priceSetId: set.value.priceSetId,
|
||||
customerId: customerForm.value.customerId,
|
||||
customerCode: customerForm.value.customerCode
|
||||
}
|
||||
console.log('submitCustomer', payload)
|
||||
const req = payload.customerPricesetId ? updateSetCustomer(payload) : addSetCustomer(payload)
|
||||
req.then(() => {
|
||||
proxy.$modal.msgSuccess('保存成功')
|
||||
customerOpen.value = false
|
||||
load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function removeCustomer(row) {
|
||||
proxy.$modal.confirm('确认解绑该客户?').then(() => delSetCustomer(row.customerPricesetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已解绑')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
refData.loadProducts()
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mt16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.rel-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.hl {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.reg {
|
||||
color: #d48806;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.sc-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sc-pop-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sc-pop-item {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sc-pop-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sc-pop-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 套餐模块 · 表单弹窗统一样式
|
||||
*
|
||||
* 为什么是全局样式而不是 scoped:
|
||||
* el-dialog 使用 append-to-body 把内容传送到 body,且 .el-dialog 元素由 Element Plus
|
||||
* 内部组件渲染,外层组件的 scoped 属性不会落到它身上,:deep() 也匹配不到。
|
||||
* 因此统一用 .ps-dialog 命名空间做全局覆盖,两个弹窗引用同一份,避免各写一套。
|
||||
*
|
||||
* 用法:<el-dialog class="ps-dialog" ...>,内容区可用下面的 ps-* 工具类。
|
||||
*/
|
||||
|
||||
/* ============ 1. 弹窗外壳 ============ */
|
||||
.ps-dialog {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* 头部:淡渐变 + 分隔线,标题收敛字号 */
|
||||
.el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 15px 20px;
|
||||
background: linear-gradient(180deg, #fbfcfe 0%, #ffffff 100%);
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
top: 13px;
|
||||
right: 14px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
.el-dialog__close {
|
||||
font-size: 16px;
|
||||
color: #a8abb2;
|
||||
}
|
||||
&:hover {
|
||||
background: #f2f3f5;
|
||||
|
||||
.el-dialog__close {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 主体:超高可滚动,滚动条收窄避免抢视觉 */
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
max-height: 66vh;
|
||||
overflow-y: auto;
|
||||
color: #303133;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #dcdfe6;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部:浅底 + 分隔线,按钮右对齐且等宽 */
|
||||
.el-dialog__footer {
|
||||
padding: 12px 20px;
|
||||
text-align: right;
|
||||
background: #fafbfc;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
|
||||
.el-button {
|
||||
min-width: 84px;
|
||||
|
||||
& + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 2. 表单排布 ============ */
|
||||
.el-form-item {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 两列布局下的行间距由 el-row 控制,去掉末项的额外留白 */
|
||||
.el-row .el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 3. 内容区工具类 ============ */
|
||||
/* 数值字段:输入框 + 右侧单位。单位放 label 里会因 label 宽度固定而换行截断 */
|
||||
.ps-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* 字段下方补充说明,独占一行 */
|
||||
.ps-hint {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #909399;
|
||||
|
||||
/* 不挂在表单控件后面时,作为独立段落使用 */
|
||||
&--block {
|
||||
flex: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 只读信息条:客户代码 chip + 名称 */
|
||||
.ps-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 6px;
|
||||
|
||||
&__code {
|
||||
flex: none;
|
||||
padding: 0 6px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #fff;
|
||||
border: 1px solid #e9e9eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: #c0c4cc;
|
||||
|
||||
.ps-readonly__name {
|
||||
font-weight: 400;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 只读附属信息块(如附加费清单) */
|
||||
.ps-subbox {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
&__count {
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: #909399;
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 价格套餐 · 纯函数与数据工厂
|
||||
*
|
||||
* 只做数据转换,不依赖组件实例 / 字典 / 接口,
|
||||
* create.vue、detail.vue 及 set/components 下的组件共用。
|
||||
*/
|
||||
import { parseTime } from '@/utils/ruoyi'
|
||||
|
||||
/** 计价类型枚举 */
|
||||
export const SET_TYPE = {
|
||||
FIXED: 'FIXED',
|
||||
DISCOUNT: 'DISCOUNT'
|
||||
}
|
||||
|
||||
/** 套餐状态枚举:D 草稿 / A 审核中 / E 生效中 / R 驳回 / S 已停用 */
|
||||
export const SET_STATUS = {
|
||||
DRAFT: 'D',
|
||||
AUDIT: 'A',
|
||||
EFFECTIVE: 'E',
|
||||
REJECT: 'R',
|
||||
STOPPED: 'S'
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {
|
||||
D: '草稿',
|
||||
A: '审核中',
|
||||
E: '生效中',
|
||||
R: '驳回',
|
||||
S: '已停用'
|
||||
}
|
||||
|
||||
const STATUS_TAG = {
|
||||
D: 'warning',
|
||||
A: 'primary',
|
||||
E: 'success',
|
||||
R: 'danger',
|
||||
S: 'info'
|
||||
}
|
||||
|
||||
/** 默认取值方式 */
|
||||
export const DEFAULT_ROUND_MODE = 'HALF_UP'
|
||||
|
||||
/** 状态文案(已过期优先) */
|
||||
export function setStatusLabel(set) {
|
||||
if (set && set.expired) return '已过期'
|
||||
return STATUS_LABEL[set && set.status] || (set && set.status) || '-'
|
||||
}
|
||||
|
||||
/** 状态标签色(已过期优先) */
|
||||
export function setStatusTagType(set) {
|
||||
if (set && set.expired) return 'danger'
|
||||
return STATUS_TAG[set && set.status] || 'info'
|
||||
}
|
||||
|
||||
/** 明细 / 客户是否可维护:草稿、驳回、生效中 */
|
||||
export function isChildEditable(status) {
|
||||
return [SET_STATUS.DRAFT, SET_STATUS.REJECT, SET_STATUS.EFFECTIVE].includes(status)
|
||||
}
|
||||
|
||||
/** 日期显示 yyyy-MM-dd */
|
||||
export function formatDate(val) {
|
||||
return val ? parseTime(val, '{y}-{m}-{d}') : ''
|
||||
}
|
||||
|
||||
/** 有效期区间文案,结束为空表示长期 */
|
||||
export function formatDateRange(begin, end) {
|
||||
return `${formatDate(begin) || '-'} ~ ${end ? formatDate(end) : '长期'}`
|
||||
}
|
||||
|
||||
/** 是否固定价模式 */
|
||||
export function isFixedType(setType) {
|
||||
return setType === SET_TYPE.FIXED
|
||||
}
|
||||
|
||||
/** 是否折扣模式 */
|
||||
export function isDiscountType(setType) {
|
||||
return setType === SET_TYPE.DISCOUNT
|
||||
}
|
||||
|
||||
/** 计价类型展示文案 */
|
||||
export function setTypeLabel(setType) {
|
||||
return isFixedType(setType) ? 'FIXED 固定价' : 'DISCOUNT 折扣'
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字转字符串,抹掉浮点误差尾巴
|
||||
* @param {number|string} v
|
||||
* @returns {string} 0.1 + 0.2 -> '0.3'
|
||||
*/
|
||||
export function trimNum(v) {
|
||||
if (v == null || v === '') return ''
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return String(v)
|
||||
return String(parseFloat(n.toFixed(6)))
|
||||
}
|
||||
|
||||
/** 重量段展示文案:1≤W≤2 */
|
||||
export function formatWeightLabel(min, max) {
|
||||
return `${trimNum(min)}≤W≤${trimNum(max)}`
|
||||
}
|
||||
|
||||
/** 国家唯一键:优先国家三字码,其次国家名称 */
|
||||
export function countryKeyOf(row) {
|
||||
if (!row) return ''
|
||||
if (row.countryCode) return row.countryCode
|
||||
if (row.countryName) return 'N:' + row.countryName
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 明细行的国家 与 产品国家配置行 是否指向同一国家 */
|
||||
export function matchCountry(detail, item) {
|
||||
if (detail.countryCode && item.countryCode) {
|
||||
return detail.countryCode === item.countryCode
|
||||
}
|
||||
return !!detail.countryName && item.countryName === detail.countryName
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家下拉选项(按产品的国家配置去重)
|
||||
* @param {Array} rows bms_product_country 行
|
||||
* @returns {Array<{key, code, name, label}>}
|
||||
*/
|
||||
export function buildCountryOptions(rows) {
|
||||
const map = new Map()
|
||||
;(rows || []).forEach(item => {
|
||||
const key = countryKeyOf(item)
|
||||
if (!key || map.has(key)) return
|
||||
map.set(key, {
|
||||
key,
|
||||
code: item.countryCode || undefined,
|
||||
name: item.countryName,
|
||||
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量段下拉选项(按已选国家过滤去重)
|
||||
* @param {Array} rows bms_product_country 行
|
||||
* @param {Object} row 当前明细行(含国家)
|
||||
* @returns {Array<{value, label, detailId, freightPrice, registrationFee}>}
|
||||
*/
|
||||
export function buildWeightOptions(rows, row) {
|
||||
const map = new Map()
|
||||
;(rows || []).filter(item => matchCountry(row, item)).forEach(item => {
|
||||
if (item.weightMin == null && item.weightMax == null) return
|
||||
const value = formatWeightLabel(item.weightMin, item.weightMax)
|
||||
if (map.has(value)) return
|
||||
map.set(value, {
|
||||
value,
|
||||
label: value,
|
||||
detailId: item.detailId,
|
||||
freightPrice: item.freightPrice,
|
||||
registrationFee: item.registrationFee
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
/** 附加费金额展示:RATE 转百分比,其余带币种 */
|
||||
export function formatChargeValue(row) {
|
||||
if (!row || row.chargeValue == null) return '-'
|
||||
const v = Number(row.chargeValue)
|
||||
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
|
||||
return v + ' ' + (row.currency || 'CNY')
|
||||
}
|
||||
|
||||
/** 附加费作用范围文案 */
|
||||
export function surchargeScopeLabel(s) {
|
||||
if (s.detailId == null) return '产品级'
|
||||
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
|
||||
return `${s.countryName || '-'} · ${w}`
|
||||
}
|
||||
|
||||
/** 附加费标签文案 */
|
||||
export function surchargeTagLabel(s) {
|
||||
return `${s.scName} ${formatChargeValue(s)}`
|
||||
}
|
||||
|
||||
/** 新增一条空明细行 */
|
||||
export function createEmptyDetailRow() {
|
||||
return {
|
||||
productCode: undefined,
|
||||
productName: undefined,
|
||||
countryCode: undefined,
|
||||
countryName: undefined,
|
||||
_countryKey: undefined,
|
||||
weightLabel: undefined,
|
||||
productCountryId: undefined,
|
||||
fvValue: undefined,
|
||||
fvRegistrationFee: undefined,
|
||||
discountValue: undefined,
|
||||
roundMode: DEFAULT_ROUND_MODE
|
||||
}
|
||||
}
|
||||
|
||||
/** 明细行 -> 可编辑行(补齐取值方式与国家键) */
|
||||
export function toEditableDetailRow(row) {
|
||||
return {
|
||||
...row,
|
||||
roundMode: row.roundMode || DEFAULT_ROUND_MODE,
|
||||
_countryKey: countryKeyOf(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增一条空客户绑定行 */
|
||||
export function createEmptyCustomerRow({ effectiveDate, expireDate, seq = 1 } = {}) {
|
||||
return {
|
||||
customerId: undefined,
|
||||
customerCode: undefined,
|
||||
customerName: '',
|
||||
bindPriority: seq,
|
||||
prBegindate: effectiveDate,
|
||||
prEnddate: expireDate
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建套餐的基本信息默认值 */
|
||||
export function createEmptyBasicInfo() {
|
||||
return {
|
||||
priceSetName: '',
|
||||
setType: SET_TYPE.FIXED,
|
||||
defaultDiscount: 88,
|
||||
effectiveDate: undefined,
|
||||
expireDate: undefined,
|
||||
priceSetNote: ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 明细行 -> 提交后端的 payload */
|
||||
export function toDetailPayload(d) {
|
||||
return {
|
||||
productCode: d.productCode,
|
||||
productName: d.productName,
|
||||
countryCode: d.countryCode || undefined,
|
||||
countryName: d.countryName || undefined,
|
||||
weightLabel: d.weightLabel || undefined,
|
||||
productCountryId: d.productCountryId || undefined,
|
||||
fvValue: d.fvValue,
|
||||
fvRegistrationFee: d.fvRegistrationFee,
|
||||
discountValue: d.discountValue,
|
||||
roundMode: d.roundMode || DEFAULT_ROUND_MODE
|
||||
}
|
||||
}
|
||||
|
||||
/** 客户绑定行 -> 提交后端的 payload */
|
||||
export function toCustomerPayload(c) {
|
||||
return { ...c }
|
||||
}
|
||||
Reference in New Issue
Block a user