1209 lines
44 KiB
Vue
1209 lines
44 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
|
import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
|
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
|
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
|
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
|
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
|
import { useKvStore } from '@/pinia/kv'
|
|
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,
|
|
NumberFieldDecrement,
|
|
NumberFieldIncrement,
|
|
NumberFieldInput,
|
|
NumberFieldRoot,
|
|
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
|
|
projectIndex?: number
|
|
majorDictId?: 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 | null
|
|
optionalFormula: string | null
|
|
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
|
|
}
|
|
|
|
interface ServiceLite {
|
|
mutiple?: boolean | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
contractId: string,
|
|
serviceId: string | number
|
|
projectInfoKey?: string
|
|
}>()
|
|
const zxFwPricingStore = useZxFwPricingStore()
|
|
const kvStore = useKvStore()
|
|
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 = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
|
const activeIndustryCode = ref('')
|
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
|
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
|
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 isMutipleService = computed(() => {
|
|
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
|
|
return service?.mutiple === true
|
|
})
|
|
const projectCount = ref<number>(1)
|
|
const PROJECT_PATH_PREFIX = 'project-'
|
|
const PROJECT_ROW_ID_SEPARATOR = '::'
|
|
const normalizeProjectCount = (value: unknown) => {
|
|
const parsed = Number(value)
|
|
if (!Number.isFinite(parsed)) return 1
|
|
return Math.max(1, Math.floor(parsed))
|
|
}
|
|
const getTargetProjectCount = () => (isMutipleService.value ? normalizeProjectCount(projectCount.value) : 1)
|
|
const buildProjectGroupPathKey = (projectIndex: number) => `${PROJECT_PATH_PREFIX}${projectIndex}`
|
|
const parseProjectIndexFromPathKey = (value: string) => {
|
|
const match = /^project-(\d+)$/.exec(value)
|
|
if (!match) return null
|
|
return normalizeProjectCount(Number(match[1]))
|
|
}
|
|
const buildScopedRowId = (projectIndex: number, majorId: string) =>
|
|
isMutipleService.value ? `${projectIndex}${PROJECT_ROW_ID_SEPARATOR}${majorId}` : majorId
|
|
const parseScopedRowId = (id: unknown) => {
|
|
const rawId = String(id || '')
|
|
const match = /^(\d+)::(.+)$/.exec(rawId)
|
|
if (!match) {
|
|
return {
|
|
projectIndex: 1,
|
|
majorDictId: rawId
|
|
}
|
|
}
|
|
return {
|
|
projectIndex: normalizeProjectCount(Number(match[1])),
|
|
majorDictId: String(match[2] || '').trim()
|
|
}
|
|
}
|
|
const resolveRowProjectIndex = (row: Partial<DetailRow> | undefined) => {
|
|
if (!row) return 1
|
|
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
|
|
return normalizeProjectCount(row.projectIndex)
|
|
}
|
|
if (Array.isArray(row.path) && row.path.length > 0) {
|
|
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
|
|
if (projectIndexFromPath != null) return projectIndexFromPath
|
|
}
|
|
return parseScopedRowId(row.id).projectIndex
|
|
}
|
|
const resolveRowMajorDictId = (row: Partial<DetailRow> | undefined) => {
|
|
if (!row) return ''
|
|
const direct = String(row.majorDictId || '').trim()
|
|
if (direct) return majorIdAliasMap.get(direct) || direct
|
|
const parsed = parseScopedRowId(row.id).majorDictId
|
|
return majorIdAliasMap.get(parsed) || parsed
|
|
}
|
|
const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
|
|
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
|
|
const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
|
|
if (!isMutipleService.value) return 1
|
|
let maxProjectIndex = 1
|
|
for (const row of rows || []) {
|
|
maxProjectIndex = Math.max(maxProjectIndex, resolveRowProjectIndex(row))
|
|
}
|
|
return maxProjectIndex
|
|
}
|
|
|
|
const getMethodState = () =>
|
|
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale')
|
|
|
|
const detailRows = computed<DetailRow[]>({
|
|
get: () => {
|
|
const rows = getMethodState()?.detailRows
|
|
return Array.isArray(rows) ? rows : []
|
|
},
|
|
set: rows => {
|
|
const currentState = getMethodState()
|
|
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'landScale', {
|
|
detailRows: rows,
|
|
projectCount: currentState?.projectCount ?? getTargetProjectCount()
|
|
})
|
|
}
|
|
})
|
|
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: []
|
|
})
|
|
}
|
|
|
|
const hasArea = item.hasArea !== false
|
|
if (!hasArea) continue
|
|
|
|
groupMap.get(parentCode)!.children.push({
|
|
id: key,
|
|
code,
|
|
name: item.name,
|
|
hasCost: item.hasCost !== false,
|
|
hasArea
|
|
})
|
|
}
|
|
|
|
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 = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
|
|
if (!activeIndustryCode.value) return []
|
|
const rows: DetailRow[] = []
|
|
for (let projectIndex = 1; projectIndex <= projectCountValue; projectIndex++) {
|
|
for (const group of detailDict) {
|
|
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
|
|
for (const child of group.children) {
|
|
const rowId = buildScopedRowId(projectIndex, child.id)
|
|
rows.push({
|
|
id: rowId,
|
|
projectIndex,
|
|
majorDictId: 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: isMutipleService.value
|
|
? [buildProjectGroupPathKey(projectIndex), group.id, rowId]
|
|
: [group.id, rowId]
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
type SourceRow = Pick<DetailRow, 'id'> &
|
|
Partial<
|
|
Pick<
|
|
DetailRow,
|
|
| 'projectIndex'
|
|
| 'majorDictId'
|
|
| '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
|
|
projectCount?: number
|
|
cloneFromProjectOne?: boolean
|
|
}
|
|
): DetailRow[] => {
|
|
const includeScaleValues = options?.includeScaleValues ?? true
|
|
const includeFactorValues = options?.includeFactorValues ?? true
|
|
const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount())
|
|
const dbValueMap = new Map<string, SourceRow>()
|
|
for (const row of rowsFromDb || []) {
|
|
const projectIndex = resolveRowProjectIndex(row)
|
|
const majorDictId = resolveRowMajorDictId(row)
|
|
if (!majorDictId) continue
|
|
dbValueMap.set(makeProjectMajorKey(projectIndex, majorDictId), row)
|
|
}
|
|
|
|
return buildDefaultRows(targetProjectCount).map(row => {
|
|
const rowProjectIndex = resolveRowProjectIndex(row)
|
|
const rowMajorDictId = resolveRowMajorDictId(row)
|
|
const fromDb =
|
|
dbValueMap.get(makeProjectMajorKey(rowProjectIndex, rowMajorDictId)) ||
|
|
(options?.cloneFromProjectOne && rowProjectIndex > 1
|
|
? dbValueMap.get(makeProjectMajorKey(1, rowMajorDictId))
|
|
: undefined)
|
|
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(rowMajorDictId),
|
|
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 formatThousandsFlexible(params.value, 3)
|
|
}
|
|
|
|
const formatEditableRatio2 = (params: any) => {
|
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
return '请输入'
|
|
}
|
|
if (params.value == null) return ''
|
|
return formatThousandsFlexible(params.value, 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 formatThousandsFlexible(roundTo(params.value, 3), 3)
|
|
}
|
|
|
|
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 = 'space-between'
|
|
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
|
|
if (!checkbox.checked) {
|
|
const budgetField = checkField === 'benchmarkBudgetBasicChecked' ? 'benchmarkBudgetBasic' : 'benchmarkBudgetOptional'
|
|
params.data[budgetField] = 0
|
|
}
|
|
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 getCheckedBenchmarkBudgetSplitByLandArea = (
|
|
row?: Pick<DetailRow, 'landArea' | 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
|
|
) => {
|
|
const split = getBenchmarkBudgetSplitByLandArea(row)
|
|
if (!split) return null
|
|
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
|
|
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
|
|
return {
|
|
...split,
|
|
basic,
|
|
optional,
|
|
total: roundTo(addNumbers(basic, optional), 2)
|
|
}
|
|
}
|
|
|
|
const getBudgetFee = (
|
|
row?: Pick<
|
|
DetailRow,
|
|
| 'landArea'
|
|
| 'benchmarkBudgetBasicChecked'
|
|
| 'benchmarkBudgetOptionalChecked'
|
|
| 'majorFactor'
|
|
| 'consultCategoryFactor'
|
|
| 'workStageFactor'
|
|
| 'workRatio'
|
|
>
|
|
) => {
|
|
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(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'
|
|
| 'benchmarkBudgetBasicChecked'
|
|
| 'benchmarkBudgetOptionalChecked'
|
|
| 'majorFactor'
|
|
| 'consultCategoryFactor'
|
|
| 'workStageFactor'
|
|
| 'workRatio'
|
|
>
|
|
) => {
|
|
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(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 getMergeColSpanBeforeTotal = (params: any) => {
|
|
if (!params.node?.group && !params.node?.rowPinned) return 1
|
|
const displayedColumns = params.api?.getAllDisplayedColumns?.()
|
|
if (!Array.isArray(displayedColumns) || !params.column) return 1
|
|
const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId())
|
|
const totalIndex = displayedColumns.findIndex((column: any) => column.getColId() === 'budgetFeeTotal')
|
|
if (currentIndex < 0 || totalIndex <= currentIndex) return 1
|
|
return totalIndex - currentIndex
|
|
}
|
|
|
|
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 formatThousandsFlexible(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
|
|
: getCheckedBenchmarkBudgetSplitByLandArea(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
|
|
: getCheckedBenchmarkBudgetSplitByLandArea(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
|
|
: getCheckedBenchmarkBudgetSplitByLandArea(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: 3 }),
|
|
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: 3 }),
|
|
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: 3 }),
|
|
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: formatEditableRatio2
|
|
},
|
|
{
|
|
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,
|
|
flex: 2,
|
|
|
|
cellRendererParams: {
|
|
suppressCount: true
|
|
},
|
|
colSpan: getMergeColSpanBeforeTotal,
|
|
valueFormatter: params => {
|
|
if (params.node?.rowPinned) {
|
|
return totalLabel.value
|
|
}
|
|
const rowData = params.data as DetailRow | undefined
|
|
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
|
|
return `${rowData.majorCode} ${rowData.majorName}`
|
|
}
|
|
const nodeId = String(params.value || '')
|
|
const projectIndex = parseProjectIndexFromPathKey(nodeId)
|
|
if (projectIndex != null) return `项目${projectIndex}`
|
|
return idLabelMap.get(nodeId) || nodeId
|
|
},
|
|
tooltipValueGetter: params => {
|
|
if (params.node?.rowPinned) return totalLabel.value
|
|
const rowData = params.data as DetailRow | undefined
|
|
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
|
|
return `${rowData.majorCode} ${rowData.majorName}`
|
|
}
|
|
const nodeId = String(params.value || '')
|
|
const projectIndex = parseProjectIndexFromPathKey(nodeId)
|
|
if (projectIndex != null) return `项目${projectIndex}`
|
|
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 sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
|
|
let hasValid = false
|
|
const total = sumByNumber(rows, row => {
|
|
const value = Number(pick(row))
|
|
if (!Number.isFinite(value)) return null
|
|
hasValid = true
|
|
return value
|
|
})
|
|
return hasValid ? total : null
|
|
}
|
|
|
|
const totalBudgetFeeBasic = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
|
|
const totalBudgetFeeOptional = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
|
|
const totalBudgetFee = computed(() =>
|
|
sumNullableBy(detailRows.value.filter(e => e.budgetFee !== null && e.budgetFee !== undefined), 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 benchmarkBudgetRawSplit = getBenchmarkBudgetSplitByLandArea(row)
|
|
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(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 =
|
|
row.benchmarkBudgetBasicChecked === false ? null : (benchmarkBudgetRawSplit?.basicFormula ?? '')
|
|
row.optionalFormula =
|
|
row.benchmarkBudgetOptionalChecked === false ? null : (benchmarkBudgetRawSplit?.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())),
|
|
projectCount: getTargetProjectCount()
|
|
}
|
|
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'landScale', payload)
|
|
const synced = await syncPricingTotalToZxFw({
|
|
contractId: props.contractId,
|
|
serviceId: props.serviceId,
|
|
field: 'landScale',
|
|
value: totalBudgetFee.value
|
|
})
|
|
if (!synced) return
|
|
} catch (error) {
|
|
console.error('saveToIndexedDB failed:', error)
|
|
}
|
|
}
|
|
|
|
const getProjectMajorKeyFromRow = (row: Partial<DetailRow> | undefined) => {
|
|
if (!row) return ''
|
|
const majorDictId = resolveRowMajorDictId(row)
|
|
if (!majorDictId) return ''
|
|
return makeProjectMajorKey(resolveRowProjectIndex(row), majorDictId)
|
|
}
|
|
|
|
const buildRowsFromImportDefaultSource = async (
|
|
targetProjectCount: number
|
|
): Promise<DetailRow[]> => {
|
|
// 与“使用默认数据”同源:先强制刷新系数,再按合同卡片默认带出。
|
|
await loadFactorDefaults()
|
|
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
|
|
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
|
|
return hasContractRows
|
|
? mergeWithDictRows(htData!.detailRows, {
|
|
includeFactorValues: true,
|
|
projectCount: targetProjectCount,
|
|
cloneFromProjectOne: true
|
|
})
|
|
: buildDefaultRows(targetProjectCount).map(row => ({
|
|
...row,
|
|
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
|
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
|
|
}))
|
|
}
|
|
|
|
const applyProjectCountChange = async (nextValue: unknown) => {
|
|
const normalized = normalizeProjectCount(nextValue)
|
|
projectCount.value = normalized
|
|
if (!isMutipleService.value) return
|
|
const previousRows = detailRows.value.map(row => ({ ...row }))
|
|
const previousProjectCount = inferProjectCountFromRows(previousRows)
|
|
if (normalized === previousProjectCount) return
|
|
|
|
if (normalized < previousProjectCount) {
|
|
detailRows.value = mergeWithDictRows(previousRows as any, { projectCount: normalized })
|
|
syncComputedValuesToDetailRows()
|
|
await saveToIndexedDB()
|
|
return
|
|
}
|
|
|
|
const defaultRows = await buildRowsFromImportDefaultSource(normalized)
|
|
const existingMap = new Map<string, DetailRow>()
|
|
for (const row of previousRows) {
|
|
const key = getProjectMajorKeyFromRow(row)
|
|
if (!key) continue
|
|
existingMap.set(key, row)
|
|
}
|
|
|
|
detailRows.value = defaultRows.map(defaultRow => {
|
|
const key = getProjectMajorKeyFromRow(defaultRow)
|
|
const existingRow = key ? existingMap.get(key) : undefined
|
|
if (!existingRow) return defaultRow
|
|
if (resolveRowProjectIndex(existingRow) > previousProjectCount) return defaultRow
|
|
return {
|
|
...defaultRow,
|
|
...existingRow,
|
|
id: defaultRow.id,
|
|
projectIndex: defaultRow.projectIndex,
|
|
majorDictId: defaultRow.majorDictId,
|
|
groupCode: defaultRow.groupCode,
|
|
groupName: defaultRow.groupName,
|
|
majorCode: defaultRow.majorCode,
|
|
majorName: defaultRow.majorName,
|
|
hasCost: defaultRow.hasCost,
|
|
hasArea: defaultRow.hasArea,
|
|
path: defaultRow.path
|
|
}
|
|
})
|
|
syncComputedValuesToDetailRows()
|
|
await saveToIndexedDB()
|
|
}
|
|
|
|
const loadFromIndexedDB = async () => {
|
|
try {
|
|
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
|
|
activeIndustryCode.value =
|
|
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
|
projectCount.value = 1
|
|
|
|
await ensureFactorDefaultsLoaded()
|
|
const applyContractDefaultRows = async () => {
|
|
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
|
|
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
|
|
const targetProjectCount = getTargetProjectCount()
|
|
detailRows.value = hasContractRows
|
|
? mergeWithDictRows(htData!.detailRows, {
|
|
includeFactorValues: true,
|
|
projectCount: targetProjectCount,
|
|
cloneFromProjectOne: true
|
|
})
|
|
: buildDefaultRows(targetProjectCount).map(row => ({
|
|
...row,
|
|
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
|
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
|
|
}))
|
|
syncComputedValuesToDetailRows()
|
|
}
|
|
if (shouldForceDefaultLoad()) {
|
|
await applyContractDefaultRows()
|
|
return
|
|
}
|
|
|
|
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale')
|
|
if (data) {
|
|
if (isMutipleService.value) {
|
|
const storedProjectCount = normalizeProjectCount(data.projectCount)
|
|
projectCount.value = storedProjectCount || inferProjectCountFromRows(data.detailRows as any)
|
|
}
|
|
detailRows.value = mergeWithDictRows(data.detailRows as any, {
|
|
projectCount: getTargetProjectCount(),
|
|
cloneFromProjectOne: true
|
|
})
|
|
syncComputedValuesToDetailRows()
|
|
return
|
|
}
|
|
|
|
await applyContractDefaultRows()
|
|
} catch (error) {
|
|
console.error('loadFromIndexedDB failed:', error)
|
|
detailRows.value = buildDefaultRows(getTargetProjectCount())
|
|
syncComputedValuesToDetailRows()
|
|
}
|
|
}
|
|
|
|
const importContractData = async () => {
|
|
try {
|
|
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
|
|
activeIndustryCode.value =
|
|
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
|
|
|
// 使用默认数据时,强制读取最新的项目系数(预算取值优先,空值回退标准系数)
|
|
await loadFactorDefaults()
|
|
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
|
|
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
|
|
const targetProjectCount = getTargetProjectCount()
|
|
detailRows.value = hasContractRows
|
|
? mergeWithDictRows(htData!.detailRows, {
|
|
includeFactorValues: true,
|
|
projectCount: targetProjectCount,
|
|
cloneFromProjectOne: true
|
|
})
|
|
: buildDefaultRows(targetProjectCount).map(row => ({
|
|
...row,
|
|
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
|
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
|
|
}))
|
|
await saveToIndexedDB()
|
|
} catch (error) {
|
|
console.error('importContractData failed:', error)
|
|
}
|
|
}
|
|
|
|
const clearAllData = async () => {
|
|
try {
|
|
detailRows.value = buildDefaultRows(getTargetProjectCount())
|
|
await saveToIndexedDB()
|
|
} catch (error) {
|
|
console.error('clearAllData failed:', error)
|
|
}
|
|
}
|
|
|
|
const handleCellValueChanged = () => {
|
|
syncComputedValuesToDetailRows()
|
|
void saveToIndexedDB()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadFromIndexedDB()
|
|
})
|
|
|
|
onActivated(() => {
|
|
void loadFromIndexedDB()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
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">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
|
|
<div v-if="isMutipleService" class="flex items-center gap-2">
|
|
<span class="text-xs text-muted-foreground">项目数量</span>
|
|
<NumberFieldRoot
|
|
v-model="projectCount"
|
|
:min="1"
|
|
:step="1"
|
|
class="inline-flex items-center rounded-md border bg-background"
|
|
@update:model-value="value => void applyProjectCountChange(value)"
|
|
>
|
|
<NumberFieldDecrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement>
|
|
<NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" />
|
|
<NumberFieldIncrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement>
|
|
</NumberFieldRoot>
|
|
</div>
|
|
</div>
|
|
<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="agGridWrapClass">
|
|
<AgGridVue :style="agGridStyle" :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>
|