399 lines
12 KiB
Vue
399 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
|
import localforage from 'localforage'
|
|
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
|
import { formatThousands } from '@/lib/numberFormat'
|
|
import { industryTypeList } from '@/sql'
|
|
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
|
|
|
interface DetailRow {
|
|
id: string
|
|
groupCode: string
|
|
groupName: string
|
|
majorCode: string
|
|
majorName: string
|
|
hasCost: boolean
|
|
hasArea: boolean
|
|
amount: number | null
|
|
landArea: number | null
|
|
path: string[]
|
|
hide?: boolean
|
|
}
|
|
|
|
interface XmBaseInfoState {
|
|
projectIndustry?: string
|
|
}
|
|
|
|
interface GridPersistState {
|
|
detailRows?: DetailRow[]
|
|
roughCalcEnabled?: boolean
|
|
totalAmount?: number | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
title: string
|
|
rowData: DetailRow[]
|
|
dbKey: string
|
|
}>()
|
|
|
|
const BASE_INFO_KEY = 'xm-base-info-v1'
|
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
|
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
|
const activeIndustryId = ref('')
|
|
const industryNameMap = new Map(
|
|
industryTypeList.flatMap(item => [
|
|
[String(item.id).trim(), item.name],
|
|
[String(item.type).trim(), item.name]
|
|
])
|
|
)
|
|
const totalLabel = computed(() => {
|
|
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
|
|
return industryName ? `${industryName}总投资` : '总投资'
|
|
})
|
|
const roughCalcEnabled = ref(false)
|
|
const visibleRowData = computed(() => props.rowData.filter(row => !row.hide))
|
|
|
|
const refreshPinnedTotalLabelCell = () => {
|
|
if (!gridApi.value) return
|
|
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
|
|
if (!pinnedTopNode) return
|
|
gridApi.value.refreshCells({
|
|
rowNodes: [pinnedTopNode],
|
|
force: true
|
|
})
|
|
}
|
|
|
|
const columnDefs: ColDef<DetailRow>[] = [
|
|
{
|
|
headerName: '造价金额(万元)',
|
|
field: 'amount',
|
|
headerClass: 'ag-right-aligned-header',
|
|
minWidth: 100,
|
|
flex: 1,
|
|
editable: params => {
|
|
if (roughCalcEnabled.value) return Boolean(params.node?.rowPinned)
|
|
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
|
|
},
|
|
cellClass: params =>
|
|
roughCalcEnabled.value && params.node?.rowPinned
|
|
? 'ag-right-aligned-cell editable-cell-line'
|
|
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
|
|
? 'ag-right-aligned-cell editable-cell-line'
|
|
: 'ag-right-aligned-cell',
|
|
cellClassRules: {
|
|
'editable-cell-empty': params =>
|
|
roughCalcEnabled.value && params.node?.rowPinned
|
|
? params.value == null || params.value === ''
|
|
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
|
|
},
|
|
aggFunc: decimalAggSum,
|
|
valueParser: params => {
|
|
if (params.newValue === '' || params.newValue == null) return null
|
|
const v = Number(params.newValue)
|
|
return Number.isFinite(v) ? roundTo(v, 2) : null
|
|
},
|
|
valueFormatter: params => {
|
|
if (roughCalcEnabled.value) {
|
|
if (!params.node?.rowPinned) return ''
|
|
if (params.value == null || params.value === '') return '点击输入'
|
|
return formatThousands(params.value)
|
|
}
|
|
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
|
|
return ''
|
|
}
|
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
return '点击输入'
|
|
}
|
|
if (params.value == null) return ''
|
|
return formatThousands(params.value)
|
|
}
|
|
},
|
|
{
|
|
headerName: '用地面积(亩)',
|
|
field: 'landArea',
|
|
headerClass: 'ag-right-aligned-header',
|
|
minWidth: 100,
|
|
flex: 1,
|
|
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
|
|
cellClass: params =>
|
|
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
|
|
? 'ag-right-aligned-cell editable-cell-line'
|
|
: 'ag-right-aligned-cell',
|
|
cellClassRules: {
|
|
'editable-cell-empty': params =>
|
|
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
|
|
},
|
|
valueParser: params => {
|
|
if (params.newValue === '' || params.newValue == null) return null
|
|
const v = Number(params.newValue)
|
|
return Number.isFinite(v) ? roundTo(v, 3) : null
|
|
},
|
|
valueFormatter: params => {
|
|
if (roughCalcEnabled.value) {
|
|
return ''
|
|
}
|
|
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
|
|
return ''
|
|
}
|
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
return '点击输入'
|
|
}
|
|
if (params.value == null) return ''
|
|
return formatThousands(params.value, 3)
|
|
}
|
|
}
|
|
]
|
|
|
|
const autoGroupColumnDef: ColDef = {
|
|
headerName: '专业编码以及工程专业名称',
|
|
minWidth: 200,
|
|
flex: 2,
|
|
cellRendererParams: {
|
|
suppressCount: true
|
|
},
|
|
valueFormatter: params => {
|
|
if (params.node?.rowPinned) return totalLabel.value
|
|
return String(params.value || '')
|
|
},
|
|
tooltipValueGetter: params => {
|
|
if (params.node?.rowPinned) return totalLabel.value
|
|
return String(params.value || '')
|
|
}
|
|
}
|
|
|
|
const pinnedTopRowData = ref<DetailRow[]>([
|
|
{
|
|
id: 'pinned-total-row',
|
|
groupCode: '',
|
|
groupName: '',
|
|
majorCode: '',
|
|
majorName: '',
|
|
hasCost: false,
|
|
hasArea: false,
|
|
amount: null,
|
|
landArea: null,
|
|
path: ['TOTAL']
|
|
}
|
|
])
|
|
|
|
const saveToIndexedDB = async () => {
|
|
try {
|
|
const payload: GridPersistState = {
|
|
detailRows: props.rowData.map(row => ({
|
|
...JSON.parse(JSON.stringify(row)),
|
|
hide: Boolean(row.hide)
|
|
}))
|
|
}
|
|
payload.roughCalcEnabled = roughCalcEnabled.value
|
|
payload.totalAmount = pinnedTopRowData.value[0].amount
|
|
await localforage.setItem(props.dbKey, payload)
|
|
} catch (error) {
|
|
console.error('saveToIndexedDB failed:', error)
|
|
}
|
|
}
|
|
|
|
const schedulePersist = () => {
|
|
if (persistTimer) clearTimeout(persistTimer)
|
|
persistTimer = setTimeout(() => {
|
|
void saveToIndexedDB()
|
|
}, 600)
|
|
}
|
|
|
|
const setDetailRowsHidden = (hidden: boolean) => {
|
|
for (const row of props.rowData) {
|
|
row.hide = hidden
|
|
}
|
|
}
|
|
|
|
const onRoughCalcSwitch = (checked: boolean) => {
|
|
gridApi.value?.stopEditing(true)
|
|
roughCalcEnabled.value = checked
|
|
setDetailRowsHidden(checked)
|
|
if (!checked) {
|
|
syncPinnedTotalForNormalMode()
|
|
}else{
|
|
pinnedTopRowData.value[0].amount = null
|
|
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
|
|
if (pinnedTopNode) {
|
|
pinnedTopNode.setDataValue('amount', null)
|
|
}
|
|
}
|
|
schedulePersist()
|
|
}
|
|
|
|
|
|
|
|
const onCellValueChanged = (event: CellValueChangedEvent) => {
|
|
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
|
|
if (typeof event.newValue === 'number') {
|
|
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
|
|
} else {
|
|
const parsed = Number(event.newValue)
|
|
pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
|
|
}
|
|
|
|
} else if (!roughCalcEnabled.value) {
|
|
syncPinnedTotalForNormalMode()
|
|
}
|
|
schedulePersist()
|
|
}
|
|
|
|
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
|
gridApi.value = event.api
|
|
void loadIndustryFromBaseInfo()
|
|
|
|
|
|
void loadGridPersistState()
|
|
void refreshPinnedTotalLabelCell()
|
|
|
|
}
|
|
|
|
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) {
|
|
// no-op
|
|
}
|
|
return params.value
|
|
}
|
|
|
|
const loadIndustryFromBaseInfo = async () => {
|
|
try {
|
|
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
|
activeIndustryId.value =
|
|
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
|
} catch (error) {
|
|
console.error('loadIndustryFromBaseInfo failed:', error)
|
|
activeIndustryId.value = ''
|
|
}
|
|
}
|
|
|
|
const loadGridPersistState = async () => {
|
|
try {
|
|
const data = await localforage.getItem<GridPersistState>(props.dbKey)
|
|
roughCalcEnabled.value = Boolean(data?.roughCalcEnabled)
|
|
const detailRows = Array.isArray(data?.detailRows) ? data.detailRows : []
|
|
const detailRowById = new Map(detailRows.map(row => [row.id, row]))
|
|
for (const row of props.rowData) {
|
|
const persisted = detailRowById.get(row.id)
|
|
if (!persisted) {
|
|
row.hide = roughCalcEnabled.value
|
|
continue
|
|
}
|
|
row.amount = typeof persisted.amount === 'number' ? roundTo(persisted.amount, 2) : null
|
|
row.landArea = typeof persisted.landArea === 'number' ? roundTo(persisted.landArea, 3) : null
|
|
row.hide = typeof persisted.hide === 'boolean' ? persisted.hide : roughCalcEnabled.value
|
|
}
|
|
pinnedTopRowData.value[0].amount = typeof data?.totalAmount === 'number' ? data.totalAmount : null
|
|
|
|
const pinnedTopNode = (gridApi as any).value.getPinnedTopRow(0)
|
|
if (pinnedTopNode) {
|
|
pinnedTopNode.setDataValue('amount', pinnedTopRowData.value[0].amount)
|
|
}
|
|
} catch (error) {
|
|
console.error('loadGridPersistState failed:', error)
|
|
roughCalcEnabled.value = false
|
|
pinnedTopRowData.value[0].amount= null
|
|
}
|
|
}
|
|
|
|
|
|
|
|
watch(totalLabel, () => {
|
|
refreshPinnedTotalLabelCell()
|
|
})
|
|
|
|
const syncPinnedTotalForNormalMode = () => {
|
|
if (roughCalcEnabled.value) return
|
|
|
|
if (!gridApi.value) {
|
|
pinnedTopRowData.value[0].amount = sumByNumber(props.rowData, row => row.amount)
|
|
return
|
|
}
|
|
let total = 0
|
|
let hasValue = false
|
|
|
|
props.rowData.forEach(node => {
|
|
|
|
const amount = node.amount
|
|
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
|
total += amount
|
|
hasValue = true
|
|
}
|
|
})
|
|
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
|
|
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
|
|
if (pinnedTopNode) {
|
|
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (persistTimer) clearTimeout(persistTimer)
|
|
gridApi.value = null
|
|
void saveToIndexedDB()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-full">
|
|
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
|
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
|
<h3 class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
|
|
{{ props.title }}
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<span class=" text-xs text-muted-foreground">粗略计算</span>
|
|
<SwitchRoot
|
|
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
|
|
:modelValue="roughCalcEnabled"
|
|
@update:modelValue="onRoughCalcSwitch"
|
|
>
|
|
<SwitchThumb
|
|
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4"
|
|
/>
|
|
</SwitchRoot>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
|
|
<AgGridVue
|
|
:style="{ height: '100%' }"
|
|
:rowData="visibleRowData"
|
|
:pinnedTopRowData="pinnedTopRowData"
|
|
:columnDefs="columnDefs"
|
|
:autoGroupColumnDef="autoGroupColumnDef"
|
|
:gridOptions="gridOptions"
|
|
:theme="myTheme"
|
|
@grid-ready="onGridReady"
|
|
@cell-value-changed="onCellValueChanged"
|
|
: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>
|