233 lines
7.8 KiB
Vue
233 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { parseNumberOrNull } from '@/lib/number'
|
||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||
|
||
interface RateMethodState {
|
||
rate: number | null
|
||
budgetFee?: number | null
|
||
remark: string
|
||
}
|
||
|
||
const props = defineProps<{
|
||
storageKey: string
|
||
contractId?: string
|
||
htMainStorageKey?: string
|
||
htRowId?: string
|
||
htMethodType?: 'rate-fee'
|
||
}>()
|
||
const zxFwPricingStore = useZxFwPricingStore()
|
||
|
||
const rate = ref<number | null>(null)
|
||
const remark = ref('')
|
||
const rateInput = ref('')
|
||
const baseValue = ref<number | null>(null)
|
||
const lastSavedSnapshot = ref('')
|
||
const useHtMethodState = computed(
|
||
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
|
||
)
|
||
const contractIdText = computed(() => {
|
||
const contractId = String(props.contractId || '').trim()
|
||
return contractId
|
||
})
|
||
const isReserveFee = computed(() => {
|
||
const mainKey = String(props.htMainStorageKey || '').trim()
|
||
if (mainKey) return mainKey.endsWith('-reserve')
|
||
return String(props.storageKey || '').includes('-reserve')
|
||
})
|
||
const contractVersion = computed(() => {
|
||
const contractId = contractIdText.value
|
||
if (!contractId) return 0
|
||
return zxFwPricingStore.contractVersions[contractId] || 0
|
||
})
|
||
const additionalWorkKeyVersion = computed(() => {
|
||
if (!isReserveFee.value) return 0
|
||
const contractId = contractIdText.value
|
||
if (!contractId) return 0
|
||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
|
||
})
|
||
const baseLabel = computed(() =>
|
||
isReserveFee.value ? '基数(咨询服务总计 + 附加工作费总计)' : '基数(所有服务费预算合计)'
|
||
)
|
||
|
||
const budgetFee = computed<number | null>(() => {
|
||
if (baseValue.value == null || rate.value == null) return null
|
||
return Number((baseValue.value * rate.value).toFixed(3))
|
||
})
|
||
|
||
const formatAmount = (value: number | null) =>
|
||
value == null ? '' : formatThousandsFlexible(value, 3)
|
||
|
||
const ensureContractLoaded = async () => {
|
||
const contractId = contractIdText.value
|
||
if (!contractId) return
|
||
try {
|
||
await zxFwPricingStore.loadContract(contractId)
|
||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||
if (!isReserveFee.value) {
|
||
baseValue.value = serviceBase == null ? null : Number(serviceBase.toFixed(3))
|
||
return
|
||
}
|
||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||
const additionalState = await zxFwPricingStore.loadHtFeeMainState<{
|
||
rateFee?: unknown
|
||
hourlyFee?: unknown
|
||
quantityUnitPriceFee?: unknown
|
||
}>(additionalStorageKey)
|
||
const additionalTotal = (additionalState?.detailRows || []).reduce((total, row) => {
|
||
const rateFee = Number(row?.rateFee)
|
||
const hourlyFee = Number(row?.hourlyFee)
|
||
const quantityFee = Number(row?.quantityUnitPriceFee)
|
||
const safeRateFee = Number.isFinite(rateFee) ? rateFee : 0
|
||
const safeHourlyFee = Number.isFinite(hourlyFee) ? hourlyFee : 0
|
||
const safeQuantityFee = Number.isFinite(quantityFee) ? quantityFee : 0
|
||
return total + safeRateFee + safeHourlyFee + safeQuantityFee
|
||
}, 0)
|
||
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
|
||
const hasAny = (serviceBase != null) || additionalTotal !== 0
|
||
baseValue.value = hasAny ? Number((serviceBaseSafe + additionalTotal).toFixed(3)) : null
|
||
} catch (error) {
|
||
console.error('load contract for rate base failed:', error)
|
||
baseValue.value = null
|
||
}
|
||
}
|
||
|
||
const loadForm = async () => {
|
||
try {
|
||
const data = useHtMethodState.value
|
||
? await zxFwPricingStore.loadHtFeeMethodState<RateMethodState>(
|
||
props.htMainStorageKey!,
|
||
props.htRowId!,
|
||
props.htMethodType!
|
||
)
|
||
: await zxFwPricingStore.loadKeyState<RateMethodState>(props.storageKey)
|
||
rate.value = typeof data?.rate === 'number' ? data.rate : null
|
||
remark.value = typeof data?.remark === 'string' ? data.remark : ''
|
||
rateInput.value = rate.value == null ? '' : String(rate.value)
|
||
const snapshot: RateMethodState = {
|
||
rate: rate.value,
|
||
budgetFee: budgetFee.value,
|
||
remark: remark.value
|
||
}
|
||
lastSavedSnapshot.value = JSON.stringify(snapshot)
|
||
} catch (error) {
|
||
console.error('load rate form failed:', error)
|
||
rate.value = null
|
||
remark.value = ''
|
||
rateInput.value = ''
|
||
lastSavedSnapshot.value = ''
|
||
}
|
||
}
|
||
|
||
const saveForm = async (force = false) => {
|
||
try {
|
||
const payload: RateMethodState = {
|
||
rate: rate.value,
|
||
budgetFee: budgetFee.value,
|
||
remark: remark.value
|
||
}
|
||
const snapshot = JSON.stringify(payload)
|
||
if (!force && snapshot === lastSavedSnapshot.value) return
|
||
if (useHtMethodState.value) {
|
||
zxFwPricingStore.setHtFeeMethodState(
|
||
props.htMainStorageKey!,
|
||
props.htRowId!,
|
||
props.htMethodType!,
|
||
payload,
|
||
{ force }
|
||
)
|
||
} else {
|
||
zxFwPricingStore.setKeyState(props.storageKey, payload, { force })
|
||
}
|
||
lastSavedSnapshot.value = snapshot
|
||
} catch (error) {
|
||
console.error('save rate form failed:', error)
|
||
}
|
||
}
|
||
|
||
const applyRateInput = () => {
|
||
const next = parseNumberOrNull(rateInput.value, { sanitize: true, precision: 3 })
|
||
rate.value = next
|
||
rateInput.value = next == null ? '' : String(next)
|
||
}
|
||
|
||
|
||
|
||
watch([rate, remark, budgetFee], () => {
|
||
void saveForm()
|
||
})
|
||
|
||
watch(
|
||
() => props.storageKey,
|
||
() => {
|
||
void loadForm()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => contractIdText.value,
|
||
() => {
|
||
void ensureContractLoaded()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => [props.storageKey, props.htMainStorageKey, props.htRowId, props.htMethodType],
|
||
() => {
|
||
void ensureContractLoaded()
|
||
}
|
||
)
|
||
|
||
watch([contractVersion, additionalWorkKeyVersion], ([nextContract, nextAdditional], [prevContract, prevAdditional]) => {
|
||
if (nextContract === prevContract && nextAdditional === prevAdditional) return
|
||
void ensureContractLoaded()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([ensureContractLoaded(), loadForm()])
|
||
})
|
||
|
||
onActivated(async () => {
|
||
await Promise.all([ensureContractLoaded(), loadForm()])
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
void saveForm(true)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-full min-h-0 flex flex-col">
|
||
<div class="rounded-lg border bg-card p-4">
|
||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<label class="space-y-1.5">
|
||
<div class="text-xs text-muted-foreground">{{ baseLabel }}</div>
|
||
<input type="text" :value="baseValue" readonly disabled tabindex="-1"
|
||
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
|
||
</label>
|
||
|
||
<label class="space-y-1.5">
|
||
<div class="text-xs text-muted-foreground">费率(可编辑,三位小数)</div>
|
||
<input v-model="rateInput" type="text" inputmode="decimal" placeholder="请输入费率,建议0.01 ~ 0.05"
|
||
class="h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
|
||
@blur="applyRateInput" />
|
||
</label>
|
||
|
||
<label class="space-y-1.5">
|
||
<div class="text-xs text-muted-foreground">预算费用(自动计算)</div>
|
||
<input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1"
|
||
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
|
||
</label>
|
||
|
||
<label class="space-y-1.5 md:col-span-2">
|
||
<div class="text-xs text-muted-foreground">说明</div>
|
||
<textarea v-model="remark" rows="4" placeholder="请输入说明"
|
||
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|