优化跳转

This commit is contained in:
wintsa 2026-03-25 10:40:19 +08:00
parent cd9cffe588
commit 1f941ca65f
36 changed files with 1899 additions and 321 deletions

View File

@ -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">
<HomeEntryView v-if="showHomeEntry" />
<Tab v-else />
<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>

View File

@ -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 () => {

View File

@ -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)
}))
}

View File

@ -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

View File

@ -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(() => {

View File

@ -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)
// ResetIndexedDB

View File

@ -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 => {
for (const row of detailRows.value) {
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
row.consultCategoryFactor = consultFactor
changed = true
}
if (!shouldSyncMajorFactor) continue
const majorDictId = resolveRowMajorDictId(row)
const nextConsultFactor = consultFactor
if (!majorChangedRowIdSet.has(majorDictId)) continue
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
if (
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
) {
return row
}
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
}))

View File

@ -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 => {
for (const row of detailRows.value) {
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
row.consultCategoryFactor = consultFactor
changed = true
}
if (!shouldSyncMajorFactor) continue
const majorDictId = resolveRowMajorDictId(row)
const nextConsultFactor = consultFactor
if (!majorChangedRowIdSet.has(majorDictId)) continue
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
if (
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
) {
return row
}
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
}))

View File

@ -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)

View File

@ -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',

View File

@ -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
}

View File

@ -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>

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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 => {
const id = String(row?.id || '').trim()
if (!id) return null
return {
id,
name: typeof row?.name === 'string' ? row.name : ''
}
})
.filter((item): item is { id: string; name: string } => Boolean(item))
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 : '',
subtotal: rowAny?.subtotal
}
})
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>
<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"
@click="projectMenuOpen = !projectMenuOpen; if (projectMenuOpen) void refreshProjectList()"
>
<ChevronDown class="mr-1 h-4 w-4" />
项目列表
</Button>
<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">
<AlertDialogTrigger as-child>
<Button variant="destructive" size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting">
<RotateCcw class="h-4 w-4 mr-1" />
重置
</Button>
</AlertDialogTrigger>
<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>

View File

@ -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(() => {

View File

@ -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

View File

@ -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,

View File

@ -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'
/* ----------------------------------------------------------------

View File

@ -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'

View File

@ -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))
}

View File

@ -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,

View File

@ -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'

View File

@ -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
View 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
}

View 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
}

View File

@ -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
}

View File

@ -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}`
}

View File

@ -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'

View File

@ -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

View File

@ -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'
})

View File

@ -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,12 +134,17 @@ 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 => {
console.error('pinia persist failed:', error)
})
void writeState(state)
.then(() => {
markProjectEdited()
})
.catch(error => {
console.error('pinia persist failed:', error)
})
}, Math.max(0, persistDebounce))
},
{ detached: true }

View File

@ -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
}
// 清理合同维度的内存态、版本号以及该合同下所有相关持久化键。

View File

@ -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[] => {

View File

@ -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"}