This commit is contained in:
zwgczx committed 2026-02-27 17:06:31 +08:00
1 parent 57a2029847
commit badf131dde
18 files changed
+1081 -2824

No files matched your search

+79 -5
View File
@@ -1,5 +1,5 @@
<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 localforage from 'localforage'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
@@ -52,8 +52,15 @@ let baseOffsetY = 0
const contractListScrollWrapRef = ref<HTMLElement | null>(null)
const contractListViewportRef = ref<HTMLElement | null>(null)
const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready')
let contractAutoScrollRaf = 0
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[] => [
@@ -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 () => {
try {
contracts.value = normalizeOrder(contracts.value)
@@ -297,6 +320,11 @@ const contractAutoScrollTick = () => {
}
const startContractAutoScroll = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'ready'
getContractListViewport()
isDraggingContracts.value = true
dragPointerClientY = null
@@ -349,13 +377,22 @@ const startDrag = (event: MouseEvent) => {
onMounted(async () => {
await loadContracts()
triggerCardEnterAnimation()
await nextTick()
getContractListViewport()
})
onActivated(() => {
triggerCardEnterAnimation()
void nextTick(() => {
getContractListViewport()
})
})
onBeforeUnmount(() => {
stopDrag()
stopContractAutoScroll()
if (cardEnterTimer) clearTimeout(cardEnterTimer)
void saveContracts()
})
</script>
@@ -432,12 +469,14 @@ onBeforeUnmount(() => {
@start="startContractAutoScroll"
@end="handleDragEnd"
>
<template #item="{ element }">
<template #item="{ element, index }">
<Card
: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'
]"
:style="cardMotionState === 'enter' ? getCardEnterStyle(index) : undefined"
@click="handleCardClick(element)"
>
<CardHeader
@@ -530,12 +569,14 @@ onBeforeUnmount(() => {
]"
>
<Card
v-for="element in filteredContracts"
v-for="(element, index) in filteredContracts"
:key="element.id"
: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'
]"
:style="cardMotionState === 'enter' ? getCardEnterStyle(index) : undefined"
@click="handleCardClick(element)"
>
<CardHeader
@@ -705,4 +746,37 @@ onBeforeUnmount(() => {
transform: translateZ(0);
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>
+7 -57
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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 { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -155,7 +155,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
@@ -181,7 +181,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
@@ -215,30 +215,9 @@ 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(() => sumByNumber(detailRows.value, row => row.amount))
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -366,32 +345,3 @@ const processCellFromClipboard = (params:any) => {
</div>
</div>
</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>
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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 { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@@ -46,90 +46,43 @@ interface XmInfoState {
const props = defineProps<{
contractId: string,
serviceId: string|number
serviceId: string | number
}>()
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[]>([])
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>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
laborBudgetUnitPrice: null,
compositeBudgetUnitPrice: null,
adoptedBudgetUnitPrice: null,
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [group.id, child.id]
})
}
}
return rows
}
@@ -213,20 +166,25 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
]
},
editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: 'sum' }),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: 'sum' }),
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: 'sum' }),
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: decimalAggSum }),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: decimalAggSum }),
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@@ -253,34 +211,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 totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalPersonnelCount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.personnelCount || 0), 0)
)
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const totalWorkdayCount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.workdayCount || 0), 0)
)
const totalServiceBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.serviceBudget || 0), 0)
)
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => row.serviceBudget))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -302,9 +237,10 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
@@ -315,6 +251,11 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
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
@@ -341,7 +290,6 @@ const handleCellValueChanged = () => {
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
})
@@ -351,14 +299,14 @@ onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
@@ -371,7 +319,7 @@ const processCellFromClipboard = (params:any) => {
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
@@ -380,58 +328,16 @@ const processCellFromClipboard = (params:any) => {
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
</div>
</template>
<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>
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community'
import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -45,13 +46,41 @@ interface XmInfoState {
const props = defineProps<{
contractId: string,
serviceId: string|number
serviceId: string | number
}>()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const detailRows = ref<DetailRow[]>([])
type serviceLite = { defCoe: number | null }
const defaultConsultCategoryFactor = computed<number | null>(() => {
const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
})
type majorLite = { code: string; name: string }
type majorLite = { code: string; name: string; defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => {
@@ -59,6 +88,11 @@ const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
return Boolean(item?.code && item?.name)
})
const getDefaultMajorFactorById = (id: string): number | null => {
const major = (majorList as Record<string, majorLite | undefined>)[id]
return typeof major?.defCoe === 'number' && Number.isFinite(major.defCoe) ? major.defCoe : null
}
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
@@ -120,8 +154,8 @@ const buildDefaultRows = (): DetailRow[] => {
majorName: child.name,
amount: null,
benchmarkBudget: null,
consultCategoryFactor: null,
majorFactor: null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null,
remark: '',
path: [group.id, child.id]
@@ -131,8 +165,9 @@ const buildDefaultRows = (): DetailRow[] => {
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
@@ -140,14 +175,25 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null,
typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
@@ -168,17 +214,41 @@ const formatEditableNumber = (params: any) => {
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 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: '工程专业名称',
minWidth: 220,
width: 240,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
}
},
{
headerName: '造价金额(万元)',
field: 'amount',
@@ -191,7 +261,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
@@ -200,16 +270,10 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'benchmarkBudget',
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',
aggFunc: decimalAggSum,
valueGetter: params => getBenchmarkBudgetByAmount(params.data),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatReadonlyNumber
},
{
headerName: '咨询分类系数',
@@ -223,7 +287,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatConsultCategoryFactor
},
{
headerName: '专业系数',
@@ -237,34 +301,34 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatMajorFactor
},
{
headerName: '预算费用',
field: 'budgetFee',
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatReadonlyNumber
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@@ -273,10 +337,10 @@ const columnDefs: ColDef<DetailRow>[] = [
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码',
minWidth: 160,
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
width: 170,
flex: 2,
cellRendererParams: {
suppressCount: true
@@ -286,39 +350,16 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
return idLabelMap.get(nodeId) || nodeId
}
}
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 totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalBenchmarkBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetByAmount(row)))
const totalBudgetFee = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
)
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -339,9 +380,10 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
@@ -352,12 +394,24 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
@@ -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
@@ -378,7 +440,6 @@ const handleCellValueChanged = () => {
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
})
@@ -388,14 +449,14 @@ onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
@@ -408,7 +469,7 @@ const processCellFromClipboard = (params:any) => {
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
@@ -417,58 +478,16 @@ const processCellFromClipboard = (params:any) => {
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
</div>
</template>
<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>
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions } from 'ag-grid-community'
import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -46,13 +47,41 @@ interface XmInfoState {
const props = defineProps<{
contractId: string,
serviceId: string|number
serviceId: string | number
}>()
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[]>([])
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>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.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)
})
const getDefaultMajorFactorById = (id: string): number | null => {
const major = (majorList as Record<string, majorLite | undefined>)[id]
return typeof major?.defCoe === 'number' && Number.isFinite(major.defCoe) ? major.defCoe : null
}
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
@@ -122,8 +156,8 @@ const buildDefaultRows = (): DetailRow[] => {
amount: null,
landArea: null,
benchmarkBudget: null,
consultCategoryFactor: null,
majorFactor: null,
consultCategoryFactor: defaultConsultCategoryFactor.value,
majorFactor: getDefaultMajorFactorById(child.id),
budgetFee: null,
remark: '',
path: [group.id, child.id]
@@ -133,8 +167,9 @@ const buildDefaultRows = (): DetailRow[] => {
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'landArea' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
@@ -142,6 +177,8 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
return {
...row,
@@ -149,8 +186,17 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
majorFactor: typeof fromDb.majorFactor === 'number' ? fromDb.majorFactor : null,
typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: defaultConsultCategoryFactor.value,
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
@@ -171,6 +217,40 @@ const formatEditableNumber = (params: any) => {
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) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
@@ -180,16 +260,6 @@ const formatEditableFlexibleNumber = (params: any) => {
}
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: '用地面积(亩)',
field: 'landArea',
@@ -202,7 +272,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableFlexibleNumber
},
@@ -211,15 +281,10 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'benchmarkBudget',
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',
aggFunc: decimalAggSum,
valueGetter: params => getBenchmarkBudgetByLandArea(params.data),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatReadonlyNumber
},
{
headerName: '咨询分类系数',
@@ -233,7 +298,7 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatConsultCategoryFactor
},
{
headerName: '专业系数',
@@ -247,34 +312,34 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatMajorFactor
},
{
headerName: '预算费用',
field: 'budgetFee',
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
valueFormatter: formatReadonlyNumber
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
@@ -283,10 +348,10 @@ const columnDefs: ColDef<DetailRow>[] = [
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码',
minWidth: 160,
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
width: 170,
flex: 2,
cellRendererParams: {
suppressCount: true
@@ -296,43 +361,19 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
return idLabelMap.get(nodeId) || nodeId
}
}
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(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalBenchmarkBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const totalBudgetFee = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
)
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetByLandArea(row)))
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -354,9 +395,10 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
@@ -367,12 +409,24 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
@@ -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
@@ -393,7 +455,6 @@ const handleCellValueChanged = () => {
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
})
@@ -403,14 +464,14 @@ onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
@@ -423,7 +484,7 @@ const processCellFromClipboard = (params:any) => {
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
@@ -432,58 +493,16 @@ const processCellFromClipboard = (params:any) => {
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
</div>
</template>
<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>
@@ -1,36 +1,23 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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 { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { serviceList, taskList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
interface DictLeaf {
id: string
code: string
name: string
}
interface DictGroup {
id: string
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
taskCode: string
taskName: string
unit: string
workload: number | null
budgetBase: number | null
budgetReferenceUnitPrice: number | null
budgetBase: string
budgetReferenceUnitPrice: string
budgetAdoptedUnitPrice: number | null
consultCategoryFactor: number | null
serviceFee: number | null
@@ -46,93 +33,114 @@ interface XmInfoState {
const props = defineProps<{
contractId: string,
serviceId: string|number
serviceId: string | number
}>()
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[]>([])
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)
})
type serviceLite = { defCoe: number | null }
type taskLite = {
serviceID: number
code: string
name: string
basicParam: string
unit: string
maxPrice: number | null
minPrice: number | null
defPrice: number | null
}
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
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
})
for (const [key, item] of serviceEntries) {
const code = item.code
const isGroup = !code.includes('-')
if (isGroup) {
if (!groupMap.has(code)) groupOrder.push(code)
groupMap.set(code, {
id: key,
code,
name: item.name,
children: []
})
continue
}
const parentCode = code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
})
}
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name
})
}
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
})()
const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
const formatTaskReferenceUnitPrice = (task: taskLite) => {
const unit = task.unit || ''
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
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 ''
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const group of detailDict) {
for (const child of group.children) {
rows.push({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
workload: null,
budgetBase: null,
budgetReferenceUnitPrice: null,
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: null,
remark: '',
path: [group.id, child.id]
})
}
const currentServiceId = Number(props.serviceId)
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({
id: rowId,
taskCode: task.code,
taskName: task.name,
unit: task.unit || '',
workload: null,
budgetBase: task.basicParam || '',
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,
consultCategoryFactor: null,
serviceFee: null,
remark: '',
path: [emptyRowId]
})
}
return rows
}
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
@@ -146,9 +154,6 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
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:
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
consultCategoryFactor:
@@ -161,11 +166,13 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
const parseNumberOrNull = (value: unknown) => {
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
}
const formatEditableNumber = (params: any) => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
@@ -173,15 +180,84 @@ const formatEditableNumber = (params: any) => {
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>[] = [
{
headerName: '工作内容',
minWidth: 220,
width: 240,
headerName: '编码',
field: 'taskCode',
minWidth: 150,
width: 170,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
valueFormatter: params => params.value || ''
},
{
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',
minWidth: 140,
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' : ''),
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: '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 === '')
!params.node?.group &&
!params.node?.rowPinned &&
!isNoTaskRow(params.data) &&
(params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
@@ -248,11 +283,14 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'consultCategoryFactor',
minWidth: 150,
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' : ''),
cellClassRules: {
'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),
valueFormatter: formatEditableNumber
@@ -262,13 +300,16 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'serviceFee',
minWidth: 150,
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' : ''),
cellClassRules: {
'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),
valueFormatter: formatEditableNumber
},
@@ -277,76 +318,40 @@ const columnDefs: ColDef<DetailRow>[] = [
field: 'remark',
minWidth: 180,
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 => {
if (isNoTaskRow(params.data)) return '无'
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
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: {
'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 = {
headerName: '编码',
minWidth: 160,
pinned: 'left',
width: 170,
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
}
}
const 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 totalServiceFee = computed(() => sumByNumber(detailRows.value, row => row.serviceFee))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
taskCode: '',
taskName: '',
unit: '',
workload: totalWorkload.value,
budgetBase: totalBudgetBase.value,
budgetReferenceUnitPrice: null,
budgetBase: '',
budgetReferenceUnitPrice: '',
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: totalServiceFee.value,
@@ -358,9 +363,10 @@ const pinnedTopRowData = computed(() => [
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
console.log('Saving to IndexedDB:', payload)
await localforage.setItem(DB_KEY.value, payload)
@@ -371,6 +377,11 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
return
}
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
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
@@ -397,7 +416,6 @@ const handleCellValueChanged = () => {
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
})
@@ -407,14 +425,14 @@ onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
@@ -427,7 +445,7 @@ const processCellFromClipboard = (params:any) => {
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
@@ -436,58 +454,15 @@ const processCellFromClipboard = (params:any) => {
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="myTheme" :treeData="false"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :suppressRowTransform="true"
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
</div>
</template>
<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>
+8 -55
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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 { majorList } from '@/sql'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -220,7 +220,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
@@ -246,7 +246,7 @@ const columnDefs: ColDef<DetailRow>[] = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
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(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalLandArea = computed(() => sumByNumber(detailRows.value, row => row.landArea))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -460,31 +441,3 @@ const processCellFromClipboard = (params:any) => {
</div>
</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>
+79 -37
View File
@@ -4,9 +4,9 @@ import type { ComponentPublicInstance } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
import localforage from 'localforage'
import 'ag-grid-enterprise'
import { myTheme } from '@/lib/diyAgGridTheme'
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
import { Search } from 'lucide-vue-next'
import {
DialogClose,
@@ -21,6 +21,7 @@ import {
import { Button } from '@/components/ui/button'
import { serviceList } from '@/sql'
import { useTabStore } from '@/pinia/tab'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
interface ServiceItem {
id: string
@@ -50,7 +51,11 @@ const props = defineProps<{
contractId: string
}>()
const tabStore = useTabStore()
const pricingPaneReloadStore = usePricingPaneReloadStore()
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 }
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
@@ -146,8 +151,9 @@ const pickerOpen = ref(false)
const pickerTempIds = ref<string[]>([])
const pickerSearch = ref('')
const dragSelecting = ref(false)
const dragMoved = ref(false)
let dragSelectChecked = false
const dragAppliedCodes = new Set<string>()
const dragBaseIds = ref<string[]>([])
const dragStartPoint = ref({ x: 0, y: 0 })
const dragCurrentPoint = ref({ x: 0, y: 0 })
const pickerItemElMap = new Map<string, HTMLElement>()
@@ -198,13 +204,19 @@ const getPricingPaneStorageKeys = (serviceId: string) => [
const clearPricingPaneValues = async (serviceId: string) => {
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)))
pricingPaneReloadStore.markReload(props.contractId, serviceId)
}
const clearRowValues = async (row: DetailRow) => {
if (isFixedRow(row)) return
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓�? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick()
row.investScale = null
@@ -215,11 +227,6 @@ const clearRowValues = async (row: DetailRow) => {
detailRows.value = [...detailRows.value]
await clearPricingPaneValues(row.id)
// 定时�?卸载保存可能产生回写,延迟再清一次更稳妥
await new Promise(resolve => setTimeout(resolve, 80))
await clearPricingPaneValues(row.id)
await saveToIndexedDB()
}
const openEditTab = (row: DetailRow) => {
@@ -285,10 +292,10 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: false,
valueGetter: params => {
if (!params.data) return null
return (
valueOrZero(params.data.investScale) +
valueOrZero(params.data.landScale) +
valueOrZero(params.data.workload) +
return addNumbers(
valueOrZero(params.data.investScale),
valueOrZero(params.data.landScale),
valueOrZero(params.data.workload),
valueOrZero(params.data.hourly)
)
},
@@ -308,22 +315,17 @@ const columnDefs: ColDef<DetailRow>[] = [
isFixedRow(params.data)
? ''
: `<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>`
}
]
const gridOptions: GridOptions<DetailRow> = {
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
defaultColDef: {
resizable: true,
sortable: false,
filter: false
},
const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
onCellClicked: async params => {
if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return
const target = params.event?.target as HTMLElement | null
@@ -462,45 +464,59 @@ const applyDragSelectionByRect = () => {
bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y)
}
const nextSelectedSet = new Set(dragBaseIds.value)
for (const [code, el] of pickerItemElMap.entries()) {
if (dragAppliedCodes.has(code)) continue
const itemRect = el.getBoundingClientRect()
const hit = isRectIntersect(rect, itemRect)
if (hit) {
applyTempChecked(code, dragSelectChecked)
dragAppliedCodes.add(code)
if (dragSelectChecked) {
nextSelectedSet.add(code)
} else {
nextSelectedSet.delete(code)
}
}
}
pickerTempIds.value = serviceDict
.map(item => item.id)
.filter(id => nextSelectedSet.has(id))
}
const stopDragSelect = () => {
dragSelecting.value = false
dragAppliedCodes.clear()
dragMoved.value = false
dragBaseIds.value = []
window.removeEventListener('mousemove', onDragSelectingMove)
window.removeEventListener('mouseup', stopDragSelect)
}
const onDragSelectingMove = (event: MouseEvent) => {
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()
}
const startDragSelect = (event: MouseEvent, code: string) => {
dragSelecting.value = true
dragAppliedCodes.clear()
dragMoved.value = false
dragBaseIds.value = [...pickerTempIds.value]
dragSelectChecked = !pickerTempIds.value.includes(code)
dragStartPoint.value = { x: event.clientX, y: event.clientY }
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
applyTempChecked(code, dragSelectChecked)
dragAppliedCodes.add(code)
window.addEventListener('mousemove', onDragSelectingMove)
window.addEventListener('mouseup', stopDragSelect)
}
const handleDragHover = (code: string) => {
if (!dragSelecting.value || dragAppliedCodes.has(code)) return
applyTempChecked(code, dragSelectChecked)
dragAppliedCodes.add(code)
const handleDragHover = (_code: string) => {
if (!dragSelecting.value || !dragMoved.value) return
applyDragSelectionByRect()
}
const saveToIndexedDB = async () => {
@@ -615,7 +631,12 @@ onBeforeUnmount(() => {
</div>
<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)"
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)"
@click.prevent>
<input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" />
@@ -652,7 +673,7 @@ onBeforeUnmount(() => {
</div>
<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"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20" />
@@ -683,4 +704,25 @@ onBeforeUnmount(() => {
line-height: 1;
padding: 0;
}
.picker-item-clickable {
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.picker-item-selected {
transform: translateY(-1px);
border-color: rgba(14, 165, 233, 0.75);
box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.3);
background: rgba(14, 165, 233, 0.08);
}
.picker-item-selected-drag {
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.35), 0 0 0 1px rgba(14, 165, 233, 0.25);
}
.picker-item-clickable:not(.picker-item-dragging):active {
transform: translateY(1px) scale(0.985);
border-color: rgba(14, 165, 233, 0.8);
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.22);
}
</style>