This commit is contained in:
wintsa 2026-02-27 17:06:31 +08:00
parent 57a2029847
commit badf131dde
18 changed files with 1081 additions and 2824 deletions

View File

@ -12,6 +12,7 @@
"ag-grid-vue3": "^35.1.0", "ag-grid-vue3": "^35.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"decimal.js": "^10.6.0",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@ -225,6 +226,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],

1870
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@
"ag-grid-vue3": "^35.1.0", "ag-grid-vue3": "^35.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"decimal.js": "^10.6.0",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import localforage from 'localforage' import localforage from 'localforage'
import { Card, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardHeader, CardTitle } from '@/components/ui/card'
@ -52,8 +52,15 @@ let baseOffsetY = 0
const contractListScrollWrapRef = ref<HTMLElement | null>(null) const contractListScrollWrapRef = ref<HTMLElement | null>(null)
const contractListViewportRef = ref<HTMLElement | null>(null) const contractListViewportRef = ref<HTMLElement | null>(null)
const isDraggingContracts = ref(false) const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready')
let contractAutoScrollRaf = 0 let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
const CARD_ENTER_STEP_MS = 58
const CARD_ENTER_DURATION_MS = 560
const CARD_ENTER_MAX_INDEX = 24
const CARD_ENTER_TOTAL_MS = CARD_ENTER_DURATION_MS + CARD_ENTER_STEP_MS * CARD_ENTER_MAX_INDEX + 80
const buildDefaultContracts = (): ContractItem[] => [ const buildDefaultContracts = (): ContractItem[] => [
@ -111,6 +118,22 @@ const scrollContractsToBottom = (behavior: ScrollBehavior = 'smooth') => {
}) })
} }
const triggerCardEnterAnimation = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'enter'
cardEnterTimer = setTimeout(() => {
cardMotionState.value = 'ready'
cardEnterTimer = null
}, CARD_ENTER_TOTAL_MS)
}
const getCardEnterStyle = (index: number) => ({
'--ht-card-enter-delay': `${Math.min(index, CARD_ENTER_MAX_INDEX) * CARD_ENTER_STEP_MS}ms`
})
const saveContracts = async () => { const saveContracts = async () => {
try { try {
contracts.value = normalizeOrder(contracts.value) contracts.value = normalizeOrder(contracts.value)
@ -297,6 +320,11 @@ const contractAutoScrollTick = () => {
} }
const startContractAutoScroll = () => { const startContractAutoScroll = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'ready'
getContractListViewport() getContractListViewport()
isDraggingContracts.value = true isDraggingContracts.value = true
dragPointerClientY = null dragPointerClientY = null
@ -349,13 +377,22 @@ const startDrag = (event: MouseEvent) => {
onMounted(async () => { onMounted(async () => {
await loadContracts() await loadContracts()
triggerCardEnterAnimation()
await nextTick() await nextTick()
getContractListViewport() getContractListViewport()
}) })
onActivated(() => {
triggerCardEnterAnimation()
void nextTick(() => {
getContractListViewport()
})
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopDrag() stopDrag()
stopContractAutoScroll() stopContractAutoScroll()
if (cardEnterTimer) clearTimeout(cardEnterTimer)
void saveContracts() void saveContracts()
}) })
</script> </script>
@ -432,12 +469,14 @@ onBeforeUnmount(() => {
@start="startContractAutoScroll" @start="startContractAutoScroll"
@end="handleDragEnd" @end="handleDragEnd"
> >
<template #item="{ element }"> <template #item="{ element, index }">
<Card <Card
:class="[ :class="[
'group cursor-pointer snap-start snap-always transition-colors hover:border-primary', 'group cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
cardMotionState === 'enter' && !isDraggingContracts ? 'ht-contract-card--enter' : 'ht-contract-card--ready',
isListLayout && 'gap-0 py-0' isListLayout && 'gap-0 py-0'
]" ]"
:style="cardMotionState === 'enter' ? getCardEnterStyle(index) : undefined"
@click="handleCardClick(element)" @click="handleCardClick(element)"
> >
<CardHeader <CardHeader
@ -530,12 +569,14 @@ onBeforeUnmount(() => {
]" ]"
> >
<Card <Card
v-for="element in filteredContracts" v-for="(element, index) in filteredContracts"
:key="element.id" :key="element.id"
:class="[ :class="[
'group cursor-pointer snap-start snap-always transition-colors hover:border-primary', 'group cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
cardMotionState === 'enter' && !isDraggingContracts ? 'ht-contract-card--enter' : 'ht-contract-card--ready',
isListLayout && 'gap-0 py-0' isListLayout && 'gap-0 py-0'
]" ]"
:style="cardMotionState === 'enter' ? getCardEnterStyle(index) : undefined"
@click="handleCardClick(element)" @click="handleCardClick(element)"
> >
<CardHeader <CardHeader
@ -705,4 +746,37 @@ onBeforeUnmount(() => {
transform: translateZ(0); transform: translateZ(0);
backface-visibility: hidden; backface-visibility: hidden;
} }
.ht-contract-card {
will-change: transform, opacity;
}
.ht-contract-card--ready {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ht-contract-card--enter {
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--ht-card-enter-delay, 0ms);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter {
animation: none;
opacity: 1;
transform: none;
}
}
</style> </style>

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community' import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { majorList } from '@/sql' import { majorList } from '@/sql'
import 'ag-grid-enterprise' import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { myTheme } from '@/lib/diyAgGridTheme' import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线 // 线+线
@ -155,7 +155,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => { valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue) const v = Number(params.newValue)
@ -181,7 +181,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => { valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue) const v = Number(params.newValue)
@ -215,30 +215,9 @@ const autoGroupColumnDef: ColDef = {
} }
} }
const gridOptions: GridOptions<DetailRow> = { const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() => const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
@ -366,32 +345,3 @@ const processCellFromClipboard = (params:any) => {
</div> </div>
</div> </div>
</template> </template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridOptions } from 'ag-grid-community' import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { majorList } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import 'ag-grid-enterprise' import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { myTheme } from '@/lib/diyAgGridTheme' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@ -49,87 +49,40 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([]) const detailRows = ref<DetailRow[]>([])
type majorLite = { code: string; name: string }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
for (const [key, item] of serviceEntries) {
const code = item.code
const isGroup = !code.includes('-')
if (isGroup) {
if (!groupMap.has(code)) groupOrder.push(code)
groupMap.set(code, {
id: key,
code,
name: item.name,
children: []
})
continue
}
const parentCode = code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
})
}
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name
})
}
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
})()
const idLabelMap = new Map<string, string>() const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
const buildDefaultRows = (): DetailRow[] => { const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = [] const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
laborBudgetUnitPrice: null,
compositeBudgetUnitPrice: null,
adoptedBudgetUnitPrice: null,
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [group.id, child.id]
})
}
}
return rows return rows
} }
@ -213,20 +166,25 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
] ]
}, },
editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'), editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: 'sum' }), editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: decimalAggSum }),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: 'sum' }), editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: 'sum' }), editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: decimalAggSum }),
{ {
headerName: '说明', headerName: '说明',
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 180,
flex: 1.2, flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => { valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入' if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || '' return params.value || ''
}, },
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@ -253,34 +211,11 @@ const autoGroupColumnDef: ColDef = {
} }
} }
const gridOptions: GridOptions<DetailRow> = { const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalPersonnelCount = computed(() => const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
detailRows.value.reduce((sum, row) => sum + (row.personnelCount || 0), 0)
)
const totalWorkdayCount = computed(() => const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => row.serviceBudget))
detailRows.value.reduce((sum, row) => sum + (row.workdayCount || 0), 0)
)
const totalServiceBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.serviceBudget || 0), 0)
)
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
@ -302,6 +237,7 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try { try {
const payload = { const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
@ -315,6 +251,11 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
@ -328,6 +269,14 @@ const loadFromIndexedDB = async () => {
} }
} }
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
@ -341,7 +290,6 @@ const handleCellValueChanged = () => {
}, 1000) }, 1000)
} }
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
}) })
@ -380,58 +328,16 @@ const processCellFromClipboard = (params:any) => {
</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 w-full flex-1">
<AgGridVue <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:style="{ height: '100%' }" :columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
:rowData="detailRows" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:pinnedTopRowData="pinnedTopRowData" :suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:columnDefs="columnDefs" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:autoGroupColumnDef="autoGroupColumnDef" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:gridOptions="gridOptions" :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
: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> </div>
</div> </div>
</template> </template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>

View File

@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community' import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { majorList } from '@/sql' import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import 'ag-grid-enterprise' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { myTheme } from '@/lib/diyAgGridTheme' import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线 // 线+线
@ -48,10 +49,38 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([]) const detailRows = ref<DetailRow[]>([])
type serviceLite = { defCoe: number | null }
const defaultConsultCategoryFactor = computed<number | null>(() => {
const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
})
type majorLite = { code: string; name: string } type majorLite = { code: string; name: string; defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>) const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0])) .sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => { .filter((entry): entry is [string, majorLite] => {
@ -59,6 +88,11 @@ const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
return Boolean(item?.code && item?.name) 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 detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>() const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = [] const groupOrder: string[] = []
@ -120,8 +154,8 @@ const buildDefaultRows = (): DetailRow[] => {
majorName: child.name, majorName: child.name,
amount: null, amount: null,
benchmarkBudget: null, benchmarkBudget: null,
consultCategoryFactor: null, consultCategoryFactor: defaultConsultCategoryFactor.value,
majorFactor: null, majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null, budgetFee: null,
remark: '', remark: '',
path: [group.id, child.id] path: [group.id, child.id]
@ -131,8 +165,9 @@ const buildDefaultRows = (): DetailRow[] => {
return rows return rows
} }
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => { type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const dbValueMap = new Map<string, DetailRow>() const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row) dbValueMap.set(row.id, row)
} }
@ -140,14 +175,25 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return buildDefaultRows().map(row => { return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id) const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
return { return {
...row, ...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null, amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null, benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor: consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null, typeof fromDb.consultCategoryFactor === 'number'
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null, ? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null, budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : '' remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
} }
@ -168,17 +214,41 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2) return Number(params.value).toFixed(2)
} }
const columnDefs: ColDef<DetailRow>[] = [ const formatConsultCategoryFactor = (params: any) => {
{ if (params.node?.group) {
headerName: '工程专业名称', if (defaultConsultCategoryFactor.value == null) return ''
minWidth: 220, return Number(defaultConsultCategoryFactor.value).toFixed(2)
width: 240,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
} }
}, return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
if (params.node?.group) {
const groupId = String(params.node?.key || '')
const v = getDefaultMajorFactorById(groupId)
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
const formatReadonlyNumber = (params: any) => {
if (params.value == null || params.value === '') return ''
return Number(params.value).toFixed(2)
}
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
const result = getBasicFeeFromScale(row?.amount, 'cost')
return result ? addNumbers(result.basic, result.optional) : null
}
const getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
return benchmarkBudget * row.majorFactor * row.consultCategoryFactor
}
const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '造价金额(万元)', headerName: '造价金额(万元)',
field: 'amount', field: 'amount',
@ -191,7 +261,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
@ -200,16 +270,10 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'benchmarkBudget', field: 'benchmarkBudget',
minWidth: 170, minWidth: 170,
flex: 1, flex: 1,
aggFunc: decimalAggSum,
editable: params => !params.node?.group && !params.node?.rowPinned, valueGetter: params => getBenchmarkBudgetByAmount(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatReadonlyNumber
}, },
{ {
headerName: '咨询分类系数', headerName: '咨询分类系数',
@ -223,7 +287,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatConsultCategoryFactor
}, },
{ {
headerName: '专业系数', headerName: '专业系数',
@ -237,34 +301,34 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatMajorFactor
}, },
{ {
headerName: '预算费用', headerName: '预算费用',
field: 'budgetFee', field: 'budgetFee',
minWidth: 150, minWidth: 150,
flex: 1, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, aggFunc: decimalAggSum,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatReadonlyNumber
}, },
{ {
headerName: '说明', headerName: '说明',
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 180,
flex: 1.2, flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => { valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入' if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || '' return params.value || ''
}, },
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@ -273,10 +337,10 @@ const columnDefs: ColDef<DetailRow>[] = [
] ]
const autoGroupColumnDef: ColDef = { const autoGroupColumnDef: ColDef = {
headerName: '专业编码', headerName: '专业编码以及工程专业名称',
minWidth: 160, minWidth: 320,
pinned: 'left', pinned: 'left',
width: 170, flex: 2,
cellRendererParams: { cellRendererParams: {
suppressCount: true suppressCount: true
@ -286,39 +350,16 @@ const autoGroupColumnDef: ColDef = {
return '总合计' return '总合计'
} }
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId return idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
} }
} }
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() => const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalBenchmarkBudget = computed(() => const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetByAmount(row)))
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalBudgetFee = computed(() => const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
)
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
@ -339,6 +380,7 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try { try {
const payload = { const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
@ -352,12 +394,24 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
return return
} }
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
return
}
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
@ -365,6 +419,14 @@ const loadFromIndexedDB = async () => {
} }
} }
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
@ -378,7 +440,6 @@ const handleCellValueChanged = () => {
}, 1000) }, 1000)
} }
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
}) })
@ -417,58 +478,16 @@ const processCellFromClipboard = (params:any) => {
</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 w-full flex-1">
<AgGridVue <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:style="{ height: '100%' }" :columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
:rowData="detailRows" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:pinnedTopRowData="pinnedTopRowData" :suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:columnDefs="columnDefs" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:autoGroupColumnDef="autoGroupColumnDef" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:gridOptions="gridOptions" :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
: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> </div>
</div> </div>
</template> </template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>

View File

@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community' import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { majorList } from '@/sql' import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import 'ag-grid-enterprise' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { myTheme } from '@/lib/diyAgGridTheme' import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线 // 线+线
@ -49,10 +50,38 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const detailRows = ref<DetailRow[]>([]) 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 } 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 skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
type majorLite = { code: string; name: string; defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>) const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0])) .sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => { .filter((entry): entry is [string, majorLite] => {
@ -60,6 +89,11 @@ const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
return Boolean(item?.code && item?.name) 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 detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>() const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = [] const groupOrder: string[] = []
@ -122,8 +156,8 @@ const buildDefaultRows = (): DetailRow[] => {
amount: null, amount: null,
landArea: null, landArea: null,
benchmarkBudget: null, benchmarkBudget: null,
consultCategoryFactor: null, consultCategoryFactor: defaultConsultCategoryFactor.value,
majorFactor: null, majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null, budgetFee: null,
remark: '', remark: '',
path: [group.id, child.id] path: [group.id, child.id]
@ -133,8 +167,9 @@ const buildDefaultRows = (): DetailRow[] => {
return rows return rows
} }
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => { type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'landArea' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const dbValueMap = new Map<string, DetailRow>() const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row) dbValueMap.set(row.id, row)
} }
@ -142,6 +177,8 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return buildDefaultRows().map(row => { return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id) const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
return { return {
...row, ...row,
@ -149,8 +186,17 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null, landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null, benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor: consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null, typeof fromDb.consultCategoryFactor === 'number'
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null, ? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null, budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : '' remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
} }
@ -171,6 +217,40 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2) return Number(params.value).toFixed(2)
} }
const formatConsultCategoryFactor = (params: any) => {
if (params.node?.group) {
if (defaultConsultCategoryFactor.value == null) return ''
return Number(defaultConsultCategoryFactor.value).toFixed(2)
}
return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
if (params.node?.group) {
const groupId = String(params.node?.key || '')
const v = getDefaultMajorFactorById(groupId)
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
const formatReadonlyNumber = (params: any) => {
if (params.value == null || params.value === '') return ''
return Number(params.value).toFixed(2)
}
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
const result = getBasicFeeFromScale(row?.landArea, 'area')
return result ? addNumbers(result.basic, result.optional) : null
}
const getBudgetFee = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByLandArea(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
return benchmarkBudget * row.majorFactor * row.consultCategoryFactor
}
const formatEditableFlexibleNumber = (params: any) => { const formatEditableFlexibleNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return '点击输入'
@ -180,16 +260,6 @@ const formatEditableFlexibleNumber = (params: any) => {
} }
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '工程专业名称',
minWidth: 220,
width: 240,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
}
},
{ {
headerName: '用地面积(亩)', headerName: '用地面积(亩)',
field: 'landArea', field: 'landArea',
@ -202,7 +272,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableFlexibleNumber valueFormatter: formatEditableFlexibleNumber
}, },
@ -211,15 +281,10 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'benchmarkBudget', field: 'benchmarkBudget',
minWidth: 170, minWidth: 170,
flex: 1, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, aggFunc: decimalAggSum,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), valueGetter: params => getBenchmarkBudgetByLandArea(params.data),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatReadonlyNumber
}, },
{ {
headerName: '咨询分类系数', headerName: '咨询分类系数',
@ -233,7 +298,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatConsultCategoryFactor
}, },
{ {
headerName: '专业系数', headerName: '专业系数',
@ -247,34 +312,34 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatMajorFactor
}, },
{ {
headerName: '预算费用', headerName: '预算费用',
field: 'budgetFee', field: 'budgetFee',
minWidth: 150, minWidth: 150,
flex: 1, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, aggFunc: decimalAggSum,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatReadonlyNumber
}, },
{ {
headerName: '说明', headerName: '说明',
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 180,
flex: 1.2, flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => { valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入' if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || '' return params.value || ''
}, },
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@ -283,10 +348,10 @@ const columnDefs: ColDef<DetailRow>[] = [
] ]
const autoGroupColumnDef: ColDef = { const autoGroupColumnDef: ColDef = {
headerName: '专业编码', headerName: '专业编码以及工程专业名称',
minWidth: 160, minWidth: 320,
pinned: 'left', pinned: 'left',
width: 170, flex: 2,
cellRendererParams: { cellRendererParams: {
suppressCount: true suppressCount: true
@ -296,43 +361,19 @@ const autoGroupColumnDef: ColDef = {
return '总合计' return '总合计'
} }
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId return idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
} }
} }
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() => const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const totalBenchmarkBudget = computed(() => const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalBudgetFee = computed(() => const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetByLandArea(row)))
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
) const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
@ -354,6 +395,7 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try { try {
const payload = { const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
@ -367,12 +409,24 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
return return
} }
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
return
}
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
@ -380,6 +434,14 @@ const loadFromIndexedDB = async () => {
} }
} }
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
@ -393,7 +455,6 @@ const handleCellValueChanged = () => {
}, 1000) }, 1000)
} }
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
}) })
@ -432,58 +493,16 @@ const processCellFromClipboard = (params:any) => {
</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 w-full flex-1">
<AgGridVue <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:style="{ height: '100%' }" :columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
:rowData="detailRows" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:pinnedTopRowData="pinnedTopRowData" :suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:columnDefs="columnDefs" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:autoGroupColumnDef="autoGroupColumnDef" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:gridOptions="gridOptions" :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
: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> </div>
</div> </div>
</template> </template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>

View File

@ -1,36 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community' import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { majorList } from '@/sql' import { serviceList, taskList } from '@/sql'
import 'ag-grid-enterprise' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { myTheme } from '@/lib/diyAgGridTheme' import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; 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 { interface DetailRow {
id: string id: string
groupCode: string taskCode: string
groupName: string taskName: string
majorCode: string unit: string
majorName: string
workload: number | null workload: number | null
budgetBase: number | null budgetBase: string
budgetReferenceUnitPrice: number | null budgetReferenceUnitPrice: string
budgetAdoptedUnitPrice: number | null budgetAdoptedUnitPrice: number | null
consultCategoryFactor: number | null consultCategoryFactor: number | null
serviceFee: number | null serviceFee: number | null
@ -49,90 +36,111 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`) 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 shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([]) const detailRows = ref<DetailRow[]>([])
type majorLite = { code: string; name: string } type serviceLite = { defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>) type taskLite = {
.sort((a, b) => Number(a[0]) - Number(b[0])) serviceID: number
.filter((entry): entry is [string, majorLite] => { code: string
const item = entry[1] name: string
return Boolean(item?.code && item?.name) basicParam: string
}) unit: string
maxPrice: number | null
const detailDict: DictGroup[] = (() => { minPrice: number | null
const groupMap = new Map<string, DictGroup>() defPrice: number | null
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] const defaultConsultCategoryFactor = computed<number | null>(() => {
if (!groupMap.has(parentCode)) { const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
const parent = codeLookup.get(parentCode) return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
}) })
}
groupMap.get(parentCode)!.children.push({ const formatTaskReferenceUnitPrice = (task: taskLite) => {
id: key, const unit = task.unit || ''
code, const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
name: item.name const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
}) if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
} if (hasMin) return `${task.minPrice}${unit}`
if (hasMax) return `${task.maxPrice}${unit}`
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group)) return ''
})()
const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
} }
const buildDefaultRows = (): DetailRow[] => { const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = [] const rows: DetailRow[] = []
for (const group of detailDict) { const currentServiceId = Number(props.serviceId)
for (const child of group.children) { const sourceTaskIds = Object.entries(taskList as Record<string, taskLite>)
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
.map(([key]) => Number(key))
.filter(Number.isFinite)
.sort((a, b) => a - b)
for (const [order, taskId] of sourceTaskIds.entries()) {
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
if (!task?.code || !task?.name) continue
const rowId = `task-${taskId}-${order}`
rows.push({ rows.push({
id: child.id, id: rowId,
groupCode: group.code, taskCode: task.code,
groupName: group.name, taskName: task.name,
majorCode: child.code, unit: task.unit || '',
majorName: child.name,
workload: null, workload: null,
budgetBase: null, budgetBase: task.basicParam || '',
budgetReferenceUnitPrice: null, budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
budgetAdoptedUnitPrice:
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
serviceFee: null,
remark: '',
path: [rowId]
})
}
if (rows.length === 0) {
const emptyRowId = `task-none-${String(props.serviceId)}`
rows.push({
id: emptyRowId,
taskCode: '无',
taskName: '无',
unit: '',
workload: null,
budgetBase: '无',
budgetReferenceUnitPrice: '无',
budgetAdoptedUnitPrice: null, budgetAdoptedUnitPrice: null,
consultCategoryFactor: null, consultCategoryFactor: null,
serviceFee: null, serviceFee: null,
remark: '', remark: '',
path: [group.id, child.id] path: [emptyRowId]
}) })
} }
}
return rows return rows
} }
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => { const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>() const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
@ -146,9 +154,6 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return { return {
...row, ...row,
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null, workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
budgetBase: typeof fromDb.budgetBase === 'number' ? fromDb.budgetBase : null,
budgetReferenceUnitPrice:
typeof fromDb.budgetReferenceUnitPrice === 'number' ? fromDb.budgetReferenceUnitPrice : null,
budgetAdoptedUnitPrice: budgetAdoptedUnitPrice:
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null, typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
consultCategoryFactor: consultCategoryFactor:
@ -161,11 +166,13 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
const parseNumberOrNull = (value: unknown) => { const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null if (value === '' || value == null) return null
const v = Number(value) const normalized = typeof value === 'string' ? value.replace(/[^0-9.\-]/g, '') : value
const v = Number(normalized)
return Number.isFinite(v) ? v : null return Number.isFinite(v) ? v : null
} }
const formatEditableNumber = (params: any) => { const formatEditableNumber = (params: any) => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return '点击输入'
} }
@ -173,15 +180,84 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2) return Number(params.value).toFixed(2)
} }
const isRepeatedTaskNameRow = (params: any) => {
const rowIndex = params.node?.rowIndex
if (typeof rowIndex !== 'number' || rowIndex <= 0) return false
const prevData = params.api.getDisplayedRowAtIndex(rowIndex - 1)?.data as DetailRow | undefined
return prevData?.taskName === params.data?.taskName
}
const getTaskNameRowSpan = (params: any) => {
if (params.node?.rowPinned) return 1
if (isRepeatedTaskNameRow(params)) return 1
const currentName = params.data?.taskName
if (!currentName) return 1
let span = 1
let offset = 1
while (true) {
const nextData = params.api.getDisplayedRowAtIndex((params.node?.rowIndex ?? 0) + offset)?.data as DetailRow | undefined
if (!nextData || nextData.taskName !== currentName) break
span += 1
offset += 1
}
return span
}
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '工作内容', headerName: '编码',
minWidth: 220, field: 'taskCode',
width: 240, minWidth: 150,
width: 170,
pinned: 'left', pinned: 'left',
valueGetter: params => { valueFormatter: params => params.value || ''
if (params.node?.rowPinned) return '' },
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || '' {
headerName: '名称',
field: 'taskName',
minWidth: 220,
width: 260,
pinned: 'left',
rowSpan: params => getTaskNameRowSpan(params),
valueFormatter: params => (isRepeatedTaskNameRow(params) ? '' : params.value || '')
},
{
headerName: '预算基数',
field: 'budgetBase',
minWidth: 170,
flex: 1,
valueFormatter: params => params.value || ''
},
{
headerName: '预算参考单价',
field: 'budgetReferenceUnitPrice',
minWidth: 170,
flex: 1,
valueFormatter: params => params.value || ''
},
{
headerName: '预算采用单价',
field: 'budgetAdoptedUnitPrice',
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' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
const unit = params.data?.unit || ''
return `${Number(params.value).toFixed(2)}${unit}`
} }
}, },
{ {
@ -189,57 +265,16 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'workload', field: 'workload',
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, 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 ? 'editable-cell-line' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group &&
}, !params.node?.rowPinned &&
aggFunc: 'sum', !isNoTaskRow(params.data) &&
valueParser: params => parseNumberOrNull(params.newValue), (params.value == null || params.value === '')
valueFormatter: formatEditableNumber
},
{
headerName: '预算基数',
field: 'budgetBase',
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '预算参考单价',
field: 'budgetReferenceUnitPrice',
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '预算采用单价',
field: 'budgetAdoptedUnitPrice',
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
@ -248,11 +283,14 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'consultCategoryFactor', field: 'consultCategoryFactor',
minWidth: 150, minWidth: 150,
flex: 1, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, 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 ? 'editable-cell-line' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
@ -262,13 +300,16 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'serviceFee', field: 'serviceFee',
minWidth: 150, minWidth: 150,
flex: 1, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, 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 ? 'editable-cell-line' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
@ -277,76 +318,40 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 180,
flex: 1.2, flex: 1.2,
editable: params => !params.node?.group && !params.node?.rowPinned, cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
valueFormatter: params => { valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入' if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || '' return params.value || ''
}, },
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
} }
} }
] ]
const autoGroupColumnDef: ColDef = { const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
headerName: '编码',
minWidth: 160,
pinned: 'left',
width: 170,
cellRendererParams: { const totalServiceFee = computed(() => sumByNumber(detailRows.value, row => row.serviceFee))
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 gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalBudgetBase = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetBase || 0), 0)
)
const totalWorkload = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.workload || 0), 0)
)
const totalServiceFee = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.serviceFee || 0), 0)
)
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
groupCode: '', taskCode: '',
groupName: '', taskName: '',
majorCode: '', unit: '',
majorName: '',
workload: totalWorkload.value, workload: totalWorkload.value,
budgetBase: totalBudgetBase.value, budgetBase: '',
budgetReferenceUnitPrice: null, budgetReferenceUnitPrice: '',
budgetAdoptedUnitPrice: null, budgetAdoptedUnitPrice: null,
consultCategoryFactor: null, consultCategoryFactor: null,
serviceFee: totalServiceFee.value, serviceFee: totalServiceFee.value,
@ -358,6 +363,7 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try { try {
const payload = { const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
@ -371,6 +377,11 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
@ -384,6 +395,14 @@ const loadFromIndexedDB = async () => {
} }
} }
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
void loadFromIndexedDB()
}
)
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
@ -397,7 +416,6 @@ const handleCellValueChanged = () => {
}, 1000) }, 1000)
} }
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
}) })
@ -436,58 +454,15 @@ const processCellFromClipboard = (params:any) => {
</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 w-full flex-1">
<AgGridVue <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:style="{ height: '100%' }" :columnDefs="columnDefs" :gridOptions="gridOptions" :theme="myTheme" :treeData="false"
:rowData="detailRows" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:pinnedTopRowData="pinnedTopRowData" :suppressRowVirtualisation="true" :suppressRowTransform="true"
:columnDefs="columnDefs" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:autoGroupColumnDef="autoGroupColumnDef" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:gridOptions="gridOptions" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:theme="myTheme" :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
@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> </div>
</div> </div>
</template> </template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community' import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { majorList } from '@/sql' import { majorList } from '@/sql'
import 'ag-grid-enterprise' import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { myTheme } from '@/lib/diyAgGridTheme' import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线 // 线+线
@ -220,7 +220,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => { valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue) const v = Number(params.newValue)
@ -246,7 +246,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params => 'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
aggFunc: 'sum', aggFunc: decimalAggSum,
valueParser: params => { valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue) const v = Number(params.newValue)
@ -280,30 +280,11 @@ const autoGroupColumnDef: ColDef = {
} }
} }
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() => const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
) const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
@ -460,31 +441,3 @@ const processCellFromClipboard = (params:any) => {
</div> </div>
</template> </template>
<style >
.ag-floating-top{
overflow-y:auto !important
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}
</style>

View File

@ -4,9 +4,9 @@ import type { ComponentPublicInstance } from 'vue'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community' import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import 'ag-grid-enterprise' import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { myTheme } from '@/lib/diyAgGridTheme'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
import { Search } from 'lucide-vue-next' import { Search } from 'lucide-vue-next'
import { import {
DialogClose, DialogClose,
@ -21,6 +21,7 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { serviceList } from '@/sql' import { serviceList } from '@/sql'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
interface ServiceItem { interface ServiceItem {
id: string id: string
@ -50,7 +51,11 @@ const props = defineProps<{
contractId: string contractId: string
}>() }>()
const tabStore = useTabStore() const tabStore = useTabStore()
const pricingPaneReloadStore = usePricingPaneReloadStore()
const DB_KEY = computed(() => `zxFW-${props.contractId}`) const DB_KEY = computed(() => `zxFW-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const PRICING_CLEAR_SKIP_TTL_MS = 5000
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null } type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>) const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
@ -146,8 +151,9 @@ const pickerOpen = ref(false)
const pickerTempIds = ref<string[]>([]) const pickerTempIds = ref<string[]>([])
const pickerSearch = ref('') const pickerSearch = ref('')
const dragSelecting = ref(false) const dragSelecting = ref(false)
const dragMoved = ref(false)
let dragSelectChecked = false let dragSelectChecked = false
const dragAppliedCodes = new Set<string>() const dragBaseIds = ref<string[]>([])
const dragStartPoint = ref({ x: 0, y: 0 }) const dragStartPoint = ref({ x: 0, y: 0 })
const dragCurrentPoint = ref({ x: 0, y: 0 }) const dragCurrentPoint = ref({ x: 0, y: 0 })
const pickerItemElMap = new Map<string, HTMLElement>() const pickerItemElMap = new Map<string, HTMLElement>()
@ -198,13 +204,19 @@ const getPricingPaneStorageKeys = (serviceId: string) => [
const clearPricingPaneValues = async (serviceId: string) => { const clearPricingPaneValues = async (serviceId: string) => {
const keys = getPricingPaneStorageKeys(serviceId) const keys = getPricingPaneStorageKeys(serviceId)
const skipUntil = Date.now() + PRICING_CLEAR_SKIP_TTL_MS
for (const key of keys) {
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, String(skipUntil))
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
}
await Promise.all(keys.map(key => localforage.removeItem(key))) await Promise.all(keys.map(key => localforage.removeItem(key)))
pricingPaneReloadStore.markReload(props.contractId, serviceId)
} }
const clearRowValues = async (row: DetailRow) => { const clearRowValues = async (row: DetailRow) => {
if (isFixedRow(row)) return if (isFixedRow(row)) return
// <EFBFBD>? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`) // ? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick() await nextTick()
row.investScale = null row.investScale = null
@ -215,11 +227,6 @@ const clearRowValues = async (row: DetailRow) => {
detailRows.value = [...detailRows.value] detailRows.value = [...detailRows.value]
await clearPricingPaneValues(row.id) await clearPricingPaneValues(row.id)
// <EFBFBD>?
await new Promise(resolve => setTimeout(resolve, 80))
await clearPricingPaneValues(row.id)
await saveToIndexedDB()
} }
const openEditTab = (row: DetailRow) => { const openEditTab = (row: DetailRow) => {
@ -285,10 +292,10 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: false, editable: false,
valueGetter: params => { valueGetter: params => {
if (!params.data) return null if (!params.data) return null
return ( return addNumbers(
valueOrZero(params.data.investScale) + valueOrZero(params.data.investScale),
valueOrZero(params.data.landScale) + valueOrZero(params.data.landScale),
valueOrZero(params.data.workload) + valueOrZero(params.data.workload),
valueOrZero(params.data.hourly) valueOrZero(params.data.hourly)
) )
}, },
@ -308,22 +315,17 @@ const columnDefs: ColDef<DetailRow>[] = [
isFixedRow(params.data) isFixedRow(params.data)
? '' ? ''
: `<div class="zxfw-action-wrap"> : `<div class="zxfw-action-wrap">
<button class="zxfw-action-btn" data-action="clear" title="清空">🧹</button>
<button class="zxfw-action-btn" data-action="edit" title="编辑"></button> <button class="zxfw-action-btn" data-action="edit" title="编辑"></button>
<button class="zxfw-action-btn" data-action="clear" title="清空(重置到默认状态)">🧹</button>
</div>` </div>`
} }
] ]
const gridOptions: GridOptions<DetailRow> = { const detailGridOptions: GridOptions<DetailRow> = {
animateRows: true, ...gridOptions,
singleClickEdit: true, treeData: false,
suppressClickEdit: false, getDataPath: undefined,
suppressContextMenu: false,
defaultColDef: {
resizable: true,
sortable: false,
filter: false
},
onCellClicked: async params => { onCellClicked: async params => {
if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return
const target = params.event?.target as HTMLElement | null const target = params.event?.target as HTMLElement | null
@ -462,45 +464,59 @@ const applyDragSelectionByRect = () => {
bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y) bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y)
} }
const nextSelectedSet = new Set(dragBaseIds.value)
for (const [code, el] of pickerItemElMap.entries()) { for (const [code, el] of pickerItemElMap.entries()) {
if (dragAppliedCodes.has(code)) continue
const itemRect = el.getBoundingClientRect() const itemRect = el.getBoundingClientRect()
const hit = isRectIntersect(rect, itemRect) const hit = isRectIntersect(rect, itemRect)
if (hit) { if (hit) {
applyTempChecked(code, dragSelectChecked) if (dragSelectChecked) {
dragAppliedCodes.add(code) nextSelectedSet.add(code)
} else {
nextSelectedSet.delete(code)
} }
} }
} }
pickerTempIds.value = serviceDict
.map(item => item.id)
.filter(id => nextSelectedSet.has(id))
}
const stopDragSelect = () => { const stopDragSelect = () => {
dragSelecting.value = false dragSelecting.value = false
dragAppliedCodes.clear() dragMoved.value = false
dragBaseIds.value = []
window.removeEventListener('mousemove', onDragSelectingMove) window.removeEventListener('mousemove', onDragSelectingMove)
window.removeEventListener('mouseup', stopDragSelect) window.removeEventListener('mouseup', stopDragSelect)
} }
const onDragSelectingMove = (event: MouseEvent) => { const onDragSelectingMove = (event: MouseEvent) => {
dragCurrentPoint.value = { x: event.clientX, y: event.clientY } dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
if (!dragMoved.value) {
const dx = Math.abs(event.clientX - dragStartPoint.value.x)
const dy = Math.abs(event.clientY - dragStartPoint.value.y)
if (dx >= 3 || dy >= 3) {
dragMoved.value = true
}
}
applyDragSelectionByRect() applyDragSelectionByRect()
} }
const startDragSelect = (event: MouseEvent, code: string) => { const startDragSelect = (event: MouseEvent, code: string) => {
dragSelecting.value = true dragSelecting.value = true
dragAppliedCodes.clear() dragMoved.value = false
dragBaseIds.value = [...pickerTempIds.value]
dragSelectChecked = !pickerTempIds.value.includes(code) dragSelectChecked = !pickerTempIds.value.includes(code)
dragStartPoint.value = { x: event.clientX, y: event.clientY } dragStartPoint.value = { x: event.clientX, y: event.clientY }
dragCurrentPoint.value = { x: event.clientX, y: event.clientY } dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
applyTempChecked(code, dragSelectChecked) applyTempChecked(code, dragSelectChecked)
dragAppliedCodes.add(code)
window.addEventListener('mousemove', onDragSelectingMove) window.addEventListener('mousemove', onDragSelectingMove)
window.addEventListener('mouseup', stopDragSelect) window.addEventListener('mouseup', stopDragSelect)
} }
const handleDragHover = (code: string) => { const handleDragHover = (_code: string) => {
if (!dragSelecting.value || dragAppliedCodes.has(code)) return if (!dragSelecting.value || !dragMoved.value) return
applyTempChecked(code, dragSelectChecked) applyDragSelectionByRect()
dragAppliedCodes.add(code)
} }
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
@ -615,7 +631,12 @@ onBeforeUnmount(() => {
</div> </div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2"> <div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<label v-for="item in filteredServiceDict" :key="item.id" :ref="el => setPickerItemRef(item.id, el)" <label v-for="item in filteredServiceDict" :key="item.id" :ref="el => setPickerItemRef(item.id, el)"
class="flex select-none items-center gap-2 rounded-md border px-3 py-2 text-sm" :class="[
'picker-item-clickable flex select-none items-center gap-2 rounded-md border px-3 py-2 text-sm',
dragMoved ? 'cursor-default picker-item-dragging' : 'cursor-pointer',
pickerTempIds.includes(item.id) ? 'picker-item-selected' : '',
dragSelecting && pickerTempIds.includes(item.id) ? 'picker-item-selected-drag' : ''
]"
@mousedown.prevent="startDragSelect($event, item.id)" @mouseenter="handleDragHover(item.id)" @mousedown.prevent="startDragSelect($event, item.id)" @mouseenter="handleDragHover(item.id)"
@click.prevent> @click.prevent>
<input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" /> <input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" />
@ -652,7 +673,7 @@ onBeforeUnmount(() => {
</div> </div>
<div ref="agGridRef" class="ag-theme-quartz w-full h-full" > <div ref="agGridRef" class="ag-theme-quartz w-full h-full" >
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="gridOptions" <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="detailGridOptions"
:theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true" :theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20" /> :undoRedoCellEditingLimit="20" />
@ -683,4 +704,25 @@ onBeforeUnmount(() => {
line-height: 1; line-height: 1;
padding: 0; 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> </style>

View File

@ -90,6 +90,19 @@ const openTabContextMenu = (event: MouseEvent, tabId: string) => {
tabContextX.value = event.clientX tabContextX.value = event.clientX
tabContextY.value = event.clientY tabContextY.value = event.clientY
tabContextOpen.value = true tabContextOpen.value = true
void nextTick(() => {
if (!tabContextRef.value) return
const gap = 8
const rect = tabContextRef.value.getBoundingClientRect()
if (tabContextX.value + rect.width + gap > window.innerWidth) {
tabContextX.value = Math.max(gap, window.innerWidth - rect.width - gap)
}
if (tabContextY.value + rect.height + gap > window.innerHeight) {
tabContextY.value = Math.max(gap, window.innerHeight - rect.height - gap)
}
if (tabContextX.value < gap) tabContextX.value = gap
if (tabContextY.value < gap) tabContextY.value = gap
})
} }
const handleGlobalMouseDown = (event: MouseEvent) => { const handleGlobalMouseDown = (event: MouseEvent) => {
@ -456,32 +469,32 @@ watch(
<div <div
v-if="tabContextOpen" v-if="tabContextOpen"
ref="tabContextRef" ref="tabContextRef"
class="fixed z-[70] min-w-[150px] rounded-md border bg-background p-1 shadow-lg" class="fixed z-[70] w-max rounded-lg border border-border/80 bg-background/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur-sm"
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }" :style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }"
> >
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasClosableTabs" :disabled="!hasClosableTabs"
@click="runTabMenuAction('all')" @click="runTabMenuAction('all')"
> >
删除所有 删除所有
</button> </button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseLeft" :disabled="!canCloseLeft"
@click="runTabMenuAction('left')" @click="runTabMenuAction('left')"
> >
删除左侧 删除左侧
</button> </button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseRight" :disabled="!canCloseRight"
@click="runTabMenuAction('right')" @click="runTabMenuAction('right')"
> >
删除右侧 删除右侧
</button> </button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseOther" :disabled="!canCloseOther"
@click="runTabMenuAction('other')" @click="runTabMenuAction('other')"
> >

37
src/lib/decimal.ts Normal file
View File

@ -0,0 +1,37 @@
import Decimal from 'decimal.js'
type MaybeNumber = number | null | undefined
type DecimalInput = Decimal.Value
export const toDecimal = (value: DecimalInput) => new Decimal(value)
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
export const addNumbers = (...values: MaybeNumber[]) => {
let total = new Decimal(0)
for (const value of values) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
let total = new Decimal(0)
for (const item of list) {
const value = pick(item)
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const decimalAggSum = (params: { values?: unknown[] }) => {
let total = new Decimal(0)
for (const value of params.values || []) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total = total.plus(value)
}
return total.toNumber()
}

View File

@ -1,5 +1,6 @@
import { import {
GridOptions,
themeQuartz themeQuartz
} from "ag-grid-community" } from "ag-grid-community"
const borderConfig = { const borderConfig = {
@ -28,3 +29,20 @@ export const myTheme = themeQuartz.withParams({
// 可选:偶数行背景色(轻微区分,更清新) // 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe" dataBackgroundColor: "#fefefe"
}); });
export const gridOptions: GridOptions<any> = {
treeData: true,
animateRows: true,
suppressAggFuncInHeader: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}

View File

@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
const buildReloadKey = (contractId: string, serviceId: string | number) =>
`${contractId}::${String(serviceId)}`
export const usePricingPaneReloadStore = defineStore('pricing-pane-reload', () => {
const reloadVersionMap = ref<Record<string, number>>({})
const markReload = (contractId: string, serviceId: string | number) => {
const key = buildReloadKey(contractId, serviceId)
const current = reloadVersionMap.value[key] || 0
reloadVersionMap.value = {
...reloadVersionMap.value,
[key]: current + 1
}
}
const getReloadVersion = (contractId: string, serviceId: string | number) => {
const key = buildReloadKey(contractId, serviceId)
return reloadVersionMap.value[key] || 0
}
return {
markReload,
getReloadVersion
}
})

View File

@ -1,3 +1,5 @@
import { roundTo, toDecimal } from '@/lib/decimal'
export const majorList = { export const majorList = {
0: { code: 'E1', name: '交通运输工程通用专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '' }, 0: { code: 'E1', name: '交通运输工程通用专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' }, 1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
@ -33,112 +35,161 @@ export const majorList = {
31: { code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房屋建筑与水运附属工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' }, 31: { code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房屋建筑与水运附属工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
} }
export const serviceList = { export const serviceList = {
0: { code: 'D1', name: '全过程造价咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: null }, 0: { code: 'D1', name: '全过程造价咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
1: { code: 'D2', name: '分阶段造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', taskList: null }, 1: { code: 'D2', name: '分阶段造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '' },
2: { code: 'D2-1', name: '前期阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '', taskList: null }, 2: { code: 'D2-1', name: '前期阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '' },
3: { code: 'D2-2-1', name: '实施阶段造价咨询(公路、水运)', maxCoe: null, minCoe: null, defCoe: 0.55, desc: '本系数适用于公路和水运工程。', taskList: null }, 3: { code: 'D2-2-1', name: '实施阶段造价咨询(公路、水运)', maxCoe: null, minCoe: null, defCoe: 0.55, desc: '本系数适用于公路和水运工程。' },
4: { code: 'D2-2-2', name: '实施阶段造价咨询(铁路)', maxCoe: null, minCoe: null, defCoe: 0.6, desc: '本系数适用于铁路工程。', taskList: null }, 4: { code: 'D2-2-2', name: '实施阶段造价咨询(铁路)', maxCoe: null, minCoe: null, defCoe: 0.6, desc: '本系数适用于铁路工程。' },
5: { code: 'D3', name: '基本造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', taskList: null }, 5: { code: 'D3', name: '基本造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '' },
6: { code: 'D3-1', name: '投资估算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '委托同一咨询人同时负责D3-1和D3-2时D3-1和D3-2的合计调整系数为0.25。', taskList: null }, 6: { code: 'D3-1', name: '投资估算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '委托同一咨询人同时负责D3-1和D3-2时D3-1和D3-2的合计调整系数为0.25。' },
7: { code: 'D3-2', name: '设计概算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '', taskList: null }, 7: { code: 'D3-2', name: '设计概算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '' },
8: { code: 'D3-3', name: '施工图预算', maxCoe: null, minCoe: null, defCoe: 0.25, desc: '委托同一咨询人同时负责D3-3和D3-4时D3-3和D3-4的合计调整系数为0.3。', taskList: null }, 8: { code: 'D3-3', name: '施工图预算', maxCoe: null, minCoe: null, defCoe: 0.25, desc: '委托同一咨询人同时负责D3-3和D3-4时D3-3和D3-4的合计调整系数为0.3。' },
9: { code: 'D3-4', name: '招标工程量清单及清单预算(或最高投标限价)', maxCoe: null, minCoe: null, defCoe: 0.15, desc: '', taskList: null }, 9: { code: 'D3-4', name: '招标工程量清单及清单预算(或最高投标限价)', maxCoe: null, minCoe: null, defCoe: 0.15, desc: '' },
10: { code: 'D3-5', name: '清理概算(仅限铁路)', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '本系数适用于铁路工程。', taskList: null }, 10: { code: 'D3-5', name: '清理概算(仅限铁路)', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '本系数适用于铁路工程。' },
11: { code: 'D3-6-1', name: '合同(工程)结算', maxCoe: null, minCoe: null, defCoe: 0.3, desc: '本系数适用于公路和水运工程。', taskList: null }, 11: { code: 'D3-6-1', name: '合同(工程)结算', maxCoe: null, minCoe: null, defCoe: 0.3, desc: '本系数适用于公路和水运工程。' },
12: { code: 'D3-6-2', name: '合同(工程)结算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '本系数适用于铁路工程。', taskList: null }, 12: { code: 'D3-6-2', name: '合同(工程)结算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '本系数适用于铁路工程。' },
13: { code: 'D3-7', name: '竣工决算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '', taskList: null }, 13: { code: 'D3-7', name: '竣工决算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '' },
14: { code: 'D4', name: '专项造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', taskList: null }, 14: { code: 'D4', name: '专项造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '' },
15: { code: 'D4-1', name: '工程造价顾问', maxCoe: null, minCoe: null, defCoe: 1, desc: '本表系数适用于采用工作量计价法基准预算的调整系数。', taskList: [0, 1] }, 15: { code: 'D4-1', name: '工程造价顾问', maxCoe: null, minCoe: null, defCoe: 1, desc: '本表系数适用于采用工作量计价法基准预算的调整系数。' },
16: { code: 'D4-2', name: '造价政策制(修)订', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: [2, 3, 4, 5, 6, 7] }, 16: { code: 'D4-2', name: '造价政策制(修)订', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21] }, 17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: ''},
18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: [22, 23, 24, 25, 26, 27, 28, 22, 23, 24, 25, 26, 27, 28] }, 18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: ''},
19: { code: 'D4-5', name: '造价信息咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: null }, 19: { code: 'D4-5', name: '造价信息咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
20: { code: 'D4-6', name: '造价鉴定', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '本表系数适用于采用规模计价法基准预算的调整系数。', taskList: null }, 20: { code: 'D4-6', name: '造价鉴定', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '本表系数适用于采用规模计价法基准预算的调整系数。' },
21: { code: 'D4-7', name: '工程成本测算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '', taskList: null }, 21: { code: 'D4-7', name: '工程成本测算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '' },
22: { code: 'D4-8', name: '工程成本核算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '', taskList: null }, 22: { code: 'D4-8', name: '工程成本核算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '' },
23: { code: 'D4-9', name: '计算工程量', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '', taskList: null }, 23: { code: 'D4-9', name: '计算工程量', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '' },
24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '', taskList: null }, 24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '' },
25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '', taskList: null }, 25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '' },
26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。', taskList: null }, 26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。' },
27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。', taskList: null }, 27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。' },
28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。', taskList: null }, 28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。' },
};
//basicParam预算基数
export const taskList = {
0: { serviceID: 15, code: 'C4-1', name: '工程造价日常顾问', basicParam: '服务月份数', required: true, unit: '万元/月', conversion: 10000, maxPrice: 0.5, minPrice: 0.3, defPrice: 0.4, desc: '' },
1: { serviceID: 15, code: 'C4-2', name: '工程造价专项顾问', basicParam: '服务项目的造价金额', required: true, unit: '%', conversion: 0.01, maxPrice: null, minPrice: null, defPrice: 0.01, desc: '适用于涉及造价费用类的顾问' },
2: { serviceID: 16, code: 'C5-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
3: { serviceID: 16, code: 'C5-2-1', name: '文件编写工作', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 5, minPrice: 3, defPrice: 4, desc: '主编' },
4: { serviceID: 16, code: 'C5-2-2', name: '文件编写工作', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '参编' },
5: { serviceID: 16, code: 'C5-3-1', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
6: { serviceID: 16, code: 'C5-3-2', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
7: { serviceID: 16, code: 'C5-3-3', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
8: { serviceID: 17, code: 'C6-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
9: { serviceID: 17, code: 'C6-2-1', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 50, minPrice: 20, defPrice: 35, desc: '国家级' },
10: { serviceID: 17, code: 'C6-2-2', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 20, minPrice: 10, defPrice: 15, desc: '省部级' },
11: { serviceID: 17, code: 'C6-2-3', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '其他级' },
12: { serviceID: 17, code: 'C6-3-1', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 80, minPrice: 50, defPrice: 65, desc: '复杂标准' },
13: { serviceID: 17, code: 'C6-3-2', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 50, minPrice: 20, defPrice: 35, desc: '较复杂标准' },
14: { serviceID: 17, code: 'C6-3-3', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 20, minPrice: 10, defPrice: 15, desc: '一般标准' },
15: { serviceID: 17, code: 'C6-3-4', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '简单标准' },
16: { serviceID: 17, code: 'C6-4-1', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
17: { serviceID: 17, code: 'C6-4-2', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
18: { serviceID: 17, code: 'C6-4-3', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
19: { serviceID: 17, code: 'C6-5-1', name: '培训与宣贯工作', basicParam: '项目数量', required: false, unit: '万元/次', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '培训与宣贯材料' },
20: { serviceID: 17, code: 'C6-5-2', name: '培训与宣贯工作', basicParam: '培训与宣贯次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 1, minPrice: 0.5, defPrice: 0.75, desc: '组织培训与宣贯' },
21: { serviceID: 17, code: 'C6-6', name: '测试与验证工作', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
22: { serviceID: 18, code: 'C7-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
23: { serviceID: 18, code: 'C7-2', name: '编制大纲', basicParam: '项目数量', required: true, unit: '万元/个', conversion: 10000, maxPrice: 3, minPrice: 2, defPrice: 2.5, desc: '包括技术与定额子目研究' },
24: { serviceID: 18, code: 'C7-3', name: '数据采集与测定', basicParam: '采集组数', required: true, unit: '万元/组', conversion: 10000, maxPrice: 0.8, minPrice: 0.2, defPrice: 0.5, desc: '现场采集方式时计' },
25: { serviceID: 18, code: 'C7-4-1', name: '数据整理与分析', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 0.3, minPrice: 0.1, defPrice: 0.2, desc: '简单定额' },
26: { serviceID: 18, code: 'C7-4-2', name: '数据整理与分析', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 3, minPrice: 0.3, defPrice: 1.65, desc: '复杂定额' },
27: { serviceID: 18, code: 'C7-5', name: '编写定额测定报告', basicParam: '项目数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 5, minPrice: 2, defPrice: 3.5, desc: '' },
28: { serviceID: 18, code: 'C7-6-1', name: '编制定额文本和释义', basicParam: '基本费用', required: true, unit: '万元/份', conversion: 10000, maxPrice: 1, minPrice: 0.5, defPrice: 0.75, desc: '20条定额子目内' },
29: { serviceID: 18, code: 'C7-6-2', name: '编制定额文本和释义', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 0.2, minPrice: 0.1, defPrice: 0.15, desc: '超过20条每增加1条' },
30: { serviceID: 18, code: 'C7-7-1', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
31: { serviceID: 18, code: 'C7-7-2', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
32: { serviceID: 18, code: 'C7-7-3', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
33: { serviceID: 18, code: 'C7-8-1', name: '培训与宣贯工作', basicParam: '项目数量', required: false, unit: '万元/次', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '培训与宣贯材料' },
34:{ serviceID :18 ,code :'C7-8-2' ,name :'培训与宣贯工作' ,basicParam :'培训与宣贯次数' ,required :false ,unit :'万元/次' ,conversion :10000 ,maxPrice :1 ,minPrice :0.5 ,defPrice :0.75 ,desc :'组织培训与宣贯'},
35: { serviceID: 18, code: 'C7-9', name: '定额测试与验证', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
};
const expertList = {
0: { code: 'C9-1-1', name: '技术员及其他', maxPrice: 800, minPrice: 600, defPrice: 700, manageCoe: 2.3 },
1: { code: 'C9-1-2', name: '助理工程师', maxPrice: 1000, minPrice: 800, defPrice: 900, manageCoe: 2.3 },
2: { code: 'C9-1-3', name: '中级工程师或二级造价工程师', maxPrice: 1500, minPrice: 1000, defPrice: 1250, manageCoe: 2.2 },
3: { code: 'C9-1-4', name: '高级工程师或一级造价工程师', maxPrice: 1800, minPrice: 1500, defPrice: 1650, manageCoe: 2.1 },
4: { code: 'C9-1-5', name: '正高级工程师或资深专家', maxPrice: 2500, minPrice: 2000, defPrice: 2250, manageCoe: 2 },
5: { code: 'C9-2-1', name: '二级造价工程师且具备中级工程师资格', maxPrice: 1500, minPrice: 1200, defPrice: 1350, manageCoe: 2.2 },
6: { code: 'C9-3-1', name: '一级造价工程师且具备中级工程师资格', maxPrice: 1800, minPrice: 1500, defPrice: 1650, manageCoe: 2.1 },
7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
}; };
let taskList = { const costScaleCal = [
0: { serviceID: 15, ref: 'C4-1', name: '工程造价日常顾问', basicParam: '服务月份数', required: true, unit: '万元/月', conversion: 10000, maxPrice: 0.5, minPrice: 0.3, defPrice: 0.4, desc: '' }, { code: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } },
1: { serviceID: 15, ref: 'C4-2', name: '工程造价专项顾问', basicParam: '服务项目的造价金额', required: true, unit: '%', conversion: 0.01, maxPrice: null, minPrice: null, defPrice: 0.01, desc: '适用于涉及造价费用类的顾问' }, { code: 'C1-2', staLine: 100, endLine: 300, basic: { staPrice: 10000, rate: 0.008 }, optional: { staPrice: 2000, rate: 0.0016 } },
2: { serviceID: 16, ref: 'C5-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' }, { code: 'C1-3', staLine: 300, endLine: 500, basic: { staPrice: 26000, rate: 0.005 }, optional: { staPrice: 5200, rate: 0.001 } },
3: { serviceID: 16, ref: 'C5-2-1', name: '文件编写工作', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 5, minPrice: 3, defPrice: 4, desc: '主编' }, { code: 'C1-4', staLine: 500, endLine: 1000, basic: { staPrice: 36000, rate: 0.004 }, optional: { staPrice: 7200, rate: 0.0008 } },
4: { serviceID: 16, ref: 'C5-2-2', name: '文件编写工作', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '参编' }, { code: 'C1-5', staLine: 1000, endLine: 5000, basic: { staPrice: 56000, rate: 0.003 }, optional: { staPrice: 11200, rate: 0.0006 } },
5: { serviceID: 16, ref: 'C5-3-1', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' }, { code: 'C1-6', staLine: 5000, endLine: 10000, basic: { staPrice: 176000, rate: 0.002 }, optional: { staPrice: 35200, rate: 0.0004 } },
6: { serviceID: 16, ref: 'C5-3-2', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' }, { code: 'C1-7', staLine: 10000, endLine: 30000, basic: { staPrice: 276000, rate: 0.0016 }, optional: { staPrice: 55200, rate: 0.00032 } },
7: { serviceID: 16, ref: 'C5-3-3', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' }, { code: 'C1-8', staLine: 30000, endLine: 50000, basic: { staPrice: 596000, rate: 0.0013 }, optional: { staPrice: 119200, rate: 0.00026 } },
8: { serviceID: 17, ref: 'C6-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' }, { code: 'C1-9', staLine: 50000, endLine: 100000, basic: { staPrice: 856000, rate: 0.001 }, optional: { staPrice: 171200, rate: 0.0002 } },
9: { serviceID: 17, ref: 'C6-2-1', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 50, minPrice: 20, defPrice: 35, desc: '国家级' }, { code: 'C1-10', staLine: 100000, endLine: 150000, basic: { staPrice: 1356000, rate: 0.0009 }, optional: { staPrice: 271200, rate: 0.00018 } },
10: { serviceID: 17, ref: 'C6-2-2', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 20, minPrice: 10, defPrice: 15, desc: '省部级' }, { code: 'C1-11', staLine: 150000, endLine: 200000, basic: { staPrice: 1806000, rate: 0.0008 }, optional: { staPrice: 361200, rate: 0.00016 } },
11: { serviceID: 17, ref: 'C6-2-3', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '其他级' }, { code: 'C1-12', staLine: 200000, endLine: 300000, basic: { staPrice: 2206000, rate: 0.0007 }, optional: { staPrice: 441200, rate: 0.00014 } },
12: { serviceID: 17, ref: 'C6-3-1', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 80, minPrice: 50, defPrice: 65, desc: '复杂标准' }, { code: 'C1-13', staLine: 300000, endLine: 400000, basic: { staPrice: 2906000, rate: 0.0006 }, optional: { staPrice: 581200, rate: 0.00012 } },
13: { serviceID: 17, ref: 'C6-3-2', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 50, minPrice: 20, defPrice: 35, desc: '较复杂标准' }, { code: 'C1-14', staLine: 400000, endLine: 600000, basic: { staPrice: 3506000, rate: 0.0005 }, optional: { staPrice: 701200, rate: 0.0001 } },
14: { serviceID: 17, ref: 'C6-3-3', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 20, minPrice: 10, defPrice: 15, desc: '一般标准' }, { code: 'C1-15', staLine: 600000, endLine: 800000, basic: { staPrice: 4506000, rate: 0.0004 }, optional: { staPrice: 901200, rate: 0.00008 } },
15: { serviceID: 17, ref: 'C6-3-4', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '简单标准' }, { code: 'C1-16', staLine: 800000, endLine: 1000000, basic: { staPrice: 5306000, rate: 0.0003 }, optional: { staPrice: 1061200, rate: 0.00006 } },
16: { serviceID: 17, ref: 'C6-4-1', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' }, { code: 'C1-17', staLine: 1000000, endLine: null, basic: { staPrice: 5906000, rate: 0.00025 }, optional: { staPrice: 1181200, rate: 0.00005 } },
17: { serviceID: 17, ref: 'C6-4-2', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
18: { serviceID: 17, ref: 'C6-4-3', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
19: { serviceID: 17, ref: 'C6-5-1', name: '培训与宣贯工作', basicParam: '项目数量', required: false, unit: '万元/次', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '培训与宣贯材料' },
20: { serviceID: 17, ref: 'C6-5-2', name: '培训与宣贯工作', basicParam: '培训与宣贯次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 1, minPrice: 0.5, defPrice: 0.75, desc: '组织培训与宣贯' },
21: { serviceID: 17, ref: 'C6-6', name: '测试与验证工作', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
22: { serviceID: 18, ref: 'C7-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
23: { serviceID: 18, ref: 'C7-2', name: '编制大纲', basicParam: '项目数量', required: true, unit: '万元/个', conversion: 10000, maxPrice: 3, minPrice: 2, defPrice: 2.5, desc: '包括技术与定额子目研究' },
24: { serviceID: 18, ref: 'C7-3', name: '数据采集与测定', basicParam: '采集组数', required: true, unit: '万元/组', conversion: 10000, maxPrice: 0.8, minPrice: 0.2, defPrice: 0.5, desc: '现场采集方式时计' },
25: { serviceID: 18, ref: 'C7-4-1', name: '数据整理与分析', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 0.3, minPrice: 0.1, defPrice: 0.2, desc: '简单定额' },
26: { serviceID: 18, ref: 'C7-4-2', name: '数据整理与分析', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 3, minPrice: 0.3, defPrice: 1.65, desc: '复杂定额' },
27: { serviceID: 18, ref: 'C7-5', name: '编写定额测定报告', basicParam: '项目数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 5, minPrice: 2, defPrice: 3.5, desc: '' },
28: { serviceID: 18, ref: 'C7-6-1', name: '编制定额文本和释义', basicParam: '基本费用', required: true, unit: '万元/份', conversion: 10000, maxPrice: 1, minPrice: 0.5, defPrice: 0.75, desc: '20条定额子目内' },
29: { serviceID: 18, ref: 'C7-6-2', name: '编制定额文本和释义', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 0.2, minPrice: 0.1, defPrice: 0.15, desc: '超过20条每增加1条' },
30: { serviceID: 18, ref: 'C7-7-1', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
31: { serviceID: 18, ref: 'C7-7-2', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
32: { serviceID: 18, ref: 'C7-7-3', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
33: { serviceID: 18, ref: 'C7-8-1', name: '培训与宣贯工作', basicParam: '项目数量', required: false, unit: '万元/次', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '培训与宣贯材料' },
34:{ serviceID :18 ,ref :'C7-8-2' ,name :'培训与宣贯工作' ,basicParam :'培训与宣贯次数' ,required :false ,unit :'万元/次' ,conversion :10000 ,maxPrice :1 ,minPrice :0.5 ,defPrice :0.75 ,desc :'组织培训与宣贯'},
35: { serviceID: 18, ref: 'C7-9', name: '定额测试与验证', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
};
export const expertList = {
0: { ref: 'C9-1-1', name: '技术员及其他', maxPrice: 800, minPrice: 600, defPrice: 700, manageCoe: 2.3 },
1: { ref: 'C9-1-2', name: '助理工程师', maxPrice: 1000, minPrice: 800, defPrice: 900, manageCoe: 2.3 },
2: { ref: 'C9-1-3', name: '中级工程师或二级造价工程师', maxPrice: 1500, minPrice: 1000, defPrice: 1250, manageCoe: 2.2 },
3: { ref: 'C9-1-4', name: '高级工程师或一级造价工程师', maxPrice: 1800, minPrice: 1500, defPrice: 1650, manageCoe: 2.1 },
4: { ref: 'C9-1-5', name: '正高级工程师或资深专家', maxPrice: 2500, minPrice: 2000, defPrice: 2250, manageCoe: 2 },
5: { ref: 'C9-2-1', name: '二级造价工程师且具备中级工程师资格', maxPrice: 1500, minPrice: 1200, defPrice: 1350, manageCoe: 2.2 },
6: { ref: 'C9-3-1', name: '一级造价工程师且具备中级工程师资格', maxPrice: 1800, minPrice: 1500, defPrice: 1650, manageCoe: 2.1 },
7: { ref: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
};
export const costScaleCal = [
{ ref: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } },
{ ref: 'C1-2', staLine: 100, endLine: 300, basic: { staPrice: 10000, rate: 0.008 }, optional: { staPrice: 2000, rate: 0.0016 } },
{ ref: 'C1-3', staLine: 300, endLine: 500, basic: { staPrice: 26000, rate: 0.005 }, optional: { staPrice: 5200, rate: 0.001 } },
{ ref: 'C1-4', staLine: 500, endLine: 1000, basic: { staPrice: 36000, rate: 0.004 }, optional: { staPrice: 7200, rate: 0.0008 } },
{ ref: 'C1-5', staLine: 1000, endLine: 5000, basic: { staPrice: 56000, rate: 0.003 }, optional: { staPrice: 11200, rate: 0.0006 } },
{ ref: 'C1-6', staLine: 5000, endLine: 10000, basic: { staPrice: 176000, rate: 0.002 }, optional: { staPrice: 35200, rate: 0.0004 } },
{ ref: 'C1-7', staLine: 10000, endLine: 30000, basic: { staPrice: 276000, rate: 0.0016 }, optional: { staPrice: 55200, rate: 0.00032 } },
{ ref: 'C1-8', staLine: 30000, endLine: 50000, basic: { staPrice: 596000, rate: 0.0013 }, optional: { staPrice: 119200, rate: 0.00026 } },
{ ref: 'C1-9', staLine: 50000, endLine: 100000, basic: { staPrice: 856000, rate: 0.001 }, optional: { staPrice: 171200, rate: 0.0002 } },
{ ref: 'C1-10', staLine: 100000, endLine: 150000, basic: { staPrice: 1356000, rate: 0.0009 }, optional: { staPrice: 271200, rate: 0.00018 } },
{ ref: 'C1-11', staLine: 150000, endLine: 200000, basic: { staPrice: 1806000, rate: 0.0008 }, optional: { staPrice: 361200, rate: 0.00016 } },
{ ref: 'C1-12', staLine: 200000, endLine: 300000, basic: { staPrice: 2206000, rate: 0.0007 }, optional: { staPrice: 441200, rate: 0.00014 } },
{ ref: 'C1-13', staLine: 300000, endLine: 400000, basic: { staPrice: 2906000, rate: 0.0006 }, optional: { staPrice: 581200, rate: 0.00012 } },
{ ref: 'C1-14', staLine: 400000, endLine: 600000, basic: { staPrice: 3506000, rate: 0.0005 }, optional: { staPrice: 701200, rate: 0.0001 } },
{ ref: 'C1-15', staLine: 600000, endLine: 800000, basic: { staPrice: 4506000, rate: 0.0004 }, optional: { staPrice: 901200, rate: 0.00008 } },
{ ref: 'C1-16', staLine: 800000, endLine: 1000000, basic: { staPrice: 5306000, rate: 0.0003 }, optional: { staPrice: 1061200, rate: 0.00006 } },
{ ref: 'C1-17', staLine: 1000000, endLine: null, basic: { staPrice: 5906000, rate: 0.00025 }, optional: { staPrice: 1181200, rate: 0.00005 } },
]; ];
export const areaScaleCal = [ const areaScaleCal = [
{ ref: 'C2-1', staLine: 0, endLine: 50, basic: { staPrice: 0, rate: 200 }, optional: { staPrice: 0, rate: 40 } }, { code: 'C2-1', staLine: 0, endLine: 50, basic: { staPrice: 0, rate: 200 }, optional: { staPrice: 0, rate: 40 } },
{ ref: 'C2-2', staLine: 50, endLine: 100, basic: { staPrice: 10000, rate: 160 }, optional: { staPrice: 2000, rate: 32 } }, { code: 'C2-2', staLine: 50, endLine: 100, basic: { staPrice: 10000, rate: 160 }, optional: { staPrice: 2000, rate: 32 } },
{ ref: 'C2-3', staLine: 100, endLine: 500, basic: { staPrice: 18000, rate: 120 }, optional: { staPrice: 3600, rate: 24 } }, { code: 'C2-3', staLine: 100, endLine: 500, basic: { staPrice: 18000, rate: 120 }, optional: { staPrice: 3600, rate: 24 } },
{ ref: 'C2-4', staLine: 500, endLine: 1000, basic: { staPrice: 66000, rate: 80 }, optional: { staPrice: 13200, rate: 16 } }, { code: 'C2-4', staLine: 500, endLine: 1000, basic: { staPrice: 66000, rate: 80 }, optional: { staPrice: 13200, rate: 16 } },
{ ref: 'C2-5', staLine: 1000, endLine: 5000, basic: { staPrice: 106000, rate: 60 }, optional: { staPrice: 21200, rate: 12 } }, { code: 'C2-5', staLine: 1000, endLine: 5000, basic: { staPrice: 106000, rate: 60 }, optional: { staPrice: 21200, rate: 12 } },
{ ref: 'C2-6', staLine: 5000, endLine: null, basic: { staPrice: 346000, rate: 20 }, optional: { staPrice: 69200, rate: 4 } }, { code: 'C2-6', staLine: 5000, endLine: null, basic: { staPrice: 346000, rate: 20 }, optional: { staPrice: 69200, rate: 4 } },
]; ];
// TODO: 补充信息服务规模计价区间后替换空数组
const infoServiceScaleCal: Array<{ staLine: number; endLine: number | null; staPrice: number; rate: number }> = []
const inRange = (sv: number, staLine: number, endLine: number | null) =>
sv >= staLine && (endLine == null || sv <= endLine)
export function getBasicFeeFromScale(scaleValue: unknown, scaleType: 'cost' | 'area' | 'amount') {
const sv = Number(scaleValue)
if (!Number.isFinite(sv) || sv <= 0) return null
if (scaleType === 'cost') {// 造价规模
const targetRange = costScaleCal.find(f => inRange(sv, f.staLine, f.endLine))
if (!targetRange) return null
const delta = toDecimal(sv).minus(targetRange.staLine)
return {
basic: roundTo(
toDecimal(targetRange.basic.staPrice).plus(delta.mul(10000).mul(targetRange.basic.rate))
),
optional: roundTo(
toDecimal(targetRange.optional.staPrice).plus(delta.mul(10000).mul(targetRange.optional.rate))
),
}
}
if (scaleType === 'area') {//用地
const targetRange = areaScaleCal.find(f => inRange(sv, f.staLine, f.endLine))
if (!targetRange) return null
const delta = toDecimal(sv).minus(targetRange.staLine)
return {
basic: roundTo(
toDecimal(targetRange.basic.staPrice).plus(delta.mul(targetRange.basic.rate))
),
optional: roundTo(
toDecimal(targetRange.optional.staPrice).plus(delta.mul(targetRange.optional.rate))
),
}
}
const targetRange = infoServiceScaleCal.find(f => inRange(sv, f.staLine, f.endLine))
if (!targetRange) return null
const delta = toDecimal(sv).minus(targetRange.staLine)
return {
basic: roundTo(
toDecimal(targetRange.staPrice).plus(delta.mul(targetRange.rate))
),
optional: 0,
}
}

View File

@ -1,8 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
#app { #app {
height: 100vh; height: 100dvh;
} }
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
@ -112,8 +114,9 @@
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
.ag-horizontal-left-spacer { .ag-horizontal-left-spacer {
overflow-x: auto overflow-x: auto;
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@ -131,3 +134,38 @@ overflow-x: auto
user-select: text; user-select: text;
} }
} }
.ag-floating-top {
overflow-y: auto !important;
}
.xmMx .editable-cell-line .ag-cell-value {
display: inline-block;
min-width: 84%;
padding: 2px 4px;
border-bottom: 1px solid #cbd5e1;
}
.xmMx .remark-wrap-cell .ag-cell-value {
display: block;
min-width: 0;
border-bottom: none !important;
white-space: normal;
line-height: 1.4;
}
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
.xmMx .editable-cell-line:hover .ag-cell-value {
border-bottom-color: #2563eb;
}
.xmMx .editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
font-style: italic;
opacity: 1 !important;
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
color: #94a3b8 !important;
}

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/lib/diyaggridtheme.ts","./src/lib/utils.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/views/contractdetailview.vue","./src/components/views/ht.vue","./src/components/views/xm.vue","./src/components/views/zxfwview.vue","./src/components/views/htinfo.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"} {"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/utils.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/contractdetailview.vue","./src/components/views/ht.vue","./src/components/views/xm.vue","./src/components/views/zxfwview.vue","./src/components/views/htinfo.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}