494 lines
16 KiB
Vue
494 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import type { ColDef } from 'ag-grid-community'
|
|
import localforage from 'localforage'
|
|
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
|
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
|
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
|
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
|
|
|
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
|
|
benchmarkBudget: number | null
|
|
consultCategoryFactor: number | null
|
|
majorFactor: number | null
|
|
budgetFee: number | null
|
|
remark: string
|
|
path: string[]
|
|
}
|
|
|
|
interface XmInfoState {
|
|
projectName: string
|
|
detailRows: DetailRow[]
|
|
}
|
|
|
|
const props = defineProps<{
|
|
contractId: string,
|
|
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; 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] => {
|
|
const item = entry[1]
|
|
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[] = []
|
|
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
|
|
|
for (const [key, item] of serviceEntries) {
|
|
const code = item.code
|
|
const isGroup = !code.includes('-')
|
|
if (isGroup) {
|
|
if (!groupMap.has(code)) groupOrder.push(code)
|
|
groupMap.set(code, {
|
|
id: key,
|
|
code,
|
|
name: item.name,
|
|
children: []
|
|
})
|
|
continue
|
|
}
|
|
|
|
const parentCode = 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,
|
|
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,
|
|
benchmarkBudget: null,
|
|
consultCategoryFactor: defaultConsultCategoryFactor.value,
|
|
majorFactor: getDefaultMajorFactorById(child.id),
|
|
budgetFee: null,
|
|
remark: '',
|
|
path: [group.id, child.id]
|
|
})
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
: 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 : ''
|
|
}
|
|
})
|
|
}
|
|
|
|
const parseNumberOrNull = (value: unknown) => {
|
|
if (value === '' || value == null) return null
|
|
const v = Number(value)
|
|
return Number.isFinite(v) ? v : null
|
|
}
|
|
|
|
const formatEditableNumber = (params: any) => {
|
|
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 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 roundTo(params.value, 2).toFixed(2)
|
|
}
|
|
|
|
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
|
|
const result = getBasicFeeFromScale(row?.amount, 'cost')
|
|
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : 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 roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
|
|
}
|
|
|
|
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: decimalAggSum,
|
|
valueParser: params => parseNumberOrNull(params.newValue),
|
|
valueFormatter: formatEditableNumber
|
|
},
|
|
{
|
|
headerName: '基准预算(元)',
|
|
field: 'benchmarkBudget',
|
|
minWidth: 170,
|
|
flex: 1,
|
|
aggFunc: decimalAggSum,
|
|
valueGetter: params => getBenchmarkBudgetByAmount(params.data),
|
|
valueParser: params => parseNumberOrNull(params.newValue),
|
|
valueFormatter: formatReadonlyNumber
|
|
},
|
|
{
|
|
headerName: '咨询分类系数',
|
|
field: 'consultCategoryFactor',
|
|
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 === '')
|
|
},
|
|
valueParser: params => parseNumberOrNull(params.newValue),
|
|
valueFormatter: formatConsultCategoryFactor
|
|
},
|
|
{
|
|
headerName: '专业系数',
|
|
field: 'majorFactor',
|
|
minWidth: 130,
|
|
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 === '')
|
|
},
|
|
valueParser: params => parseNumberOrNull(params.newValue),
|
|
valueFormatter: formatMajorFactor
|
|
},
|
|
{
|
|
headerName: '预算费用',
|
|
field: 'budgetFee',
|
|
minWidth: 150,
|
|
flex: 1,
|
|
aggFunc: decimalAggSum,
|
|
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
|
|
valueParser: params => parseNumberOrNull(params.newValue),
|
|
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 remark-wrap-cell' : ''),
|
|
cellClassRules: {
|
|
'editable-cell-empty': params =>
|
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || 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 totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
|
|
|
|
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetByAmount(row)))
|
|
|
|
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
|
|
const pinnedTopRowData = computed(() => [
|
|
{
|
|
id: 'pinned-total-row',
|
|
groupCode: '',
|
|
groupName: '',
|
|
majorCode: '',
|
|
majorName: '',
|
|
amount: totalAmount.value,
|
|
benchmarkBudget: totalBenchmarkBudget.value,
|
|
consultCategoryFactor: null,
|
|
majorFactor: null,
|
|
budgetFee: totalBudgetFee.value,
|
|
remark: '',
|
|
path: ['TOTAL']
|
|
}
|
|
])
|
|
|
|
|
|
|
|
const saveToIndexedDB = async () => {
|
|
if (shouldSkipPersist()) return
|
|
try {
|
|
const payload = {
|
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
|
}
|
|
console.log('Saving to IndexedDB:', payload)
|
|
await localforage.setItem(DB_KEY.value, payload)
|
|
} catch (error) {
|
|
console.error('saveToIndexedDB failed:', error)
|
|
}
|
|
}
|
|
|
|
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)
|
|
detailRows.value = buildDefaultRows()
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
|
(nextVersion, prevVersion) => {
|
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
|
void loadFromIndexedDB()
|
|
}
|
|
)
|
|
|
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
|
|
|
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
|
const handleCellValueChanged = () => {
|
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
|
gridPersistTimer = setTimeout(() => {
|
|
void saveToIndexedDB()
|
|
}, 1000)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadFromIndexedDB()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (persistTimer) clearTimeout(persistTimer)
|
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
|
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 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">
|
|
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
|
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
|
</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" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
|