修改价格套餐样式结构

This commit is contained in:
wuweihua
2026-09-14 11:53:11 +08:00
parent 4cc20386d9
commit e2f75afb27
16 changed files with 2348 additions and 1330 deletions
@@ -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 }
}
+82 -646
View File
@@ -7,255 +7,23 @@
</el-steps> </el-steps>
<!-- 第 1 步 --> <!-- 第 1 步 -->
<el-card v-show="active === 0" shadow="never" class="step-card"> <step-basic-info v-show="active === 0" ref="basicRef" v-model="form.basic" />
<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>
<!-- 第 2 步 --> <!-- 第 2 步 -->
<el-card v-show="active === 1" shadow="never" class="step-card"> <step-details
<template #header> v-show="active === 1"
<div class="card-head"> :details="form.details"
<span> :set-type="form.basic.setType"
第 2 步 · 套餐明细 :ref-data="refData"
<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>
<!-- 第 3 步 --> <!-- 第 3 步 -->
<el-card v-show="active === 2" shadow="never" class="step-card"> <step-customers
<template #header> v-show="active === 2"
<div class="card-head"> :customers="form.customers"
<span>第 3 步 · 绑定客户<span class="sub">可选,可后续补充</span></span> :effective-date="form.basic.effectiveDate"
<el-button type="primary" size="small" icon="Plus" @click="addCustomerRow">添加客户</el-button> :expire-date="form.basic.expireDate"
</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>
<div class="wizard-actions"> <div class="wizard-actions">
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">取消</el-button>
@@ -268,346 +36,68 @@
</template> </template>
<script setup name="PriceSetCreate"> <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 { getPriceSet, savePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const route = useRoute() const route = useRoute()
const refData = usePriceSetRefData()
const cascade = useDetailCascade(refData)
const active = ref(0) const active = ref(0)
const saving = ref(false) const saving = ref(false)
const formRef = ref() const basicRef = ref()
const productOptions = ref([])
/** productCode -> bms_product_country rows */
const countryCache = reactive({})
/** productCode -> 有效附加费关联 */
const surchargeCache = reactive({})
const surchargePending = {}
/** 整个套餐表单:基本信息 / 明细 / 绑定客户 */
const form = reactive({ const form = reactive({
priceSetId: undefined, priceSetId: undefined,
priceSetName: '', basic: createEmptyBasicInfo(),
setType: 'FIXED',
defaultDiscount: 88,
effectiveDate: undefined,
expireDate: undefined,
priceSetNote: '',
details: [], details: [],
customers: [] customers: []
}) })
const rules = { /** 下一步:仅第 1 步需要校验 */
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }], async function nextStep() {
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() {
if (active.value === 0) { if (active.value === 0) {
formRef.value.validate(valid => { const valid = await basicRef.value.validate()
if (valid) active.value++ if (!valid) return
})
return
} }
active.value++ active.value++
} }
function saveDraft() { function saveDraft() {
formRef.value.validate(valid => { save(false)
if (!valid) {
active.value = 0
return
}
doSave(false)
})
} }
function saveAndSubmit() { function saveAndSubmit() {
formRef.value.validate(valid => { save(true)
if (!valid) {
active.value = 0
return
}
doSave(true)
})
} }
function doSave(submit) { /** 校验 + 保存;校验失败回到第 1 步 */
const ids = (form.customers || []).filter(c => c.customerId).map(c => String(c.customerId)) 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) { if (new Set(ids).size !== ids.length) {
proxy.$modal.msgError('该客户已绑定套餐,一个客户只能绑定一个套餐') proxy.$modal.msgError('该客户已绑定套餐,一个客户只能绑定一个套餐')
return return
@@ -617,11 +107,30 @@ function doSave(submit) {
form.priceSetId = res.data form.priceSetId = res.data
proxy.$modal.msgSuccess(submit ? '已保存并提交审核' : '草稿已保存,套餐ID=' + res.data) proxy.$modal.msgSuccess(submit ? '已保存并提交审核' : '草稿已保存,套餐ID=' + res.data)
goBack() goBack()
}).catch(() => {
// 错误提示由请求拦截器统一处理
}).finally(() => { }).finally(() => {
saving.value = false 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() { function goBack() {
const obj = { path: '/customer/set' } const obj = { path: '/customer/set' }
if (proxy.$tab) { if (proxy.$tab) {
@@ -631,28 +140,31 @@ function goBack() {
} }
} }
/** 编辑 / 复用新建:回显已有套餐 */
function loadExisting(id) { function loadExisting(id) {
getPriceSet(id).then(res => { getPriceSet(id).then(res => {
const data = res.data || {} const data = res.data || {}
form.priceSetId = data.priceSetId form.priceSetId = data.priceSetId
form.priceSetName = data.priceSetName Object.assign(form.basic, {
form.setType = data.setType || 'FIXED' priceSetName: data.priceSetName,
form.defaultDiscount = data.defaultDiscount != null ? Number(data.defaultDiscount) : 88 setType: data.setType || 'FIXED',
form.effectiveDate = data.effectiveDate defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
form.expireDate = data.expireDate effectiveDate: data.effectiveDate,
form.priceSetNote = data.priceSetNote expireDate: data.expireDate,
form.details = (data.details || []).map(d => hydrateDetailRow(d)) priceSetNote: data.priceSetNote
})
form.details = (data.details || []).map(d => cascade.hydrateRow(d, form.basic.setType))
form.customers = (data.customers || []).map(c => ({ ...c })) form.customers = (data.customers || []).map(c => ({ ...c }))
}) })
} }
onMounted(() => { onMounted(() => {
loadProducts().finally(() => { refData.loadProducts().finally(() => {
const id = route.query.priceSetId const id = route.query.priceSetId
if (id) { if (id) {
loadExisting(id) loadExisting(id)
} else { } else {
addDetailRow() form.details.push(createEmptyDetailRow())
} }
}) })
}) })
@@ -668,84 +180,8 @@ onMounted(() => {
.step-card { .step-card {
margin-bottom: 16px; 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 { .wizard-actions {
text-align: center; text-align: center;
padding: 8px 0 24px; 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> </style>
+64 -684
View File
@@ -1,503 +1,50 @@
<template> <template>
<div class="app-container" v-loading="loading"> <div class="app-container" v-loading="loading">
<div class="detail-header"> <set-detail-header
<h2>{{ set.priceSetCode || '-' }} · {{ set.priceSetName || '套餐详情' }}</h2> :set="set"
<div class="header-actions"> @back="goList"
<el-tag :type="statusTag" size="large">{{ statusLabel }}</el-tag> @edit="goEdit"
<el-tag :type="set.setType === 'FIXED' ? 'warning' : 'primary'" size="large" effect="plain"> @submit="onSubmit"
{{ set.setType === 'FIXED' ? 'FIXED 固定价' : 'DISCOUNT 折扣' }} @approve="onApprove"
</el-tag> @reject="onReject"
<el-button @click="goList">返回列表</el-button> @disable="onDisable"
<el-button v-if="set.status === 'D' || set.status === 'R'" type="primary" @click="goEdit" v-hasPermi="['price:set:edit']">继续配置</el-button> @copy="onCopy"
<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>
<el-row :gutter="16"> <set-basic-info-card :set="set" />
<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>
<el-card shadow="never" class="mt16"> <set-detail-table :set="set" :ref-data="refData" @refresh="load" />
<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>
<el-card shadow="never" class="mt16"> <set-customer-table :set="set" @refresh="load" />
<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>
</div> </div>
</template> </template>
<script setup name="PriceSetDetail"> <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 { import {
getPriceSet, submitPriceSet, approvePriceSet, rejectPriceSet, disablePriceSet, copyPriceSet, getPriceSet, submitPriceSet, approvePriceSet, rejectPriceSet, disablePriceSet, copyPriceSet
addSetDetail, updateSetDetail, delSetDetail,
addSetCustomer, updateSetCustomer, delSetCustomer
} from '@/api/price/set' } 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 { proxy } = getCurrentInstance()
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const route = useRoute() const route = useRoute()
/** 产品 / 国家 / 附加费 参考数据与明细弹窗级联共用,与新建页同一套实现 */
const refData = usePriceSetRefData()
const loading = ref(false) const loading = ref(false)
const set = ref({}) 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() { function load() {
const id = route.query.priceSetId const id = route.query.priceSetId
if (!id) return if (!id) return
@@ -505,12 +52,44 @@ function load() {
getPriceSet(id).then(res => { getPriceSet(id).then(res => {
set.value = res.data || {} set.value = res.data || {}
const codes = [...new Set((set.value.details || []).map(d => d.productCode).filter(Boolean))] 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(() => { }).finally(() => {
loading.value = false 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() { function goList() {
const obj = { path: '/customer/set' } const obj = { path: '/customer/set' }
if (proxy.$tab) { if (proxy.$tab) {
@@ -524,207 +103,8 @@ function goEdit() {
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: set.value.priceSetId } }) 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(() => { onMounted(() => {
loadProducts() refData.loadProducts()
load() load()
}) })
</script> </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 }
}