From 9a045cfe86471e6b1a931d6b41bbfb9ccb16769a Mon Sep 17 00:00:00 2001 From: wintsa <770775984@qq.com> Date: Wed, 11 Mar 2026 11:06:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E6=94=B9=EF=BC=8C=E4=BD=BF=E7=94=A8pi?= =?UTF-8?q?nia=E4=BC=A0=E5=80=BC=EF=BC=8Cindexdb=E5=81=9A=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/HourlyFeeGrid.vue | 127 ++- src/components/common/HtFeeGrid.vue | 67 +- src/components/common/HtFeeMethodGrid.vue | 93 ++- src/components/views/Ht.vue | 126 ++- .../views/HtFeeMethodTypeLineView.vue | 15 +- src/components/views/HtFeeRateMethodForm.vue | 92 ++- .../InvestmentScalePricingPane.vue | 65 +- .../pricingView/LandScalePricingPane.vue | 65 +- .../views/pricingView/WorkloadPricingPane.vue | 58 +- src/components/views/zxFw.vue | 251 +++--- src/layout/tab.vue | 20 +- src/lib/pricingMethodTotals.ts | 104 ++- src/lib/zxFwPricingSync.ts | 2 - src/main.ts | 9 +- src/pinia/Plugin/indexdb.ts | 124 +++ src/pinia/Plugin/types.d.ts | 13 + src/pinia/htFeeMethodReload.ts | 34 - src/pinia/pricingPaneReload.ts | 52 -- src/pinia/tab.ts | 7 +- src/pinia/zxFwPricing.ts | 748 +++++++++++++++++- 20 files changed, 1527 insertions(+), 545 deletions(-) create mode 100644 src/pinia/Plugin/indexdb.ts create mode 100644 src/pinia/Plugin/types.d.ts delete mode 100644 src/pinia/htFeeMethodReload.ts delete mode 100644 src/pinia/pricingPaneReload.ts diff --git a/src/components/common/HourlyFeeGrid.vue b/src/components/common/HourlyFeeGrid.vue index 251f3f3..63846c3 100644 --- a/src/components/common/HourlyFeeGrid.vue +++ b/src/components/common/HourlyFeeGrid.vue @@ -2,15 +2,13 @@ import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue' import { AgGridVue } from 'ag-grid-vue3' import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community' -import localforage from 'localforage' import { expertList } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { formatThousandsFlexible } from '@/lib/numberFormat' import { parseNumberOrNull } from '@/lib/number' import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync' -import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' -import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload' +import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' interface DetailRow { @@ -39,8 +37,9 @@ const props = withDefaults( serviceId?: string | number enableZxFwSync?: boolean syncField?: ZxFwPricingField - syncMainStorageKey?: string - syncRowId?: string + htMainStorageKey?: string + htRowId?: string + htMethodType?: HtFeeMethodType }>(), { title: '工时法明细', @@ -48,13 +47,11 @@ const props = withDefaults( syncField: 'hourly' } ) -const pricingPaneReloadStore = usePricingPaneReloadStore() -const htFeeMethodReloadStore = useHtFeeMethodReloadStore() +const zxFwPricingStore = useZxFwPricingStore() const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const paneInstanceCreatedAt = Date.now() -const reloadSignal = ref(0) const shouldSkipPersist = () => { const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}` @@ -88,8 +85,66 @@ const shouldForceDefaultLoad = () => { return Number.isFinite(forceUntil) && Date.now() <= forceUntil } -const detailRows = ref([]) +const fallbackDetailRows = ref([]) const gridApi = ref | null>(null) +const serviceMethod = computed(() => { + if (props.syncField === 'investScale') return 'investScale' + if (props.syncField === 'landScale') return 'landScale' + if (props.syncField === 'workload') return 'workload' + if (props.syncField === 'hourly') return 'hourly' + return null +}) +const useServicePricingState = computed( + () => Boolean(props.enableZxFwSync && props.contractId && props.serviceId != null && serviceMethod.value) +) +const useHtMethodState = computed( + () => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType) +) +const getServiceMethodState = () => { + if (!useServicePricingState.value || !serviceMethod.value) return null + return zxFwPricingStore.getServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value) +} +const getHtMethodState = () => { + if (!useHtMethodState.value) return null + return zxFwPricingStore.getHtFeeMethodState( + props.htMainStorageKey!, + props.htRowId!, + props.htMethodType! + ) +} +const detailRows = computed({ + get: () => { + if (useServicePricingState.value) { + const rows = getServiceMethodState()?.detailRows + return Array.isArray(rows) ? rows : [] + } + if (useHtMethodState.value) { + const rows = getHtMethodState()?.detailRows + return Array.isArray(rows) ? rows : [] + } + return fallbackDetailRows.value + }, + set: rows => { + if (useServicePricingState.value && serviceMethod.value) { + const currentState = getServiceMethodState() + zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, { + detailRows: rows, + projectCount: currentState?.projectCount ?? null + }) + return + } + if (useHtMethodState.value) { + zxFwPricingStore.setHtFeeMethodState( + props.htMainStorageKey!, + props.htRowId!, + props.htMethodType!, + { detailRows: rows } + ) + return + } + fallbackDetailRows.value = rows + } +}) type ExpertLite = { code: string @@ -393,7 +448,18 @@ const saveToIndexedDB = async () => { detailRows: JSON.parse(JSON.stringify(detailRows.value)) } - await localforage.setItem(props.storageKey, payload) + if (useServicePricingState.value && serviceMethod.value) { + zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, payload) + } else if (useHtMethodState.value) { + zxFwPricingStore.setHtFeeMethodState( + props.htMainStorageKey!, + props.htRowId!, + props.htMethodType!, + payload + ) + } else { + zxFwPricingStore.setKeyState(props.storageKey, payload) + } if (props.enableZxFwSync && props.contractId && props.serviceId != null) { const synced = await syncPricingTotalToZxFw({ @@ -404,9 +470,6 @@ const saveToIndexedDB = async () => { }) if (!synced) return } - if (props.syncMainStorageKey && props.syncRowId) { - htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId) - } } catch (error) { console.error('saveToIndexedDB failed:', error) @@ -420,7 +483,15 @@ const loadFromIndexedDB = async () => { syncServiceBudgetToRows() return } - const data = await localforage.getItem(props.storageKey) + const data = useServicePricingState.value && serviceMethod.value + ? await zxFwPricingStore.loadServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value) + : useHtMethodState.value + ? await zxFwPricingStore.loadHtFeeMethodState( + props.htMainStorageKey!, + props.htRowId!, + props.htMethodType! + ) + : await zxFwPricingStore.loadKeyState(props.storageKey) if (data) { detailRows.value = mergeWithDictRows(data.detailRows) syncServiceBudgetToRows() @@ -435,34 +506,10 @@ const loadFromIndexedDB = async () => { } } -watch( - () => pricingPaneReloadStore.seq, - (nextVersion, prevVersion) => { - if (nextVersion === prevVersion || nextVersion === 0) return - if (!props.contractId || props.serviceId == null) return - if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, props.serviceId)) return - reloadSignal.value += 1 - } -) - -watch( - () => reloadSignal.value, - (nextVersion, prevVersion) => { - if (nextVersion === prevVersion || nextVersion === 0) return - void loadFromIndexedDB() - } -) - -let persistTimer: ReturnType | null = null -let gridPersistTimer: ReturnType | null = null - const handleCellValueChanged = () => { syncServiceBudgetToRows() gridApi.value?.refreshCells({ force: true }) - if (gridPersistTimer) clearTimeout(gridPersistTimer) - gridPersistTimer = setTimeout(() => { - void saveToIndexedDB() - }, 300) + void saveToIndexedDB() } const handleGridReady = (event: GridReadyEvent) => { @@ -505,8 +552,6 @@ onDeactivated(() => { }) onBeforeUnmount(() => { - if (persistTimer) clearTimeout(persistTimer) - if (gridPersistTimer) clearTimeout(gridPersistTimer) gridApi.value?.stopEditing() gridApi.value = null void saveToIndexedDB() diff --git a/src/components/common/HtFeeGrid.vue b/src/components/common/HtFeeGrid.vue index 4fe1cff..5f5c384 100644 --- a/src/components/common/HtFeeGrid.vue +++ b/src/components/common/HtFeeGrid.vue @@ -1,15 +1,14 @@ diff --git a/src/components/views/Ht.vue b/src/components/views/Ht.vue index 3bb4e5e..314af5b 100644 --- a/src/components/views/Ht.vue +++ b/src/components/views/Ht.vue @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { useTabStore } from '@/pinia/tab' +import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next' import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive' import { industryTypeList } from '@/sql' @@ -45,6 +46,14 @@ interface ContractSegmentPackage { projectIndustry: string contracts: ContractItem[] localforageEntries: DataEntry[] + piniaState?: { + zxFwPricing?: { + contracts?: Record + servicePricingStates?: Record + htFeeMainStates?: Record + htFeeMethodStates?: Record + } + } } interface XmBaseInfoState { projectIndustry?: string @@ -52,16 +61,18 @@ interface XmBaseInfoState { const STORAGE_KEY = 'ht-card-v1' const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw' -const CONTRACT_SEGMENT_VERSION = 1 +const CONTRACT_SEGMENT_VERSION = 2 const CONTRACT_KEY_PREFIX = 'ht-info-v3-' const SERVICE_KEY_PREFIX = 'zxFW-' const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-' const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-' const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] const PROJECT_INFO_KEY = 'xm-base-info-v1' +const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const const tabStore = useTabStore() +const zxFwPricingStore = useZxFwPricingStore() @@ -377,6 +388,112 @@ const normalizeDataEntries = (value: unknown): DataEntry[] => { })) } +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)) + +const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const buildContractPiniaPayload = async (contractIds: string[]) => { + const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean)) + const payload = { + contracts: {} as Record, + servicePricingStates: {} as Record, + htFeeMainStates: {} as Record, + htFeeMethodStates: {} as Record + } + if (idSet.size === 0) return payload + + await Promise.all(Array.from(idSet).map(id => zxFwPricingStore.loadContract(id))) + + for (const contractId of idSet) { + const contractState = zxFwPricingStore.getContractState(contractId) + if (contractState) { + payload.contracts[contractId] = cloneJson(contractState) + } + + const servicePricingState = zxFwPricingStore.servicePricingStates[contractId] + if (isRecord(servicePricingState)) { + payload.servicePricingStates[contractId] = cloneJson(servicePricingState) + } + + const mainPrefix = `htExtraFee-${contractId}-` + for (const [mainKey, mainState] of Object.entries(zxFwPricingStore.htFeeMainStates)) { + if (!mainKey.startsWith(mainPrefix)) continue + payload.htFeeMainStates[mainKey] = cloneJson(mainState) + } + + for (const [mainKey, methodState] of Object.entries(zxFwPricingStore.htFeeMethodStates)) { + if (!mainKey.startsWith(mainPrefix)) continue + payload.htFeeMethodStates[mainKey] = cloneJson(methodState) + } + } + + return payload +} + +const applyImportedContractPiniaPayload = async ( + piniaPayload: unknown, + oldToNewIdMap: Map +) => { + if (!isRecord(piniaPayload)) return + const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null + if (!zxFwPayload) return + + const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {} + const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {} + const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {} + const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {} + + for (const [oldId, newId] of oldToNewIdMap.entries()) { + const rawContractState = contractsMap[oldId] + if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) { + await zxFwPricingStore.setContractState(newId, rawContractState as any) + } + + const rawServicePricingByService = servicePricingStatesMap[oldId] + if (isRecord(rawServicePricingByService)) { + for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) { + if (!isRecord(rawServiceMethods)) continue + for (const method of SERVICE_PRICING_METHODS) { + const methodState = rawServiceMethods[method] + if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue + zxFwPricingStore.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true }) + } + } + } + + const oldMainPrefix = `htExtraFee-${oldId}-` + const newMainPrefix = `htExtraFee-${newId}-` + for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) { + if (!oldMainKey.startsWith(oldMainPrefix)) continue + if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue + const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix) + zxFwPricingStore.setHtFeeMainState(newMainKey, rawMainState as any, { force: true }) + } + + for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) { + if (!oldMainKey.startsWith(oldMainPrefix)) continue + if (!isRecord(rawByRow)) continue + const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix) + for (const [rowId, rawByMethod] of Object.entries(rawByRow)) { + if (!isRecord(rawByMethod)) continue + const ratePayload = rawByMethod['rate-fee'] + const hourlyPayload = rawByMethod['hourly-fee'] + const quantityPayload = rawByMethod['quantity-unit-price-fee'] + if (ratePayload != null) { + zxFwPricingStore.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true }) + } + if (hourlyPayload != null) { + zxFwPricingStore.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true }) + } + if (quantityPayload != null) { + zxFwPricingStore.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true }) + } + } + } + } +} + const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => { const payload = value as Partial | null return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts)) @@ -452,6 +569,7 @@ const exportSelectedContracts = async () => { const localforageEntries = await readContractRelatedForageEntries( selectedContracts.map(item => item.id) ) + const piniaPayload = await buildContractPiniaPayload(selectedContracts.map(item => item.id)) const projectIndustry = await getCurrentProjectIndustry() if (!projectIndustry) { @@ -465,7 +583,10 @@ const exportSelectedContracts = async () => { exportedAt: now.toISOString(), projectIndustry, contracts: selectedContracts, - localforageEntries + localforageEntries, + piniaState: { + zxFwPricing: piniaPayload + } } const content = await encodeZwArchive(payload) @@ -549,6 +670,7 @@ const importContractSegments = async (event: Event) => { }) await Promise.all(rewrittenEntries.map(entry => localforage.setItem(entry.key, entry.value))) + await applyImportedContractPiniaPayload(payload.piniaState, oldToNewIdMap) contracts.value = [...contracts.value, ...nextContracts] await saveContracts() diff --git a/src/components/views/HtFeeMethodTypeLineView.vue b/src/components/views/HtFeeMethodTypeLineView.vue index cc0a946..f198d5d 100644 --- a/src/components/views/HtFeeMethodTypeLineView.vue +++ b/src/components/views/HtFeeMethodTypeLineView.vue @@ -50,8 +50,9 @@ const quantityUnitPricePane = markRaw( h(HtFeeGrid, { title: '数量单价', storageKey: quantityStorageKey.value, - syncMainStorageKey: props.storageKey, - syncRowId: props.rowId + htMainStorageKey: props.storageKey, + htRowId: props.rowId, + htMethodType: 'quantity-unit-price-fee' }) } }) @@ -66,8 +67,9 @@ const rateFeePane = markRaw( h(HtFeeRateMethodForm, { storageKey: rateStorageKey.value, contractId: props.contractId, - syncMainStorageKey: props.storageKey, - syncRowId: props.rowId + htMainStorageKey: props.storageKey, + htRowId: props.rowId, + htMethodType: 'rate-fee' }) } }) @@ -82,8 +84,9 @@ const hourlyFeePane = markRaw( h(HourlyFeeGrid, { title: '工时法明细', storageKey: hourlyStorageKey.value, - syncMainStorageKey: props.storageKey, - syncRowId: props.rowId + htMainStorageKey: props.storageKey, + htRowId: props.rowId, + htMethodType: 'hourly-fee' }) } }) diff --git a/src/components/views/HtFeeRateMethodForm.vue b/src/components/views/HtFeeRateMethodForm.vue index a163309..c0b1444 100644 --- a/src/components/views/HtFeeRateMethodForm.vue +++ b/src/components/views/HtFeeRateMethodForm.vue @@ -1,9 +1,7 @@ diff --git a/src/components/views/pricingView/InvestmentScalePricingPane.vue b/src/components/views/pricingView/InvestmentScalePricingPane.vue index a1d1d54..cea402d 100644 --- a/src/components/views/pricingView/InvestmentScalePricingPane.vue +++ b/src/components/views/pricingView/InvestmentScalePricingPane.vue @@ -1,5 +1,5 @@ @@ -1118,7 +1081,7 @@ onBeforeUnmount(() => { class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> 确认删除服务 - 将逻辑删除“{{ pendingDeleteServiceName }}”,已填写的数据不会清楚,重新勾选后会恢复,是否继续? + 将逻辑删除“{{ pendingDeleteServiceName }}”,已填写的数据不会清空,重新勾选后会恢复,是否继续?
diff --git a/src/layout/tab.vue b/src/layout/tab.vue index aaf6ea3..64161f7 100644 --- a/src/layout/tab.vue +++ b/src/layout/tab.vue @@ -3,10 +3,13 @@ import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onM import type { ComponentPublicInstance } from 'vue' import draggable from 'vuedraggable' import { useTabStore } from '@/pinia/tab' +import { useZxFwPricingStore } from '@/pinia/zxFwPricing' 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, RotateCcw, X } from 'lucide-vue-next' +import { createPinia } from 'pinia'; + import localforage from 'localforage' import { AlertDialogAction, @@ -348,6 +351,7 @@ const componentMap: Record = { } const tabStore = useTabStore() +const zxFwPricingStore = useZxFwPricingStore() @@ -1098,12 +1102,16 @@ const buildExportReportPayload = async (): Promise => { const serviceId = toSafeInteger(serviceIdText) if (serviceId == null) return null - const [method1Raw, method2Raw, method3Raw, method4Raw] = await Promise.all([ - localforage.getItem>(`tzGMF-${contractId}-${serviceIdText}`), - localforage.getItem>(`ydGMF-${contractId}-${serviceIdText}`), - localforage.getItem>(`gzlF-${contractId}-${serviceIdText}`), - localforage.getItem>(`hourlyPricing-${contractId}-${serviceIdText}`) + const [method1State, method2State, method3State, method4State] = await Promise.all([ + zxFwPricingStore.loadServicePricingMethodState(contractId, serviceIdText, 'investScale'), + zxFwPricingStore.loadServicePricingMethodState(contractId, serviceIdText, 'landScale'), + zxFwPricingStore.loadServicePricingMethodState(contractId, serviceIdText, 'workload'), + zxFwPricingStore.loadServicePricingMethodState(contractId, serviceIdText, 'hourly') ]) + const method1Raw = method1State ? { detailRows: method1State.detailRows } : null + const method2Raw = method2State ? { detailRows: method2State.detailRows } : null + const method3Raw = method3State ? { detailRows: method3State.detailRows } : null + const method4Raw = method4State ? { detailRows: method4State.detailRows } : null const method1 = buildMethod1(method1Raw?.detailRows) const method2 = buildMethod2(method2Raw?.detailRows) @@ -1255,6 +1263,7 @@ const confirmImportOverride = async () => { await writeForage(localforage, normalizeEntries(payload.localforageDefault)) tabStore.resetTabs() + await tabStore.$persistNow?.() dataMenuOpen.value = false window.location.reload() } catch (error) { @@ -1275,6 +1284,7 @@ const handleReset = async () => { console.error('reset failed:', error) } finally { tabStore.resetTabs() + await tabStore.$persistNow?.() window.location.reload() } } diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts index c8fdd5d..68f9acc 100644 --- a/src/lib/pricingMethodTotals.ts +++ b/src/lib/pricingMethodTotals.ts @@ -9,6 +9,7 @@ import { import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { toFiniteNumberOrNull } from '@/lib/number' import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' +import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing' interface StoredDetailRowsState { detailRows?: T[] @@ -112,6 +113,22 @@ interface PricingMethodDefaultBuildContext { } const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' +const SERVICE_PRICING_METHODS: ServicePricingMethod[] = ['investScale', 'landScale', 'workload', 'hourly'] + +const getZxFwStoreSafely = () => { + try { + return useZxFwPricingStore() + } catch { + return null + } +} + +const toStoredDetailRowsState = (state: { detailRows?: TRow[] } | null | undefined): StoredDetailRowsState | null => { + if (!state || !Array.isArray(state.detailRows)) return null + return { + detailRows: JSON.parse(JSON.stringify(state.detailRows)) + } +} const hasOwn = (obj: unknown, key: string) => Object.prototype.hasOwnProperty.call(obj || {}, key) @@ -550,6 +567,15 @@ export const getPricingMethodDetailDbKeys = ( serviceId: string | number ): PricingMethodDetailDbKeys => { const normalizedServiceId = String(serviceId) + const store = getZxFwStoreSafely() + if (store) { + return { + investScale: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'investScale'), + landScale: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'landScale'), + workload: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'workload'), + hourly: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'hourly') + } + } return { investScale: `tzGMF-${contractId}-${normalizedServiceId}`, landScale: `ydGMF-${contractId}-${normalizedServiceId}`, @@ -628,12 +654,19 @@ export const persistDefaultPricingMethodDetailRowsForServices = async (params: { if (uniqueServiceIds.length === 0) return const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options) + const store = getZxFwStoreSafely() await Promise.all( uniqueServiceIds.map(async serviceId => { const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) const defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context) - console.log(dbKeys,defaultRows) + if (store) { + for (const method of SERVICE_PRICING_METHODS) { + store.setServicePricingMethodState(params.contractId, serviceId, method, { + detailRows: defaultRows[method] + }, { force: true }) + } + } await Promise.all([ localforage.setItem(dbKeys.investScale, { detailRows: defaultRows.investScale }), localforage.setItem(dbKeys.landScale, { detailRows: defaultRows.landScale }), @@ -654,22 +687,32 @@ export const getPricingMethodTotalsForService = async (params: { const consultFactorDbKey = `ht-consult-category-factor-v1-${params.contractId}` const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}` const baseInfoDbKey = 'xm-base-info-v1' - const investDbKey = `tzGMF-${params.contractId}-${serviceId}` - const landDbKey = `ydGMF-${params.contractId}-${serviceId}` - const workloadDbKey = `gzlF-${params.contractId}-${serviceId}` - const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}` + const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) + const store = getZxFwStoreSafely() - const [investData, landData, workloadData, hourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([ - localforage.getItem(investDbKey), - localforage.getItem(landDbKey), - localforage.getItem(workloadDbKey), - localforage.getItem(hourlyDbKey), + const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([ + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'investScale') || Promise.resolve(null), + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'landScale') || Promise.resolve(null), + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'workload') || Promise.resolve(null), + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'hourly') || Promise.resolve(null), localforage.getItem(htDbKey), localforage.getItem(consultFactorDbKey), localforage.getItem(majorFactorDbKey), localforage.getItem(baseInfoDbKey) ]) + const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([ + storeInvestData ? Promise.resolve(null) : localforage.getItem(dbKeys.investScale), + storeLandData ? Promise.resolve(null) : localforage.getItem(dbKeys.landScale), + storeWorkloadData ? Promise.resolve(null) : localforage.getItem(dbKeys.workload), + storeHourlyData ? Promise.resolve(null) : localforage.getItem(dbKeys.hourly) + ]) + + const investData = toStoredDetailRowsState(storeInvestData) || investDataFallback + const landData = toStoredDetailRowsState(storeLandData) || landDataFallback + const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback + const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback + const consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData) const majorFactorMap = buildMajorFactorMap(majorFactorData) const onlyCostScale = isOnlyCostScaleService(serviceId) @@ -744,16 +787,27 @@ export const ensurePricingMethodDetailRowsForServices = async (params: { if (uniqueServiceIds.length === 0) return const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options) + const store = getZxFwStoreSafely() await Promise.all( uniqueServiceIds.map(async serviceId => { const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) - const [investData, landData, workloadData, hourlyData] = await Promise.all([ - localforage.getItem(dbKeys.investScale), - localforage.getItem(dbKeys.landScale), - localforage.getItem(dbKeys.workload), - localforage.getItem(dbKeys.hourly) + const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData] = await Promise.all([ + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'investScale') || Promise.resolve(null), + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'landScale') || Promise.resolve(null), + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'workload') || Promise.resolve(null), + store?.loadServicePricingMethodState>(params.contractId, serviceId, 'hourly') || Promise.resolve(null) ]) + const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([ + storeInvestData ? Promise.resolve(null) : localforage.getItem(dbKeys.investScale), + storeLandData ? Promise.resolve(null) : localforage.getItem(dbKeys.landScale), + storeWorkloadData ? Promise.resolve(null) : localforage.getItem(dbKeys.workload), + storeHourlyData ? Promise.resolve(null) : localforage.getItem(dbKeys.hourly) + ]) + const investData = toStoredDetailRowsState(storeInvestData) || investDataFallback + const landData = toStoredDetailRowsState(storeLandData) || landDataFallback + const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback + const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback const shouldInitInvest = !Array.isArray(investData?.detailRows) || investData!.detailRows!.length === 0 const shouldInitLand = !Array.isArray(landData?.detailRows) || landData!.detailRows!.length === 0 @@ -770,18 +824,38 @@ export const ensurePricingMethodDetailRowsForServices = async (params: { } if (shouldInitInvest) { + if (store) { + store.setServicePricingMethodState(params.contractId, serviceId, 'investScale', { + detailRows: getDefaultRows().investScale + }, { force: true }) + } writeTasks.push(localforage.setItem(dbKeys.investScale, { detailRows: getDefaultRows().investScale })) } if (shouldInitLand) { + if (store) { + store.setServicePricingMethodState(params.contractId, serviceId, 'landScale', { + detailRows: getDefaultRows().landScale + }, { force: true }) + } writeTasks.push(localforage.setItem(dbKeys.landScale, { detailRows: getDefaultRows().landScale })) } if (shouldInitWorkload) { + if (store) { + store.setServicePricingMethodState(params.contractId, serviceId, 'workload', { + detailRows: getDefaultRows().workload + }, { force: true }) + } writeTasks.push(localforage.setItem(dbKeys.workload, { detailRows: getDefaultRows().workload })) } if (shouldInitHourly) { + if (store) { + store.setServicePricingMethodState(params.contractId, serviceId, 'hourly', { + detailRows: getDefaultRows().hourly + }, { force: true }) + } writeTasks.push(localforage.setItem(dbKeys.hourly, { detailRows: getDefaultRows().hourly })) } diff --git a/src/lib/zxFwPricingSync.ts b/src/lib/zxFwPricingSync.ts index 07ee726..ef3acad 100644 --- a/src/lib/zxFwPricingSync.ts +++ b/src/lib/zxFwPricingSync.ts @@ -2,8 +2,6 @@ import { useZxFwPricingStore, type ZxFwPricingField } from '@/pinia/zxFwPricing' export type { ZxFwPricingField } from '@/pinia/zxFwPricing' -export const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main' - export const syncPricingTotalToZxFw = async (params: { contractId: string serviceId: string | number diff --git a/src/main.ts b/src/main.ts index 680373b..75f42e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,7 @@ import { TreeDataModule,ContextMenuModule,ValidationModule } from 'ag-grid-enterprise' import { createPinia } from 'pinia' -import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +import piniaPersistedstate from '@/pinia/Plugin/indexdb' import { createApp } from 'vue' import App from './App.vue' import './style.css' @@ -51,7 +51,12 @@ const AG_GRID_MODULES = [ ] const pinia = createPinia() -pinia.use(piniaPluginPersistedstate) +pinia.use( + piniaPersistedstate({ + storeName: 'pinia', + mode: 'multiple' + }) +) // 在应用启动时一次性注册 AG Grid 运行所需模块。 ModuleRegistry.registerModules(AG_GRID_MODULES) diff --git a/src/pinia/Plugin/indexdb.ts b/src/pinia/Plugin/indexdb.ts new file mode 100644 index 0000000..5b87bd3 --- /dev/null +++ b/src/pinia/Plugin/indexdb.ts @@ -0,0 +1,124 @@ +import type { PiniaPluginContext } from 'pinia' +import localforage from 'localforage' + +export type PersistOption = boolean | { + key?: string + debounce?: number +} + +type PersistStoreExt = { + $persistNow?: () => Promise + $clearPersisted?: () => Promise +} + +type PiniaStorageConfig = LocalForageOptions & { + mode?: 'single' | 'multiple' + debounce?: number +} + +const baseConfig: PiniaStorageConfig = { + driver: localforage.INDEXEDDB, + name: 'DB', + version: 1, + description: 'pinia persisted storage', + storeName: 'pinia-storage', + debounce: 300 +} + +let singleStore: LocalForage | null = null +const multipleStores = new Map() + +const getStore = (mode: 'single' | 'multiple', storeName: string, config: LocalForageOptions) => { + if (mode === 'single') { + if (!singleStore) { + singleStore = localforage.createInstance({ ...baseConfig, ...config }) + } + return singleStore + } + if (!multipleStores.has(storeName)) { + multipleStores.set( + storeName, + localforage.createInstance({ ...baseConfig, ...config, storeName }) + ) + } + return multipleStores.get(storeName) as LocalForage +} + +const resolvePersistOptions = (persist: PersistOption | undefined) => { + if (!persist) return null + if (persist === true) return {} + return persist +} + +export default (config?: PiniaStorageConfig) => { + const finalConfig = { ...baseConfig, ...(config || {}) } + const { mode = 'single', debounce = 300, ...forageConfig } = finalConfig + + return (context: PiniaPluginContext) => { + const persistOptions = resolvePersistOptions(context.options.persist as PersistOption | undefined) + if (!persistOptions) return + + const storeId = context.store.$id + const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage' + const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName + const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName) + const lf = getStore(mode, resolvedStoreName, forageConfig) + const persistDebounce = persistOptions.debounce ?? debounce + let timer: ReturnType | null = null + let hydrating = false + let userMutatedBeforeHydrate = false + const writeState = (state: unknown) => lf.setItem(key, JSON.parse(JSON.stringify(state))) + const storeExt = context.store as typeof context.store & PersistStoreExt + + storeExt.$persistNow = async () => { + if (timer) { + clearTimeout(timer) + timer = null + } + try { + await writeState(context.store.$state) + } catch (error) { + console.error('pinia persist failed:', error) + } + } + + storeExt.$clearPersisted = async () => { + if (timer) { + clearTimeout(timer) + timer = null + } + try { + await lf.removeItem(key) + } catch (error) { + console.error('pinia clear persisted failed:', error) + } + } + + context.store.$subscribe( + (_mutation, state) => { + if (!hydrating) userMutatedBeforeHydrate = true + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + void writeState(state).catch(error => { + console.error('pinia persist failed:', error) + }) + }, Math.max(0, persistDebounce)) + }, + { detached: true } + ) + + void lf.getItem>(key) + .then(state => { + if (!state || typeof state !== 'object') return + // 若在异步hydrate返回前,store已被用户修改(如removeTab),不再回填旧缓存覆盖当前状态。 + if (userMutatedBeforeHydrate) return + hydrating = true + context.store.$patch(state as any) + hydrating = false + }) + .catch(error => { + hydrating = false + console.error('pinia hydrate failed:', error) + }) + } +} diff --git a/src/pinia/Plugin/types.d.ts b/src/pinia/Plugin/types.d.ts new file mode 100644 index 0000000..95a1193 --- /dev/null +++ b/src/pinia/Plugin/types.d.ts @@ -0,0 +1,13 @@ +import 'pinia' +import type { PersistOption } from './indexdb' + +declare module 'pinia' { + export interface DefineStoreOptionsBase { + persist?: PersistOption + } + + export interface PiniaCustomProperties { + $persistNow?: () => Promise + $clearPersisted?: () => Promise + } +} diff --git a/src/pinia/htFeeMethodReload.ts b/src/pinia/htFeeMethodReload.ts deleted file mode 100644 index 1fb5bf5..0000000 --- a/src/pinia/htFeeMethodReload.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineStore } from 'pinia' - -export interface HtFeeMethodReloadDetail { - mainStorageKey: string - rowId: string - at: number -} - -interface HtFeeMethodReloadState { - seq: number - lastEvent: HtFeeMethodReloadDetail | null -} - -export const useHtFeeMethodReloadStore = defineStore('htFeeMethodReload', { - state: (): HtFeeMethodReloadState => ({ - seq: 0, - lastEvent: null - }), - actions: { - emit(mainStorageKey: string, rowId: string) { - - const normalizedMainStorageKey = String(mainStorageKey || '').trim() - const normalizedRowId = String(rowId || '').trim() - if (!normalizedMainStorageKey || !normalizedRowId) return - this.lastEvent = { - mainStorageKey: normalizedMainStorageKey, - rowId: normalizedRowId, - at: Date.now() - } - this.seq += 1 - } - } -}) - diff --git a/src/pinia/pricingPaneReload.ts b/src/pinia/pricingPaneReload.ts deleted file mode 100644 index 3ee6749..0000000 --- a/src/pinia/pricingPaneReload.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { defineStore } from 'pinia' - -export interface PricingPaneReloadDetail { - contractId: string - serviceId: string - at: number -} - -interface PricingPaneReloadState { - seq: number - lastEvent: PricingPaneReloadDetail | null - persistedSeq: number - lastPersistedEvent: PricingPaneReloadDetail | null -} - -const toKey = (value: string | number) => String(value) - -export const usePricingPaneReloadStore = defineStore('pricingPaneReload', { - state: (): PricingPaneReloadState => ({ - seq: 0, - lastEvent: null, - persistedSeq: 0, - lastPersistedEvent: null - }), - actions: { - emit(contractId: string, serviceId: string | number) { - this.lastEvent = { - contractId: toKey(contractId), - serviceId: toKey(serviceId), - at: Date.now() - } - this.seq += 1 - }, - emitPersisted(contractId: string, serviceId: string | number) { - this.lastPersistedEvent = { - contractId: toKey(contractId), - serviceId: toKey(serviceId), - at: Date.now() - } - this.persistedSeq += 1 - } - } -}) - -export const matchPricingPaneReload = ( - detail: PricingPaneReloadDetail | null | undefined, - contractId: string, - serviceId: string | number -) => { - if (!detail) return false - return detail.contractId === toKey(contractId) && detail.serviceId === toKey(serviceId) -} diff --git a/src/pinia/tab.ts b/src/pinia/tab.ts index 8405b50..acd4151 100644 --- a/src/pinia/tab.ts +++ b/src/pinia/tab.ts @@ -65,6 +65,7 @@ export const useTabStore = defineStore( const closeAllTabs = () => { tabs.value = createDefaultTabs() + console.log(tabs.value) activeTabId.value = HOME_TAB_ID } @@ -106,10 +107,6 @@ export const useTabStore = defineStore( } }, { - persist: { - key: 'tabs', - storage: localStorage, - pick: ['tabs', 'activeTabId'] - } + persist: true } ) diff --git a/src/pinia/zxFwPricing.ts b/src/pinia/zxFwPricing.ts index d8abaa9..ecd7d32 100644 --- a/src/pinia/zxFwPricing.ts +++ b/src/pinia/zxFwPricing.ts @@ -5,6 +5,7 @@ import { addNumbers } from '@/lib/decimal' import { toFiniteNumberOrNull } from '@/lib/number' export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly' +export type ServicePricingMethod = ZxFwPricingField export interface ZxFwDetailRow { id: string @@ -24,10 +25,44 @@ export interface ZxFwState { detailRows: ZxFwDetailRow[] } +export interface ServicePricingMethodState { + detailRows: TRow[] + projectCount?: number | null +} + +export interface ServicePricingState { + investScale?: ServicePricingMethodState + landScale?: ServicePricingMethodState + workload?: ServicePricingMethodState + hourly?: ServicePricingMethodState +} + +export type HtFeeMethodType = 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee' + +export interface HtFeeMainState { + detailRows: TRow[] +} + +export type HtFeeMethodPayload = unknown + const FIXED_ROW_ID = 'fixed-budget-c' +const METHOD_STORAGE_PREFIX_MAP: Record = { + investScale: 'tzGMF', + landScale: 'ydGMF', + workload: 'gzlF', + hourly: 'hourlyPricing' +} +const STORAGE_PREFIX_METHOD_MAP = new Map( + Object.entries(METHOD_STORAGE_PREFIX_MAP).map(([method, prefix]) => [prefix, method as ServicePricingMethod]) +) +const HT_FEE_MAIN_KEY_PATTERN = /^htExtraFee-(.+)-(additional-work|reserve)$/ +const HT_FEE_METHOD_TYPES: HtFeeMethodType[] = ['rate-fee', 'hourly-fee', 'quantity-unit-price-fee'] const toKey = (contractId: string | number) => String(contractId || '').trim() +const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim() const dbKeyOf = (contractId: string) => `zxFW-${contractId}` +const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) => + `${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}` const round3 = (value: number) => Number(value.toFixed(3)) const toNumberOrZero = (value: unknown) => { const numeric = Number(value) @@ -99,30 +134,648 @@ const cloneState = (state: ZxFwState): ZxFwState => ({ detailRows: state.detailRows.map(row => ({ ...row })) }) -const loadTasks = new Map>() -const persistQueues = new Map>() +const isSameStringArray = (a: string[] | undefined, b: string[] | undefined) => { + const left = Array.isArray(a) ? a : [] + const right = Array.isArray(b) ? b : [] + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false + } + return true +} + +const isSameNullableNumber = (a: number | null | undefined, b: number | null | undefined) => { + const left = toFiniteNumberOrNull(a) + const right = toFiniteNumberOrNull(b) + return left === right +} + +const isSameRows = (a: ZxFwDetailRow[] | undefined, b: ZxFwDetailRow[] | undefined) => { + const left = Array.isArray(a) ? a : [] + const right = Array.isArray(b) ? b : [] + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i += 1) { + const l = left[i] + const r = right[i] + if (!l || !r) return false + if (l.id !== r.id) return false + if ((l.code || '') !== (r.code || '')) return false + if ((l.name || '') !== (r.name || '')) return false + if (!isSameNullableNumber(l.investScale, r.investScale)) return false + if (!isSameNullableNumber(l.landScale, r.landScale)) return false + if (!isSameNullableNumber(l.workload, r.workload)) return false + if (!isSameNullableNumber(l.hourly, r.hourly)) return false + if (!isSameNullableNumber(l.subtotal, r.subtotal)) return false + } + return true +} + +const isSameState = (a: ZxFwState | null | undefined, b: ZxFwState | null | undefined) => { + if (!a || !b) return false + if (!isSameStringArray(a.selectedIds, b.selectedIds)) return false + if (!isSameStringArray(a.selectedCodes, b.selectedCodes)) return false + return isSameRows(a.detailRows, b.detailRows) +} + +const loadTasks = new Map>() +const keyLoadTasks = new Map>() + +const toKeySnapshot = (value: unknown) => JSON.stringify(value ?? null) +const cloneAny = (value: T): T => { + if (value == null) return value + return JSON.parse(JSON.stringify(value)) as T +} + +const normalizeProjectCount = (value: unknown) => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return null + return Math.max(1, Math.floor(numeric)) +} + +const normalizeServiceMethodState = ( + payload: Partial | null | undefined +): ServicePricingMethodState => ({ + detailRows: Array.isArray(payload?.detailRows) ? cloneAny(payload.detailRows) : [], + projectCount: normalizeProjectCount(payload?.projectCount) +}) + +const parseServiceMethodStorageKey = (keyRaw: string | number) => { + const key = toKey(keyRaw) + if (!key) return null + const firstDash = key.indexOf('-') + if (firstDash <= 0 || firstDash >= key.length - 1) return null + const prefix = key.slice(0, firstDash) + const method = STORAGE_PREFIX_METHOD_MAP.get(prefix) + if (!method) return null + const rest = key.slice(firstDash + 1) + const splitIndex = rest.lastIndexOf('-') + if (splitIndex <= 0 || splitIndex >= rest.length - 1) return null + const contractId = rest.slice(0, splitIndex).trim() + const serviceId = rest.slice(splitIndex + 1).trim() + if (!contractId || !serviceId) return null + return { key, method, contractId, serviceId } +} + +const normalizeHtFeeMainState = (payload: Partial | null | undefined): HtFeeMainState => ({ + detailRows: Array.isArray(payload?.detailRows) ? cloneAny(payload.detailRows) : [] +}) + +const parseHtFeeMainStorageKey = (keyRaw: string | number) => { + const key = toKey(keyRaw) + if (!key) return null + const match = HT_FEE_MAIN_KEY_PATTERN.exec(key) + if (!match) return null + const contractId = String(match[1] || '').trim() + const feeType = String(match[2] || '').trim() + if (!contractId || !feeType) return null + return { + key, + contractId, + feeType, + mainStorageKey: key + } +} + +const parseHtFeeMethodStorageKey = (keyRaw: string | number) => { + const key = toKey(keyRaw) + if (!key) return null + for (const method of HT_FEE_METHOD_TYPES) { + const suffix = `-${method}` + if (!key.endsWith(suffix)) continue + const withoutMethod = key.slice(0, key.length - suffix.length) + const mainMatch = /^(htExtraFee-.+-(?:additional-work|reserve))-(.+)$/.exec(withoutMethod) + if (!mainMatch) continue + const mainStorageKey = String(mainMatch[1] || '').trim() + const rowId = String(mainMatch[2] || '').trim() + if (!mainStorageKey || !rowId) continue + return { + key, + mainStorageKey, + rowId, + method + } + } + return null +} export const useZxFwPricingStore = defineStore('zxFwPricing', () => { const contracts = ref>({}) const contractVersions = ref>({}) + const contractLoaded = ref>({}) + const servicePricingStates = ref>>({}) + const htFeeMainStates = ref>({}) + const htFeeMethodStates = ref>>>>({}) + const keyedStates = ref>({}) + const keyedLoaded = ref>({}) + const keyVersions = ref>({}) + const keySnapshots = ref>({}) const touchVersion = (contractId: string) => { contractVersions.value[contractId] = (contractVersions.value[contractId] || 0) + 1 } + const touchKeyVersion = (key: string) => { + keyVersions.value[key] = (keyVersions.value[key] || 0) + 1 + } - const queuePersist = async (contractId: string) => { - const prev = persistQueues.get(contractId) || Promise.resolve() - const next = prev - .then(async () => { - const current = contracts.value[contractId] - if (!current) return - await localforage.setItem(dbKeyOf(contractId), cloneState(current)) + const ensureServicePricingState = (contractIdRaw: string | number, serviceIdRaw: string | number) => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return null + if (!servicePricingStates.value[contractId]) { + servicePricingStates.value[contractId] = {} + } + if (!servicePricingStates.value[contractId][serviceId]) { + servicePricingStates.value[contractId][serviceId] = {} + } + return servicePricingStates.value[contractId][serviceId] + } + + const setServiceMethodStateInMemory = ( + contractIdRaw: string | number, + serviceIdRaw: string | number, + method: ServicePricingMethod, + payload: Partial | null | undefined + ) => { + const state = ensureServicePricingState(contractIdRaw, serviceIdRaw) + if (!state) return null + if (!payload) { + delete state[method] + return null + } + state[method] = normalizeServiceMethodState(payload) + return state[method] || null + } + + const getServicePricingMethodState = ( + contractIdRaw: string | number, + serviceIdRaw: string | number, + method: ServicePricingMethod + ) => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return null + return (servicePricingStates.value[contractId]?.[serviceId]?.[method] as ServicePricingMethodState | undefined) || null + } + + const setServicePricingMethodState = ( + contractIdRaw: string | number, + serviceIdRaw: string | number, + method: ServicePricingMethod, + payload: Partial> | null | undefined, + options?: { + force?: boolean + syncKeyState?: boolean + } + ) => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return false + + const storageKey = serviceMethodDbKeyOf(contractId, serviceId, method) + const force = options?.force === true + const syncKeyState = options?.syncKeyState !== false + const normalizedPayload = payload == null ? null : normalizeServiceMethodState(payload) + const prevSnapshot = toKeySnapshot(getServicePricingMethodState(contractId, serviceId, method)) + const nextSnapshot = toKeySnapshot(normalizedPayload) + if (!force && prevSnapshot === nextSnapshot) return false + + setServiceMethodStateInMemory(contractId, serviceId, method, normalizedPayload) + + if (syncKeyState) { + if (normalizedPayload == null) { + delete keyedStates.value[storageKey] + keyedLoaded.value[storageKey] = true + keySnapshots.value[storageKey] = toKeySnapshot(null) + touchKeyVersion(storageKey) + } else { + keyedStates.value[storageKey] = cloneAny(normalizedPayload) + keyedLoaded.value[storageKey] = true + keySnapshots.value[storageKey] = toKeySnapshot(normalizedPayload) + touchKeyVersion(storageKey) + } + } + return true + } + + const loadServicePricingMethodState = async ( + contractIdRaw: string | number, + serviceIdRaw: string | number, + method: ServicePricingMethod, + force = false + ): Promise | null> => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return null + + if (!force) { + const existing = getServicePricingMethodState(contractId, serviceId, method) + if (existing) return existing + } + + const storageKey = serviceMethodDbKeyOf(contractId, serviceId, method) + const payload = await loadKeyState>(storageKey, force) + if (!payload) { + setServiceMethodStateInMemory(contractId, serviceId, method, null) + return null + } + setServicePricingMethodState(contractId, serviceId, method, payload, { force: true, syncKeyState: false }) + return getServicePricingMethodState(contractId, serviceId, method) + } + + const removeServicePricingMethodState = ( + contractIdRaw: string | number, + serviceIdRaw: string | number, + method: ServicePricingMethod + ) => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return false + const storageKey = serviceMethodDbKeyOf(contractId, serviceId, method) + const had = getServicePricingMethodState(contractId, serviceId, method) != null + setServiceMethodStateInMemory(contractId, serviceId, method, null) + delete keyedStates.value[storageKey] + keyedLoaded.value[storageKey] = true + keySnapshots.value[storageKey] = toKeySnapshot(null) + touchKeyVersion(storageKey) + return had + } + + const getServicePricingStorageKey = ( + contractIdRaw: string | number, + serviceIdRaw: string | number, + method: ServicePricingMethod + ) => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return '' + return serviceMethodDbKeyOf(contractId, serviceId, method) + } + + const getServicePricingStorageKeys = (contractIdRaw: string | number, serviceIdRaw: string | number) => { + const contractId = toKey(contractIdRaw) + const serviceId = toServiceKey(serviceIdRaw) + if (!contractId || !serviceId) return [] as string[] + return (Object.keys(METHOD_STORAGE_PREFIX_MAP) as ServicePricingMethod[]).map(method => + serviceMethodDbKeyOf(contractId, serviceId, method) + ) + } + + const removeAllServicePricingMethodStates = (contractIdRaw: string | number, serviceIdRaw: string | number) => { + let changed = false + for (const method of Object.keys(METHOD_STORAGE_PREFIX_MAP) as ServicePricingMethod[]) { + changed = removeServicePricingMethodState(contractIdRaw, serviceIdRaw, method) || changed + } + return changed + } + + const getHtFeeMainState = (mainStorageKeyRaw: string | number): HtFeeMainState | null => { + const mainStorageKey = toKey(mainStorageKeyRaw) + if (!mainStorageKey) return null + return (htFeeMainStates.value[mainStorageKey] as HtFeeMainState | undefined) || null + } + + const setHtFeeMainState = ( + mainStorageKeyRaw: string | number, + payload: Partial> | null | undefined, + options?: { + force?: boolean + syncKeyState?: boolean + } + ) => { + const mainStorageKey = toKey(mainStorageKeyRaw) + if (!mainStorageKey) return false + const force = options?.force === true + const syncKeyState = options?.syncKeyState !== false + const normalized = payload == null ? null : normalizeHtFeeMainState(payload) + const prevSnapshot = toKeySnapshot(getHtFeeMainState(mainStorageKey)) + const nextSnapshot = toKeySnapshot(normalized) + if (!force && prevSnapshot === nextSnapshot) return false + + if (normalized == null) { + delete htFeeMainStates.value[mainStorageKey] + } else { + htFeeMainStates.value[mainStorageKey] = normalized + } + + if (syncKeyState) { + if (normalized == null) { + delete keyedStates.value[mainStorageKey] + keyedLoaded.value[mainStorageKey] = true + keySnapshots.value[mainStorageKey] = toKeySnapshot(null) + } else { + keyedStates.value[mainStorageKey] = cloneAny(normalized) + keyedLoaded.value[mainStorageKey] = true + keySnapshots.value[mainStorageKey] = toKeySnapshot(normalized) + } + touchKeyVersion(mainStorageKey) + } + return true + } + + const loadHtFeeMainState = async ( + mainStorageKeyRaw: string | number, + force = false + ): Promise | null> => { + const mainStorageKey = toKey(mainStorageKeyRaw) + if (!mainStorageKey) return null + if (!force) { + const existing = getHtFeeMainState(mainStorageKey) + if (existing) return existing + } + const payload = await loadKeyState>(mainStorageKey, force) + if (!payload) { + setHtFeeMainState(mainStorageKey, null, { force: true, syncKeyState: false }) + return null + } + setHtFeeMainState(mainStorageKey, payload, { force: true, syncKeyState: false }) + return getHtFeeMainState(mainStorageKey) + } + + const removeHtFeeMainState = (mainStorageKeyRaw: string | number) => + setHtFeeMainState(mainStorageKeyRaw, null) + + const ensureHtFeeMethodStateContainer = (mainStorageKeyRaw: string | number, rowIdRaw: string | number) => { + const mainStorageKey = toKey(mainStorageKeyRaw) + const rowId = toKey(rowIdRaw) + if (!mainStorageKey || !rowId) return null + if (!htFeeMethodStates.value[mainStorageKey]) { + htFeeMethodStates.value[mainStorageKey] = {} + } + if (!htFeeMethodStates.value[mainStorageKey][rowId]) { + htFeeMethodStates.value[mainStorageKey][rowId] = {} + } + return htFeeMethodStates.value[mainStorageKey][rowId] + } + + const getHtFeeMethodStorageKey = ( + mainStorageKeyRaw: string | number, + rowIdRaw: string | number, + method: HtFeeMethodType + ) => { + const mainStorageKey = toKey(mainStorageKeyRaw) + const rowId = toKey(rowIdRaw) + if (!mainStorageKey || !rowId) return '' + return `${mainStorageKey}-${rowId}-${method}` + } + + const getHtFeeMethodState = ( + mainStorageKeyRaw: string | number, + rowIdRaw: string | number, + method: HtFeeMethodType + ): TPayload | null => { + const mainStorageKey = toKey(mainStorageKeyRaw) + const rowId = toKey(rowIdRaw) + if (!mainStorageKey || !rowId) return null + const value = htFeeMethodStates.value[mainStorageKey]?.[rowId]?.[method] + return value == null ? null : (cloneAny(value) as TPayload) + } + + const setHtFeeMethodState = ( + mainStorageKeyRaw: string | number, + rowIdRaw: string | number, + method: HtFeeMethodType, + payload: TPayload | null | undefined, + options?: { + force?: boolean + syncKeyState?: boolean + } + ) => { + const mainStorageKey = toKey(mainStorageKeyRaw) + const rowId = toKey(rowIdRaw) + if (!mainStorageKey || !rowId) return false + const storageKey = getHtFeeMethodStorageKey(mainStorageKey, rowId, method) + if (!storageKey) return false + const force = options?.force === true + const syncKeyState = options?.syncKeyState !== false + const prevSnapshot = toKeySnapshot(getHtFeeMethodState(mainStorageKey, rowId, method)) + const nextSnapshot = toKeySnapshot(payload ?? null) + if (!force && prevSnapshot === nextSnapshot) return false + + if (payload == null) { + const byRow = htFeeMethodStates.value[mainStorageKey]?.[rowId] + if (byRow) { + delete byRow[method] + if (Object.keys(byRow).length === 0) { + delete htFeeMethodStates.value[mainStorageKey][rowId] + if (Object.keys(htFeeMethodStates.value[mainStorageKey]).length === 0) { + delete htFeeMethodStates.value[mainStorageKey] + } + } + } + } else { + const container = ensureHtFeeMethodStateContainer(mainStorageKey, rowId) + if (!container) return false + container[method] = cloneAny(payload) + } + + if (syncKeyState) { + if (payload == null) { + delete keyedStates.value[storageKey] + keyedLoaded.value[storageKey] = true + keySnapshots.value[storageKey] = toKeySnapshot(null) + } else { + keyedStates.value[storageKey] = cloneAny(payload) + keyedLoaded.value[storageKey] = true + keySnapshots.value[storageKey] = toKeySnapshot(payload) + } + touchKeyVersion(storageKey) + } + return true + } + + const loadHtFeeMethodState = async ( + mainStorageKeyRaw: string | number, + rowIdRaw: string | number, + method: HtFeeMethodType, + force = false + ): Promise => { + const mainStorageKey = toKey(mainStorageKeyRaw) + const rowId = toKey(rowIdRaw) + if (!mainStorageKey || !rowId) return null + if (!force) { + const existing = getHtFeeMethodState(mainStorageKey, rowId, method) + if (existing != null) return existing + } + const storageKey = getHtFeeMethodStorageKey(mainStorageKey, rowId, method) + const payload = await loadKeyState(storageKey, force) + if (payload == null) { + setHtFeeMethodState(mainStorageKey, rowId, method, null, { force: true, syncKeyState: false }) + return null + } + setHtFeeMethodState(mainStorageKey, rowId, method, payload, { force: true, syncKeyState: false }) + return getHtFeeMethodState(mainStorageKey, rowId, method) + } + + const removeHtFeeMethodState = ( + mainStorageKeyRaw: string | number, + rowIdRaw: string | number, + method: HtFeeMethodType + ) => setHtFeeMethodState(mainStorageKeyRaw, rowIdRaw, method, null) + + const getKeyState = (keyRaw: string | number): T | null => { + const key = toKey(keyRaw) + if (!key) return null + const serviceMeta = parseServiceMethodStorageKey(key) + if (serviceMeta) { + const methodState = getServicePricingMethodState( + serviceMeta.contractId, + serviceMeta.serviceId, + serviceMeta.method + ) + if (methodState != null) return cloneAny(methodState as T) + } + const htMethodMeta = parseHtFeeMethodStorageKey(key) + if (htMethodMeta) { + const methodState = getHtFeeMethodState( + htMethodMeta.mainStorageKey, + htMethodMeta.rowId, + htMethodMeta.method + ) + if (methodState != null) return cloneAny(methodState as T) + } + const htMainMeta = parseHtFeeMainStorageKey(key) + if (htMainMeta) { + const mainState = getHtFeeMainState(htMainMeta.mainStorageKey) + if (mainState != null) return cloneAny(mainState as T) + } + if (!Object.prototype.hasOwnProperty.call(keyedStates.value, key)) return null + return cloneAny(keyedStates.value[key] as T) + } + + const loadKeyState = async (keyRaw: string | number, force = false): Promise => { + const key = toKey(keyRaw) + if (!key) return null + const hasState = Object.prototype.hasOwnProperty.call(keyedStates.value, key) + if (!force && hasState) { + keyedLoaded.value[key] = true + if (!keySnapshots.value[key]) { + keySnapshots.value[key] = toKeySnapshot(keyedStates.value[key]) + } + return getKeyState(key) + } + // 注意:当内存中没有该key时,不应仅凭keyedLoaded短路返回null。 + // 该key可能被其他逻辑直接写入了IndexedDB(例如默认明细生成)。 + if (!force && keyLoadTasks.has(key)) return keyLoadTasks.get(key) as Promise + + const task = (async () => { + const raw = await localforage.getItem(key) + const nextSnapshot = toKeySnapshot(raw) + const prevSnapshot = keySnapshots.value[key] + keyedLoaded.value[key] = true + if (prevSnapshot !== nextSnapshot || !Object.prototype.hasOwnProperty.call(keyedStates.value, key)) { + keyedStates.value[key] = cloneAny(raw) + keySnapshots.value[key] = nextSnapshot + touchKeyVersion(key) + } + const serviceMeta = parseServiceMethodStorageKey(key) + if (serviceMeta) { + setServicePricingMethodState( + serviceMeta.contractId, + serviceMeta.serviceId, + serviceMeta.method, + raw as Partial, + { force: true, syncKeyState: false } + ) + } + const htMethodMeta = parseHtFeeMethodStorageKey(key) + if (htMethodMeta) { + setHtFeeMethodState( + htMethodMeta.mainStorageKey, + htMethodMeta.rowId, + htMethodMeta.method, + raw, + { force: true, syncKeyState: false } + ) + } + const htMainMeta = parseHtFeeMainStorageKey(key) + if (htMainMeta) { + setHtFeeMainState(htMainMeta.mainStorageKey, raw as Partial, { force: true, syncKeyState: false }) + } + return getKeyState(key) + })() + + keyLoadTasks.set(key, task) + try { + return await task + } finally { + keyLoadTasks.delete(key) + } + } + + const setKeyState = ( + keyRaw: string | number, + value: T, + options?: { + force?: boolean + } + ) => { + const key = toKey(keyRaw) + if (!key) return false + const force = options?.force === true + const nextSnapshot = toKeySnapshot(value) + const prevSnapshot = keySnapshots.value[key] + keyedLoaded.value[key] = true + if (!force && prevSnapshot === nextSnapshot) return false + const serviceMeta = parseServiceMethodStorageKey(key) + if (serviceMeta) { + setServicePricingMethodState( + serviceMeta.contractId, + serviceMeta.serviceId, + serviceMeta.method, + value as Partial, + { force: true, syncKeyState: false } + ) + } + const htMethodMeta = parseHtFeeMethodStorageKey(key) + if (htMethodMeta) { + setHtFeeMethodState( + htMethodMeta.mainStorageKey, + htMethodMeta.rowId, + htMethodMeta.method, + value, + { force: true, syncKeyState: false } + ) + } + const htMainMeta = parseHtFeeMainStorageKey(key) + if (htMainMeta) { + setHtFeeMainState(htMainMeta.mainStorageKey, value as Partial, { force: true, syncKeyState: false }) + } + keyedStates.value[key] = cloneAny(value) + keySnapshots.value[key] = nextSnapshot + touchKeyVersion(key) + return true + } + + const removeKeyState = (keyRaw: string | number) => { + const key = toKey(keyRaw) + if (!key) return false + const serviceMeta = parseServiceMethodStorageKey(key) + if (serviceMeta) { + setServiceMethodStateInMemory(serviceMeta.contractId, serviceMeta.serviceId, serviceMeta.method, null) + } + const htMethodMeta = parseHtFeeMethodStorageKey(key) + if (htMethodMeta) { + setHtFeeMethodState(htMethodMeta.mainStorageKey, htMethodMeta.rowId, htMethodMeta.method, null, { + force: true, + syncKeyState: false }) - .catch(error => { - console.error('zxFwPricing persist failed:', error) - }) - persistQueues.set(contractId, next) - await next + } + const htMainMeta = parseHtFeeMainStorageKey(key) + if (htMainMeta) { + setHtFeeMainState(htMainMeta.mainStorageKey, null, { force: true, syncKeyState: false }) + } + const hadValue = Object.prototype.hasOwnProperty.call(keyedStates.value, key) + delete keyedStates.value[key] + keyedLoaded.value[key] = true + keySnapshots.value[key] = toKeySnapshot(null) + touchKeyVersion(key) + return hadValue + } + + const getKeyVersion = (keyRaw: string | number) => { + const key = toKey(keyRaw) + if (!key) return 0 + return keyVersions.value[key] || 0 } const getContractState = (contractIdRaw: string | number) => { @@ -135,15 +788,25 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { const loadContract = async (contractIdRaw: string | number, force = false) => { const contractId = toKey(contractIdRaw) if (!contractId) return null + if (!force && contractLoaded.value[contractId]) return getContractState(contractId) if (!force && contracts.value[contractId]) return getContractState(contractId) - if (!force && loadTasks.has(contractId)) return loadTasks.get(contractId) as Promise + if (!force && loadTasks.has(contractId)) return loadTasks.get(contractId) as Promise const task = (async () => { const raw = await localforage.getItem(dbKeyOf(contractId)) - const normalized = normalizeState(raw) - contracts.value[contractId] = normalized - touchVersion(contractId) - return cloneState(normalized) + const current = contracts.value[contractId] + if (raw) { + const normalized = normalizeState(raw) + if (!current || !isSameState(current, normalized)) { + contracts.value[contractId] = normalized + touchVersion(contractId) + } + } else if (!current) { + contracts.value[contractId] = normalizeState(null) + touchVersion(contractId) + } + contractLoaded.value[contractId] = true + return getContractState(contractId) })() loadTasks.set(contractId, task) @@ -156,10 +819,14 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { const setContractState = async (contractIdRaw: string | number, state: ZxFwState) => { const contractId = toKey(contractIdRaw) - if (!contractId) return - contracts.value[contractId] = normalizeState(state) + if (!contractId) return false + const normalized = normalizeState(state) + const current = contracts.value[contractId] + if (current && isSameState(current, normalized)) return false + contracts.value[contractId] = normalized + contractLoaded.value[contractId] = true touchVersion(contractId) - await queuePersist(contractId) + return true } const updatePricingField = async (params: { @@ -196,12 +863,14 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { if (!changed) return false - contracts.value[contractId] = normalizeState({ + const nextState = normalizeState({ ...current, detailRows: nextRows }) + if (isSameState(current, nextState)) return false + contracts.value[contractId] = nextState + contractLoaded.value[contractId] = true touchVersion(contractId) - await queuePersist(contractId) return true } @@ -226,10 +895,39 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { return { contracts, contractVersions, + contractLoaded, + servicePricingStates, + htFeeMainStates, + htFeeMethodStates, + keyedStates, + keyVersions, getContractState, loadContract, setContractState, updatePricingField, - getBaseSubtotal + getBaseSubtotal, + getKeyState, + loadKeyState, + setKeyState, + removeKeyState, + getKeyVersion, + getServicePricingMethodState, + setServicePricingMethodState, + loadServicePricingMethodState, + removeServicePricingMethodState, + getServicePricingStorageKey, + getServicePricingStorageKeys, + removeAllServicePricingMethodStates, + getHtFeeMainState, + setHtFeeMainState, + loadHtFeeMainState, + removeHtFeeMainState, + getHtFeeMethodStorageKey, + getHtFeeMethodState, + setHtFeeMethodState, + loadHtFeeMethodState, + removeHtFeeMethodState } +}, { + persist: true })