优化跳转
This commit is contained in:
parent
cd9cffe588
commit
1f941ca65f
206
src/App.vue
206
src/App.vue
@ -1,12 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
||||
import Tab from '@/layout/tab.vue'
|
||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||
import localforage from 'localforage'
|
||||
import {
|
||||
buildProjectUrl,
|
||||
ensureProjectIdInUrl,
|
||||
getProjectDbName,
|
||||
NEW_PROJECT_QUERY_KEY,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_PROJECT_ID
|
||||
} from '@/lib/workspace'
|
||||
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
|
||||
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const isReady = ref(false)
|
||||
const lockConflict = ref(false)
|
||||
const currentProjectId = ref('')
|
||||
const currentProjectName = ref('')
|
||||
const conflictProjectList = ref<ProjectMeta[]>([])
|
||||
const openedProjectIds = ref<string[]>([])
|
||||
const closeCountdown = ref(10)
|
||||
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let releaseLock: (() => void) | null = null
|
||||
|
||||
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
|
||||
|
||||
@ -14,17 +33,202 @@ const handleImportComplete = () => {
|
||||
tabStore.hasCompletedSetup = true
|
||||
}
|
||||
|
||||
const refreshConflictProjectList = () => {
|
||||
void (async () => {
|
||||
const projects = listProjects()
|
||||
const enriched = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
try {
|
||||
const kvStoreInstance = localforage.createInstance({
|
||||
name: getProjectDbName(project.id),
|
||||
storeName: 'pinia-kv'
|
||||
})
|
||||
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
|
||||
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
|
||||
const projectInfo = entries?.['xm-base-info-v1']
|
||||
const projectName =
|
||||
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
|
||||
? projectInfo.projectName.trim()
|
||||
: ''
|
||||
return {
|
||||
...project,
|
||||
name: projectName || project.name
|
||||
}
|
||||
} catch {
|
||||
return project
|
||||
}
|
||||
})
|
||||
)
|
||||
conflictProjectList.value = enriched
|
||||
const hit = enriched.find(item => item.id === currentProjectId.value)
|
||||
currentProjectName.value = hit?.name || currentProjectId.value
|
||||
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(enriched.map(item => item.id)))
|
||||
})()
|
||||
}
|
||||
|
||||
const clearCloseCountdown = () => {
|
||||
if (!closeCountdownTimer) return
|
||||
clearInterval(closeCountdownTimer)
|
||||
closeCountdownTimer = null
|
||||
}
|
||||
|
||||
const startCloseCountdown = () => {
|
||||
clearCloseCountdown()
|
||||
closeCountdown.value = 10
|
||||
closeCountdownTimer = setInterval(() => {
|
||||
closeCountdown.value -= 1
|
||||
if (closeCountdown.value <= 0) {
|
||||
clearCloseCountdown()
|
||||
try {
|
||||
window.close()
|
||||
} catch {
|
||||
// 部分浏览器会阻止关闭,保留阻断页。
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const isConflictProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
|
||||
|
||||
const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean }) => {
|
||||
if (isConflictProjectOpen(projectId)) return
|
||||
const href = buildProjectUrl(projectId, options)
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
const createProjectAndOpen = () => {
|
||||
const project = createProject()
|
||||
refreshConflictProjectList()
|
||||
openProjectInNewTab(project.id, { newProject: true })
|
||||
}
|
||||
|
||||
const formatProjectEditedTime = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (num: number) => String(num).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentProjectId.value = ensureProjectIdInUrl()
|
||||
refreshConflictProjectList()
|
||||
let isNewProjectRequest = false
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
isNewProjectRequest = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
||||
} catch {
|
||||
isNewProjectRequest = false
|
||||
}
|
||||
if (currentProjectId.value !== QUICK_PROJECT_ID) {
|
||||
const lock = initProjectSessionLock({
|
||||
projectId: currentProjectId.value,
|
||||
onConflict: (next) => {
|
||||
lockConflict.value = next
|
||||
if (next) {
|
||||
refreshConflictProjectList()
|
||||
startCloseCountdown()
|
||||
} else {
|
||||
clearCloseCountdown()
|
||||
}
|
||||
}
|
||||
})
|
||||
releaseLock = lock.release
|
||||
} else {
|
||||
lockConflict.value = false
|
||||
}
|
||||
|
||||
window.addEventListener('home-import-selected', handleImportComplete)
|
||||
waitForHydration('tabs').then(() => {
|
||||
if (!tabStore.hasCompletedSetup && !isNewProjectRequest) {
|
||||
const hasProjects = listProjects().length > 0
|
||||
if (hasProjects) {
|
||||
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||
tabStore.hasCompletedSetup = true
|
||||
} else {
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
title: '项目计算',
|
||||
componentName: 'ProjectCalcView'
|
||||
})
|
||||
tabStore.hasCompletedSetup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tabStore.hasCompletedSetup && Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||
const activeId = typeof tabStore.activeTabId === 'string' ? tabStore.activeTabId : ''
|
||||
const hasActive = Boolean(activeId) && tabStore.tabs.some(tab => tab.id === activeId)
|
||||
if (!hasActive) {
|
||||
tabStore.activeTabId = tabStore.tabs[0]?.id
|
||||
}
|
||||
}
|
||||
isReady.value = true
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearCloseCountdown()
|
||||
window.removeEventListener('home-import-selected', handleImportComplete)
|
||||
if (releaseLock) {
|
||||
releaseLock()
|
||||
releaseLock = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="isReady">
|
||||
<div
|
||||
v-if="lockConflict"
|
||||
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-slate-900">检测到项目重复打开</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
项目「{{ currentProjectName }}」已在其他页面处于活跃状态。为避免 IndexedDB 数据冲突,本页面已阻断编辑。
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
本页将在 {{ closeCountdown }} 秒后自动尝试关闭。你也可以先在新标签页打开其他项目。
|
||||
</p>
|
||||
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<button
|
||||
v-for="project in conflictProjectList"
|
||||
:key="project.id"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
|
||||
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
|
||||
:disabled="isConflictProjectOpen(project.id)"
|
||||
@click="openProjectInNewTab(project.id)"
|
||||
>
|
||||
<span class="font-medium text-slate-700">
|
||||
{{ project.name }}
|
||||
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">(已打开)</span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">最后编辑:{{ formatProjectEditedTime(project.updatedAt) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
||||
@click="createProjectAndOpen"
|
||||
>
|
||||
新建项目并打开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
:class="isConflictProjectOpen('default') ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-100'"
|
||||
:disabled="isConflictProjectOpen('default')"
|
||||
@click="openProjectInNewTab('default')"
|
||||
>
|
||||
打开默认项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<HomeEntryView v-if="showHomeEntry" />
|
||||
<Tab v-else />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -54,7 +54,7 @@ import {
|
||||
type ContractSegmentPackage
|
||||
} from '@/lib/contractSegment'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
@ -228,12 +228,6 @@ const getCurrentProjectIndustry = async (): Promise<string> => {
|
||||
return typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
}
|
||||
|
||||
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)} 元` : '--'
|
||||
|
||||
@ -296,9 +290,8 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) =>
|
||||
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 total = sumNullableNumbers(parts)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
@ -309,9 +302,8 @@ const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
.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 total = sumNullableNumbers(rowTotals)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const loadContractBudgetFee = async (contractId: string) => {
|
||||
@ -322,9 +314,8 @@ const loadContractBudgetFee = async (contractId: string) => {
|
||||
loadHtMainTotalFee(`htExtraFee-${contractId}-reserve`)
|
||||
])
|
||||
const parts = [serviceFee, additionalFee, reserveFee]
|
||||
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 total = sumNullableNumbers(parts)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const refreshContractBudgets = async () => {
|
||||
|
||||
@ -5,8 +5,7 @@ import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEve
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { additionalWorkList, reserveList } from '@/sql'
|
||||
|
||||
@ -58,9 +57,8 @@ const rowData = ref<SummaryRow[]>([])
|
||||
const explanationText = ref('')
|
||||
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const toFinite = (value: unknown): number | null => toFiniteNumberOrNull(value)
|
||||
const sum3 = (values: Array<number | null | undefined>) => {
|
||||
const valid = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
|
||||
const valid = values.filter((v): v is number => toFiniteNumberOrNull(v) != null)
|
||||
if (valid.length === 0) return null
|
||||
return roundTo(valid.reduce((a, b) => a + b, 0), 3)
|
||||
}
|
||||
@ -71,15 +69,15 @@ const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
const rowBudget = toFinite(row?.serviceBudget)
|
||||
const rowBudget = toFiniteNumberOrNull(row?.serviceBudget)
|
||||
if (rowBudget != null) {
|
||||
total += rowBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFinite(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFinite(row?.personnelCount)
|
||||
const workday = toFinite(row?.workdayCount)
|
||||
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumberOrNull(row?.personnelCount)
|
||||
const workday = toFiniteNumberOrNull(row?.workdayCount)
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
@ -94,14 +92,14 @@ const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | n
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFinite(row?.budgetFee)
|
||||
const budget = toFiniteNumberOrNull(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFinite(row?.quantity)
|
||||
const unitPrice = toFinite(row?.unitPrice)
|
||||
const quantity = toFiniteNumberOrNull(row?.quantity)
|
||||
const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
@ -120,8 +118,8 @@ const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string):
|
||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||
])
|
||||
const rateFee = toFinite(rateState?.budgetFee)
|
||||
const rateValue = toFinite(rateState?.rate)
|
||||
const rateFee = toFiniteNumberOrNull(rateState?.budgetFee)
|
||||
const rateValue = toFiniteNumberOrNull(rateState?.rate)
|
||||
const hourlyFee = sumHourlyMethodFee(hourlyState)
|
||||
const quantityFee = sumQuantityMethodFee(quantityState)
|
||||
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
|
||||
@ -192,12 +190,12 @@ const buildServiceRows = (): SummaryRow[] => {
|
||||
rowType: 'service' as const,
|
||||
code: row.code || '',
|
||||
name: row.name || '',
|
||||
investScale: toFinite(row.investScale),
|
||||
landScale: toFinite(row.landScale),
|
||||
workload: toFinite(row.workload),
|
||||
hourly: toFinite(row.hourly),
|
||||
subtotal: toFinite(row.subtotal),
|
||||
finalFee: toFinite((row as { finalFee?: unknown }).finalFee) ?? toFinite(row.subtotal)
|
||||
investScale: toFiniteNumberOrNull(row.investScale),
|
||||
landScale: toFiniteNumberOrNull(row.landScale),
|
||||
workload: toFiniteNumberOrNull(row.workload),
|
||||
hourly: toFiniteNumberOrNull(row.hourly),
|
||||
subtotal: toFiniteNumberOrNull(row.subtotal),
|
||||
finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(row.subtotal)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
@ -54,7 +55,7 @@ const baseLabel = computed(() =>
|
||||
|
||||
const budgetFee = computed<number | null>(() => {
|
||||
if (baseValue.value == null || rate.value == null) return null
|
||||
return Number((baseValue.value * rate.value/100).toFixed(3))
|
||||
return roundTo(baseValue.value * rate.value / 100, 2)
|
||||
})
|
||||
|
||||
const formatAmount = (value: number | null) =>
|
||||
@ -67,7 +68,7 @@ const ensureContractLoaded = async () => {
|
||||
await zxFwPricingStore.loadContract(contractId)
|
||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||
if (!isReserveFee.value) {
|
||||
baseValue.value = serviceBase == null ? null : Number(serviceBase.toFixed(3))
|
||||
baseValue.value = serviceBase == null ? null : roundTo(serviceBase, 3)
|
||||
return
|
||||
}
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
@ -87,7 +88,7 @@ const ensureContractLoaded = async () => {
|
||||
}, 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
|
||||
baseValue.value = hasAny ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
|
||||
} catch (error) {
|
||||
console.error('load contract for rate base failed:', error)
|
||||
baseValue.value = null
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
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 { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
|
||||
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||||
@ -64,12 +64,6 @@ const contractBudget = ref<number | null>(null)
|
||||
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
|
||||
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)} 元` : '--'
|
||||
|
||||
@ -131,9 +125,8 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) =>
|
||||
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 total = sumNullableNumbers(parts)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
@ -142,9 +135,8 @@ const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
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 total = sumNullableNumbers(rowTotals)
|
||||
return total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const refreshContractBudget = async () => {
|
||||
@ -155,8 +147,8 @@ const refreshContractBudget = async () => {
|
||||
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 total = sumNullableNumbers(parts)
|
||||
contractBudget.value = total == null ? null : roundTo(total, 2)
|
||||
}
|
||||
|
||||
const budgetRefreshSignature = computed(() => {
|
||||
|
||||
@ -6,7 +6,7 @@ import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams
|
||||
import type { FirstDataRenderedEvent } from 'ag-grid-community'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||
import { roundTo, sumNullableNumbers } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import {
|
||||
@ -33,6 +33,7 @@ import { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import ServiceCheckboxSelector from '@/features/shared/components/ServiceCheckboxSelector.vue'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
|
||||
interface ServiceItem {
|
||||
id: string
|
||||
@ -308,17 +309,6 @@ const numericParser = (newValue: any): number | null => {
|
||||
return parseNumberOrNull(newValue, { precision: 3 })
|
||||
}
|
||||
|
||||
/** 类型守卫:有限数字。 */
|
||||
const isFiniteNumberValue = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
/** 可空数字求和:全为空返回 null。 */
|
||||
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
|
||||
const validValues = values.filter(isFiniteNumberValue)
|
||||
if (validValues.length === 0) return null
|
||||
return addNumbers(...validValues)
|
||||
}
|
||||
|
||||
const isSameNullableNumber = (a: number | null | undefined, b: number | null | undefined, precision = 3) => {
|
||||
if (a == null && b == null) return true
|
||||
if (a == null || b == null) return false
|
||||
@ -431,8 +421,8 @@ const clearPricingPaneValues = async (serviceId: string) => {
|
||||
const skipUntil = clearIssuedAt + PRICING_CLEAR_SKIP_TTL_MS
|
||||
const skipToken = `${clearIssuedAt}:${skipUntil}`
|
||||
for (const key of keys) {
|
||||
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, skipToken)
|
||||
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
|
||||
sessionStorage.setItem(buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, key), skipToken)
|
||||
sessionStorage.setItem(buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, key), String(skipUntil))
|
||||
}
|
||||
zxFwPricingStore.removeAllServicePricingMethodStates(props.contractId, serviceId)
|
||||
// Reset后会立刻有逻辑读取IndexedDB计算默认值,这里强制同步删除持久层,避免读到旧数据。
|
||||
|
||||
@ -54,6 +54,7 @@ import {
|
||||
normalizeScaleProjectCount
|
||||
} from '@/lib/pricingScaleProject'
|
||||
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
import {
|
||||
buildContractScaleIdMap,
|
||||
buildContractScaleMap,
|
||||
@ -128,6 +129,11 @@ interface ContractScaleChangeState {
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface FactorChangeState {
|
||||
changedRowIds?: string[]
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface ServiceLite {
|
||||
mutiple?: boolean | null
|
||||
onlyCostScale?: boolean | null
|
||||
@ -145,6 +151,8 @@ const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${props.contractId}`)
|
||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
|
||||
const HT_CONSULT_FACTOR_CHANGE_KEY = computed(() => `${HT_CONSULT_FACTOR_KEY.value}-change`)
|
||||
const HT_MAJOR_FACTOR_CHANGE_KEY = computed(() => `${HT_MAJOR_FACTOR_KEY.value}-change`)
|
||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const activeIndustryCode = ref('')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
@ -154,6 +162,8 @@ const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
let factorDefaultsLoaded = false
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
const lastAppliedConsultFactorChangeAt = ref(0)
|
||||
const lastAppliedMajorFactorChangeAt = ref(0)
|
||||
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
||||
const industryNameMap = new Map(
|
||||
industryTypeList.flatMap(item => [
|
||||
@ -204,7 +214,7 @@ const ensureFactorDefaultsLoaded = async () => {
|
||||
}
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
@ -227,7 +237,7 @@ const shouldSkipPersist = () => {
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
@ -714,27 +724,47 @@ const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
|
||||
|
||||
const syncLinkedFieldsFromContractAndFactors = async () => {
|
||||
if (detailRows.value.length === 0) return
|
||||
const consultChangeState =
|
||||
(zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? null) as FactorChangeState | null
|
||||
const majorChangeState =
|
||||
(zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? null) as FactorChangeState | null
|
||||
|
||||
const consultUpdatedAt = Number(consultChangeState?.updatedAt)
|
||||
const majorUpdatedAt = Number(majorChangeState?.updatedAt)
|
||||
const hasNewConsultChange = Number.isFinite(consultUpdatedAt) && consultUpdatedAt > lastAppliedConsultFactorChangeAt.value
|
||||
const hasNewMajorChange = Number.isFinite(majorUpdatedAt) && majorUpdatedAt > lastAppliedMajorFactorChangeAt.value
|
||||
|
||||
const consultChangedRowIds = new Set(
|
||||
(consultChangeState?.changedRowIds || []).map(id => String(id || '').trim()).filter(Boolean)
|
||||
)
|
||||
const majorChangedRowIdSet = normalizeChangedScaleRowIds(majorChangeState?.changedRowIds)
|
||||
const shouldSyncConsultFactor = hasNewConsultChange && consultChangedRowIds.has(String(props.serviceId).trim())
|
||||
const shouldSyncMajorFactor = hasNewMajorChange && majorChangedRowIdSet.size > 0
|
||||
|
||||
if (hasNewConsultChange) lastAppliedConsultFactorChangeAt.value = consultUpdatedAt
|
||||
if (hasNewMajorChange) lastAppliedMajorFactorChangeAt.value = majorUpdatedAt
|
||||
if (!shouldSyncConsultFactor && !shouldSyncMajorFactor) return
|
||||
|
||||
await loadFactorDefaults()
|
||||
const consultFactor = getDefaultConsultCategoryFactor()
|
||||
|
||||
let changed = false
|
||||
detailRows.value = detailRows.value.map(row => {
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
const nextConsultFactor = consultFactor
|
||||
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
||||
if (
|
||||
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
|
||||
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
|
||||
) {
|
||||
return row
|
||||
}
|
||||
for (const row of detailRows.value) {
|
||||
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
|
||||
row.consultCategoryFactor = consultFactor
|
||||
changed = true
|
||||
}
|
||||
if (!shouldSyncMajorFactor) continue
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
if (!majorChangedRowIdSet.has(majorDictId)) continue
|
||||
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
||||
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
|
||||
row.majorFactor = nextMajorFactor
|
||||
changed = true
|
||||
return {
|
||||
...row,
|
||||
consultCategoryFactor: nextConsultFactor,
|
||||
majorFactor: nextMajorFactor
|
||||
}
|
||||
})
|
||||
if (!changed) return
|
||||
syncComputedValuesToDetailRows()
|
||||
await saveToIndexedDB({ skipComputedSync: true })
|
||||
@ -777,13 +807,13 @@ const detailGridOptions: GridOptions<DetailRow> = {
|
||||
}
|
||||
|
||||
const linkedSourceSignature = computed(() => JSON.stringify({
|
||||
consultFactor:
|
||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
|
||||
consultFactorChange:
|
||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? null,
|
||||
majorFactor:
|
||||
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_KEY.value]
|
||||
?? kvStore.entries[HT_MAJOR_FACTOR_KEY.value]
|
||||
majorFactorChange:
|
||||
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? null
|
||||
}))
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ import {
|
||||
normalizeScaleProjectCount
|
||||
} from '@/lib/pricingScaleProject'
|
||||
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
import {
|
||||
buildContractScaleIdMap,
|
||||
buildContractScaleMap,
|
||||
@ -129,6 +130,11 @@ interface ContractScaleChangeState {
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface FactorChangeState {
|
||||
changedRowIds?: string[]
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface ServiceLite {
|
||||
mutiple?: boolean | null
|
||||
}
|
||||
@ -145,6 +151,8 @@ const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${props.contractId}`)
|
||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
|
||||
const HT_CONSULT_FACTOR_CHANGE_KEY = computed(() => `${HT_CONSULT_FACTOR_KEY.value}-change`)
|
||||
const HT_MAJOR_FACTOR_CHANGE_KEY = computed(() => `${HT_MAJOR_FACTOR_KEY.value}-change`)
|
||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const activeIndustryCode = ref('')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
@ -154,6 +162,8 @@ const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||
let factorDefaultsLoaded = false
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
const lastAppliedConsultFactorChangeAt = ref(0)
|
||||
const lastAppliedMajorFactorChangeAt = ref(0)
|
||||
const industryNameMap = new Map(
|
||||
industryTypeList.flatMap(item => [
|
||||
[String(item.id).trim(), item.name],
|
||||
@ -216,7 +226,7 @@ const ensureFactorDefaultsLoaded = async () => {
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
@ -225,7 +235,7 @@ const shouldForceDefaultLoad = () => {
|
||||
}
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
@ -592,27 +602,47 @@ const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
|
||||
|
||||
const syncLinkedFieldsFromContractAndFactors = async () => {
|
||||
if (detailRows.value.length === 0) return
|
||||
const consultChangeState =
|
||||
(zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? null) as FactorChangeState | null
|
||||
const majorChangeState =
|
||||
(zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? null) as FactorChangeState | null
|
||||
|
||||
const consultUpdatedAt = Number(consultChangeState?.updatedAt)
|
||||
const majorUpdatedAt = Number(majorChangeState?.updatedAt)
|
||||
const hasNewConsultChange = Number.isFinite(consultUpdatedAt) && consultUpdatedAt > lastAppliedConsultFactorChangeAt.value
|
||||
const hasNewMajorChange = Number.isFinite(majorUpdatedAt) && majorUpdatedAt > lastAppliedMajorFactorChangeAt.value
|
||||
|
||||
const consultChangedRowIds = new Set(
|
||||
(consultChangeState?.changedRowIds || []).map(id => String(id || '').trim()).filter(Boolean)
|
||||
)
|
||||
const majorChangedRowIdSet = normalizeChangedScaleRowIds(majorChangeState?.changedRowIds)
|
||||
const shouldSyncConsultFactor = hasNewConsultChange && consultChangedRowIds.has(String(props.serviceId).trim())
|
||||
const shouldSyncMajorFactor = hasNewMajorChange && majorChangedRowIdSet.size > 0
|
||||
|
||||
if (hasNewConsultChange) lastAppliedConsultFactorChangeAt.value = consultUpdatedAt
|
||||
if (hasNewMajorChange) lastAppliedMajorFactorChangeAt.value = majorUpdatedAt
|
||||
if (!shouldSyncConsultFactor && !shouldSyncMajorFactor) return
|
||||
|
||||
await loadFactorDefaults()
|
||||
const consultFactor = getDefaultConsultCategoryFactor()
|
||||
|
||||
let changed = false
|
||||
detailRows.value = detailRows.value.map(row => {
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
const nextConsultFactor = consultFactor
|
||||
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
||||
if (
|
||||
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
|
||||
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
|
||||
) {
|
||||
return row
|
||||
}
|
||||
for (const row of detailRows.value) {
|
||||
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
|
||||
row.consultCategoryFactor = consultFactor
|
||||
changed = true
|
||||
}
|
||||
if (!shouldSyncMajorFactor) continue
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
if (!majorChangedRowIdSet.has(majorDictId)) continue
|
||||
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
||||
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
|
||||
row.majorFactor = nextMajorFactor
|
||||
changed = true
|
||||
return {
|
||||
...row,
|
||||
consultCategoryFactor: nextConsultFactor,
|
||||
majorFactor: nextMajorFactor
|
||||
}
|
||||
})
|
||||
if (!changed) return
|
||||
syncComputedValuesToDetailRows()
|
||||
await saveToIndexedDB({ skipComputedSync: true })
|
||||
@ -650,13 +680,13 @@ const detailGridOptions: GridOptions<DetailRow> = {
|
||||
}
|
||||
|
||||
const linkedSourceSignature = computed(() => JSON.stringify({
|
||||
consultFactor:
|
||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
|
||||
consultFactorChange:
|
||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||
?? null,
|
||||
majorFactor:
|
||||
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_KEY.value]
|
||||
?? kvStore.entries[HT_MAJOR_FACTOR_KEY.value]
|
||||
majorFactorChange:
|
||||
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||
?? null
|
||||
}))
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
|
||||
import { sumNullableBy } from '@/lib/pricingScaleCalc'
|
||||
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
|
||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
|
||||
@ -66,7 +67,7 @@ const ensureFactorDefaultsLoaded = async () => {
|
||||
}
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
@ -89,7 +90,7 @@ const shouldSkipPersist = () => {
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
|
||||
@ -11,12 +11,13 @@ import type {
|
||||
} from 'ag-grid-community'
|
||||
import { expertList } from '@/sql'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { decimalAggSum, roundTo, sumByNumber, sumNullableNumbers, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
|
||||
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
@ -60,7 +61,7 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, props.storageKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
@ -83,7 +84,7 @@ const shouldSkipPersist = () => {
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, props.storageKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
@ -439,17 +440,7 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||
|
||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
|
||||
let hasValid = false
|
||||
const total = sumByNumber(rows, row => {
|
||||
const value = Number(pick(row))
|
||||
if (!Number.isFinite(value)) return null
|
||||
hasValid = true
|
||||
return value
|
||||
})
|
||||
return hasValid ? total : null
|
||||
}
|
||||
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value, row => calcServiceBudget(row)))
|
||||
const totalServiceBudget = computed(() => sumNullableNumbers(detailRows.value.map(row => calcServiceBudget(row))))
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
|
||||
@ -4,6 +4,7 @@ import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { roundTo, sumNullableNumbers, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||
@ -90,32 +91,17 @@ const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||
const SUMMARY_ROW_ID = 'fee-method-summary'
|
||||
|
||||
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
|
||||
const toFinite = (value: number | null | undefined) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
const round3 = (value: number) => Number(value.toFixed(3))
|
||||
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
|
||||
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
const value = pick(row)
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) continue
|
||||
total += value
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? round3(total) : null
|
||||
const total = sumNullableNumbers(rows.map(row => pick(row)))
|
||||
return total == null ? null : roundTo(total, 3)
|
||||
}
|
||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
|
||||
if (!row) return null
|
||||
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
|
||||
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value))
|
||||
if (!hasValid) return null
|
||||
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
|
||||
}
|
||||
const toFiniteUnknown = (value: unknown): number | null => {
|
||||
if (value == null || value === '') return null
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
const total = sumNullableNumbers(values)
|
||||
if (total == null) return null
|
||||
return roundTo(total, 3)
|
||||
}
|
||||
|
||||
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
|
||||
@ -141,12 +127,12 @@ const loadContractServiceFeeBase = async (): Promise<number | null> => {
|
||||
await zxFwPricingStore.loadContract(contractId)
|
||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||
if (!isReserveStorageKey(props.storageKey)) {
|
||||
return serviceBase == null ? null : round3(serviceBase)
|
||||
return serviceBase == null ? null : roundTo(serviceBase, 3)
|
||||
}
|
||||
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
|
||||
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
|
||||
if (!hasAnyBase) return null
|
||||
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal))
|
||||
return roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalFeeTotal), 3)
|
||||
} catch (error) {
|
||||
console.error('loadContractServiceFeeBase failed:', error)
|
||||
return null
|
||||
@ -163,21 +149,21 @@ const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
|
||||
const rowBudget = toFiniteUnknown(row?.serviceBudget)
|
||||
const rowBudget = toFiniteNumber(row?.serviceBudget)
|
||||
if (rowBudget != null) {
|
||||
total += rowBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteUnknown(row?.personnelCount)
|
||||
const workday = toFiniteUnknown(row?.workdayCount)
|
||||
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 ? round3(total) : null
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
||||
@ -188,19 +174,19 @@ const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteUnknown(row?.budgetFee)
|
||||
const budget = toFiniteNumber(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteUnknown(row?.quantity)
|
||||
const unitPrice = toFiniteUnknown(row?.unitPrice)
|
||||
const quantity = toFiniteNumber(row?.quantity)
|
||||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? round3(total) : null
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
||||
@ -215,13 +201,13 @@ const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMet
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
|
||||
])
|
||||
|
||||
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
|
||||
const rateValue = toFiniteUnknown(rateData?.rate)
|
||||
const storedRateFee = toFiniteNumber(rateData?.budgetFee)
|
||||
const rateValue = toFiniteNumber(rateData?.rate)
|
||||
const rateFee =
|
||||
contractBase != null && rateValue != null
|
||||
? round3(contractBase * rateValue / 100)
|
||||
? roundTo(contractBase * rateValue / 100, 2)
|
||||
: storedRateFee != null
|
||||
? round3(storedRateFee)
|
||||
? roundTo(storedRateFee, 2)
|
||||
: null
|
||||
|
||||
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
||||
@ -322,7 +308,7 @@ const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
|
||||
typeof row.unitPrice === 'number' &&
|
||||
Number.isFinite(row.unitPrice)
|
||||
) {
|
||||
return Number((row.quantity * row.unitPrice).toFixed(2))
|
||||
return roundTo(row.quantity * row.unitPrice, 2)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
@ -7,6 +7,7 @@ import { parseNumberOrNull } from '@/lib/number'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
|
||||
|
||||
interface DictItem {
|
||||
code: string
|
||||
@ -31,6 +32,11 @@ interface GridState {
|
||||
detailRows: FactorRow[]
|
||||
}
|
||||
|
||||
interface FactorChangeState {
|
||||
changedRowIds: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type DictSource = Record<string, DictItem>
|
||||
|
||||
const props = defineProps<{
|
||||
@ -47,6 +53,7 @@ const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
const detailRows = ref<FactorRow[]>([])
|
||||
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
||||
const CHANGE_STORAGE_KEY = computed(() => `${props.storageKey}-change`)
|
||||
|
||||
const formatReadonlyFactor = (value: unknown) => {
|
||||
if (value == null || value === '') return ''
|
||||
@ -239,6 +246,59 @@ const saveToIndexedDB = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeFactorBudgetValue = (value: unknown) => {
|
||||
const parsed = parseNumberOrNull(value, { precision: 6 })
|
||||
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const isSameNullableFactorNumber = (left: unknown, right: unknown) => {
|
||||
const normalizedLeft = normalizeFactorBudgetValue(left)
|
||||
const normalizedRight = normalizeFactorBudgetValue(right)
|
||||
if (normalizedLeft == null && normalizedRight == null) return true
|
||||
if (normalizedLeft == null || normalizedRight == null) return false
|
||||
return normalizedLeft === normalizedRight
|
||||
}
|
||||
|
||||
const parseContractFactorMeta = (storageKey: string) => {
|
||||
const consultMatch = /^ht-consult-category-factor-v1-(.+)$/.exec(storageKey)
|
||||
if (consultMatch) {
|
||||
return {
|
||||
factorType: 'consult' as const,
|
||||
contractId: String(consultMatch[1] || '').trim()
|
||||
}
|
||||
}
|
||||
const majorMatch = /^ht-major-factor-v1-(.+)$/.exec(storageKey)
|
||||
if (majorMatch) {
|
||||
return {
|
||||
factorType: 'major' as const,
|
||||
contractId: String(majorMatch[1] || '').trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const saveFactorChangeState = async (changedRowIds: string[]) => {
|
||||
if (changedRowIds.length === 0) return
|
||||
const payload: FactorChangeState = {
|
||||
changedRowIds: Array.from(new Set(changedRowIds.map(id => String(id || '').trim()).filter(Boolean))),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
if (payload.changedRowIds.length === 0) return
|
||||
zxFwPricingStore.setKeyState(CHANGE_STORAGE_KEY.value, payload, { force: true })
|
||||
|
||||
const contractMeta = parseContractFactorMeta(props.storageKey)
|
||||
if (!contractMeta?.contractId) return
|
||||
if (contractMeta.factorType === 'consult') {
|
||||
await syncContractFactorsToPricing(contractMeta.contractId, {
|
||||
consultChangedServiceIds: payload.changedRowIds
|
||||
})
|
||||
return
|
||||
}
|
||||
await syncContractFactorsToPricing(contractMeta.contractId, {
|
||||
majorChangedRowIds: payload.changedRowIds
|
||||
})
|
||||
}
|
||||
|
||||
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
|
||||
if (!storageKey) return null
|
||||
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
|
||||
@ -288,25 +348,60 @@ const loadFromIndexedDB = async () => {
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isBulkClipboardMutation = false
|
||||
let bulkBudgetSnapshot: Map<string, number | null> | null = null
|
||||
const pendingChangedRowIds = new Set<string>()
|
||||
|
||||
const queueChangedRowId = (rowId: unknown) => {
|
||||
const normalizedId = String(rowId || '').trim()
|
||||
if (!normalizedId) return
|
||||
pendingChangedRowIds.add(normalizedId)
|
||||
}
|
||||
|
||||
const flushGridPersist = async () => {
|
||||
await saveToIndexedDB()
|
||||
if (pendingChangedRowIds.size === 0) return
|
||||
const changedRowIds = Array.from(pendingChangedRowIds)
|
||||
pendingChangedRowIds.clear()
|
||||
await saveFactorChangeState(changedRowIds)
|
||||
}
|
||||
|
||||
const scheduleGridPersist = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
void flushGridPersist()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
const handleCellValueChanged = (event?: any) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
const field = String(event?.colDef?.field || '')
|
||||
if (field === 'budgetValue' && !isSameNullableFactorNumber(event?.oldValue, event?.newValue)) {
|
||||
queueChangedRowId(event?.data?.id)
|
||||
}
|
||||
scheduleGridPersist()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
bulkBudgetSnapshot = new Map(
|
||||
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
|
||||
)
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
if (bulkBudgetSnapshot) {
|
||||
const nextBudgetMap = new Map(
|
||||
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
|
||||
)
|
||||
const allRowIds = new Set<string>([...bulkBudgetSnapshot.keys(), ...nextBudgetMap.keys()])
|
||||
for (const rowId of allRowIds) {
|
||||
if (!isSameNullableFactorNumber(bulkBudgetSnapshot.get(rowId), nextBudgetMap.get(rowId))) {
|
||||
queueChangedRowId(rowId)
|
||||
}
|
||||
}
|
||||
bulkBudgetSnapshot = null
|
||||
}
|
||||
scheduleGridPersist()
|
||||
}
|
||||
|
||||
@ -348,7 +443,7 @@ onBeforeUnmount(() => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
void flushGridPersist()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export interface DataPackage {
|
||||
version: number
|
||||
packageType?: 'project-snapshot'
|
||||
exportedAt: string
|
||||
projectId?: string
|
||||
localStorage: DataEntry[]
|
||||
sessionStorage: DataEntry[]
|
||||
localforageDefault: DataEntry[]
|
||||
@ -125,5 +126,6 @@ export const isDataPackageLike = (value: unknown): value is DataPackage => {
|
||||
if (!hasRequiredArrays) return false
|
||||
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false
|
||||
if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false
|
||||
if (payload.projectId != null && typeof payload.projectId !== 'string') return false
|
||||
return true
|
||||
}
|
||||
|
||||
@ -28,16 +28,21 @@ import {
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import {
|
||||
DEFAULT_PROJECT_ID,
|
||||
NEW_PROJECT_QUERY_KEY,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_PROJECT_ID,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_CONTRACT_FALLBACK_NAME,
|
||||
QUICK_CONTRACT_ID,
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
writeProjectIdToUrl,
|
||||
setPendingHomeImportFile,
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
import { upsertProject } from '@/lib/projectRegistry'
|
||||
|
||||
interface QuickProjectInfoState {
|
||||
projectIndustry?: string
|
||||
@ -91,6 +96,8 @@ const getTodayDateString = () => {
|
||||
}
|
||||
|
||||
const enterProjectCalc = () => {
|
||||
upsertProject(DEFAULT_PROJECT_ID, '默认项目')
|
||||
writeProjectIdToUrl(DEFAULT_PROJECT_ID)
|
||||
writeWorkspaceMode('project')
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
@ -162,6 +169,7 @@ const loadQuickDefaults = async () => {
|
||||
}
|
||||
|
||||
const enterQuickCalc = (contractName: string) => {
|
||||
writeProjectIdToUrl(QUICK_PROJECT_ID)
|
||||
writeWorkspaceMode('quick')
|
||||
tabStore.enterWorkspace({
|
||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||
@ -231,6 +239,16 @@ const openHomeImport = () => {
|
||||
onMounted(() => {
|
||||
void loadProjectDefaults()
|
||||
void loadQuickDefaults()
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1') {
|
||||
void openProjectCalc()
|
||||
url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
} catch {
|
||||
// ignore url parsing errors
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
getServiceDictItemById,
|
||||
industryTypeList
|
||||
} from '@/sql'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||
@ -302,6 +303,9 @@ const applyScaleInput = (field: 'invest' | 'land') => {
|
||||
landScale.value = normalized
|
||||
}
|
||||
|
||||
const formatFactorValue = (value: number | null | undefined) =>
|
||||
value == null ? '--' : String(roundTo(value, 3))
|
||||
|
||||
const applyWorkEnvFactorInput = () => {
|
||||
const next = parseNumberOrNull(workEnvFactor.value, { sanitize: true, precision: 3 })
|
||||
workEnvFactor.value = next == null ? '' : String(next)
|
||||
@ -647,14 +651,14 @@ watch(canUseLandScale, enabled => {
|
||||
<label class="quick-calc-field">
|
||||
<span class="quick-calc-field__label">咨询分类系数</span>
|
||||
<div class="quick-calc-field__readonly">
|
||||
{{ consultCategoryFactor == null ? '--' : consultCategoryFactor.toFixed(3) }}
|
||||
{{ formatFactorValue(consultCategoryFactor) }}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="quick-calc-field">
|
||||
<span class="quick-calc-field__label">工程专业系数</span>
|
||||
<div class="quick-calc-field__readonly">
|
||||
{{ engineeringMajorFactor == null ? '--' : engineeringMajorFactor.toFixed(3) }}
|
||||
{{ formatFactorValue(engineeringMajorFactor) }}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import { useKvStore } from '@/pinia/kv'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChevronDown, CircleHelp, Loader2, RotateCcw, X } from 'lucide-vue-next'
|
||||
import { Check, ChevronDown, CircleHelp, Loader2, X } from 'lucide-vue-next'
|
||||
import localforage from 'localforage'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
@ -21,12 +21,21 @@ import {
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastRoot,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
ToastViewport,
|
||||
SelectContent,
|
||||
SelectIcon,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||
import { formatExportTimestamp } from '@/lib/contractSegment'
|
||||
@ -36,10 +45,8 @@ import {
|
||||
normalizeEntries,
|
||||
normalizeForageStoreSnapshots,
|
||||
readForage,
|
||||
readWebStorage,
|
||||
sanitizeFileNamePart,
|
||||
writeForage,
|
||||
writeWebStorage,
|
||||
type DataPackage,
|
||||
type ForageInstance,
|
||||
type ForageStore
|
||||
@ -86,13 +93,25 @@ import type {
|
||||
ZxFwStorageLike
|
||||
} from '@/features/tab/types'
|
||||
import {
|
||||
buildProjectUrl,
|
||||
getProjectDbName,
|
||||
readCurrentProjectId,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_TAB_ID,
|
||||
consumePendingHomeImportFile,
|
||||
readWorkspaceMode,
|
||||
writeProjectIdToUrl,
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
upsertProject,
|
||||
type ProjectMeta
|
||||
} from '@/lib/projectRegistry'
|
||||
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
|
||||
import { addNumbers } from '@/lib/decimal'
|
||||
import {
|
||||
buildMethod0,
|
||||
buildMethod1,
|
||||
@ -119,13 +138,17 @@ import {
|
||||
toMoney
|
||||
} from '@/lib/reportExportBuilders'
|
||||
import { exportFile } from '@/sql'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||
|
||||
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
|
||||
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
|
||||
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
|
||||
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
|
||||
const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1'
|
||||
const PINIA_PERSIST_DB_NAME = 'DB'
|
||||
const DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务'
|
||||
const MAX_PROJECT_COUNT = 10
|
||||
const PINIA_PERSIST_DB_NAME = getProjectDbName(readCurrentProjectId())
|
||||
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
|
||||
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const
|
||||
const RESET_MIN_LOADING_MS = 1000
|
||||
@ -228,6 +251,15 @@ const tabContextRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const dataMenuOpen = ref(false)
|
||||
const dataMenuRef = ref<HTMLElement | null>(null)
|
||||
const projectMenuOpen = ref(false)
|
||||
const projectMenuRef = ref<HTMLElement | null>(null)
|
||||
const newProjectDialogOpen = ref(false)
|
||||
const newProjectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const newProjectSubmitting = ref(false)
|
||||
const projectLimitDialogOpen = ref(false)
|
||||
const projectList = ref<ProjectMeta[]>([])
|
||||
const openedProjectIds = ref<string[]>([])
|
||||
const currentProjectId = ref(readCurrentProjectId())
|
||||
const resetConfirmOpen = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const importFileRef = ref<HTMLInputElement | null>(null)
|
||||
@ -301,6 +333,8 @@ const tabsModel = computed({
|
||||
tabStore.tabs = value
|
||||
}
|
||||
})
|
||||
const activeTab = computed(() => tabStore.tabs.find((t: any) => t.id === tabStore.activeTabId) || null)
|
||||
const activeTabId = computed(() => (activeTab.value ? String(activeTab.value.id || '') : ''))
|
||||
|
||||
const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value))
|
||||
|
||||
@ -326,10 +360,209 @@ const canCloseRight = computed(() => {
|
||||
const canCloseOther = computed(() =>
|
||||
tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0
|
||||
)
|
||||
const projectCountText = computed(() => `${projectList.value.length}/${MAX_PROJECT_COUNT}`)
|
||||
|
||||
const closeMenus = () => {
|
||||
tabContextOpen.value = false
|
||||
dataMenuOpen.value = false
|
||||
projectMenuOpen.value = false
|
||||
}
|
||||
|
||||
const refreshProjectList = async () => {
|
||||
const baseProjects = listProjects()
|
||||
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(baseProjects.map(item => item.id)))
|
||||
const enriched = await Promise.all(
|
||||
baseProjects.map(async (project) => {
|
||||
try {
|
||||
const kvStoreInstance = localforage.createInstance({
|
||||
name: getProjectDbName(project.id),
|
||||
storeName: 'pinia-kv'
|
||||
})
|
||||
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
|
||||
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
|
||||
const projectInfo = entries?.[PROJECT_INFO_DB_KEY]
|
||||
const projectName =
|
||||
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
|
||||
? projectInfo.projectName.trim()
|
||||
: ''
|
||||
return {
|
||||
...project,
|
||||
name: projectName || project.name
|
||||
}
|
||||
} catch {
|
||||
return project
|
||||
}
|
||||
})
|
||||
)
|
||||
projectList.value = enriched
|
||||
}
|
||||
|
||||
const isProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
|
||||
|
||||
const openProjectInNewTab = (projectId: string) => {
|
||||
if (isProjectOpen(projectId)) return
|
||||
const href = buildProjectUrl(projectId)
|
||||
window.open(href, '_blank', 'noopener')
|
||||
projectMenuOpen.value = false
|
||||
}
|
||||
|
||||
const getTodayDateString = () => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const createProjectKvAdapter = (projectId: string) => {
|
||||
const projectKvForage = localforage.createInstance({
|
||||
name: getProjectDbName(projectId),
|
||||
storeName: 'pinia-kv'
|
||||
})
|
||||
return {
|
||||
setItem: async <T = unknown>(keyRaw: string | number, value: T) => {
|
||||
const key = String(keyRaw || '').trim()
|
||||
if (!key) return
|
||||
const currentState = await projectKvForage.getItem<Record<string, unknown>>('pinia-kv')
|
||||
const nextEntries = {
|
||||
...(
|
||||
currentState?.entries && typeof currentState.entries === 'object'
|
||||
? (currentState.entries as Record<string, unknown>)
|
||||
: {}
|
||||
),
|
||||
[key]: JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
await projectKvForage.setItem('pinia-kv', {
|
||||
...(currentState && typeof currentState === 'object' ? currentState : {}),
|
||||
entries: nextEntries,
|
||||
ready: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateProjectDialog = () => {
|
||||
if (listProjects().length >= MAX_PROJECT_COUNT) {
|
||||
projectMenuOpen.value = false
|
||||
projectLimitDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
projectMenuOpen.value = false
|
||||
newProjectIndustry.value = String(industryTypeList[0]?.id || '')
|
||||
newProjectDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeCreateProjectDialog = () => {
|
||||
if (newProjectSubmitting.value) return
|
||||
newProjectDialogOpen.value = false
|
||||
}
|
||||
|
||||
const createProjectAndOpen = async () => {
|
||||
if (newProjectSubmitting.value) return
|
||||
if (listProjects().length >= MAX_PROJECT_COUNT) {
|
||||
projectMenuOpen.value = false
|
||||
newProjectDialogOpen.value = false
|
||||
projectLimitDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
const industry = String(newProjectIndustry.value || '').trim()
|
||||
if (!industry) return
|
||||
newProjectSubmitting.value = true
|
||||
try {
|
||||
const project = createProject()
|
||||
const kvAdapter = createProjectKvAdapter(project.id)
|
||||
await kvAdapter.setItem(PROJECT_INFO_DB_KEY, {
|
||||
projectIndustry: industry,
|
||||
projectName: DEFAULT_PROJECT_NAME,
|
||||
preparedBy: '',
|
||||
reviewedBy: '',
|
||||
preparedCompany: '',
|
||||
preparedDate: getTodayDateString()
|
||||
})
|
||||
await initializeProjectFactorStates(
|
||||
kvAdapter,
|
||||
industry,
|
||||
CONSULT_CATEGORY_FACTOR_DB_KEY,
|
||||
MAJOR_FACTOR_DB_KEY
|
||||
)
|
||||
void refreshProjectList()
|
||||
const href = buildProjectUrl(project.id)
|
||||
window.open(href, '_blank', 'noopener')
|
||||
newProjectDialogOpen.value = false
|
||||
} finally {
|
||||
newProjectSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateProjectDialogOpenChange = (open: boolean) => {
|
||||
if (newProjectSubmitting.value) return
|
||||
newProjectDialogOpen.value = open
|
||||
}
|
||||
|
||||
const handleCreateProjectConfirm = () => {
|
||||
void createProjectAndOpen()
|
||||
projectMenuOpen.value = false
|
||||
}
|
||||
|
||||
const formatProjectEditedTime = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const pad = (num: number) => String(num).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
const removeProjectItem = async (project: ProjectMeta) => {
|
||||
const isCurrentProject = project.id === currentProjectId.value
|
||||
const ok = window.confirm(
|
||||
isCurrentProject
|
||||
? `确认删除当前项目「${project.name}」吗?将先清空该项目全部本地数据,并跳转到新项目选择页。`
|
||||
: `确认删除项目「${project.name}」吗?这会移除该项目本地数据。`
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
const clearProjectPersistence = async () => {
|
||||
await projectDefaultForage.clear()
|
||||
await Promise.all(
|
||||
getPiniaPersistStores().map(async ({ store }) => {
|
||||
await store.clear()
|
||||
})
|
||||
)
|
||||
const clearTasks: Promise<void>[] = []
|
||||
if (tabStore.$clearPersisted) clearTasks.push(tabStore.$clearPersisted())
|
||||
if (zxFwPricingStore.$clearPersisted) clearTasks.push(zxFwPricingStore.$clearPersisted())
|
||||
if (zxFwPricingKeysStore.$clearPersisted) clearTasks.push(zxFwPricingKeysStore.$clearPersisted())
|
||||
if (zxFwPricingHtFeeStore.$clearPersisted) clearTasks.push(zxFwPricingHtFeeStore.$clearPersisted())
|
||||
if (kvStore.$clearPersisted) clearTasks.push(kvStore.$clearPersisted())
|
||||
await Promise.all(clearTasks)
|
||||
}
|
||||
|
||||
if (isCurrentProject) {
|
||||
await clearProjectPersistence()
|
||||
const removed = deleteProject(project.id)
|
||||
if (!removed) return
|
||||
const nextProject = createProject()
|
||||
writeWorkspaceMode('project')
|
||||
window.location.href = buildProjectUrl(nextProject.id, { newProject: true })
|
||||
return
|
||||
}
|
||||
|
||||
const removed = deleteProject(project.id)
|
||||
if (!removed) return
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = window.indexedDB?.deleteDatabase(getProjectDbName(project.id))
|
||||
if (!request) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => resolve()
|
||||
request.onblocked = () => resolve()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('delete project database failed:', error)
|
||||
}
|
||||
await refreshProjectList()
|
||||
}
|
||||
|
||||
const markGuideCompleted = () => {
|
||||
@ -432,6 +665,9 @@ const handleGlobalMouseDown = (event: MouseEvent) => {
|
||||
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
|
||||
dataMenuOpen.value = false
|
||||
}
|
||||
if (projectMenuOpen.value && projectMenuRef.value && !projectMenuRef.value.contains(target)) {
|
||||
projectMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
@ -631,6 +867,10 @@ const createForageStore = (storeName: string): ForageInstance =>
|
||||
storeName
|
||||
})
|
||||
|
||||
const projectDefaultForage = localforage.createInstance({
|
||||
name: PINIA_PERSIST_DB_NAME
|
||||
})
|
||||
|
||||
const getPiniaPersistStoreName = (storeId: string) => `${PINIA_PERSIST_BASE_STORE_NAME}-${storeId}`
|
||||
|
||||
const getPiniaPersistStores = () =>
|
||||
@ -662,17 +902,24 @@ const createRichTextCode = (...parts: string[]): unknown => ({
|
||||
})
|
||||
|
||||
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string }>
|
||||
return rows
|
||||
.map(row => {
|
||||
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string; subtotal?: unknown }>
|
||||
const normalized = rows.map(row => {
|
||||
const id = String(row?.id || '').trim()
|
||||
if (!id) return null
|
||||
const rowAny = row as { subtotal?: unknown }
|
||||
return {
|
||||
id,
|
||||
name: typeof row?.name === 'string' ? row.name : ''
|
||||
name: typeof row?.name === 'string' ? row.name : '',
|
||||
subtotal: rowAny?.subtotal
|
||||
}
|
||||
})
|
||||
.filter((item): item is { id: string; name: string } => Boolean(item))
|
||||
return normalized.filter(Boolean) as Array<{ id: string; name: string; subtotal?: unknown }>
|
||||
}
|
||||
|
||||
const getHtMainRowSubtotal = (row: { subtotal?: unknown } | null | undefined): number | null => {
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal == null) return null
|
||||
return toMoney(subtotal)
|
||||
}
|
||||
|
||||
const loadHtFeeMethodsByRow = async (mainStorageKey: string, rowId: string) => {
|
||||
@ -719,19 +966,20 @@ const buildAdditionalExport = async (contractId: string): Promise<ExportAddition
|
||||
await Promise.all(
|
||||
rows.map(async row => {
|
||||
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
|
||||
if (!methodPayload) return null
|
||||
const rowSubtotal = getHtMainRowSubtotal(row)
|
||||
if (!methodPayload && rowSubtotal == null) return null
|
||||
const tasks = await buildAdditionalRowTasks(contractId, row.id)
|
||||
const item: ExportAdditionalDetail = {
|
||||
id: row.id,
|
||||
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
|
||||
|
||||
name: row.name,
|
||||
fee: methodPayload.fee,
|
||||
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
|
||||
tasks
|
||||
}
|
||||
if (methodPayload.m0) item.m0 = methodPayload.m0
|
||||
if (methodPayload.m4) item.m4 = methodPayload.m4
|
||||
if (methodPayload.m5) item.m5 = methodPayload.m5
|
||||
if (methodPayload?.m0) item.m0 = methodPayload.m0
|
||||
if (methodPayload?.m4) item.m4 = methodPayload.m4
|
||||
if (methodPayload?.m5) item.m5 = methodPayload.m5
|
||||
return item
|
||||
})
|
||||
)
|
||||
@ -742,7 +990,7 @@ const buildAdditionalExport = async (contractId: string): Promise<ExportAddition
|
||||
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
|
||||
|
||||
name: '附加工作',
|
||||
fee: sumNumbers(det.map(item => item.fee)),
|
||||
fee: toMoney(sumNumbers(det.map(item => item.fee))),
|
||||
det
|
||||
}
|
||||
}
|
||||
@ -755,15 +1003,16 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
|
||||
|
||||
for (const row of rows) {
|
||||
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
|
||||
if (!methodPayload) continue
|
||||
const rowSubtotal = getHtMainRowSubtotal(row)
|
||||
if (!methodPayload && rowSubtotal == null) continue
|
||||
const reserve: ExportReserve = {
|
||||
name: row.name || '预备费',
|
||||
fee: methodPayload.fee,
|
||||
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
|
||||
tasks: []
|
||||
}
|
||||
if (methodPayload.m0) reserve.m0 = methodPayload.m0
|
||||
if (methodPayload.m4) reserve.m4 = methodPayload.m4
|
||||
if (methodPayload.m5) reserve.m5 = methodPayload.m5
|
||||
if (methodPayload?.m0) reserve.m0 = methodPayload.m0
|
||||
if (methodPayload?.m4) reserve.m4 = methodPayload.m4
|
||||
if (methodPayload?.m5) reserve.m5 = methodPayload.m5
|
||||
return reserve
|
||||
}
|
||||
return null
|
||||
@ -900,25 +1149,16 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
).filter((item): item is ExportService => Boolean(item))
|
||||
|
||||
const fixedFinalFee = toFiniteNumber(fixedRow?.finalFee)
|
||||
const serviceFinalFeeSum = sumNumbers(services.map(item => item.finalFee))
|
||||
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
|
||||
const serviceFeeSum = sumNumbers(services.map(item => item.fee))
|
||||
const fixedMethodSum = sumNumbers([
|
||||
toFiniteNumber(fixedRow?.investScale),
|
||||
toFiniteNumber(fixedRow?.landScale),
|
||||
toFiniteNumber(fixedRow?.workload),
|
||||
toFiniteNumber(fixedRow?.hourly)
|
||||
])
|
||||
const serviceFee =
|
||||
fixedFinalFee ??
|
||||
(services.length > 0 ? serviceFinalFeeSum : (fixedSubtotal ?? (serviceFeeSum !== 0 ? serviceFeeSum : fixedMethodSum)))
|
||||
const serviceFeeRaw = fixedFinalFee ?? fixedSubtotal ?? 0
|
||||
const serviceFee = toMoney(serviceFeeRaw)
|
||||
const [addtional, reserve] = await Promise.all([
|
||||
buildAdditionalExport(contractId),
|
||||
buildReserveExport(contractId)
|
||||
])
|
||||
const addtionalFee = addtional ? addtional.fee : 0
|
||||
const reserveFee = reserve ? reserve.fee : 0
|
||||
const contractFee = roundTo(addNumbers(serviceFee, addtionalFee, reserveFee), 3)
|
||||
const addtionalFee = toMoney(addtional ? addtional.fee : 0)
|
||||
const reserveFee = toMoney(reserve ? reserve.fee : 0)
|
||||
const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee))
|
||||
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
|
||||
contractScale.push({
|
||||
majorid: -1,
|
||||
@ -963,7 +1203,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
company,
|
||||
date,
|
||||
industry,
|
||||
fee: sumNumbers(contracts.map(item => item.fee)),
|
||||
fee: toMoney(sumNumbers(contracts.map(item => item.fee))),
|
||||
scaleCost: projectScaleCost,
|
||||
overview,
|
||||
desc,
|
||||
@ -977,6 +1217,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const exportData = async () => {
|
||||
try {
|
||||
const now = new Date()
|
||||
const currentProjectId = readCurrentProjectId()
|
||||
const piniaForageStores = await Promise.all(
|
||||
getPiniaPersistStores().map(async ({ storeName, store }) => ({
|
||||
storeName,
|
||||
@ -984,12 +1225,13 @@ const exportData = async () => {
|
||||
}))
|
||||
)
|
||||
const payload: DataPackage = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
packageType: 'project-snapshot',
|
||||
exportedAt: now.toISOString(),
|
||||
localStorage: readWebStorage(localStorage),
|
||||
sessionStorage: readWebStorage(sessionStorage),
|
||||
localforageDefault: await readForage(localforage),
|
||||
projectId: currentProjectId,
|
||||
localStorage: [],
|
||||
sessionStorage: [],
|
||||
localforageDefault: await readForage(projectDefaultForage),
|
||||
localforageStores: piniaForageStores
|
||||
}
|
||||
|
||||
@ -1046,6 +1288,14 @@ const prepareImportPayloadFromFile = async (file: File) => {
|
||||
if (!isDataPackageLike(payload)) {
|
||||
throw new Error('INVALID_DATA_PACKAGE')
|
||||
}
|
||||
const currentProjectId = readCurrentProjectId()
|
||||
const payloadProjectId = String(payload.projectId || '').trim()
|
||||
if (!payloadProjectId) {
|
||||
throw new Error('PROJECT_ID_MISSING')
|
||||
}
|
||||
if (payloadProjectId && payloadProjectId !== currentProjectId) {
|
||||
throw new Error(`PROJECT_ID_MISMATCH:${payloadProjectId}:${currentProjectId}`)
|
||||
}
|
||||
pendingImportPayload.value = payload
|
||||
pendingImportFileName.value = file.name
|
||||
importConfirmOpen.value = true
|
||||
@ -1060,6 +1310,14 @@ const importData = async (event: Event) => {
|
||||
await prepareImportPayloadFromFile(file)
|
||||
} catch (error) {
|
||||
console.error('import failed:', error)
|
||||
if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') {
|
||||
window.alert('导入失败:该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。')
|
||||
return
|
||||
}
|
||||
if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) {
|
||||
window.alert('导入失败:该数据包属于其他项目,不能覆盖当前项目。')
|
||||
return
|
||||
}
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
} finally {
|
||||
input.value = ''
|
||||
@ -1076,9 +1334,7 @@ const confirmImportOverride = async () => {
|
||||
const payload = pendingImportPayload.value
|
||||
if (!payload) return
|
||||
try {
|
||||
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
|
||||
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
|
||||
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
|
||||
await writeForage(projectDefaultForage, normalizeEntries(payload.localforageDefault))
|
||||
const piniaSnapshots = normalizeForageStoreSnapshots(payload.localforageStores)
|
||||
const snapshotMap = new Map(piniaSnapshots.map(item => [item.storeName, item.entries]))
|
||||
await Promise.all(
|
||||
@ -1143,6 +1399,7 @@ const handleReset = async () => {
|
||||
if (isResetting.value) return
|
||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
const resetStartedAt = Date.now()
|
||||
const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)]))
|
||||
const deleteIndexedDBByName = (dbName: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
try {
|
||||
@ -1163,20 +1420,18 @@ const handleReset = async () => {
|
||||
})
|
||||
|
||||
const purgeKnownIndexedDB = async () => {
|
||||
await Promise.all([
|
||||
deleteIndexedDBByName('DB'),
|
||||
deleteIndexedDBByName('localforage')
|
||||
])
|
||||
await Promise.all(allProjectIds.map(id => deleteIndexedDBByName(getProjectDbName(id))))
|
||||
}
|
||||
|
||||
try {
|
||||
isResetting.value = true
|
||||
dataMenuOpen.value = false
|
||||
projectMenuOpen.value = false
|
||||
|
||||
// 1) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
await localforage.clear()
|
||||
await projectDefaultForage.clear()
|
||||
|
||||
// 2) 清 pinia 分库持久化
|
||||
await Promise.all(
|
||||
@ -1196,6 +1451,7 @@ const handleReset = async () => {
|
||||
await purgeKnownIndexedDB()
|
||||
|
||||
// 5) 需要保留的最小标记恢复
|
||||
writeProjectIdToUrl('default')
|
||||
writeWorkspaceMode('project')
|
||||
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||||
|
||||
@ -1219,6 +1475,9 @@ const handleResetConfirmOpenChange = (open: boolean) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentProjectId.value = readCurrentProjectId()
|
||||
upsertProject(currentProjectId.value)
|
||||
void refreshProjectList()
|
||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
@ -1386,22 +1645,70 @@ watch(
|
||||
使用引导
|
||||
</Button>
|
||||
|
||||
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" size="sm"
|
||||
<div v-if="readWorkspaceMode() !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="app-toolbar-btn shrink-0 cursor-pointer"
|
||||
:disabled="isResetting">
|
||||
<RotateCcw class="h-4 w-4 mr-1" />
|
||||
重置
|
||||
:disabled="isResetting"
|
||||
@click="projectMenuOpen = !projectMenuOpen; if (projectMenuOpen) void refreshProjectList()"
|
||||
>
|
||||
<ChevronDown class="mr-1 h-4 w-4" />
|
||||
项目列表
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<div
|
||||
v-if="projectMenuOpen"
|
||||
class="absolute right-0 top-full z-50 mt-1 w-[420px] rounded-md border bg-background p-2 shadow-md"
|
||||
>
|
||||
<div class="max-h-56 space-y-1 overflow-auto">
|
||||
<div
|
||||
v-for="project in projectList"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 rounded px-2 py-1.5"
|
||||
:class="isProjectOpen(project.id) ? 'opacity-60' : 'hover:bg-muted'"
|
||||
>
|
||||
<button
|
||||
class="flex-1 text-left text-sm"
|
||||
:class="isProjectOpen(project.id) ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||
:disabled="isProjectOpen(project.id)"
|
||||
@click="openProjectInNewTab(project.id)"
|
||||
>
|
||||
<div class="font-medium leading-5">
|
||||
{{ project.name }}
|
||||
<span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">(已打开)</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">最后编辑:{{ formatProjectEditedTime(project.updatedAt) }}</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="removeProjectItem(project)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-end gap-2 border-t pt-2">
|
||||
<span class="mr-auto text-xs text-muted-foreground">项目数量:{{ projectCountText }}</span>
|
||||
<Button size="sm" class="h-8 px-3 text-xs" @click="openCreateProjectDialog">
|
||||
新建项目
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
|
||||
重置全部项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
将清空所有项目数据,并恢复默认页面,确认继续吗?
|
||||
将清空全部项目数据,并恢复默认页面,确认继续吗?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" :disabled="isResetting" @click="resetConfirmOpen = false">取消</Button>
|
||||
@ -1434,15 +1741,95 @@ watch(
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
|
||||
<AlertDialogRoot :open="newProjectDialogOpen" @update:open="handleCreateProjectDialogOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">新建项目</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
选择工程行业后,将在新标签页直接打开新项目计算页面。
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">工程行业</label>
|
||||
<SelectRoot v-model="newProjectIndustry">
|
||||
<SelectTrigger
|
||||
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
|
||||
>
|
||||
<SelectValue placeholder="请选择工程行业" />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
:side-offset="6"
|
||||
position="popper"
|
||||
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
|
||||
>
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem
|
||||
v-for="item in industryTypeList"
|
||||
:key="`new-project-${item.id}`"
|
||||
:value="String(item.id)"
|
||||
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
||||
>
|
||||
<SelectItemText>{{ item.name }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button :disabled="newProjectSubmitting || !newProjectIndustry" @click="handleCreateProjectConfirm">
|
||||
<Loader2 v-if="newProjectSubmitting" class="mr-1 h-4 w-4 animate-spin" />
|
||||
{{ newProjectSubmitting ? '创建中...' : '新建并打开' }}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
|
||||
<AlertDialogRoot :open="projectLimitDialogOpen" @update:open="projectLimitDialogOpen = $event">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">项目数量已达上限</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
当前项目数量已达到 {{ MAX_PROJECT_COUNT }} 个,请先删除一个项目后再添加。
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogAction as-child>
|
||||
<Button @click="projectLimitDialogOpen = false">我知道了</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="isResetting" class="fixed inset-0 z-40 cursor-wait bg-transparent" />
|
||||
|
||||
<div class="flex-1 overflow-auto relative">
|
||||
<div v-for="tab in tabStore.tabs" :key="tab.id" :ref="el => setTabPanelRef(tab.id, el)"
|
||||
v-show="tabStore.activeTabId === tab.id" class="h-full w-full animate-in fade-in duration-300">
|
||||
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
||||
<div
|
||||
v-if="activeTab"
|
||||
:key="activeTab.id"
|
||||
:ref="el => setTabPanelRef(activeTabId, el)"
|
||||
class="h-full w-full animate-in fade-in duration-300"
|
||||
>
|
||||
<component :is="componentMap[activeTab.componentName]" v-bind="activeTab.props || {}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
} from 'reka-ui'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
||||
import { readCurrentProjectId } from '@/lib/workspace'
|
||||
interface TypeLineCategory {
|
||||
key: string
|
||||
label: string
|
||||
@ -45,6 +46,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
|
||||
const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`)
|
||||
|
||||
const readStoredCategory = (key: string) => {
|
||||
const sessionValue = sessionStorage.getItem(key)
|
||||
@ -60,7 +62,7 @@ const writeStoredCategory = (key: string, value: string) => {
|
||||
const resolveInitialCategory = () => {
|
||||
const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
|
||||
if (!props.persistActiveCategory) return defaultKey
|
||||
const savedKey = readStoredCategory(cacheKey.value)
|
||||
const savedKey = readStoredCategory(scopedCacheKey.value)
|
||||
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
||||
return validSavedKey ? (savedKey as string) : defaultKey
|
||||
}
|
||||
@ -68,7 +70,7 @@ const resolveInitialCategory = () => {
|
||||
const activeCategory = ref(resolveInitialCategory())
|
||||
|
||||
watch(
|
||||
() => [props.categories, props.defaultCategory, cacheKey.value],
|
||||
() => [props.categories, props.defaultCategory, scopedCacheKey.value],
|
||||
() => {
|
||||
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
|
||||
if (isCurrentValid) return
|
||||
@ -82,7 +84,7 @@ watch(
|
||||
const switchCategory = (cat: string) => {
|
||||
activeCategory.value = cat
|
||||
if (!props.persistActiveCategory) return
|
||||
writeStoredCategory(cacheKey.value, cat)
|
||||
writeStoredCategory(scopedCacheKey.value, cat)
|
||||
}
|
||||
|
||||
const activeComponent = computed(() => {
|
||||
|
||||
@ -8,9 +8,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
|
||||
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
|
||||
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
export const toFiniteNumberOrNull = (value: unknown): number | null =>
|
||||
isFiniteNumber(value) ? value : null
|
||||
|
||||
export const toFiniteNumberOrZero = (value: unknown): number =>
|
||||
toFiniteNumberOrNull(value) ?? 0
|
||||
|
||||
export const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (isFiniteNumber(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const numeric = Number(trimmed)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sumFiniteValues = (values: Iterable<unknown>) => {
|
||||
let total = new Decimal(0)
|
||||
for (const value of values) {
|
||||
@ -32,6 +49,12 @@ export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
|
||||
return total.toNumber()
|
||||
}
|
||||
|
||||
export const sumNullableNumbers = (values: MaybeNumber[]): number | null => {
|
||||
const validValues = values.filter(isFiniteNumber)
|
||||
if (validValues.length === 0) return null
|
||||
return addNumbers(...validValues)
|
||||
}
|
||||
|
||||
export const decimalAggSum = (params: { values?: unknown[] }) => {
|
||||
const values = params.values || []
|
||||
let hasFinite = false
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { evaluateDecimalExpression, roundTo } from '@/lib/decimal'
|
||||
import {
|
||||
evaluateDecimalExpression,
|
||||
isFiniteNumber,
|
||||
roundTo,
|
||||
toFiniteNumberOrNull
|
||||
} from '@/lib/decimal'
|
||||
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
export const toFiniteNumberOrNull = (value: unknown): number | null =>
|
||||
isFiniteNumber(value) ? value : null
|
||||
export { isFiniteNumber, toFiniteNumberOrNull }
|
||||
|
||||
export const parseNumberOrNull = (
|
||||
value: unknown,
|
||||
|
||||
@ -6,8 +6,7 @@
|
||||
*/
|
||||
|
||||
import { expertList } from '@/sql'
|
||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
|
||||
@ -5,8 +5,7 @@ import {
|
||||
getServiceDictById,
|
||||
taskList
|
||||
} from '@/sql'
|
||||
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||
import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
|
||||
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||
|
||||
@ -5,15 +5,20 @@
|
||||
* 用于控制计价法组件在清除/重建默认数据时的竞态。
|
||||
*/
|
||||
|
||||
import { readCurrentProjectId } from '@/lib/workspace'
|
||||
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
|
||||
export const buildProjectScopedSessionKey = (prefix: string, dbKey: string) =>
|
||||
`${prefix}${readCurrentProjectId()}:${dbKey}`
|
||||
|
||||
/**
|
||||
* 判断当前是否应跳过持久化写入
|
||||
* 用于防止组件卸载时覆盖刚被清除的数据
|
||||
*/
|
||||
export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
@ -40,7 +45,7 @@ export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean
|
||||
* 读取后立即清除标记(一次性)
|
||||
*/
|
||||
export const shouldForceDefaultLoad = (dbKey: string): boolean => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
@ -54,7 +59,7 @@ export const shouldForceDefaultLoad = (dbKey: string): boolean => {
|
||||
* @param durationMs 有效时长(毫秒),默认 3000ms
|
||||
*/
|
||||
export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
|
||||
const now = Date.now()
|
||||
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
|
||||
}
|
||||
@ -65,6 +70,6 @@ export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
|
||||
* @param durationMs 有效时长(毫秒),默认 3000ms
|
||||
*/
|
||||
export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}`
|
||||
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
|
||||
sessionStorage.setItem(storageKey, String(Date.now() + durationMs))
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||
import type {
|
||||
ScaleCalcRow,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { getBasicFeeFromScale } from '@/sql'
|
||||
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
|
||||
type ScaleMode = 'cost' | 'area'
|
||||
|
||||
|
||||
@ -6,8 +6,7 @@
|
||||
*/
|
||||
|
||||
import { taskList } from '@/sql'
|
||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { getDefaultConsultCategoryFactor } from '@/lib/pricingScaleCalc'
|
||||
import type { WorkloadCalcRow, TaskLite } from '@/types/pricing'
|
||||
|
||||
|
||||
149
src/lib/projectRegistry.ts
Normal file
149
src/lib/projectRegistry.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace'
|
||||
|
||||
const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1'
|
||||
|
||||
export interface ProjectMeta {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastOpenedAt: string
|
||||
}
|
||||
|
||||
type ProjectRegistryPayload = {
|
||||
projects: ProjectMeta[]
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString()
|
||||
const lastEditedTouchAt = new Map<string, number>()
|
||||
|
||||
const defaultProjects = (): ProjectMeta[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const sanitizeProject = (item: Partial<ProjectMeta> | null | undefined): ProjectMeta | null => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const id = normalizeProjectId(item.id)
|
||||
const name = String(item.name || '').trim() || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`)
|
||||
const createdAt = typeof item.createdAt === 'string' && item.createdAt ? item.createdAt : nowIso()
|
||||
const updatedAt = typeof item.updatedAt === 'string' && item.updatedAt ? item.updatedAt : createdAt
|
||||
const lastOpenedAt =
|
||||
typeof item.lastOpenedAt === 'string' && item.lastOpenedAt ? item.lastOpenedAt : updatedAt
|
||||
return { id, name, createdAt, updatedAt, lastOpenedAt }
|
||||
}
|
||||
|
||||
const readPayload = (): ProjectRegistryPayload => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROJECT_REGISTRY_KEY)
|
||||
if (!raw) return { projects: defaultProjects() }
|
||||
const parsed = JSON.parse(raw) as Partial<ProjectRegistryPayload>
|
||||
const projects = Array.isArray(parsed?.projects)
|
||||
? parsed.projects.map(item => sanitizeProject(item)).filter((item): item is ProjectMeta => Boolean(item))
|
||||
: []
|
||||
if (projects.length === 0) return { projects: defaultProjects() }
|
||||
return { projects }
|
||||
} catch {
|
||||
return { projects: defaultProjects() }
|
||||
}
|
||||
}
|
||||
|
||||
const writePayload = (payload: ProjectRegistryPayload) => {
|
||||
localStorage.setItem(PROJECT_REGISTRY_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export const listProjects = () => {
|
||||
const payload = readPayload()
|
||||
return payload.projects
|
||||
.filter(item => item.id !== QUICK_PROJECT_ID)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime())
|
||||
}
|
||||
|
||||
export const upsertProject = (
|
||||
projectIdRaw: string,
|
||||
nameRaw?: string,
|
||||
options?: {
|
||||
touchUpdatedAt?: boolean
|
||||
touchLastOpenedAt?: boolean
|
||||
}
|
||||
) => {
|
||||
const id = normalizeProjectId(projectIdRaw)
|
||||
if (id === QUICK_PROJECT_ID) return
|
||||
const name = String(nameRaw || '').trim()
|
||||
const payload = readPayload()
|
||||
const now = nowIso()
|
||||
const touchUpdatedAt = options?.touchUpdatedAt === true
|
||||
const touchLastOpenedAt = options?.touchLastOpenedAt !== false
|
||||
const index = payload.projects.findIndex(item => item.id === id)
|
||||
if (index < 0) {
|
||||
payload.projects.push({
|
||||
id,
|
||||
name: name || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastOpenedAt: now
|
||||
})
|
||||
} else {
|
||||
const current = payload.projects[index]
|
||||
payload.projects[index] = {
|
||||
...current,
|
||||
name: name || current.name,
|
||||
updatedAt: touchUpdatedAt ? now : current.updatedAt,
|
||||
lastOpenedAt: touchLastOpenedAt ? now : current.lastOpenedAt
|
||||
}
|
||||
}
|
||||
writePayload(payload)
|
||||
}
|
||||
|
||||
export const touchProjectEdited = (
|
||||
projectIdRaw: string,
|
||||
options?: {
|
||||
throttleMs?: number
|
||||
}
|
||||
) => {
|
||||
const id = normalizeProjectId(projectIdRaw)
|
||||
if (id === QUICK_PROJECT_ID) return false
|
||||
const throttleMs = Math.max(0, Number(options?.throttleMs ?? 5000))
|
||||
const nowMs = Date.now()
|
||||
const lastTouch = lastEditedTouchAt.get(id) ?? 0
|
||||
if (throttleMs > 0 && nowMs - lastTouch < throttleMs) return false
|
||||
|
||||
const payload = readPayload()
|
||||
const index = payload.projects.findIndex(item => item.id === id)
|
||||
if (index < 0) return false
|
||||
const now = nowIso()
|
||||
const current = payload.projects[index]
|
||||
payload.projects[index] = {
|
||||
...current,
|
||||
updatedAt: now,
|
||||
lastOpenedAt: now
|
||||
}
|
||||
writePayload(payload)
|
||||
lastEditedTouchAt.set(id, nowMs)
|
||||
return true
|
||||
}
|
||||
|
||||
export const createProject = (nameRaw?: string) => {
|
||||
const payload = readPayload()
|
||||
const now = nowIso()
|
||||
let id = ''
|
||||
do {
|
||||
id = normalizeProjectId(`p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
|
||||
} while (payload.projects.some(item => item.id === id))
|
||||
const name = String(nameRaw || '').trim() || `项目-${payload.projects.length + 1}`
|
||||
const project: ProjectMeta = { id, name, createdAt: now, updatedAt: now, lastOpenedAt: now }
|
||||
payload.projects.push(project)
|
||||
writePayload(payload)
|
||||
return project
|
||||
}
|
||||
|
||||
export const deleteProject = (projectIdRaw: string) => {
|
||||
const id = normalizeProjectId(projectIdRaw)
|
||||
if (id === QUICK_PROJECT_ID) return false
|
||||
const payload = readPayload()
|
||||
const nextProjects = payload.projects.filter(item => item.id !== id)
|
||||
if (nextProjects.length === payload.projects.length) return false
|
||||
payload.projects = nextProjects
|
||||
writePayload(payload)
|
||||
return true
|
||||
}
|
||||
159
src/lib/projectSessionLock.ts
Normal file
159
src/lib/projectSessionLock.ts
Normal file
@ -0,0 +1,159 @@
|
||||
const LOCK_TTL_MS = 12_000
|
||||
const HEARTBEAT_MS = 4_000
|
||||
export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:'
|
||||
const CHANNEL_NAME = 'jgjs-project-lock-channel'
|
||||
|
||||
type LockPayload = {
|
||||
sessionId: string
|
||||
projectId: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const now = () => Date.now()
|
||||
const lockKeyOf = (projectId: string) => `${PROJECT_LOCK_KEY_PREFIX}${projectId}`
|
||||
|
||||
const parseLockPayload = (raw: string | null): LockPayload | null => {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const data = JSON.parse(raw) as Partial<LockPayload>
|
||||
if (!data || typeof data !== 'object') return null
|
||||
if (typeof data.sessionId !== 'string' || typeof data.projectId !== 'string') return null
|
||||
if (typeof data.updatedAt !== 'number' || !Number.isFinite(data.updatedAt)) return null
|
||||
return {
|
||||
sessionId: data.sessionId,
|
||||
projectId: data.projectId,
|
||||
updatedAt: data.updatedAt
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isExpired = (payload: LockPayload) => now() - payload.updatedAt > LOCK_TTL_MS
|
||||
|
||||
const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
type InitProjectLockParams = {
|
||||
projectId: string
|
||||
onConflict: (conflicted: boolean) => void
|
||||
}
|
||||
|
||||
export const initProjectSessionLock = (params: InitProjectLockParams) => {
|
||||
const projectId = String(params.projectId || '').trim()
|
||||
const onConflict = params.onConflict
|
||||
const sessionId = randomSessionId()
|
||||
const key = lockKeyOf(projectId)
|
||||
let conflicted = false
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let bc: BroadcastChannel | null = null
|
||||
|
||||
const emitConflict = (next: boolean) => {
|
||||
if (conflicted === next) return
|
||||
conflicted = next
|
||||
onConflict(next)
|
||||
}
|
||||
|
||||
const writeHeartbeat = () => {
|
||||
if (conflicted) return
|
||||
const payload: LockPayload = { sessionId, projectId, updatedAt: now() }
|
||||
localStorage.setItem(key, JSON.stringify(payload))
|
||||
bc?.postMessage({ type: 'heartbeat', projectId, sessionId })
|
||||
}
|
||||
|
||||
const clearOwnLock = () => {
|
||||
const current = parseLockPayload(localStorage.getItem(key))
|
||||
if (current?.sessionId === sessionId) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
bc?.postMessage({ type: 'release', projectId, sessionId })
|
||||
}
|
||||
|
||||
const detectConflict = () => {
|
||||
const current = parseLockPayload(localStorage.getItem(key))
|
||||
if (!current || isExpired(current)) {
|
||||
emitConflict(false)
|
||||
writeHeartbeat()
|
||||
return
|
||||
}
|
||||
emitConflict(current.sessionId !== sessionId)
|
||||
}
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== key) return
|
||||
detectConflict()
|
||||
}
|
||||
|
||||
const onBeforeUnload = () => {
|
||||
clearOwnLock()
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = parseLockPayload(localStorage.getItem(key))
|
||||
if (existing && !isExpired(existing) && existing.sessionId !== sessionId) {
|
||||
emitConflict(true)
|
||||
} else {
|
||||
writeHeartbeat()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('init project session lock failed:', error)
|
||||
}
|
||||
|
||||
if (!conflicted) {
|
||||
heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS)
|
||||
}
|
||||
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
bc = new BroadcastChannel(CHANNEL_NAME)
|
||||
bc.onmessage = (event: MessageEvent<{ type?: string; projectId?: string; sessionId?: string }>) => {
|
||||
const payload = event.data
|
||||
if (!payload || payload.projectId !== projectId) return
|
||||
if (payload.sessionId === sessionId) return
|
||||
if (payload.type === 'heartbeat') {
|
||||
detectConflict()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
return {
|
||||
get conflicted() {
|
||||
return conflicted
|
||||
},
|
||||
release: () => {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
window.removeEventListener('storage', onStorage)
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
clearOwnLock()
|
||||
if (bc) {
|
||||
bc.close()
|
||||
bc = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hasActiveProjectSessionLock = (projectIdRaw: string) => {
|
||||
const projectId = String(projectIdRaw || '').trim()
|
||||
if (!projectId) return false
|
||||
try {
|
||||
const payload = parseLockPayload(localStorage.getItem(lockKeyOf(projectId)))
|
||||
return Boolean(payload && !isExpired(payload))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const collectActiveProjectSessionLocks = (projectIds: string[]) => {
|
||||
const result = new Set<string>()
|
||||
for (const projectId of projectIds) {
|
||||
if (hasActiveProjectSessionLock(projectId)) {
|
||||
result.add(String(projectId || '').trim())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { serviceList } from '@/sql'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { roundTo, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
export { toFiniteNumber, toFiniteNumberOrZero }
|
||||
|
||||
interface ScaleMethodRowLike {
|
||||
id: string
|
||||
@ -168,21 +169,6 @@ interface ExportMethod5DetailLike {
|
||||
remark: string
|
||||
}
|
||||
|
||||
export const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const num = Number(trimmed)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
|
||||
|
||||
export const toSafeInteger = (value: unknown): number | null => {
|
||||
const num = Number(value)
|
||||
if (!Number.isInteger(num)) return null
|
||||
@ -579,17 +565,7 @@ export const buildServiceFee = (
|
||||
method4: { fee: number } | null
|
||||
) => {
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal != null) return subtotal
|
||||
|
||||
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
||||
if (methodSum !== 0) return methodSum
|
||||
|
||||
return sumNumbers([
|
||||
toFiniteNumber(row?.investScale),
|
||||
toFiniteNumber(row?.landScale),
|
||||
toFiniteNumber(row?.workload),
|
||||
toFiniteNumber(row?.hourly)
|
||||
])
|
||||
return subtotal != null ? toMoney(subtotal) : 0
|
||||
}
|
||||
|
||||
export const buildMethod0 = (payload: RateMethodRowLike | null | undefined) => {
|
||||
@ -644,8 +620,7 @@ export const buildServiceFinalFee = (
|
||||
method4: { fee: number } | null
|
||||
) => {
|
||||
const finalFee = toFiniteNumber(row?.finalFee)
|
||||
if (finalFee != null) return finalFee
|
||||
if (finalFee != null) return toMoney(finalFee)
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal != null) return subtotal
|
||||
return sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
||||
return subtotal != null ? toMoney(subtotal) : 0
|
||||
}
|
||||
|
||||
@ -6,6 +6,11 @@ export const LEGACY_PROJECT_TAB_ID = 'ProjectCalcView'
|
||||
export const FIXED_WORKSPACE_TAB_IDS = [PROJECT_TAB_ID, QUICK_TAB_ID] as const
|
||||
|
||||
export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1'
|
||||
export const PROJECT_ID_QUERY_KEY = 'projectId'
|
||||
export const NEW_PROJECT_QUERY_KEY = 'newProject'
|
||||
export const DEFAULT_PROJECT_ID = 'default'
|
||||
export const QUICK_PROJECT_ID = 'quick'
|
||||
export const PROJECT_DB_NAME_PREFIX = 'DB'
|
||||
|
||||
export const QUICK_CONTRACT_ID = 'quick-contract-default'
|
||||
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
|
||||
@ -59,3 +64,55 @@ export const consumePendingHomeImportFile = () => {
|
||||
pendingHomeImportFile = null
|
||||
return file
|
||||
}
|
||||
|
||||
export const normalizeProjectId = (value: unknown) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return DEFAULT_PROJECT_ID
|
||||
return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || DEFAULT_PROJECT_ID
|
||||
}
|
||||
|
||||
export const readCurrentProjectId = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
return normalizeProjectId(url.searchParams.get(PROJECT_ID_QUERY_KEY))
|
||||
} catch {
|
||||
return DEFAULT_PROJECT_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const writeProjectIdToUrl = (projectIdRaw: string) => {
|
||||
try {
|
||||
const projectId = normalizeProjectId(projectIdRaw)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(PROJECT_ID_QUERY_KEY, projectId)
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
} catch (error) {
|
||||
console.error('write project id to url failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureProjectIdInUrl = () => {
|
||||
const id = readCurrentProjectId()
|
||||
writeProjectIdToUrl(id)
|
||||
return id
|
||||
}
|
||||
|
||||
export const buildProjectUrl = (projectIdRaw: string, options?: { newProject?: boolean }) => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(PROJECT_ID_QUERY_KEY, normalizeProjectId(projectIdRaw))
|
||||
if (options?.newProject) {
|
||||
url.searchParams.set(NEW_PROJECT_QUERY_KEY, '1')
|
||||
} else {
|
||||
url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
|
||||
}
|
||||
return `${url.pathname}${url.search}${url.hash}`
|
||||
} catch {
|
||||
return `/?${PROJECT_ID_QUERY_KEY}=${encodeURIComponent(normalizeProjectId(projectIdRaw))}`
|
||||
}
|
||||
}
|
||||
|
||||
export const getProjectDbName = (projectIdRaw: string) => {
|
||||
const projectId = normalizeProjectId(projectIdRaw)
|
||||
return `${PROJECT_DB_NAME_PREFIX}-${projectId}`
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { toFiniteNumberOrNull } from '@/lib/decimal'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals'
|
||||
import {
|
||||
isSameNullableNumber,
|
||||
@ -13,6 +13,7 @@ import {
|
||||
parseScopedRowId,
|
||||
resolveScaleRowMajorDictId as resolveRowMajorDictId
|
||||
} from '@/lib/pricingScaleLink'
|
||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { getServiceDictItemById } from '@/sql'
|
||||
import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing'
|
||||
@ -59,6 +60,57 @@ const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => {
|
||||
return roundTo(sumByNumber(rows, row => row.budgetFee ?? null), 2)
|
||||
}
|
||||
|
||||
type WorkloadDetailRow = {
|
||||
id: string
|
||||
conversion?: number | null
|
||||
workload?: number | null
|
||||
budgetAdoptedUnitPrice?: number | null
|
||||
consultCategoryFactor?: number | null
|
||||
basicFee?: number | null
|
||||
serviceFee?: number | null
|
||||
}
|
||||
|
||||
const normalizeServiceIdSet = (serviceIds?: Array<string | number>) =>
|
||||
new Set((serviceIds || []).map(id => String(id || '').trim()).filter(Boolean))
|
||||
|
||||
const calcWorkloadBasicFee = (row: WorkloadDetailRow) => {
|
||||
if (String(row.id || '').startsWith('task-none-')) return null
|
||||
const price = row.budgetAdoptedUnitPrice
|
||||
const conversion = row.conversion
|
||||
const workload = row.workload
|
||||
if (
|
||||
typeof price !== 'number' ||
|
||||
!Number.isFinite(price) ||
|
||||
typeof conversion !== 'number' ||
|
||||
!Number.isFinite(conversion) ||
|
||||
typeof workload !== 'number' ||
|
||||
!Number.isFinite(workload)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
|
||||
}
|
||||
|
||||
const calcWorkloadServiceFee = (row: WorkloadDetailRow) => {
|
||||
if (String(row.id || '').startsWith('task-none-')) return null
|
||||
const factor = row.consultCategoryFactor
|
||||
const basicFee = calcWorkloadBasicFee(row)
|
||||
if (
|
||||
basicFee == null ||
|
||||
typeof factor !== 'number' ||
|
||||
!Number.isFinite(factor)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(basicFee).mul(factor), 2)
|
||||
}
|
||||
|
||||
const getWorkloadMethodTotalServiceFee = (rows: WorkloadDetailRow[]) => {
|
||||
const hasValue = rows.some(row => typeof row.serviceFee === 'number' && Number.isFinite(row.serviceFee))
|
||||
if (!hasValue) return null
|
||||
return roundTo(sumByNumber(rows, row => row.serviceFee ?? null), 2)
|
||||
}
|
||||
|
||||
const matchesChangedScaleRow = (row: ScaleDetailRow, changedRowIds?: Set<string>) => {
|
||||
if (!changedRowIds || changedRowIds.size === 0) return true
|
||||
const directRowId = String(row.id || '').trim()
|
||||
@ -232,6 +284,233 @@ export const syncContractScaleToPricing = async (
|
||||
}
|
||||
}
|
||||
|
||||
const syncScaleMethodFactors = async (params: {
|
||||
contractId: string
|
||||
serviceId: string
|
||||
method: 'investScale' | 'landScale'
|
||||
syncConsultFactor: boolean
|
||||
consultFactor: number | null
|
||||
majorChangedRowIds?: Set<string>
|
||||
majorFactorMap: Map<string, number | null>
|
||||
}) => {
|
||||
const store = useZxFwPricingStore()
|
||||
const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>(
|
||||
params.contractId,
|
||||
params.serviceId,
|
||||
params.method
|
||||
)
|
||||
if (!methodState?.detailRows?.length) return 0
|
||||
|
||||
let changed = false
|
||||
let changedRowCount = 0
|
||||
const mode = params.method === 'investScale' ? 'cost' : 'area'
|
||||
const nextRows = methodState.detailRows.map(rawRow => {
|
||||
const row = { ...rawRow }
|
||||
let rowChanged = false
|
||||
|
||||
if (params.syncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) {
|
||||
row.consultCategoryFactor = params.consultFactor
|
||||
rowChanged = true
|
||||
}
|
||||
|
||||
if (params.majorChangedRowIds?.size) {
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
if (params.majorChangedRowIds.has(majorDictId)) {
|
||||
const nextMajorFactor = params.majorFactorMap.get(majorDictId) ?? null
|
||||
if (!isSameNullableNumber(row.majorFactor, nextMajorFactor)) {
|
||||
row.majorFactor = nextMajorFactor
|
||||
rowChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowChanged) return rawRow
|
||||
const recomputed = recomputeScaleDetailRow(row, mode)
|
||||
if (isSameScaleDetailRow(rawRow, recomputed, mode)) return rawRow
|
||||
changed = true
|
||||
changedRowCount += 1
|
||||
return recomputed
|
||||
})
|
||||
|
||||
if (!changed) return 0
|
||||
|
||||
store.setServicePricingMethodState(
|
||||
params.contractId,
|
||||
params.serviceId,
|
||||
params.method,
|
||||
{
|
||||
detailRows: nextRows,
|
||||
projectCount: methodState.projectCount ?? null
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
|
||||
await syncPricingTotalToZxFw({
|
||||
contractId: params.contractId,
|
||||
serviceId: params.serviceId,
|
||||
field: params.method,
|
||||
value: getScaleMethodTotalBudgetFee(nextRows)
|
||||
})
|
||||
return changedRowCount
|
||||
}
|
||||
|
||||
const syncWorkloadMethodConsultFactor = async (params: {
|
||||
contractId: string
|
||||
serviceId: string
|
||||
consultFactor: number | null
|
||||
}) => {
|
||||
const store = useZxFwPricingStore()
|
||||
const methodState = await store.loadServicePricingMethodState<WorkloadDetailRow>(
|
||||
params.contractId,
|
||||
params.serviceId,
|
||||
'workload'
|
||||
)
|
||||
if (!methodState?.detailRows?.length) return 0
|
||||
|
||||
let changed = false
|
||||
let changedRowCount = 0
|
||||
const nextRows = methodState.detailRows.map(rawRow => {
|
||||
const row = { ...rawRow }
|
||||
let rowChanged = false
|
||||
|
||||
if (!isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) {
|
||||
row.consultCategoryFactor = params.consultFactor
|
||||
rowChanged = true
|
||||
}
|
||||
|
||||
const nextBasicFee = calcWorkloadBasicFee(row)
|
||||
const nextServiceFee = calcWorkloadServiceFee(row)
|
||||
if (!isSameNullableNumber(row.basicFee, nextBasicFee)) {
|
||||
row.basicFee = nextBasicFee
|
||||
rowChanged = true
|
||||
}
|
||||
if (!isSameNullableNumber(row.serviceFee, nextServiceFee)) {
|
||||
row.serviceFee = nextServiceFee
|
||||
rowChanged = true
|
||||
}
|
||||
|
||||
if (!rowChanged) return rawRow
|
||||
changed = true
|
||||
changedRowCount += 1
|
||||
return row
|
||||
})
|
||||
|
||||
if (!changed) return 0
|
||||
|
||||
store.setServicePricingMethodState(
|
||||
params.contractId,
|
||||
params.serviceId,
|
||||
'workload',
|
||||
{
|
||||
detailRows: nextRows,
|
||||
projectCount: methodState.projectCount ?? null
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
|
||||
await syncPricingTotalToZxFw({
|
||||
contractId: params.contractId,
|
||||
serviceId: params.serviceId,
|
||||
field: 'workload',
|
||||
value: getWorkloadMethodTotalServiceFee(nextRows)
|
||||
})
|
||||
return changedRowCount
|
||||
}
|
||||
|
||||
export interface ContractFactorSyncResult {
|
||||
updatedServiceCount: number
|
||||
updatedMethodCount: number
|
||||
updatedRowCount: number
|
||||
}
|
||||
|
||||
export const syncContractFactorsToPricing = async (
|
||||
contractId: string,
|
||||
options?: {
|
||||
consultChangedServiceIds?: string[]
|
||||
majorChangedRowIds?: string[]
|
||||
}
|
||||
): Promise<ContractFactorSyncResult> => {
|
||||
const store = useZxFwPricingStore()
|
||||
await store.loadContract(contractId)
|
||||
const currentState = store.getContractState(contractId)
|
||||
const selectedIds = Array.from(new Set((currentState?.selectedIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
||||
if (selectedIds.length === 0) {
|
||||
return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 }
|
||||
}
|
||||
|
||||
const consultChangedServiceIdSet = normalizeServiceIdSet(options?.consultChangedServiceIds)
|
||||
const majorChangedRowIdSet = normalizeChangedScaleRowIds(options?.majorChangedRowIds)
|
||||
if (consultChangedServiceIdSet.size === 0 && majorChangedRowIdSet.size === 0) {
|
||||
return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 }
|
||||
}
|
||||
|
||||
const [consultFactorMap, majorFactorMap] = await Promise.all([
|
||||
loadConsultCategoryFactorMap(`ht-consult-category-factor-v1-${contractId}`),
|
||||
loadMajorFactorMap(`ht-major-factor-v1-${contractId}`)
|
||||
])
|
||||
|
||||
let updatedMethodCount = 0
|
||||
let updatedRowCount = 0
|
||||
const updatedServiceIdSet = new Set<string>()
|
||||
|
||||
for (const serviceId of selectedIds) {
|
||||
const syncConsultFactor = consultChangedServiceIdSet.has(serviceId)
|
||||
const syncMajorFactor = majorChangedRowIdSet.size > 0
|
||||
if (!syncConsultFactor && !syncMajorFactor) continue
|
||||
|
||||
const consultFactor = consultFactorMap.get(serviceId) ?? null
|
||||
|
||||
const investChangedCount = await syncScaleMethodFactors({
|
||||
contractId,
|
||||
serviceId,
|
||||
method: 'investScale',
|
||||
syncConsultFactor,
|
||||
consultFactor,
|
||||
majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined,
|
||||
majorFactorMap
|
||||
})
|
||||
if (investChangedCount > 0) {
|
||||
updatedServiceIdSet.add(serviceId)
|
||||
updatedMethodCount += 1
|
||||
updatedRowCount += investChangedCount
|
||||
}
|
||||
|
||||
const landChangedCount = await syncScaleMethodFactors({
|
||||
contractId,
|
||||
serviceId,
|
||||
method: 'landScale',
|
||||
syncConsultFactor,
|
||||
consultFactor,
|
||||
majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined,
|
||||
majorFactorMap
|
||||
})
|
||||
if (landChangedCount > 0) {
|
||||
updatedServiceIdSet.add(serviceId)
|
||||
updatedMethodCount += 1
|
||||
updatedRowCount += landChangedCount
|
||||
}
|
||||
|
||||
if (syncConsultFactor) {
|
||||
const workloadChangedCount = await syncWorkloadMethodConsultFactor({
|
||||
contractId,
|
||||
serviceId,
|
||||
consultFactor
|
||||
})
|
||||
if (workloadChangedCount > 0) {
|
||||
updatedServiceIdSet.add(serviceId)
|
||||
updatedMethodCount += 1
|
||||
updatedRowCount += workloadChangedCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedServiceCount: updatedServiceIdSet.size,
|
||||
updatedMethodCount,
|
||||
updatedRowCount
|
||||
}
|
||||
}
|
||||
|
||||
export const syncPricingTotalToZxFw = async (params: {
|
||||
contractId: string
|
||||
serviceId: string | number
|
||||
|
||||
21
src/main.ts
21
src/main.ts
@ -23,6 +23,8 @@ import piniaPersistedstate from '@/pinia/Plugin/indexdb'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace'
|
||||
import { listProjects } from '@/lib/projectRegistry'
|
||||
|
||||
LicenseManager.setLicenseKey(
|
||||
'[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'
|
||||
@ -47,9 +49,28 @@ const AG_GRID_MODULES = [
|
||||
LocaleModule,ValidationModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule ,ServerSideRowModelApiModule
|
||||
]
|
||||
|
||||
const pickBootstrapProjectId = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const explicit = String(url.searchParams.get('projectId') || '').trim()
|
||||
if (explicit) return ensureProjectIdInUrl()
|
||||
const projects = listProjects()
|
||||
if (projects.length > 0) {
|
||||
const lastEdited = projects[0]
|
||||
url.searchParams.set('projectId', lastEdited.id)
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
return ensureProjectIdInUrl()
|
||||
} catch {
|
||||
return ensureProjectIdInUrl()
|
||||
}
|
||||
}
|
||||
|
||||
const pinia = createPinia()
|
||||
const currentProjectId = pickBootstrapProjectId()
|
||||
pinia.use(
|
||||
piniaPersistedstate({
|
||||
name: getProjectDbName(currentProjectId),
|
||||
storeName: 'pinia',
|
||||
mode: 'multiple'
|
||||
})
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { PiniaPluginContext } from 'pinia'
|
||||
import localforage from 'localforage'
|
||||
import { touchProjectEdited } from '@/lib/projectRegistry'
|
||||
import { QUICK_PROJECT_ID, readCurrentProjectId } from '@/lib/workspace'
|
||||
|
||||
export type PersistOption = boolean | {
|
||||
key?: string
|
||||
@ -84,6 +86,7 @@ export default (config?: PiniaStorageConfig) => {
|
||||
if (!persistOptions) return
|
||||
|
||||
const storeId = context.store.$id
|
||||
const shouldTouchProjectEditedAt = storeId !== 'tabs'
|
||||
const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage'
|
||||
const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName
|
||||
const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName)
|
||||
@ -96,6 +99,12 @@ export default (config?: PiniaStorageConfig) => {
|
||||
const clonedState = JSON.parse(JSON.stringify(state))
|
||||
return lf.setItem(key, trimStringValuesDeep(clonedState))
|
||||
}
|
||||
const markProjectEdited = () => {
|
||||
if (!shouldTouchProjectEditedAt) return
|
||||
const currentProjectId = readCurrentProjectId()
|
||||
if (!currentProjectId || currentProjectId === QUICK_PROJECT_ID) return
|
||||
touchProjectEdited(currentProjectId)
|
||||
}
|
||||
const storeExt = context.store as typeof context.store & PersistStoreExt
|
||||
|
||||
storeExt.$persistNow = async () => {
|
||||
@ -105,6 +114,7 @@ export default (config?: PiniaStorageConfig) => {
|
||||
}
|
||||
try {
|
||||
await writeState(context.store.$state)
|
||||
markProjectEdited()
|
||||
} catch (error) {
|
||||
console.error('pinia persist failed:', error)
|
||||
}
|
||||
@ -124,10 +134,15 @@ export default (config?: PiniaStorageConfig) => {
|
||||
|
||||
context.store.$subscribe(
|
||||
(_mutation, state) => {
|
||||
if (!hydrating) userMutatedBeforeHydrate = true
|
||||
if (hydrating) return
|
||||
userMutatedBeforeHydrate = true
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
void writeState(state).catch(error => {
|
||||
void writeState(state)
|
||||
.then(() => {
|
||||
markProjectEdited()
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('pinia persist failed:', error)
|
||||
})
|
||||
}, Math.max(0, persistDebounce))
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { addNumbers } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import {
|
||||
addNumbers,
|
||||
roundTo,
|
||||
sumNullableNumbers,
|
||||
toFiniteNumberOrNull,
|
||||
toFiniteNumberOrZero
|
||||
} from '@/lib/decimal'
|
||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||
import {
|
||||
parseHtFeeMainStorageKey,
|
||||
@ -68,17 +73,9 @@ const toKey = (contractId: string | number) => String(contractId || '').trim()
|
||||
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
|
||||
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
|
||||
`${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}`
|
||||
const round3 = (value: number) => Number(value.toFixed(3))
|
||||
const isFiniteNumberValue = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
|
||||
const validValues = values.filter(isFiniteNumberValue)
|
||||
if (validValues.length === 0) return null
|
||||
return addNumbers(...validValues)
|
||||
}
|
||||
const round3Nullable = (value: number | null | undefined) => {
|
||||
const numeric = toFiniteNumberOrNull(value)
|
||||
return numeric == null ? null : round3(numeric)
|
||||
return numeric == null ? null : roundTo(numeric, 3)
|
||||
}
|
||||
const normalizeProcessValue = (value: unknown, rowId: string) => {
|
||||
if (rowId === FIXED_ROW_ID) return null
|
||||
@ -89,6 +86,37 @@ const cloneAny = <T>(value: T): T => {
|
||||
if (value == null) return value
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
interface HtFeeMainDetailRowLike {
|
||||
id?: unknown
|
||||
rateFee?: unknown
|
||||
hourlyFee?: unknown
|
||||
quantityUnitPriceFee?: unknown
|
||||
subtotal?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
interface HtRateMethodStateLike {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
interface HtHourlyMethodRowLike {
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
serviceBudget?: unknown
|
||||
}
|
||||
interface HtHourlyMethodStateLike {
|
||||
detailRows?: HtHourlyMethodRowLike[]
|
||||
}
|
||||
interface HtQuantityMethodRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
interface HtQuantityMethodStateLike {
|
||||
detailRows?: HtQuantityMethodRowLike[]
|
||||
}
|
||||
|
||||
const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
|
||||
(Array.isArray(rows) ? rows : []).map(item => {
|
||||
@ -422,6 +450,157 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
const loadHtFeeMethodState = htFeeStore.loadHtFeeMethodState
|
||||
const removeHtFeeMethodState = htFeeStore.removeHtFeeMethodState
|
||||
|
||||
const sumHourlyMethodFee = (state: HtHourlyMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows)
|
||||
? state.detailRows.filter(row => toFiniteNumberOrNull(row?.serviceBudget) != null)
|
||||
: []
|
||||
if (rows.length === 0) return null
|
||||
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
const serviceBudget = toFiniteNumberOrNull(row?.serviceBudget)
|
||||
if (serviceBudget != null) {
|
||||
total += serviceBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumberOrNull(row?.personnelCount)
|
||||
const workday = toFiniteNumberOrNull(row?.workdayCount)
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: HtQuantityMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows)
|
||||
? state.detailRows.filter(row => toFiniteNumberOrNull(row?.budgetFee) != null)
|
||||
: []
|
||||
if (rows.length === 0) return null
|
||||
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteNumberOrNull(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteNumberOrNull(row?.quantity)
|
||||
const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 3) : null
|
||||
}
|
||||
|
||||
const getHtRowSubtotal = (row: HtFeeMainDetailRowLike | null | undefined): number | null => {
|
||||
if (!row) return null
|
||||
const values = [
|
||||
toFiniteNumberOrNull(row.rateFee),
|
||||
toFiniteNumberOrNull(row.hourlyFee),
|
||||
toFiniteNumberOrNull(row.quantityUnitPriceFee)
|
||||
]
|
||||
if (!values.some(value => value != null)) return null
|
||||
let sum = 0
|
||||
for (const value of values) {
|
||||
sum += toFiniteNumberOrZero(value)
|
||||
}
|
||||
return roundTo(sum, 3)
|
||||
}
|
||||
|
||||
const sumMainStateSubtotal = (rows: HtFeeMainDetailRowLike[]): number | null => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null
|
||||
const subtotalValues = rows.map(row => getHtRowSubtotal(row)).filter(value => value != null) as number[]
|
||||
if (subtotalValues.length === 0) return null
|
||||
return roundTo(subtotalValues.reduce((sum, value) => sum + value, 0), 3)
|
||||
}
|
||||
|
||||
const syncHtMainByBase = async (
|
||||
mainStorageKey: string,
|
||||
baseValue: number | null
|
||||
): Promise<number | null> => {
|
||||
const mainState = await loadHtFeeMainState<HtFeeMainDetailRowLike>(mainStorageKey)
|
||||
const sourceRows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||||
if (sourceRows.length === 0) return null
|
||||
|
||||
const nextRows = await Promise.all(
|
||||
sourceRows.map(async sourceRow => {
|
||||
const rowId = String(sourceRow?.id || '').trim()
|
||||
if (!rowId) return sourceRow
|
||||
|
||||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||||
loadHtFeeMethodState<HtRateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||
loadHtFeeMethodState<HtHourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||
loadHtFeeMethodState<HtQuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||
])
|
||||
|
||||
const rateValue = toFiniteNumberOrNull(rateState?.rate)
|
||||
const nextRateFee = baseValue != null && rateValue != null ? roundTo(baseValue * rateValue / 100, 2) : null
|
||||
if (rateState) {
|
||||
const currentRateFee = toFiniteNumberOrNull(rateState.budgetFee)
|
||||
if (currentRateFee !== nextRateFee) {
|
||||
setHtFeeMethodState(
|
||||
mainStorageKey,
|
||||
rowId,
|
||||
'rate-fee',
|
||||
{
|
||||
...rateState,
|
||||
budgetFee: nextRateFee
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const nextHourlyFee = sumHourlyMethodFee(hourlyState)
|
||||
const nextQuantityFee = sumQuantityMethodFee(quantityState)
|
||||
const nextRow = {
|
||||
...sourceRow,
|
||||
rateFee: nextRateFee,
|
||||
hourlyFee: nextHourlyFee,
|
||||
quantityUnitPriceFee: nextQuantityFee
|
||||
}
|
||||
return {
|
||||
...nextRow,
|
||||
subtotal: getHtRowSubtotal(nextRow)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const prevSnapshot = toKeySnapshot(sourceRows)
|
||||
const nextSnapshot = toKeySnapshot(nextRows)
|
||||
if (prevSnapshot !== nextSnapshot) {
|
||||
setHtFeeMainState(mainStorageKey, { detailRows: nextRows }, { force: true })
|
||||
return sumMainStateSubtotal(nextRows)
|
||||
}
|
||||
return sumMainStateSubtotal(sourceRows)
|
||||
}
|
||||
|
||||
const syncHtExtraFeeByContractBase = async (contractId: string) => {
|
||||
if (!contractId) return
|
||||
try {
|
||||
const serviceBase = getBaseSubtotal(contractId)
|
||||
const additionalKey = `htExtraFee-${contractId}-additional-work`
|
||||
const reserveKey = `htExtraFee-${contractId}-reserve`
|
||||
const additionalTotal = await syncHtMainByBase(additionalKey, serviceBase)
|
||||
|
||||
const hasReserveBase = serviceBase != null || additionalTotal != null
|
||||
const reserveBase = hasReserveBase
|
||||
? roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalTotal), 3)
|
||||
: null
|
||||
await syncHtMainByBase(reserveKey, reserveBase)
|
||||
} catch (error) {
|
||||
console.error('syncHtExtraFeeByContractBase failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getKeyState = <T = unknown>(keyRaw: string | number): T | null => {
|
||||
const key = toKey(keyRaw)
|
||||
if (!key) return null
|
||||
@ -675,7 +854,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
if (isSameState(current, nextState)) return false
|
||||
contracts.value[contractId] = nextState
|
||||
contractLoaded.value[contractId] = true
|
||||
const targetRow = nextState.detailRows.find(row => String(row.id || '') === targetServiceId)
|
||||
await syncHtExtraFeeByContractBase(contractId)
|
||||
|
||||
return true
|
||||
}
|
||||
@ -687,7 +866,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
if (!state?.detailRows?.length) return null
|
||||
const fixedRow = state.detailRows.find(row => String(row.id || '') === FIXED_ROW_ID)
|
||||
const fixedFinalFee = toFiniteNumberOrNull(fixedRow?.finalFee)
|
||||
if (fixedFinalFee != null) return round3(fixedFinalFee)
|
||||
if (fixedFinalFee != null) return roundTo(fixedFinalFee, 3)
|
||||
|
||||
let hasValid = false
|
||||
const sum = state.detailRows.reduce((acc, row) => {
|
||||
@ -696,7 +875,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
if (fee != null) hasValid = true
|
||||
return fee == null ? acc : acc + fee
|
||||
}, 0)
|
||||
return hasValid ? round3(sum) : null
|
||||
return hasValid ? roundTo(sum, 3) : null
|
||||
}
|
||||
|
||||
// 清理合同维度的内存态、版本号以及该合同下所有相关持久化键。
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
import ExcelJS from "ExcelJS";
|
||||
// 统一数字千分位格式化,默认保留 2 位小数。
|
||||
@ -7,10 +7,7 @@ const numberFormatter = (value: unknown, fractionDigits = 2) =>
|
||||
formatThousands(value, fractionDigits)
|
||||
|
||||
// 将任意输入安全转为有限数字;无效值统一按 0 处理。
|
||||
const toFiniteNumber = (value: unknown) => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
const toFiniteNumber = toFiniteNumberOrZero
|
||||
|
||||
// 兼容导出 tasks 对象结构:[{ text: [] }, { serviceid, text: [] }]
|
||||
const normalizeTaskTexts = (tasks: unknown): string[] => {
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/quickcalcworkbenchview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/features/ht/components/ht.vue","./src/features/ht/components/htadditionalworkfee.vue","./src/features/ht/components/htbaseinfo.vue","./src/features/ht/components/htconsultcategoryfactor.vue","./src/features/ht/components/htcontractsummary.vue","./src/features/ht/components/htfeeratemethodform.vue","./src/features/ht/components/htmajorfactor.vue","./src/features/ht/components/htreservefee.vue","./src/features/ht/components/htcard.vue","./src/features/ht/components/htinfo.vue","./src/features/ht/components/zxfw.vue","./src/features/pricing/components/hourlypricingpane.vue","./src/features/pricing/components/investmentscalepricingpane.vue","./src/features/pricing/components/landscalepricingpane.vue","./src/features/pricing/components/workloadpricingpane.vue","./src/features/shared/components/hourlyfeegrid.vue","./src/features/shared/components/htfeegrid.vue","./src/features/shared/components/htfeemethodgrid.vue","./src/features/shared/components/methodunavailablenotice.vue","./src/features/shared/components/servicecheckboxselector.vue","./src/features/shared/components/workcontentgrid.vue","./src/features/shared/components/xmfactorgrid.vue","./src/features/shared/components/xmcommonaggrid.vue","./src/features/workbench/components/homeentryview.vue","./src/features/workbench/components/htfeemethodtypelineview.vue","./src/features/workbench/components/quickcalcworkbenchview.vue","./src/features/workbench/components/zxfwview.vue","./src/features/xm/components/xmconsultcategoryfactor.vue","./src/features/xm/components/xmmajorfactor.vue","./src/features/xm/components/info.vue","./src/features/xm/components/xmcard.vue","./src/features/xm/components/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
Loading…
x
Reference in New Issue
Block a user