556 lines
19 KiB
Vue
556 lines
19 KiB
Vue
<template>
|
||
<div class="app-container" v-loading="loading">
|
||
<!-- 编辑模式:整屏切换,等同独立页面 -->
|
||
<price-edit-panel
|
||
v-if="editVisible"
|
||
:price-set-id="set.priceSetId"
|
||
:psv-id="editPsvId"
|
||
@close="closeEdit"
|
||
/>
|
||
|
||
<template v-else>
|
||
<set-detail-header
|
||
:set="set"
|
||
@back="goList"
|
||
@disable="onDisable"
|
||
@enable="onEnable"
|
||
/>
|
||
|
||
<set-basic-info-card :set="set" />
|
||
|
||
<set-customer-table :set="set" @refresh="load" />
|
||
|
||
<set-version-table
|
||
v-model="currentPsvId"
|
||
:versions="set.versions || []"
|
||
:removing-psv-id="removingPsvId"
|
||
@create-version="onCreateVersion"
|
||
@open-details="onOpenVersionDetails"
|
||
@view-import-attach="onViewImportAttach"
|
||
@view-price-log="onViewPriceLog"
|
||
@activate="onActivateVersion"
|
||
@reschedule="onRescheduleVersion"
|
||
@edit="onEditVersion"
|
||
@remove="onRemoveVersion"
|
||
/>
|
||
|
||
<set-version-detail-drawer
|
||
v-model="detailDrawerVisible"
|
||
:version="detailVersion"
|
||
:set-type="set.setType"
|
||
/>
|
||
<set-version-excel-file-drawer ref="versionExcelDrawerRef" />
|
||
<set-version-price-log-drawer
|
||
v-model="priceLogVisible"
|
||
:version="priceLogVersion"
|
||
/>
|
||
|
||
<el-dialog
|
||
v-model="activateVisible"
|
||
:title="activateDialogTitle"
|
||
width="460px"
|
||
append-to-body
|
||
@closed="onActivateClosed"
|
||
>
|
||
<el-form ref="activateFormRef" :model="activateForm" :rules="activateRules" label-width="100px">
|
||
<el-form-item label="生效时间" prop="effectiveTime">
|
||
<el-date-picker
|
||
v-model="activateForm.effectiveTime"
|
||
type="datetime"
|
||
format="YYYY-MM-DD HH:00"
|
||
value-format="YYYY-MM-DD HH:00:00"
|
||
popper-class="activate-hour-picker"
|
||
:placeholder="activatePh"
|
||
:disabled-date="disabledActivateDate"
|
||
:disabled-hours="disabledActivateHours"
|
||
style="width: 100%"
|
||
@change="onActivateTimeChange"
|
||
@visible-change="onActivatePickerVisible"
|
||
/>
|
||
</el-form-item>
|
||
<div class="activate-hint">{{ activateDialogHint }}</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="activateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="activateSubmitting" @click="submitActivate">{{ activateConfirmText }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="PriceSetDetail">
|
||
import SetDetailHeader from './components/detail/SetDetailHeader.vue'
|
||
import SetBasicInfoCard from './components/detail/SetBasicInfoCard.vue'
|
||
import SetCustomerTable from './components/detail/SetCustomerTable.vue'
|
||
import SetVersionTable from './components/detail/SetVersionTable.vue'
|
||
import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue'
|
||
import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDrawer.vue'
|
||
import SetVersionPriceLogDrawer from './components/detail/SetVersionPriceLogDrawer.vue'
|
||
import PriceEditPanel from './components/create/PriceEditPanel.vue'
|
||
import { usePriceSetRefData } from './composables/usePriceSetRefData'
|
||
import { usePriceSetModule } from './composables/usePriceSetModule'
|
||
import { isDraftVersion, isPendingVersion, isEffectVersion, isImportFreshBuildMode } from './utils/priceSet'
|
||
|
||
const mod = usePriceSetModule()
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const refData = usePriceSetRefData()
|
||
|
||
const loading = ref(false)
|
||
const removingPsvId = ref(null)
|
||
const set = ref({})
|
||
const currentPsvId = ref()
|
||
const detailDrawerVisible = ref(false)
|
||
const detailVersion = ref(null)
|
||
const versionExcelDrawerRef = ref()
|
||
const editVisible = ref(false)
|
||
const editPsvId = ref(undefined)
|
||
const priceLogVisible = ref(false)
|
||
const priceLogVersion = ref(null)
|
||
/** 已加载的套餐ID;页面被 keep-alive 缓存复用时据此判断是否需要重新加载 */
|
||
const loadedId = ref(null)
|
||
/** 从详情页跳到新建/编辑套餐版本页后,返回时套餐版本数据可能已变化,需强制刷新 */
|
||
const pendingRefresh = ref(false)
|
||
|
||
/** 切换到另一个套餐时,重置所有临时面板状态,避免残留上一套餐的数据 */
|
||
function resetTransientState() {
|
||
set.value = {}
|
||
currentPsvId.value = undefined
|
||
detailDrawerVisible.value = false
|
||
detailVersion.value = null
|
||
editVisible.value = false
|
||
editPsvId.value = undefined
|
||
priceLogVisible.value = false
|
||
priceLogVersion.value = null
|
||
activateVisible.value = false
|
||
activateSubmitting.value = false
|
||
activateMode.value = 'activate'
|
||
activateForm.psvId = undefined
|
||
activateForm.versionNo = ''
|
||
activateForm.effectiveTime = undefined
|
||
activatePrevEffectiveTime.value = undefined
|
||
}
|
||
|
||
function onOpenVersionDetails(row) {
|
||
detailVersion.value = row || null
|
||
detailDrawerVisible.value = true
|
||
}
|
||
|
||
function onViewImportAttach(row) {
|
||
if (!row || row.psvId == null) return
|
||
versionExcelDrawerRef.value?.open?.(row)
|
||
}
|
||
|
||
function onViewPriceLog(row) {
|
||
if (!row || row.psvId == null) return
|
||
priceLogVersion.value = row
|
||
priceLogVisible.value = true
|
||
}
|
||
|
||
|
||
function load() {
|
||
const id = route.query.priceSetId
|
||
if (!id) return
|
||
// 立即标记,首次挂载后紧随的 activated 不会重复请求
|
||
loadedId.value = String(id)
|
||
loading.value = true
|
||
mod.api.getPriceSet(id).then(res => {
|
||
set.value = res.data || {}
|
||
const versions = set.value.versions || []
|
||
const cur = set.value.version || versions[0]
|
||
currentPsvId.value = cur ? cur.psvId : undefined
|
||
}).finally(() => {
|
||
loading.value = false
|
||
})
|
||
}
|
||
|
||
/**
|
||
* keep-alive 复用同一组件实例时同步数据:
|
||
* - priceSetId 变化:加载新套餐
|
||
* - 从新建/编辑版本页返回(force):即使套餐相同也强制重载,保证版本列表最新
|
||
*/
|
||
function syncFromRoute(force) {
|
||
const id = route.query.priceSetId
|
||
if (!id) return
|
||
pendingRefresh.value = false
|
||
if (!force && String(id) === String(loadedId.value)) return
|
||
resetTransientState()
|
||
load()
|
||
}
|
||
|
||
function confirmAction(text, request, successText, done) {
|
||
proxy.$modal.confirm(text).then(() => request()).then(() => {
|
||
proxy.$modal.msgSuccess(successText)
|
||
done ? done() : load()
|
||
}).catch(() => {})
|
||
}
|
||
|
||
function onDisable() {
|
||
confirmAction(mod.labels.disableConfirm.replace('{name}', set.value.priceSetName || ''), () => mod.api.disablePriceSet(set.value.priceSetId), '已停用')
|
||
}
|
||
function onEnable() {
|
||
confirmAction(mod.labels.enableConfirm.replace('{name}', set.value.priceSetName || ''), () => mod.api.enablePriceSet(set.value.priceSetId), '已启用')
|
||
}
|
||
/** Effective time precision: hour only (mm:ss always 00) */
|
||
function pad2(n) {
|
||
return String(n).padStart(2, '0')
|
||
}
|
||
|
||
function formatHour(d) {
|
||
if (!d || Number.isNaN(d.getTime())) return undefined
|
||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
|
||
+ ' ' + pad2(d.getHours()) + ':00:00'
|
||
}
|
||
|
||
function normalizeToHour(val) {
|
||
if (val == null || val === '') return val
|
||
if (val instanceof Date) return formatHour(val)
|
||
const s = String(val).trim().replace('T', ' ')
|
||
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
|
||
if (m) return m[1] + ' ' + m[2] + ':00:00'
|
||
return formatHour(new Date(s.replace(/-/g, '/'))) || val
|
||
}
|
||
|
||
function formatNow() {
|
||
const d = new Date()
|
||
d.setHours(9, 0, 0, 0)
|
||
return formatHour(d)
|
||
}
|
||
|
||
function onActivateTimeChange(val) {
|
||
activateForm.effectiveTime = normalizeToHour(val)
|
||
if (activateFormRef.value) {
|
||
activateFormRef.value.validateField('effectiveTime')
|
||
}
|
||
}
|
||
|
||
const activateVisible = ref(false)
|
||
const activateSubmitting = ref(false)
|
||
const activateFormRef = ref()
|
||
const activateMode = ref('activate')
|
||
const activatePh = '请选择生效时间'
|
||
const activateForm = reactive({
|
||
psvId: undefined,
|
||
versionNo: '',
|
||
effectiveTime: undefined
|
||
})
|
||
const activateDialogTitle = computed(() =>
|
||
activateMode.value === 'reschedule' ? '修改生效时间' : '启动版本'
|
||
)
|
||
const activateConfirmText = computed(() =>
|
||
activateMode.value === 'reschedule' ? '确认修改' : '确认启动'
|
||
)
|
||
const activatePrevEffectiveTime = ref(undefined)
|
||
const activateIsImportFresh = ref(false)
|
||
/** Other versions' effective/expire times (hour) — forbidden for IMPORT_FRESH */
|
||
const activateBoundaryTimes = ref([])
|
||
const activateEffBeforePrevMsg = '生效时间不能早于上一版本的生效时间'
|
||
const activateEffHitBoundaryMsg = '生效时间不能与已有版本的生效时间或失效时间相同'
|
||
const activateDialogHint = computed(() => {
|
||
if (activateIsImportFresh.value) {
|
||
const base = activateMode.value === 'reschedule'
|
||
? 'Excel 新建价:生效时间可落在历史版本区间内,将自动截断该版本失效时间并同步产品/明细。'
|
||
: 'Excel 新建价:生效时间可落在历史版本区间内;落在版本 B 区间时,B 失效时间改为本次启动时间,本版本失效时间取 B 原失效时间,并同步双方产品/明细。'
|
||
return base + '不可与已有版本的生效时间或失效时间相同。'
|
||
}
|
||
const base = activateMode.value === 'reschedule'
|
||
? '仅待生效版本可修改;若时间晚于当前仍为待生效,到时自动生效并使上一版本失效。'
|
||
: '若生效时间晚于当前,版本将进入「待生效」;到时自动生效并使上一版本失效。'
|
||
const prev = activatePrevEffectiveTime.value
|
||
if (!prev) return base
|
||
return base + '不得早于上一版:' + prev + '。'
|
||
})
|
||
const activateRules = computed(() => ({
|
||
effectiveTime: [
|
||
{ required: true, message: '请选择生效时间', trigger: 'change' },
|
||
{
|
||
validator: (_r, v, cb) => {
|
||
if (!v) return cb()
|
||
if (activateIsImportFresh.value) {
|
||
const ms = toTimeMs(v)
|
||
if (activateBoundaryTimes.value.some(b => toTimeMs(b) === ms)) {
|
||
return cb(new Error(activateEffHitBoundaryMsg))
|
||
}
|
||
return cb()
|
||
}
|
||
const prev = activatePrevEffectiveTime.value
|
||
if (prev && toTimeMs(v) < toTimeMs(prev)) return cb(new Error(activateEffBeforePrevMsg))
|
||
cb()
|
||
},
|
||
trigger: 'change'
|
||
}
|
||
]
|
||
}))
|
||
|
||
function toTimeMs(val) {
|
||
if (val == null || val === '') return NaN
|
||
const n = normalizeToHour(val)
|
||
if (!n) return NaN
|
||
return new Date(String(n).replace(/-/g, '/')).getTime()
|
||
}
|
||
|
||
function findPrevVersionEffectiveTime(row) {
|
||
if (!row) return undefined
|
||
const versions = set.value.versions || []
|
||
const curSeq = Number(row.versionSeq) || 0
|
||
let prev = null
|
||
for (const v of versions) {
|
||
if (!v || v.psvId == null || String(v.psvId) === String(row.psvId)) continue
|
||
if (String(v.isDel) === '1') continue
|
||
const vs = Number(v.versionSeq) || 0
|
||
if (vs >= curSeq || !v.effectiveTime) continue
|
||
if (!prev || vs > (Number(prev.versionSeq) || 0)) prev = v
|
||
}
|
||
return prev ? normalizeToHour(prev.effectiveTime) : undefined
|
||
}
|
||
|
||
/** Collect other versions' effective/expire hour times (forbidden for IMPORT_FRESH). */
|
||
function collectActivateBoundaryTimes(excludePsvId) {
|
||
const out = []
|
||
const seen = new Set()
|
||
for (const v of set.value.versions || []) {
|
||
if (!v || v.psvId == null || String(v.psvId) === String(excludePsvId)) continue
|
||
if (String(v.isDel) === '1') continue
|
||
;[v.effectiveTime, v.expireTime].forEach(t => {
|
||
const n = t ? normalizeToHour(t) : ''
|
||
if (!n || seen.has(n)) return
|
||
seen.add(n)
|
||
out.push(n)
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function pickActivateDefaultTime(prev, importFresh) {
|
||
const now = formatNow()
|
||
if (importFresh || !prev) return now
|
||
return toTimeMs(now) >= toTimeMs(prev) ? now : prev
|
||
}
|
||
|
||
function disabledActivateDate(date) {
|
||
if (activateIsImportFresh.value) return false
|
||
const prev = activatePrevEffectiveTime.value
|
||
if (!prev || !date) return false
|
||
const prevDay = new Date(String(prev).replace(/-/g, '/'))
|
||
prevDay.setHours(0, 0, 0, 0)
|
||
const d = new Date(date)
|
||
d.setHours(0, 0, 0, 0)
|
||
return d.getTime() < prevDay.getTime()
|
||
}
|
||
|
||
function disabledActivateHours() {
|
||
const cur = activateForm.effectiveTime
|
||
const curD = cur
|
||
? new Date(String(normalizeToHour(cur)).replace(/-/g, '/'))
|
||
: new Date()
|
||
if (activateIsImportFresh.value) {
|
||
const hours = new Set()
|
||
for (const b of activateBoundaryTimes.value) {
|
||
const bd = new Date(String(b).replace(/-/g, '/'))
|
||
if (
|
||
bd.getFullYear() === curD.getFullYear() &&
|
||
bd.getMonth() === curD.getMonth() &&
|
||
bd.getDate() === curD.getDate()
|
||
) {
|
||
hours.add(bd.getHours())
|
||
}
|
||
}
|
||
return [...hours]
|
||
}
|
||
const prev = activatePrevEffectiveTime.value
|
||
if (!prev) return []
|
||
const prevD = new Date(String(prev).replace(/-/g, '/'))
|
||
if (
|
||
prevD.getFullYear() !== curD.getFullYear() ||
|
||
prevD.getMonth() !== curD.getMonth() ||
|
||
prevD.getDate() !== curD.getDate()
|
||
) {
|
||
return []
|
||
}
|
||
const minH = prevD.getHours()
|
||
const hours = []
|
||
for (let h = 0; h < minH; h++) hours.push(h)
|
||
return hours
|
||
}
|
||
|
||
function onActivatePickerVisible(visible) {
|
||
if (visible && activateFormRef.value) {
|
||
activateFormRef.value.validateField('effectiveTime')
|
||
}
|
||
}
|
||
|
||
function openActivateDialog(row, mode) {
|
||
if (!row || row.psvId == null) return
|
||
activateMode.value = mode
|
||
activateForm.psvId = row.psvId
|
||
activateForm.versionNo = row.versionNo || ''
|
||
activateIsImportFresh.value = isImportFreshBuildMode(row.priceBuildMode)
|
||
activatePrevEffectiveTime.value = findPrevVersionEffectiveTime(row)
|
||
activateBoundaryTimes.value = activateIsImportFresh.value
|
||
? collectActivateBoundaryTimes(row.psvId)
|
||
: []
|
||
const prev = activatePrevEffectiveTime.value
|
||
if (mode === 'reschedule') {
|
||
const cur = normalizeToHour(row.effectiveTime)
|
||
if (activateIsImportFresh.value) {
|
||
activateForm.effectiveTime = cur || pickActivateDefaultTime(prev, true)
|
||
} else if (cur && (!prev || toTimeMs(cur) >= toTimeMs(prev))) {
|
||
activateForm.effectiveTime = cur
|
||
} else {
|
||
activateForm.effectiveTime = pickActivateDefaultTime(prev, false)
|
||
}
|
||
} else {
|
||
activateForm.effectiveTime = pickActivateDefaultTime(prev, activateIsImportFresh.value)
|
||
}
|
||
activateVisible.value = true
|
||
}
|
||
|
||
function onActivateVersion(row) {
|
||
openActivateDialog(row, 'activate')
|
||
}
|
||
|
||
function onRescheduleVersion(row) {
|
||
openActivateDialog(row, 'reschedule')
|
||
}
|
||
|
||
function onActivateClosed() {
|
||
activateMode.value = 'activate'
|
||
activateForm.psvId = undefined
|
||
activateForm.versionNo = ''
|
||
activateForm.effectiveTime = undefined
|
||
activatePrevEffectiveTime.value = undefined
|
||
activateIsImportFresh.value = false
|
||
activateBoundaryTimes.value = []
|
||
activateFormRef.value && activateFormRef.value.clearValidate()
|
||
}
|
||
|
||
function submitActivate() {
|
||
activateFormRef.value.validate(valid => {
|
||
if (!valid) return
|
||
activateForm.effectiveTime = normalizeToHour(activateForm.effectiveTime)
|
||
activateSubmitting.value = true
|
||
mod.api.activatePriceSetVersion(activateForm.psvId, { effectiveTime: activateForm.effectiveTime })
|
||
.then(() => {
|
||
const eff = activateForm.effectiveTime
|
||
const pending = eff && new Date(eff.replace(/-/g, '/')).getTime() > Date.now()
|
||
const okMsg = activateMode.value === 'reschedule'
|
||
? (pending ? '已更新为待生效' : '生效时间已更新,版本已生效')
|
||
: (pending ? '已设为待生效' : '版本已启动')
|
||
proxy.$modal.msgSuccess(okMsg)
|
||
activateVisible.value = false
|
||
load()
|
||
})
|
||
.finally(() => {
|
||
activateSubmitting.value = false
|
||
})
|
||
})
|
||
}
|
||
function onCreateVersion() {
|
||
const priceSetId = set.value.priceSetId
|
||
if (priceSetId == null) return
|
||
const hasDraft = (set.value.versions || []).some(v => v && String(v.isDel) !== '1' && String(v.status) === '0')
|
||
if (hasDraft) {
|
||
proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再新建')
|
||
return
|
||
}
|
||
// Do not insert draft until user clicks finish on the build-price page.
|
||
router.push({
|
||
path: mod.routes.create,
|
||
query: { priceSetId, step: '2', newVersion: '1' }
|
||
})
|
||
}
|
||
function onRemoveVersion(row) {
|
||
if (!row || row.psvId == null) return
|
||
const msg = isEffectVersion(row)
|
||
? "确认删除该版本(生效时间:" + (row.effectiveTime || '未设置') + ")?若订单已引用该版本则仅软删版本记录;若无订单引用则整版本硬删。产品与明细均会物理删除,且上一版将自动恢复生效。"
|
||
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?若订单已引用该版本则仅软删版本记录;若无订单引用则整版本硬删。产品与明细均会物理删除。"
|
||
proxy.$modal.confirm(msg).then(() => {
|
||
removingPsvId.value = row.psvId
|
||
proxy.$modal.loading("正在删除,请稍候...")
|
||
return mod.api.deletePriceSetVersion(row.psvId).then(() => {
|
||
proxy.$modal.msgSuccess("版本已删除")
|
||
load()
|
||
}).finally(() => {
|
||
removingPsvId.value = null
|
||
proxy.$modal.closeLoading()
|
||
})
|
||
}).catch(() => {})
|
||
}
|
||
function onEditVersion(row) {
|
||
if (!row || row.psvId == null) return
|
||
const priceSetId = set.value.priceSetId
|
||
const noMode = !row.priceBuildMode
|
||
const noProducts = !(row.products || []).some(p => p && p.productCode)
|
||
// Empty unfinished draft (new version, not finished): resume create step2 with build-mode choice
|
||
if (isDraftVersion(row) && noMode && noProducts && priceSetId != null) {
|
||
router.push({
|
||
path: mod.routes.create,
|
||
query: { priceSetId, psvId: row.psvId, step: '2', newVersion: '1' }
|
||
})
|
||
return
|
||
}
|
||
editPsvId.value = row.psvId
|
||
editVisible.value = true
|
||
}
|
||
|
||
/** 关闭内嵌编辑面板,刷新版本列表(明细数、状态可能变化) */
|
||
function closeEdit() {
|
||
editVisible.value = false
|
||
load()
|
||
}
|
||
function goList() {
|
||
const obj = { path: mod.routes.list }
|
||
if (proxy.$tab) {
|
||
proxy.$tab.closeOpenPage(obj)
|
||
} else {
|
||
proxy.$router.push(obj)
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
refData.loadProducts()
|
||
load()
|
||
})
|
||
|
||
// 从其他页签切回(新建完成跳转、列表点另一条详情)时同步数据;
|
||
// 从新建/编辑版本页返回时即使套餐ID相同也强制刷新版本列表
|
||
onActivated(() => {
|
||
syncFromRoute(pendingRefresh.value)
|
||
})
|
||
|
||
// 组件已处于激活状态时路由 query 变化的兜底
|
||
watch(() => route.query.priceSetId, () => {
|
||
syncFromRoute(false)
|
||
})
|
||
|
||
// 离开详情页去新建/编辑套餐版本,返回后需要重新加载
|
||
onBeforeRouteLeave((to) => {
|
||
if (to && to.path === mod.routes.create) {
|
||
pendingRefresh.value = true
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.activate-hint {
|
||
margin: -4px 0 0 100px;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* Hour-only: hide minute/second columns in activate datetime popper */
|
||
.activate-hour-picker .el-time-spinner__wrapper:nth-child(2),
|
||
.activate-hour-picker .el-time-spinner__wrapper:nth-child(3) {
|
||
display: none;
|
||
}
|
||
.activate-hour-picker .el-time-spinner__wrapper:nth-child(1) {
|
||
width: 100%;
|
||
}
|
||
</style>
|