This commit is contained in:
zwgczx committed 2026-02-27 17:06:31 +08:00
1 parent 57a2029847
commit badf131dde
18 files changed
+1081 -2824

No files matched your search

@@ -1,11 +1,12 @@
<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 type { ColDef } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -45,13 +46,41 @@ interface XmInfoState {
const props = defineProps<{
contractId: string,
serviceId: string|number
serviceId: string | number
}>()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([])
type serviceLite = { defCoe: number | null }
const defaultConsultCategoryFactor = computed<number | null>(() => {
const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
})
type majorLite = { code: string; name: string }
type majorLite = { code: string; name: string; defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => {
@@ -59,6 +88,11 @@ const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
return Boolean(item?.code && item?.name)
})
const getDefaultMajorFactorById = (id: string): number | null => {
const major = (majorList as Record<string, majorLite | undefined>)[id]
return typeof major?.defCoe === 'number' && Number.isFinite(major.defCoe) ? major.defCoe : null
}
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
@@ -120,8 +154,8 @@ const buildDefaultRows = (): DetailRow[] => {
majorName: child.name,
amount: null,
benchmarkBudget: null,
consultCategoryFactor: null,
majorFactor: null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null,
remark: '',
path: [group.id, child.id]
@@ -131,8 +165,9 @@ const buildDefaultRows = (): DetailRow[] => {
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
@@ -140,14 +175,25 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null,
typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
@@ -168,17 +214,41 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2)
}
const formatConsultCategoryFactor = (params: any) => {
if (params.node?.group) {
if (defaultConsultCategoryFactor.value == null) return ''
return Number(defaultConsultCategoryFactor.value).toFixed(2)
}
return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
if (params.node?.group) {
const groupId = String(params.node?.key || '')
const v = getDefaultMajorFactorById(groupId)
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
const formatReadonlyNumber = (params: any) => {
if (params.value == null || params.value === '') return ''
return Number(params.value).toFixed(2)
}
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
const result = getBasicFeeFromScale(row?.amount, 'cost')
return result ? addNumbers(result.basic, result.optional) : null
}
const getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
return benchmarkBudget * row.majorFactor * row.consultCategoryFactor
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '工程专业名称',
minWidth: 220,
width: 240,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
}
},
{
headerName: '造价金额(万元)',
field: 'amount',
@@ -191,7 +261,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
@@ -200,16 +270,10 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'benchmarkBudget',
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',
aggFunc: decimalAggSum,
valueGetter: params => getBenchmarkBudgetByAmount(params.data),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatReadonlyNumber
},
{
headerName: '咨询分类系数',
@@ -223,7 +287,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatConsultCategoryFactor
},
{
headerName: '专业系数',
@@ -237,34 +301,34 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatMajorFactor
},
{
headerName: '预算费用',
field: 'budgetFee',
minWidth: 150,
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',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatReadonlyNumber
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@@ -273,10 +337,10 @@ const columnDefs: ColDef<DetailRow>[] = [
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码',
minWidth: 160,
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
width: 170,
flex: 2,
cellRendererParams: {
suppressCount: true
@@ -286,39 +350,16 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
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 totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalBenchmarkBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetByAmount(row)))
const totalBudgetFee = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
)
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -339,9 +380,10 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
@@ -352,12 +394,24 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
@@ -365,6 +419,14 @@ const loadFromIndexedDB = async () => {
}
}
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null
@@ -378,7 +440,6 @@ const handleCellValueChanged = () => {
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
})
@@ -388,14 +449,14 @@ onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
@@ -408,7 +469,7 @@ const processCellFromClipboard = (params:any) => {
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
@@ -417,58 +478,16 @@ const processCellFromClipboard = (params:any) => {
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<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"
/>
<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>