352 lines
12 KiB
Vue
352 lines
12 KiB
Vue
<template>
|
||
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
|
||
<TypeLine
|
||
scene="ht-tab"
|
||
:title="`合同段:${contractName}`"
|
||
:subtitle="`合同段ID:${contractId}`"
|
||
:meta-text="`合同段预算金额:${formatBudgetAmount(contractBudget)}`"
|
||
:copy-text="contractId"
|
||
:storage-key="`project-active-cat-${contractId}`"
|
||
default-category="info"
|
||
:categories="xmCategories"
|
||
/>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
|
||
import TypeLine from '@/layout/typeLine.vue';
|
||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||
import { roundTo } from '@/lib/decimal'
|
||
import { formatThousands } from '@/lib/numberFormat'
|
||
|
||
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||
const props = defineProps<{
|
||
contractId: string; // 合同ID(必传)
|
||
contractName: string; // 合同名称(必传)
|
||
projectInfoKey?: string; // 工作区基础信息键
|
||
projectScaleKey?: string | null; // 工作区规模信息键
|
||
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
|
||
projectMajorFactorKey?: string; // 工作区工程专业系数键
|
||
}>();
|
||
const zxFwPricingStore = useZxFwPricingStore()
|
||
|
||
interface HtFeeMainRowLike {
|
||
id?: unknown
|
||
}
|
||
|
||
interface RateMethodStateLike {
|
||
budgetFee?: unknown
|
||
}
|
||
|
||
interface HourlyMethodRowLike {
|
||
serviceBudget?: unknown
|
||
adoptedBudgetUnitPrice?: unknown
|
||
personnelCount?: unknown
|
||
workdayCount?: unknown
|
||
}
|
||
|
||
interface HourlyMethodStateLike {
|
||
detailRows?: HourlyMethodRowLike[]
|
||
}
|
||
|
||
interface QuantityMethodRowLike {
|
||
id?: unknown
|
||
budgetFee?: unknown
|
||
quantity?: unknown
|
||
unitPrice?: unknown
|
||
}
|
||
|
||
interface QuantityMethodStateLike {
|
||
detailRows?: QuantityMethodRowLike[]
|
||
}
|
||
|
||
const contractBudget = ref<number | null>(null)
|
||
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const toFiniteNumber = (value: unknown): number | null => {
|
||
if (value == null || value === '') return null
|
||
const num = Number(value)
|
||
return Number.isFinite(num) ? num : null
|
||
}
|
||
|
||
const formatBudgetAmount = (value: number | null | undefined) =>
|
||
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||
|
||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||
if (rows.length === 0) return null
|
||
let hasValid = false
|
||
let total = 0
|
||
for (const row of rows) {
|
||
const serviceBudget = toFiniteNumber(row?.serviceBudget)
|
||
if (serviceBudget != null) {
|
||
total += serviceBudget
|
||
hasValid = true
|
||
continue
|
||
}
|
||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||
const personnel = toFiniteNumber(row?.personnelCount)
|
||
const workday = toFiniteNumber(row?.workdayCount)
|
||
if (adopted == null || personnel == null || workday == null) continue
|
||
total += adopted * personnel * workday
|
||
hasValid = true
|
||
}
|
||
return hasValid ? roundTo(total, 2) : null
|
||
}
|
||
|
||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||
if (rows.length === 0) return null
|
||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
|
||
if (subtotal != null) return roundTo(subtotal, 2)
|
||
let hasValid = false
|
||
let total = 0
|
||
for (const row of rows) {
|
||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||
const budget = toFiniteNumber(row?.budgetFee)
|
||
if (budget != null) {
|
||
total += budget
|
||
hasValid = true
|
||
continue
|
||
}
|
||
const quantity = toFiniteNumber(row?.quantity)
|
||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||
if (quantity == null || unitPrice == null) continue
|
||
total += quantity * unitPrice
|
||
hasValid = true
|
||
}
|
||
return hasValid ? roundTo(total, 2) : null
|
||
}
|
||
|
||
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
|
||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||
])
|
||
const parts = [
|
||
toFiniteNumber(rateState?.budgetFee),
|
||
sumHourlyMethodFee(hourlyState),
|
||
sumQuantityMethodFee(quantityState)
|
||
]
|
||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||
if (validParts.length === 0) return null
|
||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||
}
|
||
|
||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
|
||
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||
if (rowIds.length === 0) return null
|
||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
||
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||
if (validTotals.length === 0) return null
|
||
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
||
}
|
||
|
||
const refreshContractBudget = async () => {
|
||
await zxFwPricingStore.loadContract(props.contractId)
|
||
const serviceFee = zxFwPricingStore.getBaseSubtotal(props.contractId)
|
||
const [additionalFee, reserveFee] = await Promise.all([
|
||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-additional-work`),
|
||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
||
])
|
||
const parts = [serviceFee, additionalFee, reserveFee]
|
||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||
contractBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||
}
|
||
|
||
const budgetRefreshSignature = computed(() => {
|
||
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
|
||
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
|
||
return JSON.stringify({
|
||
contractState: zxFwPricingStore.contracts[props.contractId] || null,
|
||
addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
|
||
reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
|
||
addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
|
||
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
|
||
})
|
||
})
|
||
|
||
const scheduleRefreshContractBudget = () => {
|
||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||
budgetRefreshTimer = setTimeout(() => {
|
||
void refreshContractBudget()
|
||
}, 80)
|
||
}
|
||
|
||
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
||
interface XmCategoryItem {
|
||
key:
|
||
| 'info'
|
||
| 'base-info'
|
||
| 'consult-category-factor'
|
||
| 'major-factor'
|
||
| 'work-grid'
|
||
| 'contract'
|
||
| 'additional-work-fee'
|
||
| 'reserve-fee'
|
||
| 'all';
|
||
label: string;
|
||
component: Component; // 标记为 Vue 组件类型
|
||
}
|
||
|
||
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
|
||
const htView = markRaw(
|
||
defineComponent({
|
||
name: 'HtInfoWithProps',
|
||
setup() {
|
||
const AsyncHtInfo = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/htInfo.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 htInfo 组件失败:', err);
|
||
}
|
||
});
|
||
return () => h(AsyncHtInfo, {
|
||
contractId: props.contractId,
|
||
projectScaleKey: props.projectScaleKey,
|
||
projectInfoKey: props.projectInfoKey
|
||
});
|
||
}
|
||
})
|
||
);
|
||
|
||
const zxfwView = markRaw(
|
||
defineComponent({
|
||
name: 'ZxFwWithProps',
|
||
setup() {
|
||
const AsyncZxFw = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/zxFw.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 zxFw 组件失败:', err);
|
||
}
|
||
});
|
||
return () => h(AsyncZxFw, {
|
||
contractId: props.contractId,
|
||
contractName: props.contractName,
|
||
projectInfoKey: props.projectInfoKey
|
||
});
|
||
}
|
||
})
|
||
);
|
||
|
||
const consultCategoryFactorView = markRaw(
|
||
defineComponent({
|
||
name: 'HtConsultCategoryFactorWithProps',
|
||
setup() {
|
||
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/HtConsultCategoryFactor.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
|
||
}
|
||
});
|
||
return () => h(AsyncHtConsultCategoryFactor, {
|
||
contractId: props.contractId,
|
||
projectInfoKey: props.projectInfoKey,
|
||
parentStorageKey: props.projectConsultCategoryFactorKey
|
||
});
|
||
}
|
||
})
|
||
);
|
||
|
||
const majorFactorView = markRaw(
|
||
defineComponent({
|
||
name: 'HtMajorFactorWithProps',
|
||
setup() {
|
||
const AsyncHtMajorFactor = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/HtMajorFactor.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 HtMajorFactor 组件失败:', err);
|
||
}
|
||
});
|
||
return () => h(AsyncHtMajorFactor, {
|
||
contractId: props.contractId,
|
||
projectInfoKey: props.projectInfoKey,
|
||
parentStorageKey: props.projectMajorFactorKey
|
||
});
|
||
}
|
||
})
|
||
);
|
||
|
||
|
||
|
||
const htBaseInfoView = markRaw(
|
||
defineComponent({
|
||
name: 'HtBaseInfoWithProps',
|
||
setup() {
|
||
const AsyncHtBaseInfo = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/HtBaseInfo.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 HtBaseInfo 组件失败:', err)
|
||
}
|
||
})
|
||
return () => h(AsyncHtBaseInfo, { contractId: props.contractId })
|
||
}
|
||
})
|
||
)
|
||
|
||
const additionalWorkFeeView = markRaw(
|
||
defineComponent({
|
||
name: 'HtAdditionalWorkFeeWithProps',
|
||
setup() {
|
||
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/HtAdditionalWorkFee.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
|
||
}
|
||
});
|
||
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId, contractName: props.contractName });
|
||
}
|
||
})
|
||
);
|
||
|
||
const reserveFeeView = markRaw(
|
||
defineComponent({
|
||
name: 'HtReserveFeeWithProps',
|
||
setup() {
|
||
const AsyncHtReserveFee = defineAsyncComponent({
|
||
loader: () => import('@/components/ht/HtReserveFee.vue'),
|
||
onError: (err) => {
|
||
console.error('加载 HtReserveFee 组件失败:', err);
|
||
}
|
||
});
|
||
return () => h(AsyncHtReserveFee, { contractId: props.contractId, contractName: props.contractName });
|
||
}
|
||
})
|
||
);
|
||
|
||
// 4. 给分类数组添加严格类型标注
|
||
const xmCategories: XmCategoryItem[] = [
|
||
{ key: 'base-info', label: '基础信息', component: htBaseInfoView },
|
||
{ key: 'info', label: '规模信息', component: htView },
|
||
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
|
||
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView },
|
||
{ key: 'contract', label: '咨询服务', component: zxfwView },
|
||
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView },
|
||
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView },
|
||
{ key: 'all', label: '汇总', component: reserveFeeView },
|
||
|
||
|
||
];
|
||
|
||
watch(budgetRefreshSignature, (next, prev) => {
|
||
if (next === prev) return
|
||
scheduleRefreshContractBudget()
|
||
})
|
||
|
||
onMounted(() => {
|
||
void refreshContractBudget()
|
||
})
|
||
|
||
onActivated(() => {
|
||
void refreshContractBudget()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||
})
|
||
</script>
|
||
|