大改,使用pinia传值,indexdb做持久化

This commit is contained in:
wintsa 2026-03-11 11:06:04 +08:00
parent 3ad7bae1a9
commit 9a045cfe86
20 changed files with 1527 additions and 545 deletions

View File

@ -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<DetailRow[]>([])
const fallbackDetailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
const serviceMethod = computed<ServicePricingMethod | null>(() => {
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<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
}
const getHtMethodState = () => {
if (!useHtMethodState.value) return null
return zxFwPricingStore.getHtFeeMethodState<GridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
}
const detailRows = computed<DetailRow[]>({
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<GridState>(props.storageKey)
const data = useServicePricingState.value && serviceMethod.value
? await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
: useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<GridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<GridState>(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<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | 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<DetailRow>) => {
@ -505,8 +552,6 @@ onDeactivated(() => {
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value?.stopEditing()
gridApi.value = null
void saveToIndexedDB()

View File

@ -1,15 +1,14 @@
<script setup lang="ts">
import { defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import localforage from 'localforage'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal'
import { Button } from '@/components/ui/button'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
@ -40,10 +39,11 @@ interface FeeGridState {
const props = defineProps<{
title: string
storageKey: string
syncMainStorageKey?: string
syncRowId?: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'quantity-unit-price-fee'
}>()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
@ -76,11 +76,35 @@ const ensureSubtotalRow = (rows: FeeRow[]) => {
return [...normalRows, createSubtotalRow()]
}
const detailRows = ref<FeeRow[]>([])
const fallbackDetailRows = ref<FeeRow[]>([])
const gridApi = ref<GridApi<FeeRow> | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const detailRows = computed<FeeRow[]>({
get: () => {
if (!useHtMethodState.value) return fallbackDetailRows.value
const state = zxFwPricingStore.getHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
const rows = state?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
if (!useHtMethodState.value) {
fallbackDetailRows.value = rows
return
}
zxFwPricingStore.setHtFeeMethodState(props.htMainStorageKey!, props.htRowId!, props.htMethodType!, {
detailRows: rows
})
}
})
const addRow = () => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
@ -193,10 +217,15 @@ const saveToIndexedDB = async () => {
const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
await localforage.setItem(props.storageKey, payload)
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
@ -205,7 +234,13 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<FeeGridState>(props.storageKey)
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<FeeGridState>(props.storageKey)
detailRows.value = mergeWithStoredRows(data?.detailRows)
syncComputedValuesToRows()
} catch (error) {
@ -363,8 +398,6 @@ const detailGridOptions: GridOptions<FeeRow> = {
treeData: false
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api
}
@ -372,10 +405,7 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
const handleCellValueChanged = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ force: true })
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
void saveToIndexedDB()
}
onMounted(async () => {
@ -394,7 +424,6 @@ watch(
)
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value = null
void saveToIndexedDB()
})

View File

@ -2,7 +2,6 @@
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import localforage from 'localforage'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
@ -10,7 +9,6 @@ import { formatThousandsFlexible } from '@/lib/numberFormat'
import { Pencil, Eraser } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import {
AlertDialogAction,
@ -80,7 +78,6 @@ const props = defineProps<{
fixedNames?: string[]
}>()
const tabStore = useTabStore()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
@ -102,11 +99,6 @@ const toFiniteUnknown = (value: unknown): number | null => {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
const buildMethodStorageKey = (
rowId: string,
method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee'
) => `${props.storageKey}-${rowId}-${method}`
const loadContractServiceFeeBase = async (): Promise<number | null> => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return null
@ -172,9 +164,9 @@ const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMet
rows.map(async row => {
if (!row?.id) return row
const [rateData, hourlyData, quantityData] = await Promise.all([
localforage.getItem<MethodRateState>(buildMethodStorageKey(row.id, 'rate-fee')),
localforage.getItem<MethodHourlyState>(buildMethodStorageKey(row.id, 'hourly-fee')),
localforage.getItem<MethodQuantityState>(buildMethodStorageKey(row.id, 'quantity-unit-price-fee'))
zxFwPricingStore.loadHtFeeMethodState<MethodRateState>(props.storageKey, row.id, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodHourlyState>(props.storageKey, row.id, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
])
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
@ -205,7 +197,17 @@ const fixedNames = computed(() =>
: []
)
const hasFixedNames = computed(() => fixedNames.value.length > 0)
const detailRows = ref<FeeMethodRow[]>([createDefaultRow()])
const detailRows = computed<FeeMethodRow[]>({
get: () => {
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setHtFeeMainState(props.storageKey, {
detailRows: rows
})
}
})
const summaryRow = computed<FeeMethodRow>(() => {
const totals = detailRows.value.reduce(
(acc, row) => {
@ -235,6 +237,7 @@ const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
const clearConfirmOpen = ref(false)
const pendingClearRowId = ref<string | null>(null)
const pendingClearRowName = ref('')
const lastSavedSnapshot = ref('')
const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id
@ -325,12 +328,15 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async () => {
const saveToIndexedDB = async (force = false) => {
try {
const payload: FeeMethodState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
await localforage.setItem(props.storageKey, payload)
const snapshot = JSON.stringify(payload.detailRows)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setHtFeeMainState(props.storageKey, payload, { force })
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
@ -338,17 +344,16 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<FeeMethodState>(props.storageKey)
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
const mergedRows = mergeWithStoredRows(data?.detailRows)
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB()
await saveToIndexedDB(true)
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
const mergedRows = mergeWithStoredRows([])
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB()
await saveToIndexedDB(true)
}
}
@ -360,11 +365,9 @@ const addRow = () => {
const clearRow = async (id: string) => {
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
await nextTick()
await Promise.all([
localforage.removeItem(buildMethodStorageKey(id, 'rate-fee')),
localforage.removeItem(buildMethodStorageKey(id, 'hourly-fee')),
localforage.removeItem(buildMethodStorageKey(id, 'quantity-unit-price-fee'))
])
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'rate-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'hourly-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'quantity-unit-price-fee')
detailRows.value = detailRows.value.map(row =>
row.id !== id
? row
@ -553,7 +556,14 @@ const detailGridOptions: GridOptions<FeeMethodRow> = {
}
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
let reloadTimer: ReturnType<typeof setTimeout> | null = null
const scheduleReloadFromStorage = () => {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
void loadFromIndexedDB()
}, 80)
}
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
gridApi.value = event.api
@ -561,10 +571,7 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
if (isSummaryRow(event.data)) return
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
void saveToIndexedDB()
}
onMounted(async () => {
@ -572,22 +579,28 @@ onMounted(async () => {
})
onActivated(() => {
void loadFromIndexedDB()
scheduleReloadFromStorage()
})
const storageKeyRef = computed(() => props.storageKey)
watch(storageKeyRef, () => {
void loadFromIndexedDB()
scheduleReloadFromStorage()
})
watch(
() => htFeeMethodReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
const detail = htFeeMethodReloadStore.lastEvent
if (!detail) return
if (String(detail.mainStorageKey || '').trim() !== String(props.storageKey || '').trim()) return
void loadFromIndexedDB()
() =>
detailRows.value
.map(row => {
const rateKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'rate-fee')
const hourlyKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'hourly-fee')
const quantityKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'quantity-unit-price-fee')
return `${row.id}:${zxFwPricingStore.getKeyVersion(rateKey)}:${zxFwPricingStore.getKeyVersion(hourlyKey)}:${zxFwPricingStore.getKeyVersion(quantityKey)}`
})
.join('|'),
(nextSignature, prevSignature) => {
if (!nextSignature && !prevSignature) return
if (nextSignature === prevSignature) return
scheduleReloadFromStorage()
}
)
@ -599,7 +612,7 @@ watch(
},
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
scheduleReloadFromStorage()
}
)
@ -613,9 +626,9 @@ watch([hasFixedNames], () => {
})
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
if (reloadTimer) clearTimeout(reloadTimer)
gridApi.value = null
void saveToIndexedDB()
void saveToIndexedDB(true)
})
</script>

View File

@ -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<string, unknown>
servicePricingStates?: Record<string, unknown>
htFeeMainStates?: Record<string, unknown>
htFeeMethodStates?: Record<string, unknown>
}
}
}
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<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value))
const cloneJson = <T>(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<string, unknown>,
servicePricingStates: {} as Record<string, unknown>,
htFeeMainStates: {} as Record<string, unknown>,
htFeeMethodStates: {} as Record<string, unknown>
}
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<string, string>
) => {
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<ContractSegmentPackage> | 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()

View File

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

View File

@ -1,9 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import localforage from 'localforage'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface RateMethodState {
@ -15,71 +13,95 @@ interface RateMethodState {
const props = defineProps<{
storageKey: string
contractId?: string
syncMainStorageKey?: string
syncRowId?: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'rate-fee'
}>()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const base = ref<number | null>(null)
const rate = ref<number | null>(null)
const remark = ref('')
const rateInput = ref('')
const contractVersion = computed(() => {
const lastSavedSnapshot = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const contractIdText = computed(() => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return 0
return zxFwPricingStore.contractVersions[contractId] || 0
return contractId
})
const base = computed<number | null>(() => {
const contractId = contractIdText.value
if (!contractId) return null
return zxFwPricingStore.getBaseSubtotal(contractId)
})
const round3 = (value: number) => Number(value.toFixed(3))
const budgetFee = computed<number | null>(() => {
if (base.value == null || rate.value == null) return null
return round3(base.value * rate.value)
return Number((base.value * rate.value).toFixed(3))
})
const formatAmount = (value: number | null) =>
value == null ? '' : formatThousandsFlexible(value, 3)
const loadBase = async () => {
const contractId = String(props.contractId || '').trim()
if (!contractId) {
base.value = null
return
}
const ensureContractLoaded = async () => {
const contractId = contractIdText.value
if (!contractId) return
try {
await zxFwPricingStore.loadContract(contractId)
const nextBase = zxFwPricingStore.getBaseSubtotal(contractId)
base.value = nextBase == null ? null : round3(nextBase)
} catch (error) {
console.error('load rate base failed:', error)
base.value = null
console.error('load contract for rate base failed:', error)
}
}
const loadForm = async () => {
try {
const data = await localforage.getItem<RateMethodState>(props.storageKey)
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<RateMethodState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<RateMethodState>(props.storageKey)
rate.value = typeof data?.rate === 'number' ? data.rate : null
remark.value = typeof data?.remark === 'string' ? data.remark : ''
rateInput.value = rate.value == null ? '' : String(rate.value)
const snapshot: RateMethodState = {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
}
lastSavedSnapshot.value = JSON.stringify(snapshot)
} catch (error) {
console.error('load rate form failed:', error)
rate.value = null
remark.value = ''
rateInput.value = ''
lastSavedSnapshot.value = ''
}
}
const saveForm = async () => {
const saveForm = async (force = false) => {
try {
await localforage.setItem<RateMethodState>(props.storageKey, {
const payload: RateMethodState = {
rate: rate.value,
budgetFee: budgetFee.value,
remark: remark.value
})
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
}
const snapshot = JSON.stringify(payload)
if (!force && snapshot === lastSavedSnapshot.value) return
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload,
{ force }
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload, { force })
}
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('save rate form failed:', error)
}
@ -105,26 +127,22 @@ watch(
)
watch(
() => contractVersion.value,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadBase()
() => contractIdText.value,
() => {
void ensureContractLoaded()
}
)
onMounted(async () => {
await Promise.all([loadBase(), loadForm()])
await Promise.all([ensureContractLoaded(), loadForm()])
})
onActivated(async () => {
await Promise.all([loadBase(), loadForm()])
await Promise.all([ensureContractLoaded(), loadForm()])
})
onBeforeUnmount(() => {
void saveForm()
void saveForm(true)
})
</script>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
@ -93,7 +93,7 @@ const props = defineProps<{
contractId: string,
serviceId: string | number
}>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
@ -106,7 +106,6 @@ const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
@ -238,7 +237,22 @@ const shouldForceDefaultLoad = () => {
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([])
const getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'investScale')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
const currentState = getMethodState()
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'investScale', {
detailRows: rows,
projectCount: currentState?.projectCount ?? getTargetProjectCount()
})
}
})
type majorLite = {
code: string
name: string
@ -1016,10 +1030,10 @@ const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())),
projectCount: getTargetProjectCount()
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'investScale', payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
@ -1149,10 +1163,11 @@ const loadFromIndexedDB = async () => {
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'investScale')
if (data) {
if (isMutipleService.value) {
projectCount.value = inferProjectCountFromRows(data.detailRows as any)
const storedProjectCount = normalizeProjectCount(data.projectCount)
projectCount.value = storedProjectCount || inferProjectCountFromRows(data.detailRows as any)
}
detailRows.value = isOnlyCostScaleService.value
? buildOnlyCostScaleRows(data.detailRows as any, {
@ -1222,35 +1237,9 @@ const clearAllData = async () => {
}
}
watch(
() => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) 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<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
void saveToIndexedDB()
}
onMounted(async () => {
@ -1262,8 +1251,6 @@ onActivated(() => {
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params: any) => {

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
@ -93,7 +93,7 @@ const props = defineProps<{
contractId: string,
serviceId: string | number
}>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
@ -106,7 +106,6 @@ const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
@ -182,7 +181,22 @@ const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
return maxProjectIndex
}
const detailRows = ref<DetailRow[]>([])
const getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
const currentState = getMethodState()
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'landScale', {
detailRows: rows,
projectCount: currentState?.projectCount ?? getTargetProjectCount()
})
}
})
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
@ -869,10 +883,10 @@ const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())),
projectCount: getTargetProjectCount()
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'landScale', payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
@ -989,10 +1003,11 @@ const loadFromIndexedDB = async () => {
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale')
if (data) {
if (isMutipleService.value) {
projectCount.value = inferProjectCountFromRows(data.detailRows as any)
const storedProjectCount = normalizeProjectCount(data.projectCount)
projectCount.value = storedProjectCount || inferProjectCountFromRows(data.detailRows as any)
}
detailRows.value = mergeWithDictRows(data.detailRows as any, {
projectCount: getTargetProjectCount(),
@ -1047,35 +1062,9 @@ const clearAllData = async () => {
}
}
watch(
() => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) 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<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
void saveToIndexedDB()
}
onMounted(async () => {
@ -1087,8 +1076,6 @@ onActivated(() => {
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params: any) => {

View File

@ -1,15 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage'
import { taskList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
@ -42,7 +41,7 @@ const props = defineProps<{
serviceId: string | number
}>()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
@ -50,7 +49,6 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
@ -93,7 +91,20 @@ const shouldForceDefaultLoad = () => {
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([])
const getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', {
detailRows: rows
})
}
})
type taskLite = {
serviceID: number
@ -424,8 +435,7 @@ const saveToIndexedDB = async () => {
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
@ -452,7 +462,7 @@ const loadFromIndexedDB = async () => {
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
@ -465,34 +475,8 @@ const loadFromIndexedDB = async () => {
}
}
watch(
() => pricingPaneReloadStore.seq,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) 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<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
void saveToIndexedDB()
}
onMounted(async () => {
@ -500,8 +484,6 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params: any) => {

View File

@ -11,12 +11,10 @@ import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import {
ensurePricingMethodDetailRowsForServices,
persistDefaultPricingMethodDetailRowsForServices,
getPricingMethodTotalsForService,
getPricingMethodTotalsForServices,
type PricingMethodTotals
} from '@/lib/pricingMethodTotals'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
@ -55,7 +53,7 @@ interface DetailRow {
actions?: unknown
}
interface ZxFwState {
interface ZxFwViewState {
selectedIds?: string[]
selectedCodes?: string[]
detailRows: DetailRow[]
@ -77,7 +75,6 @@ const props = defineProps<{
contractName?: string
}>()
const tabStore = useTabStore()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const zxFwPricingStore = useZxFwPricingStore()
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
@ -85,9 +82,6 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const PRICING_CLEAR_SKIP_TTL_MS = 5000
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
const projectIndustry = ref('')
const syncingFromStore = ref(false)
const localSavedVersion = ref(0)
const zxFwStoreVersion = computed(() => zxFwPricingStore.contractVersions[props.contractId] || 0)
type ServiceListItem = {
code?: string
@ -145,8 +139,49 @@ const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id)
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: '小计' }
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
const selectedIds = ref<string[]>([])
const detailRows = ref<DetailRow[]>([])
const selectedIds = computed<string[]>(() => zxFwPricingStore.contracts[props.contractId]?.selectedIds || [])
const detailRows = computed<DetailRow[]>(() =>
(zxFwPricingStore.contracts[props.contractId]?.detailRows || []).map(row => ({
id: String(row.id || ''),
code: row.code || '',
name: row.name || '',
investScale: typeof row.investScale === 'number' ? row.investScale : null,
landScale: typeof row.landScale === 'number' ? row.landScale : null,
workload: typeof row.workload === 'number' ? row.workload : null,
hourly: typeof row.hourly === 'number' ? row.hourly : null,
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
actions: row.actions
}))
)
const getCurrentContractState = (): ZxFwViewState => {
const current = zxFwPricingStore.getContractState(props.contractId)
if (current) {
return {
selectedIds: Array.isArray(current.selectedIds) ? [...current.selectedIds] : [],
selectedCodes: Array.isArray(current.selectedCodes) ? [...current.selectedCodes] : [],
detailRows: (current.detailRows || []).map(row => ({
id: String(row.id || ''),
code: row.code || '',
name: row.name || '',
investScale: typeof row.investScale === 'number' ? row.investScale : null,
landScale: typeof row.landScale === 'number' ? row.landScale : null,
workload: typeof row.workload === 'number' ? row.workload : null,
hourly: typeof row.hourly === 'number' ? row.hourly : null,
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
actions: row.actions
}))
}
}
return {
selectedIds: [],
detailRows: []
}
}
const setCurrentContractState = async (nextState: ZxFwViewState) => {
await zxFwPricingStore.setContractState(props.contractId, nextState)
}
const pickerOpen = ref(false)
const pickerTempIds = ref<string[]>([])
@ -374,12 +409,8 @@ const getFixedRowSubtotal = () =>
getMethodTotal('hourly')
)
const getPricingPaneStorageKeys = (serviceId: string) => [
`tzGMF-${props.contractId}-${serviceId}`,
`ydGMF-${props.contractId}-${serviceId}`,
`gzlF-${props.contractId}-${serviceId}`,
`hourlyPricing-${props.contractId}-${serviceId}`
]
const getPricingPaneStorageKeys = (serviceId: string) =>
zxFwPricingStore.getServicePricingStorageKeys(props.contractId, serviceId)
const clearPricingPaneValues = async (serviceId: string) => {
const keys = getPricingPaneStorageKeys(serviceId)
@ -390,14 +421,16 @@ const clearPricingPaneValues = async (serviceId: string) => {
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, skipToken)
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
}
zxFwPricingStore.removeAllServicePricingMethodStates(props.contractId, serviceId)
// ResetIndexedDB
await Promise.all(keys.map(key => localforage.removeItem(key)))
pricingPaneReloadStore.emit(props.contractId, serviceId)
}
const clearRowValues = async (row: DetailRow) => {
if (isFixedRow(row)) return
// ? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
//
tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick()
await clearPricingPaneValues(row.id)
await ensurePricingMethodDetailRowsForServices({
@ -411,7 +444,8 @@ const clearRowValues = async (row: DetailRow) => {
options: PRICING_TOTALS_OPTIONS
})
const sanitizedTotals = sanitizePricingTotalsByService(row.id, totals)
const clearedRows = detailRows.value.map(item =>
const currentState = getCurrentContractState()
const clearedRows = currentState.detailRows.map(item =>
item.id !== row.id
? item
: {
@ -426,7 +460,7 @@ const clearRowValues = async (row: DetailRow) => {
const nextLandScale = getMethodTotalFromRows(clearedRows, 'landScale')
const nextWorkload = getMethodTotalFromRows(clearedRows, 'workload')
const nextHourly = getMethodTotalFromRows(clearedRows, 'hourly')
detailRows.value = clearedRows.map(item =>
const nextRows = clearedRows.map(item =>
isFixedRow(item)
? {
...item,
@ -438,7 +472,10 @@ const clearRowValues = async (row: DetailRow) => {
}
: item
)
await saveToIndexedDB()
await setCurrentContractState({
...currentState,
detailRows: nextRows
})
}
const openEditTab = (row: DetailRow) => {
@ -682,20 +719,24 @@ const ensurePricingDetailRowsForCurrentSelection = async () => {
}
const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
const currentState = getCurrentContractState()
const targetIds = Array.from(
new Set(
serviceIds.filter(id =>
detailRows.value.some(row => !isFixedRow(row) && String(row.id) === String(id))
currentState.detailRows.some(row => !isFixedRow(row) && String(row.id) === String(id))
)
)
)
if (targetIds.length === 0) {
detailRows.value = applyFixedRowTotals(detailRows.value)
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowTotals(currentState.detailRows)
})
return
}
await persistDefaultPricingMethodDetailRowsForServices({
await ensurePricingMethodDetailRowsForServices({
contractId: props.contractId,
serviceIds: targetIds,
options: PRICING_TOTALS_OPTIONS
@ -708,7 +749,7 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
})
const targetSet = new Set(targetIds.map(id => String(id)))
const nextRows = detailRows.value.map(row => {
const nextRows = currentState.detailRows.map(row => {
if (isFixedRow(row) || !targetSet.has(String(row.id))) return row
const totalsRaw = totalsByServiceId.get(String(row.id))
const totals = totalsRaw ? sanitizePricingTotalsByService(String(row.id), totalsRaw) : null
@ -722,15 +763,19 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
}
})
detailRows.value = applyFixedRowTotals(nextRows)
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowTotals(nextRows)
})
}
const applySelection = (codes: string[]) => {
const prevSelectedSet = new Set(selectedIds.value)
const applySelection = async (codes: string[]) => {
const currentState = getCurrentContractState()
const prevSelectedSet = new Set(currentState.selectedIds || [])
const uniqueIds = Array.from(new Set(codes)).filter(
id => serviceById.value.has(id) && id !== fixedBudgetRow.id
)
const existingMap = new Map(detailRows.value.map(row => [row.id, row]))
const existingMap = new Map(currentState.detailRows.map(row => [row.id, row]))
const baseRows: DetailRow[] = uniqueIds
.map(id => {
@ -777,18 +822,21 @@ const applySelection = (codes: string[]) => {
tabStore.removeTab(`zxfw-edit-${props.contractId}-${id}`)
}
selectedIds.value = uniqueIds
detailRows.value = [...baseRows, fixedRow]
await setCurrentContractState({
...currentState,
selectedIds: uniqueIds,
detailRows: applyFixedRowTotals([...baseRows, fixedRow])
})
}
const handleServiceSelectionChange = async (ids: string[]) => {
const prevIds = [...selectedIds.value]
applySelection(ids)
const nextSelectedSet = new Set(selectedIds.value)
const addedIds = selectedIds.value.filter(id => !prevIds.includes(id) && nextSelectedSet.has(id))
await applySelection(ids)
const nextSelectedIds = getCurrentContractState().selectedIds || []
const nextSelectedSet = new Set(nextSelectedIds)
const addedIds = nextSelectedIds.filter(id => !prevIds.includes(id) && nextSelectedSet.has(id))
await fillPricingTotalsForServiceIds(addedIds)
await ensurePricingDetailRowsForCurrentSelection()
await saveToIndexedDB()
}
const preparePickerOpen = () => {
@ -811,14 +859,7 @@ const handlePickerOpenChange = (open: boolean) => {
}
const confirmPicker = async () => {
applySelection(pickerTempIds.value)
try {
// await fillPricingTotalsForSelectedRows()
await saveToIndexedDB()
} catch (error) {
console.error('confirmPicker failed:', error)
await saveToIndexedDB()
}
await applySelection(pickerTempIds.value)
}
const clearPickerSelection = () => {
@ -925,90 +966,30 @@ const handleDragHover = (_code: string) => {
applyDragSelectionByRect()
}
const buildPersistDetailRows = () => {
const rows = detailRows.value.map(row => ({ ...row }))
const nextInvestScale = getMethodTotalFromRows(rows, 'investScale')
const nextLandScale = getMethodTotalFromRows(rows, 'landScale')
const nextWorkload = getMethodTotalFromRows(rows, 'workload')
const nextHourly = getMethodTotalFromRows(rows, 'hourly')
const fixedSubtotal = addNumbers(nextInvestScale, nextLandScale, nextWorkload, nextHourly)
return rows.map(row =>
isFixedRow(row)
? {
...row,
investScale: nextInvestScale,
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: fixedSubtotal
}
: {
...row,
subtotal: addNumbers(
valueOrZero(row.investScale),
valueOrZero(row.landScale),
valueOrZero(row.workload),
valueOrZero(row.hourly)
)
}
)
}
const saveToIndexedDB = async () => {
try {
const payload: ZxFwState = {
selectedIds: [...selectedIds.value],
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
await zxFwPricingStore.setContractState(props.contractId, payload)
localSavedVersion.value = zxFwPricingStore.contractVersions[props.contractId] || 0
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
if (syncingFromStore.value) return
syncingFromStore.value = true
const initializeContractState = async () => {
try {
await zxFwPricingStore.loadContract(props.contractId)
const data = zxFwPricingStore.getContractState(props.contractId)
if (!data) {
selectedIds.value = []
detailRows.value = []
return
}
const idsFromStorage = data.selectedIds
|| (data.selectedCodes || []).map(code => serviceIdByCode.value.get(code)).filter((id): id is string => Boolean(id))
applySelection(idsFromStorage)
const savedRowMap = new Map((data.detailRows || []).map(row => [row.id, row]))
detailRows.value = detailRows.value.map(row => {
const old = savedRowMap.get(row.id)
if (!old) return row
const nextValues = sanitizePricingFieldsByService(row.id, {
investScale: typeof old.investScale === 'number' ? old.investScale : null,
landScale: typeof old.landScale === 'number' ? old.landScale : null,
workload: typeof old.workload === 'number' ? old.workload : null,
hourly: typeof old.hourly === 'number' ? old.hourly : null
})
return {
...row,
investScale: nextValues.investScale,
landScale: nextValues.landScale,
workload: nextValues.workload,
hourly: nextValues.hourly
}
})
detailRows.value = applyFixedRowTotals(detailRows.value)
const idsFromStorage = data?.selectedIds
|| (data?.selectedCodes || []).map(code => serviceIdByCode.value.get(code)).filter((id): id is string => Boolean(id))
await applySelection(idsFromStorage || [])
await ensurePricingDetailRowsForCurrentSelection()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
selectedIds.value = []
detailRows.value = []
} finally {
syncingFromStore.value = false
console.error('initializeContractState failed:', error)
await setCurrentContractState({
selectedIds: [],
detailRows: applyFixedRowTotals([{
id: fixedBudgetRow.id,
code: fixedBudgetRow.code,
name: fixedBudgetRow.name,
investScale: null,
landScale: null,
workload: null,
hourly: null,
subtotal: null,
actions: null
}])
})
}
}
@ -1023,46 +1004,28 @@ const loadProjectIndustry = async () => {
}
}
watch(
() => zxFwStoreVersion.value,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
if (nextVersion === localSavedVersion.value) return
void loadFromIndexedDB()
}
)
watch(serviceIdSignature, () => {
const availableIds = new Set(serviceDict.value.map(item => item.id))
const nextSelectedIds = selectedIds.value.filter(id => availableIds.has(id))
if (nextSelectedIds.length !== selectedIds.value.length) {
applySelection(nextSelectedIds)
void saveToIndexedDB()
void applySelection(nextSelectedIds)
}
})
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
}
const handleCellValueChanged = () => {}
onMounted(async () => {
await loadProjectIndustry()
await loadFromIndexedDB()
await initializeContractState()
})
onActivated(async () => {
await loadProjectIndustry()
await loadFromIndexedDB()
await initializeContractState()
})
onBeforeUnmount(() => {
stopDragSelect()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
</script>
@ -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">
<AlertDialogTitle class="text-base font-semibold">确认删除服务</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将逻辑删除{{ pendingDeleteServiceName }}已填写的数据不会清重新勾选后会恢复是否继续
将逻辑删除{{ pendingDeleteServiceName }}已填写的数据不会清重新勾选后会恢复是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>

View File

@ -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<string, any> = {
}
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
@ -1098,12 +1102,16 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const [method1Raw, method2Raw, method3Raw, method4Raw] = await Promise.all([
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`tzGMF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`ydGMF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<WorkloadMethodRowLike>>(`gzlF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<HourlyMethodRowLike>>(`hourlyPricing-${contractId}-${serviceIdText}`)
const [method1State, method2State, method3State, method4State] = await Promise.all([
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale'),
zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload'),
zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(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()
}
}

View File

@ -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<T = any> {
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 = <TRow = unknown>(state: { detailRows?: TRow[] } | null | undefined): StoredDetailRowsState<TRow> | 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<StoredDetailRowsState>(investDbKey),
localforage.getItem<StoredDetailRowsState>(landDbKey),
localforage.getItem<StoredDetailRowsState>(workloadDbKey),
localforage.getItem<StoredDetailRowsState>(hourlyDbKey),
const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'hourly') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(htDbKey),
localforage.getItem<StoredFactorState>(consultFactorDbKey),
localforage.getItem<StoredFactorState>(majorFactorDbKey),
localforage.getItem<XmBaseInfoState>(baseInfoDbKey)
])
const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([
storeInvestData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(dbKeys.investScale),
storeLandData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(dbKeys.landScale),
storeWorkloadData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(dbKeys.workload),
storeHourlyData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(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<StoredDetailRowsState>(dbKeys.investScale),
localforage.getItem<StoredDetailRowsState>(dbKeys.landScale),
localforage.getItem<StoredDetailRowsState>(dbKeys.workload),
localforage.getItem<StoredDetailRowsState>(dbKeys.hourly)
const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData] = await Promise.all([
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'hourly') || Promise.resolve(null)
])
const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([
storeInvestData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(dbKeys.investScale),
storeLandData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(dbKeys.landScale),
storeWorkloadData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(dbKeys.workload),
storeHourlyData ? Promise.resolve(null) : localforage.getItem<StoredDetailRowsState>(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 }))
}

View File

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

View File

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

124
src/pinia/Plugin/indexdb.ts Normal file
View File

@ -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<void>
$clearPersisted?: () => Promise<void>
}
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<string, LocalForage>()
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<typeof setTimeout> | 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<Record<string, unknown>>(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)
})
}
}

13
src/pinia/Plugin/types.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import 'pinia'
import type { PersistOption } from './indexdb'
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
persist?: PersistOption
}
export interface PiniaCustomProperties {
$persistNow?: () => Promise<void>
$clearPersisted?: () => Promise<void>
}
}

View File

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

View File

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

View File

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

View File

@ -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<TRow = unknown> {
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<TRow = unknown> {
detailRows: TRow[]
}
export type HtFeeMethodPayload = unknown
const FIXED_ROW_ID = 'fixed-budget-c'
const METHOD_STORAGE_PREFIX_MAP: Record<ServicePricingMethod, string> = {
investScale: 'tzGMF',
landScale: 'ydGMF',
workload: 'gzlF',
hourly: 'hourlyPricing'
}
const STORAGE_PREFIX_METHOD_MAP = new Map<string, ServicePricingMethod>(
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<string, Promise<ZxFwState>>()
const persistQueues = new Map<string, Promise<void>>()
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<string, Promise<ZxFwState | null>>()
const keyLoadTasks = new Map<string, Promise<unknown>>()
const toKeySnapshot = (value: unknown) => JSON.stringify(value ?? null)
const cloneAny = <T>(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<ServicePricingMethodState> | 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<HtFeeMainState> | 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<Record<string, ZxFwState>>({})
const contractVersions = ref<Record<string, number>>({})
const contractLoaded = ref<Record<string, boolean>>({})
const servicePricingStates = ref<Record<string, Record<string, ServicePricingState>>>({})
const htFeeMainStates = ref<Record<string, HtFeeMainState>>({})
const htFeeMethodStates = ref<Record<string, Record<string, Partial<Record<HtFeeMethodType, HtFeeMethodPayload>>>>>({})
const keyedStates = ref<Record<string, unknown>>({})
const keyedLoaded = ref<Record<string, boolean>>({})
const keyVersions = ref<Record<string, number>>({})
const keySnapshots = ref<Record<string, string>>({})
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<ZxFwState>(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<ServicePricingMethodState> | 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 = <TRow = unknown>(
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<TRow> | undefined) || null
}
const setServicePricingMethodState = <TRow = unknown>(
contractIdRaw: string | number,
serviceIdRaw: string | number,
method: ServicePricingMethod,
payload: Partial<ServicePricingMethodState<TRow>> | 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 <TRow = unknown>(
contractIdRaw: string | number,
serviceIdRaw: string | number,
method: ServicePricingMethod,
force = false
): Promise<ServicePricingMethodState<TRow> | null> => {
const contractId = toKey(contractIdRaw)
const serviceId = toServiceKey(serviceIdRaw)
if (!contractId || !serviceId) return null
if (!force) {
const existing = getServicePricingMethodState<TRow>(contractId, serviceId, method)
if (existing) return existing
}
const storageKey = serviceMethodDbKeyOf(contractId, serviceId, method)
const payload = await loadKeyState<ServicePricingMethodState<TRow>>(storageKey, force)
if (!payload) {
setServiceMethodStateInMemory(contractId, serviceId, method, null)
return null
}
setServicePricingMethodState(contractId, serviceId, method, payload, { force: true, syncKeyState: false })
return getServicePricingMethodState<TRow>(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 = <TRow = unknown>(mainStorageKeyRaw: string | number): HtFeeMainState<TRow> | null => {
const mainStorageKey = toKey(mainStorageKeyRaw)
if (!mainStorageKey) return null
return (htFeeMainStates.value[mainStorageKey] as HtFeeMainState<TRow> | undefined) || null
}
const setHtFeeMainState = <TRow = unknown>(
mainStorageKeyRaw: string | number,
payload: Partial<HtFeeMainState<TRow>> | 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 <TRow = unknown>(
mainStorageKeyRaw: string | number,
force = false
): Promise<HtFeeMainState<TRow> | null> => {
const mainStorageKey = toKey(mainStorageKeyRaw)
if (!mainStorageKey) return null
if (!force) {
const existing = getHtFeeMainState<TRow>(mainStorageKey)
if (existing) return existing
}
const payload = await loadKeyState<HtFeeMainState<TRow>>(mainStorageKey, force)
if (!payload) {
setHtFeeMainState(mainStorageKey, null, { force: true, syncKeyState: false })
return null
}
setHtFeeMainState(mainStorageKey, payload, { force: true, syncKeyState: false })
return getHtFeeMainState<TRow>(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 = <TPayload = HtFeeMethodPayload>(
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 = <TPayload = HtFeeMethodPayload>(
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 <TPayload = HtFeeMethodPayload>(
mainStorageKeyRaw: string | number,
rowIdRaw: string | number,
method: HtFeeMethodType,
force = false
): Promise<TPayload | null> => {
const mainStorageKey = toKey(mainStorageKeyRaw)
const rowId = toKey(rowIdRaw)
if (!mainStorageKey || !rowId) return null
if (!force) {
const existing = getHtFeeMethodState<TPayload>(mainStorageKey, rowId, method)
if (existing != null) return existing
}
const storageKey = getHtFeeMethodStorageKey(mainStorageKey, rowId, method)
const payload = await loadKeyState<TPayload>(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<TPayload>(mainStorageKey, rowId, method)
}
const removeHtFeeMethodState = (
mainStorageKeyRaw: string | number,
rowIdRaw: string | number,
method: HtFeeMethodType
) => setHtFeeMethodState(mainStorageKeyRaw, rowIdRaw, method, null)
const getKeyState = <T = unknown>(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 <T = unknown>(keyRaw: string | number, force = false): Promise<T | null> => {
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<T>(key)
}
// 注意当内存中没有该key时不应仅凭keyedLoaded短路返回null。
// 该key可能被其他逻辑直接写入了IndexedDB例如默认明细生成
if (!force && keyLoadTasks.has(key)) return keyLoadTasks.get(key) as Promise<T | null>
const task = (async () => {
const raw = await localforage.getItem<T>(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<ServicePricingMethodState>,
{ 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<HtFeeMainState>, { force: true, syncKeyState: false })
}
return getKeyState<T>(key)
})()
keyLoadTasks.set(key, task)
try {
return await task
} finally {
keyLoadTasks.delete(key)
}
}
const setKeyState = <T = unknown>(
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<ServicePricingMethodState>,
{ 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<HtFeeMainState>, { 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<ZxFwState>
if (!force && loadTasks.has(contractId)) return loadTasks.get(contractId) as Promise<ZxFwState | null>
const task = (async () => {
const raw = await localforage.getItem<ZxFwState>(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
})