356 lines
11 KiB
Vue
356 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
|
import { parseNumberOrNull } from '@/lib/number'
|
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
|
import { useKvStore } from '@/pinia/kv'
|
|
|
|
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[]
|
|
}
|
|
|
|
type DictSource = Record<string, DictItem>
|
|
|
|
const props = defineProps<{
|
|
title: string
|
|
storageKey: string
|
|
parentStorageKey?: string
|
|
dict: DictSource
|
|
disableBudgetEditWhenStandardNull?: boolean
|
|
excludeNotshowByZxflxs?: boolean
|
|
initBudgetValueFromStandard?: boolean
|
|
}>()
|
|
|
|
const kvStore = useKvStore()
|
|
const detailRows = ref<FactorRow[]>([])
|
|
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
|
|
|
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 '点击输入'
|
|
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 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: '标准系数',
|
|
field: 'standardFactor',
|
|
minWidth: 86,
|
|
maxWidth: 100,
|
|
headerClass: 'ag-right-aligned-header',
|
|
cellClass: 'ag-right-aligned-cell',
|
|
flex: 0.9,
|
|
valueFormatter: params => formatReadonlyFactor(params.value)
|
|
},
|
|
{
|
|
headerName: '预算取值',
|
|
field: 'budgetValue',
|
|
minWidth: 86,
|
|
maxWidth: 100,
|
|
headerClass: 'ag-right-aligned-header',
|
|
cellClass: params => {
|
|
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
|
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line'
|
|
},
|
|
flex: 0.9,
|
|
cellClassRules: {
|
|
'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: '说明',
|
|
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 || '点击输入',
|
|
cellClass: 'editable-cell-line remark-wrap-cell',
|
|
cellClassRules: {
|
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
|
}
|
|
}
|
|
]
|
|
|
|
const autoGroupColumnDef: ColDef<FactorRow> = {
|
|
headerName: '专业编码以及工程专业名称',
|
|
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))
|
|
}
|
|
await kvStore.setItem(props.storageKey, payload)
|
|
} catch (error) {
|
|
console.error('saveToIndexedDB failed:', error)
|
|
}
|
|
}
|
|
|
|
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
|
|
if (!storageKey) return null
|
|
const data = await kvStore.getItem<GridState>(storageKey)
|
|
if (!data?.detailRows || !Array.isArray(data.detailRows)) return null
|
|
return data
|
|
}
|
|
|
|
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
|
|
const handleCellValueChanged = () => {
|
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
|
gridPersistTimer = setTimeout(() => {
|
|
void saveToIndexedDB()
|
|
}, 500)
|
|
}
|
|
|
|
const processCellForClipboard = (params: any) => {
|
|
if (Array.isArray(params.value)) {
|
|
return JSON.stringify(params.value)
|
|
}
|
|
return params.value
|
|
}
|
|
|
|
const processCellFromClipboard = (params: any) => {
|
|
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 = null
|
|
void saveToIndexedDB()
|
|
})
|
|
</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="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
|
<AgGridVue
|
|
:style="{ height: '100%' }"
|
|
:rowData="detailRows"
|
|
:columnDefs="columnDefs"
|
|
:autoGroupColumnDef="autoGroupColumnDef"
|
|
:gridOptions="gridOptions"
|
|
:theme="myTheme"
|
|
:treeData="true"
|
|
@cell-value-changed="handleCellValueChanged"
|
|
: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"
|
|
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|