This commit is contained in:
luojiaquan
2026-09-30 16:57:51 +08:00
19 changed files with 1260 additions and 260 deletions
+36 -4
View File
@@ -56,7 +56,7 @@ export async function savePriceSetAsync(data, onProgress) {
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 800))
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('save timeout')
}
@@ -202,7 +202,7 @@ export function addSetDetail(data) {
/**
* Async import quote Excel into FIXED package detail prices; poll until done.
* @param {File|Blob} file
* @param {{ priceSetId: string|number, psvId?: string|number }} query
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean }} query
* @param {(task: object) => void} [onProgress]
* @returns {Promise<{ task: object }>}
*/
@@ -212,6 +212,7 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
const params = new URLSearchParams()
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
if (query && query.updateOnly) params.set('updateOnly', 'true')
const submitRes = await request({
url: '/price/set/detail/importPrices?' + params.toString(),
method: 'post',
@@ -225,6 +226,37 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
return pollPriceSetSaveTask(taskId, onProgress)
}
/**
* Preview pull product library (no DB write). Returns { products, details, message }.
*/
export function previewPullFromLibrary() {
return request({
url: '/price/set/detail/previewPullFromLibrary',
method: 'post'
})
}
/**
* Preview Excel import (no DB write).
* @param {File|Blob} file
* @param {{ updateOnly?: boolean, details?: Array }} [opts]
*/
export function previewImportDetailPrices(file, opts = {}) {
const formData = new FormData()
formData.append('file', file)
if (opts.updateOnly) formData.append('updateOnly', 'true')
if (opts.updateOnly && Array.isArray(opts.details)) {
formData.append('detailsJson', JSON.stringify(opts.details))
}
return request({
url: '/price/set/detail/previewImportPrices',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 10 * 60 * 1000
})
}
/**
* Async pull product library into package version details; poll until done.
* @param {{ priceSetId: string|number, psvId?: string|number, force?: boolean }} query
@@ -263,7 +295,7 @@ async function pollPriceSetSaveTask(taskId, onProgress) {
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 800))
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('task timeout')
}
@@ -306,7 +338,7 @@ export async function updateSetDetailSurchargeAsync(details, onProgress) {
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 800))
await new Promise(resolve => setTimeout(resolve, 400))
}
throw new Error('surcharge save timeout')
}
@@ -24,6 +24,7 @@
:psv-id="version.psvId"
@update:psv-id="v => { if (v != null && v !== '') version.psvId = v }"
:set-type="setType"
:price-build-mode="version.priceBuildMode"
:effective-time="version.effectiveTime"
:version-no="version.versionNo"
:ref-data="refData"
@@ -52,7 +53,7 @@ const refData = usePriceSetRefData()
const loading = ref(false)
const loaded = ref(false)
const setType = ref('FIXED')
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined })
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined, priceBuildMode: undefined })
const details = ref([])
const products = ref([])
@@ -77,6 +78,7 @@ function load() {
version.psvId = current.psvId
version.versionNo = current.versionNo || 'V1'
version.effectiveTime = current.effectiveTime
version.priceBuildMode = current.priceBuildMode
details.value = []
products.value = (current.products || []).map(p => ({ ...p }))
@@ -7,40 +7,6 @@
<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="priceSetNote">
<el-input v-model="basic.priceSetNote" type="textarea" :rows="3" maxlength="200" placeholder="套餐业务说明,显示给客户与运营" />
</el-form-item>
@@ -49,20 +15,15 @@
</template>
<script setup name="StepBasicInfo">
/**
* 第 1 步 · 基本信息
* 直接读写父级传入的 basic 对象(同一引用),校验规则由本组件维护。
*/
/** Step1 basic info. setType always FIXED on save; priceBuildMode chosen on Step2. */
const basic = defineModel({ type: Object, required: true })
const formRef = ref()
const rules = {
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }],
setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }],
}
/** 供父级在下一步 / 保存前调用 */
function validate() {
return new Promise(resolve => {
if (!formRef.value) return resolve(false)
@@ -83,33 +44,4 @@ defineExpose({ validate })
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>
@@ -6,24 +6,55 @@
{{ headerTitle }}
<span class="sub">{{ typeHint }}</span>
</span>
<div class="card-actions">
<el-button size="small" :disabled="!priceSetId || pulling" @click="handlePullLibrary">拉取产品列表</el-button>
<div v-if="hasBuildMode" class="card-actions">
<el-button
v-if="isFixedType(setType)"
v-if="showPullAction"
size="small"
:disabled="(livePersist && !priceSetId) || pulling"
@click="handlePullLibrary"
>拉取产品列表</el-button>
<el-button
v-if="showImportAction"
type="primary"
size="small"
:disabled="!priceSetId || pulling"
:disabled="(livePersist && !priceSetId) || pulling"
@click="openImport"
>导入 Excel 建价</el-button>
>{{ importActionLabel }}</el-button>
</div>
</div>
</template>
<div v-if="!livePersist" class="build-choice">
<p class="build-choice__tip">创建价格方式</p>
<div class="build-choice__cards">
<button
type="button"
class="build-card build-card--pull"
:class="{ selected: isPullBuildMode(priceBuildMode) }"
:disabled="choosingMode"
@click="chooseBuildMode('PULL')"
>
<div class="build-card__title">按产品价建价</div>
<div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div>
</button>
<button
type="button"
class="build-card build-card--import"
:class="{ selected: isImportBuildMode(priceBuildMode) }"
:disabled="!isFixedType(setType) || choosingMode"
@click="chooseBuildMode('IMPORT')"
>
<div class="build-card__title">Excel 导入建价</div>
<div class="build-card__desc">上传威狮 B2C 小包价表,直接生成套餐销售价</div>
</button>
</div>
</div>
<div class="filters">
<el-input v-model="query.keyword" clearable :placeholder="phKeyword" style="width: 180px" />
<el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
<!-- <el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-select> -->
<el-button size="small" @click="noop">查询</el-button>
</div>
@@ -54,6 +85,7 @@
</el-table-column>
</el-table>
<surcharge-picker-dialog
v-model="scVisible"
:title="scTitle"
@@ -87,7 +119,7 @@
<excel-import-dialog
ref="importRef"
title="导入 Excel 建价"
:title="importDialogTitle"
width="560px"
:show-update-support="false"
:import-desc="importDesc"
@@ -122,11 +154,13 @@ import {
formatWeightLabel,
isDiscountType,
isFixedType,
isImportBuildMode,
isPullBuildMode,
setTypeLabel,
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, previewPullFromLibrary, previewImportDetailPrices, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
const props = defineProps({
details: { type: Array, required: true },
@@ -134,13 +168,16 @@ const props = defineProps({
priceSetId: { type: [Number, String], default: undefined },
psvId: { type: [Number, String], default: undefined },
setType: { type: String, default: 'FIXED' },
priceBuildMode: { type: String, default: undefined },
effectiveTime: { type: String, default: undefined },
versionNo: { type: String, default: '' },
refData: { type: Object, required: true },
headerTitle: { type: String, default: '第 2 步 · 编辑产品价格' }
headerTitle: { type: String, default: '第 2 步 · 编辑产品价格' },
/** true=edit live DB; false=create local draft until finish */
livePersist: { type: Boolean, default: true }
})
const emit = defineEmits(['update:psvId'])
const emit = defineEmits(['update:psvId', 'update:priceBuildMode'])
const { proxy } = getCurrentInstance()
const { bms_tax_mode } = useDict('bms_tax_mode')
const { hydrateRow } = useDetailCascade(props.refData)
@@ -182,14 +219,53 @@ const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const progressDialogTitle = ref('正在导入价格')
const importDesc = [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
'仅固定价套餐可导入;可与「拉取产品列表」二选一或叠加使用。'
]
const hasBuildMode = computed(() => {
if (isPullBuildMode(props.priceBuildMode) || isImportBuildMode(props.priceBuildMode)) return true
// Legacy / already has products: skip choice cards
return (props.products || []).some(p => p && p.productCode)
})
const choosingMode = ref(false)
const isModifyImport = computed(() => {
if (isPullBuildMode(props.priceBuildMode)) return true
if (isImportBuildMode(props.priceBuildMode)) return false
return (props.products || []).some(p => p && p.productCode)
})
const importActionLabel = computed(() =>
isModifyImport.value ? '导入修改价格' : '导入 Excel 建价'
)
const importDialogTitle = computed(() => importActionLabel.value)
const importDesc = computed(() => {
if (isModifyImport.value) {
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
'仅固定价套餐可导入;未匹配行不会新增明细。'
]
}
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
'仅固定价套餐可导入。'
]
})
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
const emptyTip = '暂无产品,请先拉取产品列表或导入 Excel 建价'
const typeHint = computed(() => {
if (props.livePersist) return ''
if (isPullBuildMode(props.priceBuildMode)) return '· 按产品价建价'
if (isImportBuildMode(props.priceBuildMode)) return '· Excel 导入'
if (hasBuildMode.value) return '· 当前:' + setTypeLabel(props.setType)
return '· 请选择创建价格方式'
})
// PULL: hide pull btn (already pulled), show modify-import.
// IMPORT: show create-import.
const showPullAction = computed(() => false)
const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.setType))
const emptyTip = computed(() => {
if (props.livePersist) return '暂无产品'
if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成'
if (isImportBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价'
return '暂无产品,请先选择创建价格方式'
})
/** Resolve version id: prop first, else reload package current version. */
@@ -220,9 +296,30 @@ function clearDetailCache() {
Object.keys(detailByProduct).forEach(k => delete detailByProduct[k])
}
/** Clear local product/detail draft when create wizard switches build mode. */
function clearLocalDraft() {
clearDetailCache()
if (Array.isArray(props.details)) {
props.details.splice(0, props.details.length)
}
if (Array.isArray(props.products)) {
props.products.splice(0, props.products.length)
}
priceVisible.value = false
editCode.value = ''
editName.value = ''
editRows.value = []
editGroup.value = null
}
async function loadProductDetails(productCode, force) {
if (!productCode) return []
if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
if (!props.livePersist) {
const rows = (props.details || []).filter(d => d && d.productCode === productCode)
detailByProduct[productCode] = rows
return rows
}
const psvId = await resolvePsvId(false)
if (!psvId) return []
const res = await listSetDetailByProduct(psvId, productCode)
@@ -264,8 +361,46 @@ const pulling = ref(false)
function noop() {}
async function chooseBuildMode(mode) {
if (!mode || choosingMode.value || props.livePersist) return
if (mode === props.priceBuildMode) return
choosingMode.value = true
try {
emit('update:priceBuildMode', mode)
} finally {
choosingMode.value = false
}
}
let localKeySeq = 0
/** Apply preview products/details into parent reactive arrays (create defer mode). */
function applyLocalDraft(products, details) {
clearDetailCache()
const hydrated = (details || []).map(d => {
const row = hydrateRow({ ...d }, props.setType)
row.psdId = undefined
row.pspId = undefined
row.priceSetId = undefined
row.psvId = undefined
row._localKey = 'L' + (++localKeySeq)
return row
})
if (Array.isArray(props.details)) {
props.details.splice(0, props.details.length, ...hydrated)
}
if (Array.isArray(props.products)) {
props.products.splice(0, props.products.length, ...((products || []).map(p => ({ ...p }))))
}
hydrated.forEach(d => {
if (!d || !d.productCode) return
if (!detailByProduct[d.productCode]) detailByProduct[d.productCode] = []
detailByProduct[d.productCode].push(d)
})
}
async function openImport() {
if (!props.priceSetId) {
if (props.livePersist && !props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
}
@@ -273,15 +408,16 @@ async function openImport() {
proxy.$modal.msgWarning('仅固定价套餐支持导入')
return
}
const psvId = await resolvePsvId(true)
if (!psvId) {
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
return
if (props.livePersist) {
const psvId = await resolvePsvId(true)
if (!psvId) {
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
return
}
}
importRef.value && importRef.value.open()
}
async function runImportAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
@@ -293,9 +429,23 @@ async function runImportAsync(uploadFile) {
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
if (!props.livePersist) {
importProgressMessage.value = '正在解析价表…'
importProgressPercent.value = 30
const res = await previewImportDetailPrices(raw, {
updateOnly: isModifyImport.value,
details: isModifyImport.value ? (props.details || []) : undefined
})
const data = res.data || {}
const products = isModifyImport.value ? (props.products || []) : (data.products || [])
applyLocalDraft(products, data.details || [])
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return data.message || '导入完成'
}
const { task } = await importSetDetailPricesAsync(
raw,
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false) },
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), updateOnly: isModifyImport.value },
applyBuildProgress
)
importProgressPercent.value = 100
@@ -337,6 +487,7 @@ function applyBuildProgress(t) {
}
async function onImportSuccess() {
if (!props.livePersist) return
await reloadDetailsFromServer()
}
@@ -370,7 +521,7 @@ async function reloadDetailsFromServer() {
}
async function handlePullLibrary() {
if (!props.priceSetId) {
if (props.livePersist && !props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
}
@@ -387,11 +538,11 @@ async function handlePullLibrary() {
await pullAllFromLibrary({ force, silent: false })
}
/** Backend pull product/product_country into prc_price_set_detail (async). */
/** Pull product library: live -> async DB; create defer -> preview into local state. */
async function pullAllFromLibrary(opts = {}) {
const silent = !!opts.silent
const force = !!opts.force || !!opts.replace
if (!props.priceSetId) {
if (props.livePersist && !props.priceSetId) {
if (!silent) proxy.$modal.msgWarning('缺少套餐ID')
return { added: 0 }
}
@@ -402,6 +553,19 @@ async function pullAllFromLibrary(opts = {}) {
importProgressStatus.value = undefined
pulling.value = true
try {
if (!props.livePersist) {
importProgressMessage.value = '正在拉取产品库…'
importProgressPercent.value = 40
const res = await previewPullFromLibrary()
const data = res.data || {}
applyLocalDraft(data.products || [], data.details || [])
importProgressPercent.value = 100
importProgressStatus.value = 'success'
if (!silent) {
proxy.$modal.msgSuccess(data.message || '拉取完成')
}
return { added: (props.products || []).length }
}
const { task } = await pullSetDetailsFromLibraryAsync(
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), force },
applyBuildProgress
@@ -495,8 +659,9 @@ function openSurchargeFromDrawer(row) {
}
/** persist one detail row to DB immediately */
/** persist one detail row to DB immediately (edit mode only) */
async function persistDetail(row, withSurcharge) {
if (!props.livePersist) return
if (!row || !row.psdId || !props.priceSetId) return
const payload = toDetailPayload(row)
payload.priceSetId = props.priceSetId
@@ -506,7 +671,12 @@ async function persistDetail(row, withSurcharge) {
/** drawer hover remove surcharge -> write snapshot for this row */
async function onRowSurchargeChange(row) {
if (!row || !row.psdId) return
if (!row) return
if (!props.livePersist) {
proxy.$modal.msgSuccess('附加费已移除(完成时统一落库)')
return
}
if (!row.psdId) return
try {
await persistDetail(row, true)
proxy.$modal.msgSuccess('附加费已移除')
@@ -515,9 +685,14 @@ async function onRowSurchargeChange(row) {
}
}
/** drawer field change -> immediate DB update */
/** drawer field change -> immediate DB update (edit) or local only (create) */
async function onRowPriceChange(row) {
if (!row || !row.psdId) return
if (!row) return
if (!props.livePersist) {
proxy.$modal.msgSuccess('价格已更新(完成时统一落库)')
return
}
if (!row.psdId) return
const prevFv = row._editPrevFv
const prevReg = row._editPrevReg
try {
@@ -533,10 +708,14 @@ async function saveDiscount() {
const hit = (props.details || []).find(d => d.productCode === discCode.value)
if (hit) {
hit.discountValue = discValue.value
try {
await persistDetail(hit, false)
proxy.$modal.msgSuccess('折扣已写入数据库')
} catch (e) {}
if (!props.livePersist) {
proxy.$modal.msgSuccess('折扣已更新(完成时统一落库)')
} else {
try {
await persistDetail(hit, false)
proxy.$modal.msgSuccess('折扣已写入数据库')
} catch (e) {}
}
}
discVisible.value = false
}
@@ -640,11 +819,15 @@ function onSurchargeConfirm(picked) {
? `已纳入 ${applied} 项附加费${scopeText};${skipped} 项因适用国家不匹配被跳过`
: `已纳入 ${ids.size} 项附加费${scopeText}`)
: `已清空附加费${scopeText}`
scVisible.value = false
if (!props.livePersist) {
proxy.$modal.msgSuccess(okText + '(完成时统一落库)')
return
}
const payload = targets.filter(r => r.psdId).map(r => toDetailPayload(r)).map(p => {
p.priceSetId = props.priceSetId
return p
})
scVisible.value = false
if (!payload.length) {
proxy.$modal.msgSuccess(okText)
return
@@ -658,14 +841,16 @@ function onSurchargeConfirm(picked) {
}
async function removeProduct(code) {
try {
const rows = await loadProductDetails(code)
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
if (ids.length) {
await delSetDetail(ids.join(','))
if (props.livePersist) {
try {
const rows = await loadProductDetails(code)
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
if (ids.length) {
await delSetDetail(ids.join(','))
}
} catch (e) {
return
}
} catch (e) {
return
}
delete detailByProduct[code]
if (Array.isArray(props.details)) {
@@ -681,7 +866,7 @@ async function removeProduct(code) {
proxy.$modal.msgSuccess('已移除产品明细')
}
defineExpose({ pullAllFromLibrary })
defineExpose({ pullAllFromLibrary, clearLocalDraft })
</script>
<style scoped>
@@ -707,6 +892,60 @@ defineExpose({ pullAllFromLibrary })
font-size: 13px;
word-break: break-all;
}
.build-choice {
padding: 8px 0 16px;
text-align: left;
}
.build-choice__tip {
margin: 0 0 12px;
color: #606266;
font-size: 13px;
font-weight: 600;
}
.build-choice__cards {
display: flex;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.build-card {
width: 260px;
min-height: 88px;
padding: 14px 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.build-card.selected {
border-color: #1890ff;
background: #e6f7ff;
}
.build-card:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
.build-card:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.build-card--pull:hover:not(:disabled) { border-color: #67c23a; }
.build-card--import:hover:not(:disabled) { border-color: #409eff; }
.build-card__title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
.build-card--pull .build-card__title { color: #67c23a; }
.build-card--import .build-card__title { color: #409eff; }
.build-card__desc {
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.card-actions {
flex: none;
display: flex;
+119 -62
View File
@@ -18,8 +18,11 @@
:psv-id="form.version.psvId"
@update:psv-id="onPsvIdUpdate"
:set-type="form.basic.setType"
:price-build-mode="form.version.priceBuildMode"
@update:price-build-mode="onPriceBuildModeUpdate"
:version-no="form.version.versionNo"
:ref-data="refData"
:live-persist="livePersist"
/>
<div class="wizard-actions">
@@ -27,7 +30,7 @@
<el-button v-if="active > 0 && !directStep2" @click="active--">上一步</el-button>
<el-button v-if="active === 0" type="primary" :loading="creating" @click="goStep2">创建套餐价格</el-button>
<el-button v-if="active === 0" type="info" :loading="saving" @click="saveDraft">保存</el-button>
<el-button v-if="active === 1" type="primary" @click="finishEdit">完成</el-button>
<el-button v-if="active === 1" type="primary" :loading="creating" @click="finishEdit">完成</el-button>
</div>
<el-dialog
@@ -77,6 +80,8 @@ const detailsRef = ref()
const copyMode = computed(() => String(route.query.copyVersion || '') === '1')
/** Entered from detail: continue-config / copy-version jump to step2 — hide 上一步 */
const directStep2 = computed(() => copyMode.value || String(route.query.step || '') === '2')
/** Edit existing version: live PUT/pull/import. New create / copy-version: defer until finish. */
const livePersist = computed(() => !!form.priceSetId && !copyMode.value)
const step1Title = '基本信息'
const step2Title = '编辑产品价格'
@@ -84,7 +89,7 @@ const step2Title = '编辑产品价格'
const form = reactive({
priceSetId: undefined,
basic: createEmptyBasicInfo(),
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined },
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined, priceBuildMode: undefined },
details: [],
products: [],
customers: []
@@ -142,45 +147,65 @@ async function runSaveWithProgress(payload) {
}
}
/** Step1 -> Step2: create empty draft version only; products/details persist on pull or Excel import */
/** Step1 -> Step2: validate only; no DB write on create (defer until finish). */
async function goStep2() {
const validBasic = await basicRef.value.validate()
if (!validBasic) return
if (form.priceSetId && form.version.psvId) {
active.value = 1
return
}
creating.value = true
try {
// Never auto-pull product library on enter Step2 (even if old clients sent the flag).
const payload = buildPayload()
delete payload.pullFromLibrary
delete payload.forcePullLibrary
delete payload.details
const priceSetId = await runSaveWithProgress(payload)
form.priceSetId = priceSetId
const fresh = await getPriceSet(priceSetId)
applySetData(fresh.data || {}, false)
if (!(form.products || []).some(p => p && p.productCode) && !form.version.priceBuildMode) {
form.details = []
form.products = []
if (!form.version.psvId) {
const versions = (fresh.data && fresh.data.versions) || []
const v = (fresh.data && fresh.data.version && fresh.data.version.psvId)
? fresh.data.version
: versions[0]
if (v && v.psvId) {
form.version.psvId = v.psvId
form.version.versionNo = v.versionNo || form.version.versionNo
}
}
if (!form.version.psvId) {
proxy.$modal.msgError('套餐版本创建失败,请重试')
}
active.value = 1
}
/** Step2 finish: create/copy async save all; edit already live-saved. */
async function finishEdit() {
if (livePersist.value) {
if (!form.priceSetId) {
goBack()
return
}
active.value = 1
proxy.$modal.msgSuccess('已创建空套餐版本,请在第 2 步拉取或导入价格')
proxy.$modal.msgSuccess('修改已实时落库')
const obj = {
path: '/customer/setDetail',
query: { priceSetId: form.priceSetId }
}
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
return
}
const validBasic = await basicRef.value.validate()
if (!validBasic) {
active.value = 0
return
}
if (!form.version.priceBuildMode) {
proxy.$modal.msgWarning('请先选择创建价格方式')
return
}
const detailRows = (form.details || []).filter(d => d && d.productCode)
if (!detailRows.length) {
proxy.$modal.msgWarning('请先拉取产品或导入 Excel 建价')
return
}
creating.value = true
try {
const payload = buildPayload({ forceDetails: true })
const priceSetId = await runSaveWithProgress(payload)
form.priceSetId = priceSetId
proxy.$modal.msgSuccess('套餐已保存')
const obj = {
path: '/customer/setDetail',
query: { priceSetId }
}
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
} catch (e) {
// surfaced
} finally {
@@ -188,24 +213,6 @@ async function goStep2() {
}
}
/** Step2: prices already live-saved via PUT /detail; go to detail page. */
function finishEdit() {
if (!form.priceSetId) {
goBack()
return
}
proxy.$modal.msgSuccess('修改已实时落库')
const obj = {
path: '/customer/setDetail',
query: { priceSetId: form.priceSetId }
}
if (proxy.$tab) {
proxy.$tab.closeOpenPage(obj)
} else {
proxy.$router.push(obj)
}
}
/** Step0 only: persist basic draft (no detail wipe). */
async function saveDraft() {
const validBasic = await basicRef.value.validate()
@@ -226,27 +233,36 @@ async function saveDraft() {
}
}
function buildPayload() {
function buildPayload(opts = {}) {
const { basic, version } = form
const detailRows = form.details.filter(d => d && d.productCode)
// Price edits already persist via PUT /detail; product remove uses DELETE.
// Omit details when all rows already have psdId so save only updates header/version.
// Edit live-save: omit details when all rows already have psdId.
// Create finish: forceDetails sends all rows (psdId cleared) for insert.
const hasUnsavedDetail = detailRows.some(d => d.psdId == null)
const payload = {
priceSetId: form.priceSetId,
priceSetName: basic.priceSetName,
setType: basic.setType,
defaultDiscount: basic.setType === 'DISCOUNT' ? basic.defaultDiscount : undefined,
setType: 'FIXED',
defaultDiscount: undefined,
priceSetNote: basic.priceSetNote,
version: {
psvId: copyMode.value ? undefined : version.psvId,
versionNo: version.versionNo
versionNo: version.versionNo,
priceBuildMode: version.priceBuildMode || undefined
}
}
if (hasUnsavedDetail) {
payload.details = detailRows.map(toDetailPayload)
if (opts.forceDetails || hasUnsavedDetail) {
payload.details = detailRows.map(d => {
const p = toDetailPayload(d)
if (opts.forceDetails) {
delete p.psdId
delete p.pspId
delete p.priceSetId
delete p.psvId
}
return p
})
}
// Customer bind is managed on detail page APIs; only send when present to avoid wipe.
const customers = form.customers.filter(c => c && c.customerCode).map(toCustomerPayload)
if (customers.length) {
payload.customers = customers
@@ -267,11 +283,50 @@ function onPsvIdUpdate(psvId) {
if (psvId != null && psvId !== '') form.version.psvId = psvId
}
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
async function onPriceBuildModeUpdate(mode) {
if (!mode) return
const prev = form.version.priceBuildMode
if (prev === mode) return
form.version.priceBuildMode = mode
try {
// Create/copy: switching build mode clears the product table before reload/import.
if (!livePersist.value && prev) {
form.details.splice(0, form.details.length)
form.products.splice(0, form.products.length)
if (detailsRef.value && detailsRef.value.clearLocalDraft) {
detailsRef.value.clearLocalDraft()
}
}
if (livePersist.value) {
const payload = buildPayload()
delete payload.pullFromLibrary
delete payload.forcePullLibrary
delete payload.details
await runSaveWithProgress(payload)
}
await nextTick()
if (mode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
const hasProducts = (form.products || []).some(p => p && p.productCode)
if (!hasProducts) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
proxy.$modal.msgSuccess('已拉取产品列表,可继续编辑价格')
} else if (prev !== mode) {
proxy.$modal.msgSuccess('已切换为按产品价建价,可导入 Excel 修改价格')
}
} else if (mode === 'IMPORT' && prev !== mode) {
proxy.$modal.msgSuccess('请导入 Excel 建价')
}
} catch (e) {
form.version.priceBuildMode = prev
}
}
function applySetData(data, asCopy) {
form.priceSetId = data.priceSetId
Object.assign(form.basic, {
priceSetName: data.priceSetName,
setType: data.setType || 'FIXED',
setType: 'FIXED',
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
priceSetNote: data.priceSetNote
})
@@ -285,11 +340,13 @@ function applySetData(data, asCopy) {
form.version.versionNo = nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
form.version.priceBuildMode = undefined
} else {
form.version.psvId = current.psvId
form.version.versionNo = current.versionNo || nextVersionNo(versions)
form.version.effectiveTime = current.effectiveTime
form.version.expireTime = current.expireTime
form.version.priceBuildMode = current.priceBuildMode || undefined
}
form.details = []
form.products = asCopy ? [] : ((current.products || []).map(p => ({ ...p })))
@@ -12,6 +12,20 @@ export const SET_TYPE = {
DISCOUNT: 'DISCOUNT'
}
/** Version create-price mode */
export const PRICE_BUILD_MODE = {
PULL: 'PULL',
IMPORT: 'IMPORT'
}
export function isPullBuildMode(mode) {
return mode === PRICE_BUILD_MODE.PULL
}
export function isImportBuildMode(mode) {
return mode === PRICE_BUILD_MODE.IMPORT
}
/** 套餐状态枚举:D 待启用 / A 审核中 / E 生效 / R 驳回 / S 已停用 */
export const SET_STATUS = {
DRAFT: 'D',
@@ -359,6 +373,7 @@ export function createEmptyBasicInfo() {
priceSetName: '',
setType: SET_TYPE.FIXED,
defaultDiscount: 88,
priceBuildMode: undefined,
effectiveDate: undefined,
expireDate: undefined,
priceSetNote: ''