This commit is contained in:
zwgczx committed 2026-03-04 17:43:06 +08:00
1 parent 33913c29d2
commit 42fd6e48c4
19 files changed
+1163 -420

No files matched your search

+130 -144
View File
@@ -1,16 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import type { ColDef, GridApi } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import MethodUnavailableNotice from '@/components/views/pricingView/MethodUnavailableNotice.vue'
interface DictLeaf {
id: string
@@ -36,26 +34,31 @@ interface DetailRow {
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
interface XmScaleState {
detailRows?: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const DB_KEY = 'xm-info-v3'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const BASE_INFO_KEY = 'xm-base-info-v1'
type MajorLite = { code: string; name: string }
const projectName = ref('')
const detailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
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 gridPersistTimer: ReturnType<typeof setTimeout> | null = null
let isSnapping = false
let hostResizeObserver: ResizeObserver | null = null
const activeIndustryCode = ref('')
const hasProjectBaseInfo = ref(false)
const detailDict = ref<DictGroup[]>([])
const idLabelMap = ref(new Map<string, string>())
const updateGridCardHeight = () => {
if (!snapScrollHost || !rootRef.value) return
@@ -64,8 +67,7 @@ const updateGridCardHeight = () => {
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
agGridHeight.value = nextHeight + 10
}
const bindSnapScrollHost = () => {
@@ -115,7 +117,7 @@ function handleSnapHostScroll() {
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] => {
@@ -123,12 +125,12 @@ const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
return Boolean(item?.code && item?.name)
})
const detailDict: DictGroup[] = (() => {
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(majorEntries.map(([key, item]) => [item.code, { id: key, ...item }]))
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, ...item }]))
for (const [key, item] of majorEntries) {
for (const [key, item] of entries) {
const isGroup = !item.code.includes('-')
if (isGroup) {
if (!groupMap.has(item.code)) groupOrder.push(item.code)
@@ -161,19 +163,32 @@ const detailDict: DictGroup[] = (() => {
}
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 rebuildDictByIndustry = (industryCode: string) => {
if (!industryCode) {
detailDict.value = []
idLabelMap.value = new Map()
return
}
const filteredEntries = majorEntries.filter(([, item]) =>
item.code === industryCode || item.code.startsWith(`${industryCode}-`)
)
const nextDict = buildDetailDict(filteredEntries)
const nextLabelMap = new Map<string, string>()
for (const group of nextDict) {
nextLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
nextLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
detailDict.value = nextDict
idLabelMap.value = nextLabelMap
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const group of detailDict.value) {
for (const child of group.children) {
rows.push({
id: child.id,
@@ -209,26 +224,23 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>{
// console.log(!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === ''))
return !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')}
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (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) ? v : null
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
@@ -241,27 +253,29 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (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) ? v : null
return Number.isFinite(v) ? roundTo(v, 3) : 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))
return formatThousands(params.value, 3)
}
}
]
@@ -269,8 +283,7 @@ const columnDefs: ColDef<DetailRow>[] = [
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 200,
flex:2, // 核心:开启弹性布局,自动占满剩余空间
flex: 2,
cellRendererParams: {
suppressCount: true
},
@@ -279,20 +292,17 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
return idLabelMap.value.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
return idLabelMap.value.get(nodeId) || nodeId
}
}
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -301,18 +311,16 @@ const pinnedTopRowData = computed(() => [
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
landArea: null,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (!activeIndustryCode.value) return
try {
const payload: XmInfoState = {
projectName: projectName.value,
detailRows: JSON.parse(JSON.stringify(detailRows.value))
const payload: XmScaleState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(DB_KEY, payload)
} catch (error) {
@@ -322,37 +330,30 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
if (data) {
console.log('loaded data:', data)
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
hasProjectBaseInfo.value = Boolean(baseInfo)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
rebuildDictByIndustry(activeIndustryCode.value)
if (!activeIndustryCode.value) {
detailRows.value = []
return
}
const data = await localforage.getItem<XmScaleState>(DB_KEY)
if (data?.detailRows) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
projectName.value = DEFAULT_PROJECT_NAME
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
projectName.value = DEFAULT_PROJECT_NAME
detailRows.value = buildDefaultRows()
hasProjectBaseInfo.value = false
detailRows.value = []
}
}
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(() => {
@@ -360,105 +361,90 @@ const handleCellValueChanged = () => {
}, 1000)
}
watch(projectName, schedulePersist)
onMounted(async () => {
await loadFromIndexedDB()
bindSnapScrollHost()
requestAnimationFrame(() => {
updateGridCardHeight()
})
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onActivated(() => {
void loadFromIndexedDB()
})
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) => {
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(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;
};
const scrollToGridSection = () => {
const target = gridSectionRef.value || agGridRef.value
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
return params.value
}
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (e) {
// no-op
}
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="rootRef" class="h-full">
<MethodUnavailableNotice
v-if="!hasProjectBaseInfo"
title="请先在“基础信息”里新建项目"
message="完成新建后将自动加载规模信息。"
/>
<div
ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden"
:style="{ height: `${agGridHeight}px` }"
v-else
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">
<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"
@click="scrollToGridSection"
>
项目明细
</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"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<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"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</div>
</div>
</div>