JGJS2026/src/components/views/pricingView/LandScalePricingPane.vue
2026-03-07 16:09:06 +08:00

926 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
import { getMajorDictEntries, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'reka-ui'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
id: string
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
benchmarkBudget: number | null
benchmarkBudgetBasic: number | null
benchmarkBudgetOptional: number | null
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
basicFormula: string
optionalFormula: string
consultCategoryFactor: number | null
majorFactor: number | null
workStageFactor: number | null
workRatio: number | null
budgetFee: number | null
budgetFeeBasic: number | null
budgetFeeOptional: number | null
remark: string
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const props = defineProps<{
contractId: string,
serviceId: string | number
}>()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const detailRows = ref<DetailRow[]>([])
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value),
loadMajorFactorMap(HT_MAJOR_FACTOR_KEY.value)
])
consultCategoryFactorMap.value = consultMap
majorFactorMap.value = majorMap
factorDefaultsLoaded = true
}
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
await loadFactorDefaults()
}
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 shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
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,
hasCost: item.hasCost !== false,
hasArea: item.hasArea !== false
})
}
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[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
for (const child of group.children) {
rows.push({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',
optionalFormula: '',
consultCategoryFactor: null,
majorFactor: null,
workStageFactor: 1,
workRatio: 100,
budgetFee: null,
budgetFeeBasic: null,
budgetFeeOptional: null,
remark: '',
path: [group.id, child.id]
})
}
}
return rows
}
type SourceRow = Pick<DetailRow, 'id'> &
Partial<
Pick<
DetailRow,
| 'amount'
| 'landArea'
| 'benchmarkBudget'
| 'benchmarkBudgetBasic'
| 'benchmarkBudgetOptional'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'basicFormula'
| 'optionalFormula'
| 'consultCategoryFactor'
| 'majorFactor'
| 'workStageFactor'
| 'workRatio'
| 'budgetFee'
| 'budgetFeeBasic'
| 'budgetFeeOptional'
| 'remark'
>
>
const mergeWithDictRows = (
rowsFromDb: SourceRow[] | undefined,
options?: { includeScaleValues?: boolean; includeFactorValues?: boolean }
): DetailRow[] => {
const includeScaleValues = options?.includeScaleValues ?? true
const includeFactorValues = options?.includeFactorValues ?? true
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
const rowId = String(row.id)
dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, 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: includeScaleValues && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: includeScaleValues && row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
benchmarkBudgetBasicChecked:
typeof fromDb.benchmarkBudgetBasicChecked === 'boolean' ? fromDb.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked:
typeof fromDb.benchmarkBudgetOptionalChecked === 'boolean' ? fromDb.benchmarkBudgetOptionalChecked : true,
basicFormula: typeof fromDb.basicFormula === 'string' ? fromDb.basicFormula : '',
optionalFormula: typeof fromDb.optionalFormula === 'string' ? fromDb.optionalFormula : '',
consultCategoryFactor:
!includeFactorValues
? null
: typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
majorFactor:
!includeFactorValues
? null
: typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor,
workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
budgetFeeBasic: typeof fromDb.budgetFeeBasic === 'number' ? fromDb.budgetFeeBasic : null,
budgetFeeOptional: typeof fromDb.budgetFeeOptional === 'number' ? fromDb.budgetFeeOptional : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
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) => {
return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
return formatEditableNumber(params)
}
const formatReadonlyMoney = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousands(roundTo(params.value, 2))
}
type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
const valueText = formatReadonlyMoney(params)
const hasValue = params.value != null && params.value !== ''
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
return valueText
}
const wrapper = document.createElement('div')
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'flex-end'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => {
params.data[checkField] = checkbox.checked
handleCellValueChanged()
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
wrapper.append(checkbox, valueSpan)
return wrapper
}
const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
getBenchmarkBudgetSplitByScale(row?.landArea, 'area')
const getBudgetFee = (
row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
) => {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null
const splitBudgetFee = getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
})
return splitBudgetFee ? splitBudgetFee.total : null
}
const getBudgetFeeSplit = (
row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
) => {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
})
}
const formatEditableFlexibleNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value, 3)
}
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 90,
flex: 2,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},
valueParser: params => {
const value = parseNumberOrNull(params.newValue)
return value == null ? null : roundTo(value, 3)
},
valueFormatter: formatEditableFlexibleNumber
},
{
headerName: '基准预算(元)',
marryChildren: true,
children: [
{
headerName: '基本工作',
field: 'benchmarkBudgetBasic',
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
valueGetter: params =>
params.node?.rowPinned
? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '可选工作',
field: 'benchmarkBudgetOptional',
colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
valueGetter: params =>
params.node?.rowPinned
? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '小计',
field: 'benchmarkBudget',
colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
valueGetter: params =>
params.node?.rowPinned
? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney
}
]
},
{
headerName: '预算费用',
marryChildren: true,
children: [
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
colId: 'consultCategoryFactor',
minWidth: 80,
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, { precision: 2 }),
valueFormatter: formatConsultCategoryFactor
},
{
headerName: '专业系数',
field: 'majorFactor',
colId: 'majorFactor',
minWidth: 80,
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, { precision: 2 }),
valueFormatter: formatMajorFactor
},
{
headerName: '工作环节系数(编审系数)',
field: 'workStageFactor',
colId: 'workStageFactor',
minWidth: 80,
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, { precision: 2 }),
valueFormatter: formatEditableNumber
},
{
headerName: '工作占比(%)',
field: 'workRatio',
colId: 'workRatio',
minWidth: 80,
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, { precision: 2 }),
valueFormatter: formatEditableNumber
},
{
headerName: '合计',
field: 'budgetFee',
colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueFormatter: formatReadonlyMoney
}
]
},
{
headerName: '说明',
field: 'remark',
minWidth: 100,
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: 250,
pinned: 'left',
flex: 2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return totalLabel.value
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalBenchmarkBudgetBasic = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.basic))
const totalBenchmarkBudgetOptional = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.optional))
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.total))
const totalBudgetFeeBasic = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: null,
landArea: null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',
optionalFormula: '',
consultCategoryFactor: null,
majorFactor: null,
workStageFactor: null,
workRatio: null,
budgetFee: totalBudgetFee.value,
budgetFeeBasic: totalBudgetFeeBasic.value,
budgetFeeOptional: totalBudgetFeeOptional.value,
remark: '',
path: ['TOTAL']
}
])
const syncComputedValuesToDetailRows = () => {
for (const row of detailRows.value) {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row)
const budgetFeeSplit = benchmarkBudgetSplit
? getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
: null
row.benchmarkBudget = benchmarkBudgetSplit?.total ?? null
row.benchmarkBudgetBasic = benchmarkBudgetSplit?.basic ?? null
row.benchmarkBudgetOptional = benchmarkBudgetSplit?.optional ?? null
row.basicFormula = benchmarkBudgetSplit?.basicFormula ?? ''
row.optionalFormula = benchmarkBudgetSplit?.optionalFormula ?? ''
row.budgetFee = budgetFeeSplit?.total ?? null
row.budgetFeeBasic = budgetFeeSplit?.basic ?? null
row.budgetFeeOptional = budgetFeeSplit?.optional ?? null
}
}
const buildPersistDetailRows = () => {
syncComputedValuesToDetailRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'landScale',
value: totalBudgetFee.value
})
if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
await ensureFactorDefaultsLoaded()
const applyContractDefaultRows = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
syncComputedValuesToDetailRows()
}
if (shouldForceDefaultLoad()) {
await applyContractDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
syncComputedValuesToDetailRows()
return
}
await applyContractDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
syncComputedValuesToDetailRows()
}
}
const importContractData = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
// 使用默认数据时,强制读取最新的项目系数(预算取值优先,空值回退标准系数)
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
await saveToIndexedDB()
} catch (error) {
console.error('importContractData failed:', error)
}
}
const clearAllData = async () => {
try {
detailRows.value = buildDefaultRows()
await saveToIndexedDB()
} catch (error) {
console.error('clearAllData failed:', error)
}
}
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 = () => {
syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void 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="flex items-center gap-2">
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">清空</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前用地规模明细是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认覆盖当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用合同默认数据覆盖当前用地规模明细是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button @click="importContractData">确认覆盖</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</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>