重构
This commit is contained in:
1 parent
33913c29d2
commit
42fd6e48c4
19 files changed
+1163
-420
No files matched your search
+130
-144
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user