大改,使用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 { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { expertList } from '@/sql' import { expertList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
interface DetailRow { interface DetailRow {
@ -39,8 +37,9 @@ const props = withDefaults(
serviceId?: string | number serviceId?: string | number
enableZxFwSync?: boolean enableZxFwSync?: boolean
syncField?: ZxFwPricingField syncField?: ZxFwPricingField
syncMainStorageKey?: string htMainStorageKey?: string
syncRowId?: string htRowId?: string
htMethodType?: HtFeeMethodType
}>(), }>(),
{ {
title: '工时法明细', title: '工时法明细',
@ -48,13 +47,11 @@ const props = withDefaults(
syncField: 'hourly' syncField: 'hourly'
} }
) )
const pricingPaneReloadStore = usePricingPaneReloadStore() const zxFwPricingStore = useZxFwPricingStore()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const paneInstanceCreatedAt = Date.now() const paneInstanceCreatedAt = Date.now()
const reloadSignal = ref(0)
const shouldSkipPersist = () => { const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}` const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
@ -88,8 +85,66 @@ const shouldForceDefaultLoad = () => {
return Number.isFinite(forceUntil) && Date.now() <= forceUntil return Number.isFinite(forceUntil) && Date.now() <= forceUntil
} }
const detailRows = ref<DetailRow[]>([]) const fallbackDetailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null) 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 = { type ExpertLite = {
code: string code: string
@ -393,7 +448,18 @@ const saveToIndexedDB = async () => {
detailRows: JSON.parse(JSON.stringify(detailRows.value)) 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) { if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
const synced = await syncPricingTotalToZxFw({ const synced = await syncPricingTotalToZxFw({
@ -404,9 +470,6 @@ const saveToIndexedDB = async () => {
}) })
if (!synced) return if (!synced) return
} }
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
}
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
@ -420,7 +483,15 @@ const loadFromIndexedDB = async () => {
syncServiceBudgetToRows() syncServiceBudgetToRows()
return 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) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
syncServiceBudgetToRows() 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 = () => { const handleCellValueChanged = () => {
syncServiceBudgetToRows() syncServiceBudgetToRows()
gridApi.value?.refreshCells({ force: true }) gridApi.value?.refreshCells({ force: true })
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
}, 300)
} }
const handleGridReady = (event: GridReadyEvent<DetailRow>) => { const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
@ -505,8 +552,6 @@ onDeactivated(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value?.stopEditing() gridApi.value?.stopEditing()
gridApi.value = null gridApi.value = null
void saveToIndexedDB() void saveToIndexedDB()

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next' import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive' import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import { industryTypeList } from '@/sql' import { industryTypeList } from '@/sql'
@ -45,6 +46,14 @@ interface ContractSegmentPackage {
projectIndustry: string projectIndustry: string
contracts: ContractItem[] contracts: ContractItem[]
localforageEntries: DataEntry[] localforageEntries: DataEntry[]
piniaState?: {
zxFwPricing?: {
contracts?: Record<string, unknown>
servicePricingStates?: Record<string, unknown>
htFeeMainStates?: Record<string, unknown>
htFeeMethodStates?: Record<string, unknown>
}
}
} }
interface XmBaseInfoState { interface XmBaseInfoState {
projectIndustry?: string projectIndustry?: string
@ -52,16 +61,18 @@ interface XmBaseInfoState {
const STORAGE_KEY = 'ht-card-v1' const STORAGE_KEY = 'ht-card-v1'
const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw' const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
const CONTRACT_SEGMENT_VERSION = 1 const CONTRACT_SEGMENT_VERSION = 2
const CONTRACT_KEY_PREFIX = 'ht-info-v3-' const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
const SERVICE_KEY_PREFIX = 'zxFW-' const SERVICE_KEY_PREFIX = 'zxFW-'
const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-' const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-' const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-']
const PROJECT_INFO_KEY = 'xm-base-info-v1' const PROJECT_INFO_KEY = 'xm-base-info-v1'
const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
const tabStore = useTabStore() 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 isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
const payload = value as Partial<ContractSegmentPackage> | null const payload = value as Partial<ContractSegmentPackage> | null
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts)) return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
@ -452,6 +569,7 @@ const exportSelectedContracts = async () => {
const localforageEntries = await readContractRelatedForageEntries( const localforageEntries = await readContractRelatedForageEntries(
selectedContracts.map(item => item.id) selectedContracts.map(item => item.id)
) )
const piniaPayload = await buildContractPiniaPayload(selectedContracts.map(item => item.id))
const projectIndustry = await getCurrentProjectIndustry() const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) { if (!projectIndustry) {
@ -465,7 +583,10 @@ const exportSelectedContracts = async () => {
exportedAt: now.toISOString(), exportedAt: now.toISOString(),
projectIndustry, projectIndustry,
contracts: selectedContracts, contracts: selectedContracts,
localforageEntries localforageEntries,
piniaState: {
zxFwPricing: piniaPayload
}
} }
const content = await encodeZwArchive(payload) 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 Promise.all(rewrittenEntries.map(entry => localforage.setItem(entry.key, entry.value)))
await applyImportedContractPiniaPayload(payload.piniaState, oldToNewIdMap)
contracts.value = [...contracts.value, ...nextContracts] contracts.value = [...contracts.value, ...nextContracts]
await saveContracts() await saveContracts()

View File

@ -50,8 +50,9 @@ const quantityUnitPricePane = markRaw(
h(HtFeeGrid, { h(HtFeeGrid, {
title: '数量单价', title: '数量单价',
storageKey: quantityStorageKey.value, storageKey: quantityStorageKey.value,
syncMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
syncRowId: props.rowId htRowId: props.rowId,
htMethodType: 'quantity-unit-price-fee'
}) })
} }
}) })
@ -66,8 +67,9 @@ const rateFeePane = markRaw(
h(HtFeeRateMethodForm, { h(HtFeeRateMethodForm, {
storageKey: rateStorageKey.value, storageKey: rateStorageKey.value,
contractId: props.contractId, contractId: props.contractId,
syncMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
syncRowId: props.rowId htRowId: props.rowId,
htMethodType: 'rate-fee'
}) })
} }
}) })
@ -82,8 +84,9 @@ const hourlyFeePane = markRaw(
h(HourlyFeeGrid, { h(HourlyFeeGrid, {
title: '工时法明细', title: '工时法明细',
storageKey: hourlyStorageKey.value, storageKey: hourlyStorageKey.value,
syncMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
syncRowId: props.rowId htRowId: props.rowId,
htMethodType: 'hourly-fee'
}) })
} }
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,13 @@ import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onM
import type { ComponentPublicInstance } from 'vue' import type { ComponentPublicInstance } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { ChevronDown, CircleHelp, RotateCcw, X } from 'lucide-vue-next' import { ChevronDown, CircleHelp, RotateCcw, X } from 'lucide-vue-next'
import { createPinia } from 'pinia';
import localforage from 'localforage' import localforage from 'localforage'
import { import {
AlertDialogAction, AlertDialogAction,
@ -348,6 +351,7 @@ const componentMap: Record<string, any> = {
} }
const tabStore = useTabStore() const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
@ -1098,12 +1102,16 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const serviceId = toSafeInteger(serviceIdText) const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null if (serviceId == null) return null
const [method1Raw, method2Raw, method3Raw, method4Raw] = await Promise.all([ const [method1State, method2State, method3State, method4State] = await Promise.all([
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`tzGMF-${contractId}-${serviceIdText}`), zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`ydGMF-${contractId}-${serviceIdText}`), zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale'),
localforage.getItem<DetailRowsStorageLike<WorkloadMethodRowLike>>(`gzlF-${contractId}-${serviceIdText}`), zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload'),
localforage.getItem<DetailRowsStorageLike<HourlyMethodRowLike>>(`hourlyPricing-${contractId}-${serviceIdText}`) 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 method1 = buildMethod1(method1Raw?.detailRows)
const method2 = buildMethod2(method2Raw?.detailRows) const method2 = buildMethod2(method2Raw?.detailRows)
@ -1255,6 +1263,7 @@ const confirmImportOverride = async () => {
await writeForage(localforage, normalizeEntries(payload.localforageDefault)) await writeForage(localforage, normalizeEntries(payload.localforageDefault))
tabStore.resetTabs() tabStore.resetTabs()
await tabStore.$persistNow?.()
dataMenuOpen.value = false dataMenuOpen.value = false
window.location.reload() window.location.reload()
} catch (error) { } catch (error) {
@ -1275,6 +1284,7 @@ const handleReset = async () => {
console.error('reset failed:', error) console.error('reset failed:', error)
} finally { } finally {
tabStore.resetTabs() tabStore.resetTabs()
await tabStore.$persistNow?.()
window.location.reload() window.location.reload()
} }
} }

View File

@ -9,6 +9,7 @@ import {
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number' import { toFiniteNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
interface StoredDetailRowsState<T = any> { interface StoredDetailRowsState<T = any> {
detailRows?: T[] detailRows?: T[]
@ -112,6 +113,22 @@ interface PricingMethodDefaultBuildContext {
} }
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' 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) => const hasOwn = (obj: unknown, key: string) =>
Object.prototype.hasOwnProperty.call(obj || {}, key) Object.prototype.hasOwnProperty.call(obj || {}, key)
@ -550,6 +567,15 @@ export const getPricingMethodDetailDbKeys = (
serviceId: string | number serviceId: string | number
): PricingMethodDetailDbKeys => { ): PricingMethodDetailDbKeys => {
const normalizedServiceId = String(serviceId) 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 { return {
investScale: `tzGMF-${contractId}-${normalizedServiceId}`, investScale: `tzGMF-${contractId}-${normalizedServiceId}`,
landScale: `ydGMF-${contractId}-${normalizedServiceId}`, landScale: `ydGMF-${contractId}-${normalizedServiceId}`,
@ -628,12 +654,19 @@ export const persistDefaultPricingMethodDetailRowsForServices = async (params: {
if (uniqueServiceIds.length === 0) return if (uniqueServiceIds.length === 0) return
const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options) const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options)
const store = getZxFwStoreSafely()
await Promise.all( await Promise.all(
uniqueServiceIds.map(async serviceId => { uniqueServiceIds.map(async serviceId => {
const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId)
const defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context) 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([ await Promise.all([
localforage.setItem(dbKeys.investScale, { detailRows: defaultRows.investScale }), localforage.setItem(dbKeys.investScale, { detailRows: defaultRows.investScale }),
localforage.setItem(dbKeys.landScale, { detailRows: defaultRows.landScale }), 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 consultFactorDbKey = `ht-consult-category-factor-v1-${params.contractId}`
const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}` const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}`
const baseInfoDbKey = 'xm-base-info-v1' const baseInfoDbKey = 'xm-base-info-v1'
const investDbKey = `tzGMF-${params.contractId}-${serviceId}` const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId)
const landDbKey = `ydGMF-${params.contractId}-${serviceId}` const store = getZxFwStoreSafely()
const workloadDbKey = `gzlF-${params.contractId}-${serviceId}`
const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}`
const [investData, landData, workloadData, hourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([ const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([
localforage.getItem<StoredDetailRowsState>(investDbKey), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(landDbKey), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(workloadDbKey), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(hourlyDbKey), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'hourly') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(htDbKey), localforage.getItem<StoredDetailRowsState>(htDbKey),
localforage.getItem<StoredFactorState>(consultFactorDbKey), localforage.getItem<StoredFactorState>(consultFactorDbKey),
localforage.getItem<StoredFactorState>(majorFactorDbKey), localforage.getItem<StoredFactorState>(majorFactorDbKey),
localforage.getItem<XmBaseInfoState>(baseInfoDbKey) 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 consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData)
const majorFactorMap = buildMajorFactorMap(majorFactorData) const majorFactorMap = buildMajorFactorMap(majorFactorData)
const onlyCostScale = isOnlyCostScaleService(serviceId) const onlyCostScale = isOnlyCostScaleService(serviceId)
@ -744,16 +787,27 @@ export const ensurePricingMethodDetailRowsForServices = async (params: {
if (uniqueServiceIds.length === 0) return if (uniqueServiceIds.length === 0) return
const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options) const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options)
const store = getZxFwStoreSafely()
await Promise.all( await Promise.all(
uniqueServiceIds.map(async serviceId => { uniqueServiceIds.map(async serviceId => {
const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId)
const [investData, landData, workloadData, hourlyData] = await Promise.all([ const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData] = await Promise.all([
localforage.getItem<StoredDetailRowsState>(dbKeys.investScale), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(dbKeys.landScale), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(dbKeys.workload), store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
localforage.getItem<StoredDetailRowsState>(dbKeys.hourly) 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 shouldInitInvest = !Array.isArray(investData?.detailRows) || investData!.detailRows!.length === 0
const shouldInitLand = !Array.isArray(landData?.detailRows) || landData!.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 (shouldInitInvest) {
if (store) {
store.setServicePricingMethodState(params.contractId, serviceId, 'investScale', {
detailRows: getDefaultRows().investScale
}, { force: true })
}
writeTasks.push(localforage.setItem(dbKeys.investScale, { detailRows: getDefaultRows().investScale })) writeTasks.push(localforage.setItem(dbKeys.investScale, { detailRows: getDefaultRows().investScale }))
} }
if (shouldInitLand) { if (shouldInitLand) {
if (store) {
store.setServicePricingMethodState(params.contractId, serviceId, 'landScale', {
detailRows: getDefaultRows().landScale
}, { force: true })
}
writeTasks.push(localforage.setItem(dbKeys.landScale, { detailRows: getDefaultRows().landScale })) writeTasks.push(localforage.setItem(dbKeys.landScale, { detailRows: getDefaultRows().landScale }))
} }
if (shouldInitWorkload) { if (shouldInitWorkload) {
if (store) {
store.setServicePricingMethodState(params.contractId, serviceId, 'workload', {
detailRows: getDefaultRows().workload
}, { force: true })
}
writeTasks.push(localforage.setItem(dbKeys.workload, { detailRows: getDefaultRows().workload })) writeTasks.push(localforage.setItem(dbKeys.workload, { detailRows: getDefaultRows().workload }))
} }
if (shouldInitHourly) { if (shouldInitHourly) {
if (store) {
store.setServicePricingMethodState(params.contractId, serviceId, 'hourly', {
detailRows: getDefaultRows().hourly
}, { force: true })
}
writeTasks.push(localforage.setItem(dbKeys.hourly, { detailRows: getDefaultRows().hourly })) 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 type { ZxFwPricingField } from '@/pinia/zxFwPricing'
export const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main'
export const syncPricingTotalToZxFw = async (params: { export const syncPricingTotalToZxFw = async (params: {
contractId: string contractId: string
serviceId: string | number serviceId: string | number

View File

@ -22,7 +22,7 @@ import {
TreeDataModule,ContextMenuModule,ValidationModule TreeDataModule,ContextMenuModule,ValidationModule
} from 'ag-grid-enterprise' } from 'ag-grid-enterprise'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import piniaPersistedstate from '@/pinia/Plugin/indexdb'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
@ -51,7 +51,12 @@ const AG_GRID_MODULES = [
] ]
const pinia = createPinia() const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) pinia.use(
piniaPersistedstate({
storeName: 'pinia',
mode: 'multiple'
})
)
// 在应用启动时一次性注册 AG Grid 运行所需模块。 // 在应用启动时一次性注册 AG Grid 运行所需模块。
ModuleRegistry.registerModules(AG_GRID_MODULES) 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 = () => { const closeAllTabs = () => {
tabs.value = createDefaultTabs() tabs.value = createDefaultTabs()
console.log(tabs.value)
activeTabId.value = HOME_TAB_ID activeTabId.value = HOME_TAB_ID
} }
@ -106,10 +107,6 @@ export const useTabStore = defineStore(
} }
}, },
{ {
persist: { persist: true
key: 'tabs',
storage: localStorage,
pick: ['tabs', 'activeTabId']
}
} }
) )

View File

@ -5,6 +5,7 @@ import { addNumbers } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number' import { toFiniteNumberOrNull } from '@/lib/number'
export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly' export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly'
export type ServicePricingMethod = ZxFwPricingField
export interface ZxFwDetailRow { export interface ZxFwDetailRow {
id: string id: string
@ -24,10 +25,44 @@ export interface ZxFwState {
detailRows: ZxFwDetailRow[] 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 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 toKey = (contractId: string | number) => String(contractId || '').trim()
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
const dbKeyOf = (contractId: string) => `zxFW-${contractId}` 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 round3 = (value: number) => Number(value.toFixed(3))
const toNumberOrZero = (value: unknown) => { const toNumberOrZero = (value: unknown) => {
const numeric = Number(value) const numeric = Number(value)
@ -99,30 +134,648 @@ const cloneState = (state: ZxFwState): ZxFwState => ({
detailRows: state.detailRows.map(row => ({ ...row })) detailRows: state.detailRows.map(row => ({ ...row }))
}) })
const loadTasks = new Map<string, Promise<ZxFwState>>() const isSameStringArray = (a: string[] | undefined, b: string[] | undefined) => {
const persistQueues = new Map<string, Promise<void>>() 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', () => { export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
const contracts = ref<Record<string, ZxFwState>>({}) const contracts = ref<Record<string, ZxFwState>>({})
const contractVersions = ref<Record<string, number>>({}) 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) => { const touchVersion = (contractId: string) => {
contractVersions.value[contractId] = (contractVersions.value[contractId] || 0) + 1 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 ensureServicePricingState = (contractIdRaw: string | number, serviceIdRaw: string | number) => {
const prev = persistQueues.get(contractId) || Promise.resolve() const contractId = toKey(contractIdRaw)
const next = prev const serviceId = toServiceKey(serviceIdRaw)
.then(async () => { if (!contractId || !serviceId) return null
const current = contracts.value[contractId] if (!servicePricingStates.value[contractId]) {
if (!current) return servicePricingStates.value[contractId] = {}
await localforage.setItem<ZxFwState>(dbKeyOf(contractId), cloneState(current)) }
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) const htMainMeta = parseHtFeeMainStorageKey(key)
}) if (htMainMeta) {
persistQueues.set(contractId, next) setHtFeeMainState(htMainMeta.mainStorageKey, null, { force: true, syncKeyState: false })
await next }
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) => { const getContractState = (contractIdRaw: string | number) => {
@ -135,15 +788,25 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
const loadContract = async (contractIdRaw: string | number, force = false) => { const loadContract = async (contractIdRaw: string | number, force = false) => {
const contractId = toKey(contractIdRaw) const contractId = toKey(contractIdRaw)
if (!contractId) return null if (!contractId) return null
if (!force && contractLoaded.value[contractId]) return getContractState(contractId)
if (!force && contracts.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 task = (async () => {
const raw = await localforage.getItem<ZxFwState>(dbKeyOf(contractId)) const raw = await localforage.getItem<ZxFwState>(dbKeyOf(contractId))
const current = contracts.value[contractId]
if (raw) {
const normalized = normalizeState(raw) const normalized = normalizeState(raw)
if (!current || !isSameState(current, normalized)) {
contracts.value[contractId] = normalized contracts.value[contractId] = normalized
touchVersion(contractId) touchVersion(contractId)
return cloneState(normalized) }
} else if (!current) {
contracts.value[contractId] = normalizeState(null)
touchVersion(contractId)
}
contractLoaded.value[contractId] = true
return getContractState(contractId)
})() })()
loadTasks.set(contractId, task) loadTasks.set(contractId, task)
@ -156,10 +819,14 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
const setContractState = async (contractIdRaw: string | number, state: ZxFwState) => { const setContractState = async (contractIdRaw: string | number, state: ZxFwState) => {
const contractId = toKey(contractIdRaw) const contractId = toKey(contractIdRaw)
if (!contractId) return if (!contractId) return false
contracts.value[contractId] = normalizeState(state) 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) touchVersion(contractId)
await queuePersist(contractId) return true
} }
const updatePricingField = async (params: { const updatePricingField = async (params: {
@ -196,12 +863,14 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
if (!changed) return false if (!changed) return false
contracts.value[contractId] = normalizeState({ const nextState = normalizeState({
...current, ...current,
detailRows: nextRows detailRows: nextRows
}) })
if (isSameState(current, nextState)) return false
contracts.value[contractId] = nextState
contractLoaded.value[contractId] = true
touchVersion(contractId) touchVersion(contractId)
await queuePersist(contractId)
return true return true
} }
@ -226,10 +895,39 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
return { return {
contracts, contracts,
contractVersions, contractVersions,
contractLoaded,
servicePricingStates,
htFeeMainStates,
htFeeMethodStates,
keyedStates,
keyVersions,
getContractState, getContractState,
loadContract, loadContract,
setContractState, setContractState,
updatePricingField, 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
}) })