Files
BMS/bms-front/src/views/price/set/detail.vue
T
2026-11-06 10:11:15 +08:00

556 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>