491 lines
14 KiB
Vue
491 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+10
|
|
|
|
}
|
|
|
|
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 String(Number(params.value))
|
|
}
|
|
}
|
|
]
|
|
|
|
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))
|
|
}
|
|
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-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<label class="block text-sm font-medium text-foreground">项目名称</label>
|
|
<p class="mt-1 text-xs text-muted-foreground">该名称会用于项目级计算与展示</p>
|
|
</div>
|
|
</div>
|
|
<input
|
|
v-model="projectName"
|
|
type="text"
|
|
placeholder="请输入项目名称"
|
|
class="h-10 w-full max-w-6xl rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref="gridSectionRef"
|
|
class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden"
|
|
:style="{ height: `${agGridHeight}px` }"
|
|
>
|
|
<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 flex-1 min-h-0">
|
|
<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>
|