完成大部分

This commit is contained in:
zwgczx committed 2026-02-28 16:34:36 +08:00
1 parent e97707ac59
commit 13b03e016e
21 files changed
+1628 -275

No files matched your search

+1 -5
View File
@@ -25,10 +25,7 @@ interface ContractItem {
}
const STORAGE_KEY = 'ht-card-v1'
const formStore = localforage.createInstance({
name: 'jgjs-pricing-db',
storeName: 'form_state'
})
const tabStore = useTabStore()
@@ -221,7 +218,6 @@ const removeRelatedTabsByContractId = (contractId: string) => {
const cleanupContractRelatedData = async (contractId: string) => {
await Promise.all([
removeForageKeysByContractId(localforage, contractId),
removeForageKeysByContractId(formStore as any, contractId)
])
}
+9 -1
View File
@@ -14,9 +14,17 @@ import TypeLine from '@/layout/typeLine.vue'
const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
const consultCategoryFactorView = markRaw(
defineAsyncComponent(() => import('@/components/views/XmConsultCategoryFactor.vue'))
)
const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
)
const xmCategories = [
{ key: 'info', label: '基础信息', component: xmView },
{ key: 'contract', label: '合同段管理', component: htView }
{ key: 'contract', label: '合同段管理', component: htView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
]
</script>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { serviceList } from '@/sql'
import XmFactorGrid from '@/components/views/XmFactorGrid.vue'
</script>
<template>
<XmFactorGrid
title="咨询分类系数明细"
storage-key="xm-consult-category-factor-v1"
:dict="serviceList"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
/>
</template>
+331
View File
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
interface DictItem {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
interface FactorRow {
id: string
code: string
name: string
standardFactor: number | null
budgetValue: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: FactorRow[]
}
type DictSource = Record<string, DictItem>
const props = defineProps<{
title: string
storageKey: string
dict: DictSource
disableBudgetEditWhenStandardNull?: boolean
excludeNotshowByZxflxs?: boolean
}>()
const detailRows = ref<FactorRow[]>([])
const gridApi = ref<GridApi<FactorRow> | null>(null)
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
const formatReadonlyFactor = (value: unknown) => {
if (value == null || value === '') return ''
return Number(value).toFixed(2)
}
const formatEditableFactor = (params: any) => {
if (params.value == null || params.value === '') return '点击输入'
return Number(params.value).toFixed(2)
}
const sortedDictEntries = () =>
Object.entries(props.dict)
.filter((entry): entry is [string, DictItem] => {
const item = entry[1]
if (!item?.code || !item?.name) return false
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
return true
})
.sort((a, b) => {
const aNum = Number(a[0])
const bNum = Number(b[0])
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum
return String(a[0]).localeCompare(String(b[0]))
})
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
const parts = code.split('-').filter(Boolean)
if (!parts.length) return [selfId]
const path: string[] = []
let currentCode = parts[0]
const firstId = codeIdMap.get(currentCode)
if (firstId) path.push(firstId)
for (let i = 1; i < parts.length; i += 1) {
currentCode = `${currentCode}-${parts[i]}`
const id = codeIdMap.get(currentCode)
if (id) path.push(id)
}
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
return path
}
const buildDefaultRows = (): FactorRow[] => {
const entries = sortedDictEntries()
const codeIdMap = new Map<string, string>()
for (const [id, item] of entries) {
codeIdMap.set(item.code, id)
}
return entries.map(([id, item]) => {
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
return {
id,
code: item.code,
name: item.name,
standardFactor,
budgetValue: null,
remark: '',
path: buildCodePath(item.code, id, codeIdMap)
}
})
}
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
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 hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
return {
...row,
budgetValue:
typeof fromDb.budgetValue === 'number'
? fromDb.budgetValue
: hasBudgetValue
? null
: row.budgetValue,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
}
})
}
const columnDefs: ColDef<FactorRow>[] = [
{
headerName: '标准系数',
field: 'standardFactor',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
flex: 0.9,
valueFormatter: params => formatReadonlyFactor(params.value)
},
{
headerName: '预算取值',
field: 'budgetValue',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line'
},
flex: 0.9,
cellClassRules: {
'editable-cell-empty': params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return !disabled && (params.value == null || params.value === '')
}
},
editable: params => {
if (!props.disableBudgetEditWhenStandardNull) return true
return params.data?.standardFactor != null
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
if (disabled && (params.value == null || params.value === '')) return ''
return formatEditableFactor(params)
}
},
{
headerName: '说明',
field: 'remark',
minWidth: 170,
flex: 2.4,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: true,
valueFormatter: params => params.value || '点击输入',
cellClass: 'editable-cell-line remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
}
]
const autoGroupColumnDef: ColDef<FactorRow> = {
headerName: '专业编码以及工程专业名称',
minWidth: 220,
flex: 2.2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
const key = String(params.node?.key || '')
const dictItem = (props.dict as DictSource)[key]
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
}
}
const saveToIndexedDB = async () => {
try {
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(props.storageKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<GridState>(props.storageKey)
if (data?.detailRows) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 500)
}
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 (_error) {
return params.value
}
return params.value
}
const fitColumnsToGrid = () => {
if (!gridApi.value) return
requestAnimationFrame(() => {
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 68 })
})
}
const handleGridReady = (event: GridReadyEvent<FactorRow>) => {
gridApi.value = event.api
fitColumnsToGrid()
}
const handleGridSizeChanged = (_event: GridSizeChangedEvent<FactorRow>) => {
fitColumnsToGrid()
}
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<FactorRow>) => {
fitColumnsToGrid()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value = null
void saveToIndexedDB()
})
</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">{{ title }}</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"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
:treeData="true"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@grid-size-changed="handleGridSizeChanged"
@first-data-rendered="handleFirstDataRendered"
/>
</div>
</div>
</div>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { majorList } from '@/sql'
import XmFactorGrid from '@/components/views/XmFactorGrid.vue'
</script>
<template>
<XmFactorGrid
title="工程专业系数明细"
storage-key="xm-major-factor-v1"
:dict="majorList"
/>
</template>
+46 -12
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -46,6 +47,7 @@ const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = 'xm-info-v3'
const detailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
type majorLite = { code: string; name: string }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
@@ -146,11 +148,12 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@@ -166,13 +169,13 @@ const columnDefs: ColDef<DetailRow>[] = [
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
return formatThousands(params.value)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
minWidth: 100,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
@@ -199,10 +202,12 @@ const columnDefs: ColDef<DetailRow>[] = [
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
minWidth: 200,
maxWidth: 300,
flex:2, // 核心:开启弹性布局,自动占满剩余空间
wrapText: true,
autoHeight: true,
// cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
cellRendererParams: {
suppressCount: true
},
@@ -212,6 +217,11 @@ const autoGroupColumnDef: ColDef = {
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
@@ -306,19 +316,39 @@ const processCellFromClipboard = (params:any) => {
}
return params.value;
};
const fitColumnsToGrid = () => {
if (!gridApi.value) return
requestAnimationFrame(() => {
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 80 })
})
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
fitColumnsToGrid()
}
const handleGridSizeChanged = (_event: GridSizeChangedEvent<DetailRow>) => {
fitColumnsToGrid()
}
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
fitColumnsToGrid()
}
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="h-full min-h-0 min-w-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<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">
<div class="ag-theme-quartz h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
@@ -335,10 +365,14 @@ const processCellFromClipboard = (params:any) => {
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@grid-size-changed="handleGridSizeChanged"
@first-data-rendered="handleFirstDataRendered"
/>
</div>
@@ -3,33 +3,21 @@ import { computed, 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 { expertList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
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
laborBudgetUnitPrice: number | null
compositeBudgetUnitPrice: number | null
expertCode: string
expertName: string
laborBudgetUnitPrice: string
compositeBudgetUnitPrice: string
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
@@ -74,14 +62,77 @@ const shouldForceDefaultLoad = () => {
const detailRows = ref<DetailRow[]>([])
type ExpertLite = {
code: string
name: string
maxPrice: number | null
minPrice: number | null
defPrice: number | null
manageCoe: number | null
}
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ExpertLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const formatPriceRange = (min: number | null, max: number | null) => {
const hasMin = typeof min === 'number' && Number.isFinite(min)
const hasMax = typeof max === 'number' && Number.isFinite(max)
if (hasMin && hasMax) return `${min}-${max}`
if (hasMin) return String(min)
if (hasMax) return String(max)
return ''
}
const idLabelMap = new Map<string, string>()
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
if (
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return ''
}
const min = typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
: null
const max = typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
: null
return formatPriceRange(min, max)
}
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
if (
typeof expert.defPrice !== 'number' ||
!Number.isFinite(expert.defPrice) ||
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return null
}
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const [expertId, expert] of expertEntries) {
const rowId = `expert-${expertId}`
rows.push({
id: rowId,
expertCode: expert.code,
expertName: expert.name,
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [rowId]
})
}
return rows
}
@@ -98,10 +149,6 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
laborBudgetUnitPrice:
typeof fromDb.laborBudgetUnitPrice === 'number' ? fromDb.laborBudgetUnitPrice : null,
compositeBudgetUnitPrice:
typeof fromDb.compositeBudgetUnitPrice === 'number' ? fromDb.compositeBudgetUnitPrice : null,
adoptedBudgetUnitPrice:
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
@@ -118,6 +165,17 @@ const parseNumberOrNull = (value: unknown) => {
return Number.isFinite(v) ? v : null
}
const parseNonNegativeIntegerOrNull = (value: unknown) => {
if (value === '' || value == null) return null
if (typeof value === 'number') {
return Number.isInteger(value) && value >= 0 ? value : null
}
const normalized = String(value).trim()
if (!/^\d+$/.test(normalized)) return null
const v = Number(normalized)
return Number.isSafeInteger(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
@@ -126,6 +184,31 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2)
}
const formatEditableInteger = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
}
const calcServiceBudget = (row: DetailRow | undefined) => {
const adopted = row?.adoptedBudgetUnitPrice
const personnel = row?.personnelCount
const workday = row?.workdayCount
if (
typeof adopted !== 'number' ||
!Number.isFinite(adopted) ||
typeof personnel !== 'number' ||
!Number.isFinite(personnel) ||
typeof workday !== 'number' ||
!Number.isFinite(workday)
) {
return null
}
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
}
const editableNumberCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
@@ -146,29 +229,96 @@ const editableNumberCol = <K extends keyof DetailRow>(
...extra
})
const editableMoneyCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value)
},
...extra
})
const readonlyTextCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 170,
flex: 1,
editable: false,
valueFormatter: params => params.value || '',
...extra
})
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{
headerName: '编码',
field: 'expertCode',
minWidth: 120,
width: 140,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '人员名称',
field: 'expertName',
minWidth: 200,
width: 220,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
}
tooltipField: 'expertName',
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
marryChildren: true,
children: [
editableNumberCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
editableNumberCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
]
},
editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: decimalAggSum }),
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', {
aggFunc: decimalAggSum,
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
valueFormatter: formatEditableInteger
}),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: decimalAggSum }),
{
headerName: '服务预算(元)',
field: 'serviceBudget',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
valueFormatter: params => {
if (params.value == null || params.value === '') return ''
return formatThousands(params.value)
}
},
{
headerName: '说明',
field: 'remark',
@@ -192,39 +342,18 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
}
]
const autoGroupColumnDef: ColDef = {
headerName: '编码',
minWidth: 150,
pinned: 'left',
width: 160,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
}
}
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => row.serviceBudget))
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
laborBudgetUnitPrice: null,
compositeBudgetUnitPrice: null,
expertCode: '总合计',
expertName: '',
laborBudgetUnitPrice: '',
compositeBudgetUnitPrice: '',
adoptedBudgetUnitPrice: null,
personnelCount: totalPersonnelCount.value,
workdayCount: totalWorkdayCount.value,
@@ -244,6 +373,15 @@ const saveToIndexedDB = async () => {
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'hourly',
value: totalServiceBudget.value
})
if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
@@ -315,6 +453,12 @@ const processCellFromClipboard = (params: any) => {
}
return params.value;
};
const handleGridReady = (params: any) => {
const w = window as any
if (!w.__agGridApis) w.__agGridApis = {}
w.__agGridApis = params.api
}
</script>
<template>
@@ -329,7 +473,8 @@ const processCellFromClipboard = (params: any) => {
<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"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="myTheme" :treeData="false"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
@@ -339,5 +484,3 @@ const processCellFromClipboard = (params: any) => {
</div>
</div>
</template>
@@ -3,10 +3,13 @@ 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 { getBasicFeeFromScale, majorList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } 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 { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -53,6 +56,25 @@ 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 consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(),
loadMajorFactorMap()
])
consultCategoryFactorMap.value = consultMap
majorFactorMap.value = majorMap
factorDefaultsLoaded = true
}
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
@@ -74,12 +96,6 @@ const shouldForceDefaultLoad = () => {
}
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]))
@@ -88,11 +104,6 @@ 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[] = []
@@ -154,7 +165,7 @@ const buildDefaultRows = (): DetailRow[] => {
majorName: child.name,
amount: null,
benchmarkBudget: null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null,
remark: '',
@@ -187,7 +198,7 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] =>
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
: getDefaultConsultCategoryFactor(),
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
@@ -216,8 +227,9 @@ const formatEditableNumber = (params: any) => {
const formatConsultCategoryFactor = (params: any) => {
if (params.node?.group) {
if (defaultConsultCategoryFactor.value == null) return ''
return Number(defaultConsultCategoryFactor.value).toFixed(2)
const v = getDefaultConsultCategoryFactor()
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
@@ -232,9 +244,17 @@ const formatMajorFactor = (params: any) => {
return formatEditableNumber(params)
}
const formatReadonlyNumber = (params: any) => {
const formatEditableMoney = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value)
}
const formatReadonlyMoney = (params: any) => {
if (params.value == null || params.value === '') return ''
return roundTo(params.value, 2).toFixed(2)
return formatThousands(roundTo(params.value, 2))
}
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
@@ -252,34 +272,38 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1,
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 2,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatEditableMoney
},
{
headerName: '基准预算(元)',
field: 'benchmarkBudget',
minWidth: 170,
flex: 1,
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 2,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params => getBenchmarkBudgetByAmount(params.data),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatReadonlyNumber
valueFormatter: formatReadonlyMoney
},
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
minWidth: 150,
flex: 1,
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
@@ -292,8 +316,9 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '专业系数',
field: 'majorFactor',
minWidth: 130,
flex: 1,
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
@@ -306,12 +331,14 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '预算费用',
field: 'budgetFee',
minWidth: 150,
flex: 1,
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex:2,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatReadonlyNumber
valueFormatter: formatReadonlyMoney
},
{
headerName: '说明',
@@ -338,10 +365,13 @@ const columnDefs: ColDef<DetailRow>[] = [
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
minWidth: 250,
pinned: 'left',
flex: 2,
// wrapText: true,
// cellStyle: { whiteSpace: 'normal', lineHeight: '1.5', padding: '2px' },
// autoHeight: true,
cellRendererParams: {
suppressCount: true
},
@@ -351,6 +381,11 @@ const autoGroupColumnDef: ColDef = {
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
@@ -387,6 +422,15 @@ const saveToIndexedDB = async () => {
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'investScale',
value: totalBudgetFee.value
})
if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
@@ -394,6 +438,7 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
@@ -465,6 +510,8 @@ const processCellFromClipboard = (params: any) => {
}
return params.value;
};
</script>
<template>
@@ -3,10 +3,13 @@ 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 { getBasicFeeFromScale, majorList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } 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 { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -54,13 +57,26 @@ 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 consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
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
})
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(),
loadMajorFactorMap()
])
consultCategoryFactorMap.value = consultMap
majorFactorMap.value = majorMap
factorDefaultsLoaded = true
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
@@ -89,11 +105,6 @@ 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[] = []
@@ -156,7 +167,7 @@ const buildDefaultRows = (): DetailRow[] => {
amount: null,
landArea: null,
benchmarkBudget: null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null,
remark: '',
@@ -190,7 +201,7 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] =>
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
: getDefaultConsultCategoryFactor(),
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
@@ -219,8 +230,9 @@ const formatEditableNumber = (params: any) => {
const formatConsultCategoryFactor = (params: any) => {
if (params.node?.group) {
if (defaultConsultCategoryFactor.value == null) return ''
return Number(defaultConsultCategoryFactor.value).toFixed(2)
const v = getDefaultConsultCategoryFactor()
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
@@ -235,9 +247,9 @@ const formatMajorFactor = (params: any) => {
return formatEditableNumber(params)
}
const formatReadonlyNumber = (params: any) => {
const formatReadonlyMoney = (params: any) => {
if (params.value == null || params.value === '') return ''
return roundTo(params.value, 2).toFixed(2)
return formatThousands(roundTo(params.value, 2))
}
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
@@ -279,18 +291,21 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '基准预算(元)',
field: 'benchmarkBudget',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params => getBenchmarkBudgetByLandArea(params.data),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatReadonlyNumber
valueFormatter: formatReadonlyMoney
},
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
minWidth: 150,
flex: 1,
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
@@ -303,8 +318,9 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '专业系数',
field: 'majorFactor',
minWidth: 130,
flex: 1,
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
@@ -317,12 +333,14 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '预算费用',
field: 'budgetFee',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatReadonlyNumber
valueFormatter: formatReadonlyMoney
},
{
headerName: '说明',
@@ -362,6 +380,11 @@ const autoGroupColumnDef: ColDef = {
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
@@ -402,6 +425,15 @@ const saveToIndexedDB = async () => {
}
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)
}
@@ -409,6 +441,7 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
@@ -480,6 +513,8 @@ const processCellFromClipboard = (params: any) => {
}
return params.value;
};
</script>
<template>
@@ -3,10 +3,13 @@ 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 { serviceList, taskList } from '@/sql'
import { taskList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, toDecimal } from '@/lib/decimal'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@@ -40,6 +43,17 @@ const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
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())
let factorDefaultsLoaded = false
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap()
factorDefaultsLoaded = true
}
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
@@ -62,7 +76,6 @@ const shouldForceDefaultLoad = () => {
const detailRows = ref<DetailRow[]>([])
type serviceLite = { defCoe: number | null }
type taskLite = {
serviceID: number
code: string
@@ -76,11 +89,6 @@ type taskLite = {
desc: string | 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
})
const formatTaskReferenceUnitPrice = (task: taskLite) => {
const unit = task.unit || ''
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
@@ -121,7 +129,7 @@ const buildDefaultRows = (): DetailRow[] => {
budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
budgetAdoptedUnitPrice:
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
serviceFee: null,
remark: task.desc|| '',
path: [rowId]
@@ -193,12 +201,6 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2)
}
const formatReadonlyNumber = (params: any) => {
if (isNoTaskRow(params.data)) return '无'
if (params.value == null || params.value === '') return ''
return roundTo(params.value, 2).toFixed(2)
}
const spanRowsByTaskName = (params: any) => {
const rowA = params?.nodeA?.data as DetailRow | undefined
const rowB = params?.nodeB?.data as DetailRow | undefined
@@ -216,7 +218,8 @@ const columnDefs: ColDef<DetailRow>[] = [
minWidth: 100,
width: 120,
pinned: 'left',
valueFormatter: params => params.value || ''
colSpan: params => (params.node?.rowPinned ? 3 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '名称',
@@ -227,7 +230,7 @@ const columnDefs: ColDef<DetailRow>[] = [
autoHeight: true,
spanRows: true,
valueFormatter: params => params.value || ''
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算基数',
@@ -238,7 +241,7 @@ const columnDefs: ColDef<DetailRow>[] = [
width: 180,
pinned: 'left',
spanRows: spanRowsByTaskName,
valueFormatter: params => params.value || ''
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
@@ -250,10 +253,11 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '预算采用单价',
field: 'budgetAdoptedUnitPrice',
headerClass: 'ag-right-aligned-header',
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group &&
@@ -269,7 +273,7 @@ const columnDefs: ColDef<DetailRow>[] = [
}
if (params.value == null) return ''
const unit = params.data?.unit || ''
return `${Number(params.value).toFixed(2)}${unit}`
return `${formatThousands(params.value)}${unit}`
}
},
{
@@ -293,8 +297,9 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
minWidth: 150,
flex: 1,
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
@@ -310,12 +315,18 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '服务费用(元)',
field: 'serviceFee',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => calcServiceFee(params.data),
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
aggFunc: decimalAggSum,
valueFormatter: formatReadonlyNumber
valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无'
if (params.value == null || params.value === '') return ''
return formatThousands(roundTo(params.value, 2))
}
},
{
headerName: '说明',
@@ -343,6 +354,27 @@ const columnDefs: ColDef<DetailRow>[] = [
}
]
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
const totalServiceFee = computed(() => sumByNumber(detailRows.value, row => calcServiceFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
taskCode: '总合计',
taskName: '',
unit: '',
conversion: null,
workload: totalWorkload.value,
budgetBase: '',
budgetReferenceUnitPrice: '',
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: totalServiceFee.value,
remark: '',
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
@@ -354,6 +386,15 @@ const saveToIndexedDB = async () => {
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'workload',
value: totalServiceFee.value
})
if (synced) {
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
@@ -366,6 +407,8 @@ const loadFromIndexedDB = async () => {
return
}
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
@@ -430,6 +473,7 @@ const processCellFromClipboard = (params: any) => {
}
return params.value;
};
const mydiyTheme = myTheme.withParams({
rowBorder: {
style: "solid",
@@ -455,15 +499,15 @@ const mydiyTheme = myTheme.withParams({
</div>
<div v-if="isWorkloadMethodApplicable" class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows"
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
:enableCellSpan="true"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
@@ -480,6 +524,3 @@ const mydiyTheme = myTheme.withParams({
</div>
</div>
</template>
<!-- :rowSelection="'multiple'"
:enableClickSelection="false" -->
<!-- :suppressRowTransform="true" -->
+43 -9
View File
@@ -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 } from 'ag-grid-community'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -43,8 +44,9 @@ interface XmInfoState {
const DB_KEY = 'xm-info-v3'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const projectName = ref(DEFAULT_PROJECT_NAME)
const projectName = ref('')
const detailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
const rootRef = ref<HTMLElement | null>(null)
const gridSectionRef = ref<HTMLElement | null>(null)
const agGridRef = ref<HTMLElement | null>(null)
@@ -211,11 +213,12 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@@ -231,13 +234,13 @@ const columnDefs: ColDef<DetailRow>[] = [
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
return formatThousands(params.value)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
minWidth: 100,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
editable: params => !params.node?.group && !params.node?.rowPinned,
@@ -264,8 +267,7 @@ const columnDefs: ColDef<DetailRow>[] = [
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
minWidth: 200,
flex:2, // 核心:开启弹性布局,自动占满剩余空间
cellRendererParams: {
@@ -277,6 +279,11 @@ const autoGroupColumnDef: ColDef = {
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
@@ -316,14 +323,18 @@ const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<XmInfoState>(DB_KEY)
if (data) {
console.log('loaded data:', data)
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
projectName.value = DEFAULT_PROJECT_NAME
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
projectName.value = DEFAULT_PROJECT_NAME
detailRows.value = buildDefaultRows()
}
}
@@ -385,6 +396,26 @@ const processCellFromClipboard = (params:any) => {
return params.value;
};
const fitColumnsToGrid = () => {
if (!gridApi.value) return
requestAnimationFrame(() => {
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 80 })
})
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
fitColumnsToGrid()
}
const handleGridSizeChanged = (_event: GridSizeChangedEvent<DetailRow>) => {
fitColumnsToGrid()
}
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
fitColumnsToGrid()
}
const scrollToGridSection = () => {
const target = gridSectionRef.value || agGridRef.value
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -440,11 +471,14 @@ const scrollToGridSection = () => {
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@grid-size-changed="handleGridSizeChanged"
@first-data-rendered="handleFirstDataRendered"
/>
</div>
</div>
+85 -65
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
@@ -7,6 +7,9 @@ import localforage from 'localforage'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { getPricingMethodTotalsForService, getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { Search } from 'lucide-vue-next'
import {
DialogClose,
@@ -95,7 +98,7 @@ const updateGridCardHeight = () => {
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight-20
agGridHeight.value = nextHeight-40
}
@@ -218,15 +221,23 @@ const clearRowValues = async (row: DetailRow) => {
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick()
row.investScale = null
row.landScale = null
row.workload = null
row.hourly = null
row.subtotal = null
detailRows.value = [...detailRows.value]
await clearPricingPaneValues(row.id)
const totals = await getPricingMethodTotalsForService({
contractId: props.contractId,
serviceId: row.id
})
detailRows.value = detailRows.value.map(item =>
item.id !== row.id
? item
: {
...item,
investScale: totals.investScale,
landScale: totals.landScale,
workload: totals.workload,
hourly: totals.hourly
}
)
await saveToIndexedDB()
}
const openEditTab = (row: DetailRow) => {
@@ -239,56 +250,66 @@ const openEditTab = (row: DetailRow) => {
}
const columnDefs: ColDef<DetailRow>[] = [
{ headerName: '编码', field: 'code', minWidth: 80, flex: 1 },
{ headerName: '名称', field: 'name', minWidth: 320, flex: 2 },
{ headerName: '编码', field: 'code', minWidth: 50, maxWidth: 100 },
{ headerName: '名称', field: 'name', minWidth: 250, flex: 3, tooltipField: 'name' },
{
headerName: '投资规模法',
field: 'investScale',
headerClass: 'ag-right-aligned-header',
minWidth: 140,
flex: 2,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
},
{
headerName: '用地规模法',
field: 'landScale',
headerClass: 'ag-right-aligned-header',
minWidth: 140,
flex: 2,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : String(Number(params.value)))
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 2))
},
{
headerName: '工作量法',
field: 'workload',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 2,
cellClass: 'ag-right-aligned-cell',
editable: false,
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
},
{
headerName: '工时法',
field: 'hourly',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 2,
cellClass: 'ag-right-aligned-cell',
editable: false,
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
},
{
headerName: '小计',
field: 'subtotal',
headerClass: 'ag-right-aligned-header',
flex: 3,
minWidth: 120,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
@@ -299,14 +320,14 @@ const columnDefs: ColDef<DetailRow>[] = [
valueOrZero(params.data.hourly)
)
},
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
},
{
headerName: '操作',
field: 'actions',
minWidth: 88,
minWidth: 50,
flex: 1,
maxWidth: 120,
editable: false,
sortable: false,
filter: false,
@@ -341,6 +362,29 @@ const detailGridOptions: GridOptions<DetailRow> = {
}
}
const fillPricingTotalsForSelectedRows = async () => {
const serviceRows = detailRows.value.filter(row => !isFixedRow(row))
if (serviceRows.length === 0) return
const totalsByServiceId = await getPricingMethodTotalsForServices({
contractId: props.contractId,
serviceIds: serviceRows.map(row => row.id)
})
detailRows.value = detailRows.value.map(row => {
if (isFixedRow(row)) return row
const totals = totalsByServiceId.get(String(row.id))
if (!totals) return row
return {
...row,
investScale: totals.investScale,
landScale: totals.landScale,
workload: totals.workload,
hourly: totals.hourly
}
})
}
const applySelection = (codes: string[]) => {
const prevSelectedSet = new Set(selectedIds.value)
const uniqueIds = Array.from(new Set(codes)).filter(
@@ -410,9 +454,15 @@ const handlePickerOpenChange = (open: boolean) => {
pickerOpen.value = open
}
const confirmPicker = () => {
const confirmPicker = async () => {
applySelection(pickerTempIds.value)
void saveToIndexedDB()
try {
await fillPricingTotalsForSelectedRows()
await saveToIndexedDB()
} catch (error) {
console.error('confirmPicker failed:', error)
await saveToIndexedDB()
}
}
const clearPickerSelection = () => {
@@ -556,6 +606,12 @@ const loadFromIndexedDB = async () => {
hourly: typeof old.hourly === 'number' ? old.hourly : null
}
})
try {
await fillPricingTotalsForSelectedRows()
} catch (error) {
console.error('fillPricingTotalsForSelectedRows failed:', error)
}
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
selectedIds.value = []
@@ -563,6 +619,14 @@ const loadFromIndexedDB = async () => {
}
}
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, ZXFW_RELOAD_SERVICE_KEY),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
@@ -692,47 +756,3 @@ onBeforeUnmount(() => {
</div>
</template>
<style>
.ag-floating-top {
overflow-y: auto !important;
}
.zxfw-action-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 100%;
}
.zxfw-action-btn {
border: 0;
background: transparent;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.picker-item-clickable {
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.picker-item-selected {
transform: translateY(-1px);
border-color: rgba(14, 165, 233, 0.75);
box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.3);
background: rgba(14, 165, 233, 0.08);
}
.picker-item-selected-drag {
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.35), 0 0 0 1px rgba(14, 165, 233, 0.25);
}
.picker-item-clickable:not(.picker-item-dragging):active {
transform: translateY(1px) scale(0.985);
border-color: rgba(14, 165, 233, 0.8);
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.22);
}
</style>