From e2f75afb272a94bc656320163a73fe31d0325af0 Mon Sep 17 00:00:00 2001
From: wuweihua <88888888>
Date: Mon, 14 Sep 2026 11:53:11 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BB=B7=E6=A0=BC=E5=A5=97?=
=?UTF-8?q?=E9=A4=90=E6=A0=B7=E5=BC=8F=E7=BB=93=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../price/set/components/SurchargeTags.vue | 104 +++
.../set/components/create/StepBasicInfo.vue | 125 +++
.../set/components/create/StepCustomers.vue | 111 +++
.../set/components/create/StepDetails.vue | 186 +++++
.../components/detail/SetBasicInfoCard.vue | 102 +++
.../components/detail/SetCustomerDialog.vue | 165 ++++
.../components/detail/SetCustomerTable.vue | 113 +++
.../set/components/detail/SetDetailDialog.vue | 227 ++++++
.../set/components/detail/SetDetailHeader.vue | 123 +++
.../set/components/detail/SetDetailTable.vue | 189 +++++
.../set/components/detail/SetSectionCard.vue | 104 +++
.../set/composables/usePriceSetRefData.js | 189 +++++
bms-front/src/views/price/set/create.vue | 728 ++---------------
bms-front/src/views/price/set/detail.vue | 748 ++----------------
.../src/views/price/set/styles/dialog.scss | 214 +++++
.../src/views/price/set/utils/priceSet.js | 250 ++++++
16 files changed, 2348 insertions(+), 1330 deletions(-)
create mode 100644 bms-front/src/views/price/set/components/SurchargeTags.vue
create mode 100644 bms-front/src/views/price/set/components/create/StepBasicInfo.vue
create mode 100644 bms-front/src/views/price/set/components/create/StepCustomers.vue
create mode 100644 bms-front/src/views/price/set/components/create/StepDetails.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetCustomerDialog.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetCustomerTable.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetDetailDialog.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetDetailHeader.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetDetailTable.vue
create mode 100644 bms-front/src/views/price/set/components/detail/SetSectionCard.vue
create mode 100644 bms-front/src/views/price/set/composables/usePriceSetRefData.js
create mode 100644 bms-front/src/views/price/set/styles/dialog.scss
create mode 100644 bms-front/src/views/price/set/utils/priceSet.js
diff --git a/bms-front/src/views/price/set/components/SurchargeTags.vue b/bms-front/src/views/price/set/components/SurchargeTags.vue
new file mode 100644
index 00000000..f616b4f3
--- /dev/null
+++ b/bms-front/src/views/price/set/components/SurchargeTags.vue
@@ -0,0 +1,104 @@
+
+
+
{{ emptyText }}
+
+
+
+
+
{{ s.scName }}
+
+ {{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
+ · {{ surchargeScopeLabel(s) }}
+
+
+
+
+
+
+ {{ surchargeTagLabel(s) }}
+
+
+
+
+ {{ surchargeTagLabel(s) }}
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/create/StepBasicInfo.vue b/bms-front/src/views/price/set/components/create/StepBasicInfo.vue
new file mode 100644
index 00000000..abde7ea6
--- /dev/null
+++ b/bms-front/src/views/price/set/components/create/StepBasicInfo.vue
@@ -0,0 +1,125 @@
+
+
+ 第 1 步 · 基本信息
+
+
+
+ 显示给客户的套餐对外名称
+
+
+
+
+
+
FIXED 固定价
+
可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费
+
+
+
DISCOUNT 折扣
+
按默认等级价统一乘折扣,可选产品范围
+
+
+
+
+
+
+ %
+ 仅 DISCOUNT 模式生效
+ 范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略
+
+
+
+
+
+
+
+
+ 空表示长期有效
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/create/StepCustomers.vue b/bms-front/src/views/price/set/components/create/StepCustomers.vue
new file mode 100644
index 00000000..cd4a3fb2
--- /dev/null
+++ b/bms-front/src/views/price/set/components/create/StepCustomers.vue
@@ -0,0 +1,111 @@
+
+
+
+
+ 第 3 步 · 绑定客户可选,可后续补充
+ 添加客户
+
+
+
+
+
+
+
+
+ onCustomerChange(row, code, item)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 移除
+
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/create/StepDetails.vue b/bms-front/src/views/price/set/components/create/StepDetails.vue
new file mode 100644
index 00000000..9dad9eee
--- /dev/null
+++ b/bms-front/src/views/price/set/components/create/StepDetails.vue
@@ -0,0 +1,186 @@
+
+
+
+
+
+ 第 2 步 · 套餐明细
+ 当前:{{ setTypeLabel(setType) }}模式
+
+ 添加明细行
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue b/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue
new file mode 100644
index 00000000..16b373c3
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
{{ item.label }}
+
+ {{ item.value }}
+ {{ item.value }}
+
+
+
+
+
说明
+
{{ set.priceSetNote || '—' }}
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetCustomerDialog.vue b/bms-front/src/views/price/set/components/detail/SetCustomerDialog.vue
new file mode 100644
index 00000000..d04fe643
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetCustomerDialog.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+ {{ form.customerCode || '未选择' }}
+ {{ form.customerName || '选择客户后自动带出' }}
+
+
+
+
+
+ 数字越小优先级越高,1 为最高;同一客户命中多个套餐时按此排序
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ bindRangeHint }}
+
+
+
+ 取 消
+ 确 定
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue b/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue
new file mode 100644
index 00000000..b2581c56
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue
@@ -0,0 +1,113 @@
+
+
+
+ 添加客户
+
+
+
+
+
+ {{ row.customerCode }}
+
+
+
+
+
+ 最高
+ {{ row.bindPriority }}
+
+
+
+ {{ formatDateRange(row.prBegindate, row.prEnddate) }}
+
+
+
+ 编辑
+ 解绑
+
+
+
+
+ 解绑后该客户可绑定其他套餐;生效中的套餐修改绑定会即时影响计费命中。
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue b/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue
new file mode 100644
index 00000000..f95e5684
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RMB/KG
+
+
+
+
+
+
+
+ RMB/票
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %
+
+ 留空表示使用套餐默认折扣
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 产品匹配附加费
+ {{ surcharges.length }}
+
+
+
+
+
+
+
+ 取 消
+ 确 定
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue b/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue
new file mode 100644
index 00000000..f2a3394b
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetDetailTable.vue b/bms-front/src/views/price/set/components/detail/SetDetailTable.vue
new file mode 100644
index 00000000..1f11511d
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetDetailTable.vue
@@ -0,0 +1,189 @@
+
+
+
+ 添加明细
+
+
+
+
+
+
+ {{ row.productCode }}
+ {{ row.productName || '' }}
+
+
+
+
+
+
+ {{ row.countryName || row.countryCode || '-' }}
+
+
+
+
+ {{ row.fvValue }}
+ RMB/KG
+
+
+
+
+ {{ row.fvRegistrationFee }}
+ RMB/票
+
+
+
+
+
+
+ 默认折扣
+ {{ row.discountValue }}%
+
+
+
+
+
+
+
+
+
+
+ {{ roundModeLabel(row.roundMode) }}
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+ {{ hint }}
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/components/detail/SetSectionCard.vue b/bms-front/src/views/price/set/components/detail/SetSectionCard.vue
new file mode 100644
index 00000000..c3e380d7
--- /dev/null
+++ b/bms-front/src/views/price/set/components/detail/SetSectionCard.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ {{ title }}
+
+ {{ count }}
+
+ {{ sub }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bms-front/src/views/price/set/composables/usePriceSetRefData.js b/bms-front/src/views/price/set/composables/usePriceSetRefData.js
new file mode 100644
index 00000000..f7485fbf
--- /dev/null
+++ b/bms-front/src/views/price/set/composables/usePriceSetRefData.js
@@ -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 }
+}
diff --git a/bms-front/src/views/price/set/create.vue b/bms-front/src/views/price/set/create.vue
index 62b59b6e..1a0f412f 100644
--- a/bms-front/src/views/price/set/create.vue
+++ b/bms-front/src/views/price/set/create.vue
@@ -7,255 +7,23 @@
-
- 第 1 步 · 基本信息
-
-
-
- 显示给客户的套餐对外名称
-
-
-
-
-
FIXED 固定价
-
可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费
-
-
-
DISCOUNT 折扣
-
按默认等级价统一乘折扣,可选产品范围
-
-
-
-
-
- %
- 仅 DISCOUNT 模式生效
- 范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略
-
-
-
-
-
-
- 空表示长期有效
-
-
-
-
-
-
+
-
-
-
-
- 第 2 步 · 套餐明细
- 当前:{{ form.setType === 'FIXED' ? 'FIXED 固定价' : 'DISCOUNT 折扣' }}模式
-
- 添加明细行
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
{{ s.scName }}
-
- {{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
- · {{ surchargeScopeLabel(s) }}
-
-
-
-
-
-
- {{ s.scName }} {{ formatChargeValue(s) }}
-
-
-
- 无
-
-
-
-
-
-
-
-
-
-
-
- 删除
-
-
-
-
+
-
-
-
- 第 3 步 · 绑定客户可选,可后续补充
- 添加客户
-
-
-
-
-
-
- onBindCustomerChange(row, code, item)"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 移除
-
-
-
-
+
取消
@@ -268,346 +36,68 @@
-
-
diff --git a/bms-front/src/views/price/set/styles/dialog.scss b/bms-front/src/views/price/set/styles/dialog.scss
new file mode 100644
index 00000000..5dcb2bdd
--- /dev/null
+++ b/bms-front/src/views/price/set/styles/dialog.scss
@@ -0,0 +1,214 @@
+/**
+ * 套餐模块 · 表单弹窗统一样式
+ *
+ * 为什么是全局样式而不是 scoped:
+ * el-dialog 使用 append-to-body 把内容传送到 body,且 .el-dialog 元素由 Element Plus
+ * 内部组件渲染,外层组件的 scoped 属性不会落到它身上,:deep() 也匹配不到。
+ * 因此统一用 .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;
+ }
+}
diff --git a/bms-front/src/views/price/set/utils/priceSet.js b/bms-front/src/views/price/set/utils/priceSet.js
new file mode 100644
index 00000000..3d83884f
--- /dev/null
+++ b/bms-front/src/views/price/set/utils/priceSet.js
@@ -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 }
+}