509 lines
16 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
interface DictItem {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
order?: number | null
}
interface FactorRow {
id: string
code: string
name: string
standardFactor: number | null
budgetValue: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: FactorRow[]
}
interface FactorChangeState {
changedRowIds: string[]
updatedAt: number
}
type DictSource = Record<string, DictItem>
const props = defineProps<{
title: string
storageKey: string
parentStorageKey?: string
dict: DictSource
disableBudgetEditWhenStandardNull?: boolean
excludeNotshowByZxflxs?: boolean
initBudgetValueFromStandard?: boolean
}>()
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore()
const detailRows = ref<FactorRow[]>([])
const gridApi = ref<GridApi<FactorRow> | null>(null)
const CHANGE_STORAGE_KEY = computed(() => `${props.storageKey}-change`)
const formatReadonlyFactor = (value: unknown) => {
if (value == null || value === '') return ''
const parsed = parseNumberOrNull(value, { precision: 3 })
if (parsed == null) return ''
return String(Number(parsed))
}
const formatEditableFactor = (params: any) => {
if (params.value == null || params.value === '') return t('xmFactorGrid.clickToInput')
const parsed = parseNumberOrNull(params.value, { precision: 3 })
if (parsed == null) return ''
return String(Number(parsed))
}
const sortedDictEntries = () =>
Object.entries(props.dict)
.filter((entry): entry is [string, DictItem] => {
const item = entry[1]
if (!item?.code || !item?.name) return false
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
return true
})
.sort((a, b) => {
const aOrder = Number(a[1]?.order)
const bOrder = Number(b[1]?.order)
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
})
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
const parts = code.split('-').filter(Boolean)
if (!parts.length) return [selfId]
const path: string[] = []
let currentCode = parts[0]
const firstId = codeIdMap.get(currentCode)
if (firstId) path.push(firstId)
for (let i = 1; i < parts.length; i += 1) {
currentCode = `${currentCode}-${parts[i]}`
const id = codeIdMap.get(currentCode)
if (id) path.push(id)
}
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
return path
}
const buildDefaultRows = (): FactorRow[] => {
const entries = sortedDictEntries()
const codeIdMap = new Map<string, string>()
for (const [id, item] of entries) {
codeIdMap.set(item.code, id)
}
return entries.map(([id, item]) => {
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
return {
id,
code: item.code,
name: item.name,
standardFactor,
budgetValue: props.initBudgetValueFromStandard ? standardFactor : null,
remark: '',
path: buildCodePath(item.code, id, codeIdMap)
}
})
}
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
const hasMeaningfulFactorValue = (rows: SourceRow[] | undefined) =>
Array.isArray(rows) &&
rows.some(row => {
const hasBudgetValue = typeof row?.budgetValue === 'number' && Number.isFinite(row.budgetValue)
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
return hasBudgetValue || hasRemark
})
const hasUsablePersistedRows = (state: GridState | null | undefined) =>
Array.isArray(state?.detailRows) &&
state.detailRows.some(row => {
const hasFactor =
typeof row?.budgetValue === 'number' ||
typeof row?.standardFactor === 'number'
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
return hasFactor || hasRemark || String(row?.id || '').trim() !== ''
})
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
return {
...row,
budgetValue:
typeof fromDb.budgetValue === 'number'
? fromDb.budgetValue
: hasBudgetValue
? null
: row.budgetValue,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
}
})
}
const columnDefs: ColDef<FactorRow>[] = [
{
headerName: t('xmFactorGrid.columns.standardFactor'),
field: 'standardFactor',
type: 'numericColumn',
cellClass: 'ag-right-aligned-cell',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => formatReadonlyFactor(params.value)
},
{
headerName: t('xmFactorGrid.columns.budgetValue'),
field: 'budgetValue',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return disabled ? '' : 'editable-cell-line'
},
flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return !disabled && (params.value == null || params.value === '')
}
},
editable: params => {
if (!props.disableBudgetEditWhenStandardNull) return true
return params.data?.standardFactor != null
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
if (disabled && (params.value == null || params.value === '')) return ''
return formatEditableFactor(params)
}
},
{
headerName: t('xmFactorGrid.columns.remark'),
field: 'remark',
minWidth: 170,
flex: 2.4,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: true,
valueFormatter: params => params.value || t('xmFactorGrid.clickToInput'),
cellClass: ' remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef<FactorRow> = {
headerName: t('xmFactorGrid.columns.groupName'),
minWidth: 220,
flex: 2.2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
const key = String(params.node?.key || '')
const dictItem = (props.dict as DictSource)[key]
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
}
}
const saveToIndexedDB = async () => {
try {
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
zxFwPricingStore.setKeyState(props.storageKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const normalizeFactorBudgetValue = (value: unknown) => {
const parsed = parseNumberOrNull(value, { precision: 6 })
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
}
const isSameNullableFactorNumber = (left: unknown, right: unknown) => {
const normalizedLeft = normalizeFactorBudgetValue(left)
const normalizedRight = normalizeFactorBudgetValue(right)
if (normalizedLeft == null && normalizedRight == null) return true
if (normalizedLeft == null || normalizedRight == null) return false
return normalizedLeft === normalizedRight
}
const parseContractFactorMeta = (storageKey: string) => {
const consultMatch = /^ht-consult-category-factor-v1-(.+)$/.exec(storageKey)
if (consultMatch) {
return {
factorType: 'consult' as const,
contractId: String(consultMatch[1] || '').trim()
}
}
const majorMatch = /^ht-major-factor-v1-(.+)$/.exec(storageKey)
if (majorMatch) {
return {
factorType: 'major' as const,
contractId: String(majorMatch[1] || '').trim()
}
}
return null
}
const saveFactorChangeState = async (changedRowIds: string[]) => {
if (changedRowIds.length === 0) return
const payload: FactorChangeState = {
changedRowIds: Array.from(new Set(changedRowIds.map(id => String(id || '').trim()).filter(Boolean))),
updatedAt: Date.now()
}
if (payload.changedRowIds.length === 0) return
zxFwPricingStore.setKeyState(CHANGE_STORAGE_KEY.value, payload, { force: true })
const contractMeta = parseContractFactorMeta(props.storageKey)
if (!contractMeta?.contractId) return
if (contractMeta.factorType === 'consult') {
await syncContractFactorsToPricing(contractMeta.contractId, {
consultChangedServiceIds: payload.changedRowIds
})
return
}
await syncContractFactorsToPricing(contractMeta.contractId, {
majorChangedRowIds: payload.changedRowIds
})
}
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
if (!storageKey) return null
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
if (hasUsablePersistedRows(piniaData)) return piniaData
// 兼容历史 kvStore 数据:命中后迁移到 pinia keyed state。
const legacyData = await kvStore.getItem<GridState>(storageKey)
if (!legacyData?.detailRows || !Array.isArray(legacyData.detailRows)) return null
zxFwPricingStore.setKeyState(storageKey, legacyData, { force: true })
return legacyData
}
const handleGridReady = (event: GridReadyEvent<FactorRow>) => {
gridApi.value = event.api
}
const loadFromIndexedDB = async () => {
try {
const data = await loadGridState(props.storageKey)
if (data && hasMeaningfulFactorValue(data.detailRows)) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
const parentStorageKey = props.parentStorageKey?.trim()
if (parentStorageKey) {
const parentData = await loadGridState(parentStorageKey)
if (parentData && hasMeaningfulFactorValue(parentData.detailRows)) {
detailRows.value = mergeWithDictRows(parentData.detailRows)
await saveToIndexedDB()
return
}
}
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
await saveToIndexedDB()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
let isBulkClipboardMutation = false
let bulkBudgetSnapshot: Map<string, number | null> | null = null
const pendingChangedRowIds = new Set<string>()
const queueChangedRowId = (rowId: unknown) => {
const normalizedId = String(rowId || '').trim()
if (!normalizedId) return
pendingChangedRowIds.add(normalizedId)
}
const flushGridPersist = async () => {
await saveToIndexedDB()
if (pendingChangedRowIds.size === 0) return
const changedRowIds = Array.from(pendingChangedRowIds)
pendingChangedRowIds.clear()
await saveFactorChangeState(changedRowIds)
}
const scheduleGridPersist = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void flushGridPersist()
}, 500)
}
const handleCellValueChanged = (event?: any) => {
if (isBulkClipboardMutation) return
const field = String(event?.colDef?.field || '')
if (field === 'budgetValue' && !isSameNullableFactorNumber(event?.oldValue, event?.newValue)) {
queueChangedRowId(event?.data?.id)
}
scheduleGridPersist()
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
bulkBudgetSnapshot = new Map(
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
)
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
if (bulkBudgetSnapshot) {
const nextBudgetMap = new Map(
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
)
const allRowIds = new Set<string>([...bulkBudgetSnapshot.keys(), ...nextBudgetMap.keys()])
for (const rowId of allRowIds) {
if (!isSameNullableFactorNumber(bulkBudgetSnapshot.get(rowId), nextBudgetMap.get(rowId))) {
queueChangedRowId(rowId)
}
}
bulkBudgetSnapshot = null
}
scheduleGridPersist()
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'budgetValue') {
return parseNumberOrNull(params.value, { precision: 3 })
}
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
watch(
() => props.dict,
() => {
void loadFromIndexedDB()
},
{ deep: true }
)
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value?.stopEditing()
gridApi.value = null
void flushGridPersist()
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="detailRows"
:columnDefs="gridColumnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
:animateRows="true"
:treeData="true"
@cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
/>
</div>
</div>
</div>
</template>