509 lines
16 KiB
Vue
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>
|
|
|