2026-02-26 15:55:36 +08:00

487 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
interface DictLeaf {
id: string
code: string
name: string
}
interface DictGroup {
id: string
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
amount: number | null
landArea: number | null
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const DB_KEY = 'xm-info-v3'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const projectName = ref(DEFAULT_PROJECT_NAME)
const detailRows = ref<DetailRow[]>([])
const rootRef = ref<HTMLElement | null>(null)
const gridSectionRef = ref<HTMLElement | null>(null)
const agGridRef = ref<HTMLElement | null>(null)
const agGridHeight = ref(580)
let snapScrollHost: HTMLElement | null = null
let snapTimer: ReturnType<typeof setTimeout> | null = null
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
let isSnapping = false
let hostResizeObserver: ResizeObserver | null = null
const updateGridCardHeight = () => {
if (!snapScrollHost || !rootRef.value) return
const contentWrap = rootRef.value.parentElement
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight
}
const bindSnapScrollHost = () => {
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
if (!snapScrollHost) return
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
hostResizeObserver?.disconnect()
hostResizeObserver = new ResizeObserver(() => {
updateGridCardHeight()
})
hostResizeObserver.observe(snapScrollHost)
updateGridCardHeight()
}
const unbindSnapScrollHost = () => {
if (snapScrollHost) {
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
}
hostResizeObserver?.disconnect()
hostResizeObserver = null
snapScrollHost = null
}
const trySnapToGrid = () => {
if (isSnapping || !snapScrollHost || !agGridRef.value) return
const hostRect = snapScrollHost.getBoundingClientRect()
const gridRect = agGridRef.value.getBoundingClientRect()
const offsetTop = gridRect.top - hostRect.top
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
const inSnapRange = offsetTop > -120 && offsetTop < 180
if (!inVisibleBand || !inSnapRange) return
isSnapping = true
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (snapLockTimer) clearTimeout(snapLockTimer)
snapLockTimer = setTimeout(() => {
isSnapping = false
}, 420)
}
function handleSnapHostScroll() {
if (isSnapping) return
if (snapTimer) clearTimeout(snapTimer)
snapTimer = setTimeout(() => {
trySnapToGrid()
}, 90)
}
type MajorLite = { code: string; name: string }
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, MajorLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(majorEntries.map(([key, item]) => [item.code, { id: key, ...item }]))
for (const [key, item] of majorEntries) {
const isGroup = !item.code.includes('-')
if (isGroup) {
if (!groupMap.has(item.code)) groupOrder.push(item.code)
groupMap.set(item.code, {
id: key,
code: item.code,
name: item.name,
children: []
})
continue
}
const parentCode = item.code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
})
}
groupMap.get(parentCode)!.children.push({
id: key,
code: item.code,
name: item.name
})
}
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
})()
const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
path: [group.id, child.id]
})
}
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
try {
const payload: XmInfoState = {
projectName: projectName.value,
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log(payload)
await localforage.setItem(DB_KEY, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
if (data) {
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 250)
}
// const handleBeforeUnload = () => {
// void saveToIndexedDB()
// }
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}
watch(projectName, schedulePersist)
onMounted(async () => {
await loadFromIndexedDB()
bindSnapScrollHost()
requestAnimationFrame(() => {
updateGridCardHeight()
})
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload)
unbindSnapScrollHost()
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
if (snapTimer) clearTimeout(snapTimer)
if (snapLockTimer) clearTimeout(snapLockTimer)
void saveToIndexedDB()
})
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 (e) {
// 解析失败时返回原始值,无需额外处理
}
return params.value;
};
</script>
<template>
<div ref="rootRef" class="space-y-6">
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
<div class="mb-2 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-foreground">项目名称</label>
</div>
<input
v-model="projectName"
type="text"
placeholder="请输入项目名称"
class="h-10 w-full max-w-xl rounded-md border bg-background px-3 text-sm outline-none ring-offset-background transition focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div
ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3"
>
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">项目明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</div>
</div>
</div>
</template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>