This commit is contained in:
zwgczx committed 2026-02-26 15:55:36 +08:00
1 parent 1609f19b9c
commit 37f4a99914
14 files changed
+953 -427

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
<TypeLine
scene="ht-tab"
:title="`合同段:${contractName}`"
storage-key="project-active-cat"
:storage-key="`project-active-cat-${contractId}`"
default-category="info"
:categories="xmCategories"
/>
+63 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import draggable from 'vuedraggable'
import localforage from 'localforage'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
@@ -23,6 +23,10 @@ interface ContractItem {
}
const STORAGE_KEY = 'ht-card-v1'
const formStore = localforage.createInstance({
name: 'jgjs-pricing-db',
storeName: 'form_state'
})
const tabStore = useTabStore()
@@ -79,6 +83,40 @@ const saveContracts = async () => {
}
}
const removeForageKeysByContractId = async (store: typeof localforage, contractId: string) => {
try {
const keys = await store.keys()
const targetKeys = keys.filter(key => key.includes(contractId))
await Promise.all(targetKeys.map(key => store.removeItem(key)))
} catch (error) {
console.error('remove forage keys by contract id failed:', contractId, error)
}
}
const removeRelatedTabsByContractId = (contractId: string) => {
const relatedTabIds = tabStore.tabs
.filter(tab => {
const propsContractId = tab?.props?.contractId
return (
tab.id === `contract-${contractId}` ||
tab.id.startsWith(`zxfw-edit-${contractId}-`) ||
propsContractId === contractId
)
})
.map(tab => tab.id)
for (const tabId of relatedTabIds) {
tabStore.removeTab(tabId)
}
}
const cleanupContractRelatedData = async (contractId: string) => {
await Promise.all([
removeForageKeysByContractId(localforage, contractId),
removeForageKeysByContractId(formStore as any, contractId)
])
}
const loadContracts = async () => {
try {
const saved = await localforage.getItem<ContractItem[]>(STORAGE_KEY)
@@ -122,6 +160,13 @@ const createContract = async () => {
if (!name) return
if (editingContractId.value) {
const current = contracts.value.find(item => item.id === editingContractId.value)
if (!current) return
if (current.name === name) {
closeCreateModal()
return
}
contracts.value = contracts.value.map(item =>
item.id === editingContractId.value ? { ...item, name } : item
)
@@ -147,13 +192,28 @@ const createContract = async () => {
}
const deleteContract = async (id: string) => {
// 先关闭合同段及其咨询服务相关 tab,避免页面仍在运行时继续写回缓存
removeRelatedTabsByContractId(id)
await nextTick()
await cleanupContractRelatedData(id)
// 组件卸载时会异步保存一次,延迟再清一遍避免数据回写
await new Promise(resolve => setTimeout(resolve, 80))
await cleanupContractRelatedData(id)
contracts.value = contracts.value.filter(item => item.id !== id)
await saveContracts()
tabStore.removeTab(`contract-${id}`)
notify('删除成功')
}
const handleDragEnd = async () => {
const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => {
if (
event.oldIndex == null ||
event.newIndex == null ||
event.oldIndex === event.newIndex
) {
return
}
await saveContracts()
notify('排序完成')
}
+3 -2
View File
@@ -2,7 +2,7 @@
<TypeLine
scene="zxfw-pricing-tab"
title="咨询服务计算"
storage-key="zxfw-pricing-active-cat"
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
default-category="investment-scale-method"
:categories="pricingCategories"
/>
@@ -15,6 +15,7 @@ import TypeLine from '@/layout/typeLine.vue'
const props = defineProps<{
contractId: string
contractName?: string
serviceId: string|number
}>()
interface PricingCategoryItem {
@@ -35,7 +36,7 @@ const createPricingPane = (name: string) =>
}
})
return () => h(AsyncPricingView, { contractId: props.contractId })
return () => h(AsyncPricingView, { contractId: props.contractId, serviceId: props.serviceId })
}
})
)
+12 -31
View File
@@ -5,38 +5,11 @@ import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import {
import { myTheme } from '@/lib/diyAgGridTheme'
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
@@ -70,6 +43,7 @@ const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = 'xm-info-v3'
const detailRows = ref<DetailRow[]>([])
@@ -300,6 +274,13 @@ const loadFromIndexedDB = async () => {
return
}
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
const xmData = await localforage.getItem<XmInfoState>(XM_DB_KEY)
if (xmData?.detailRows) {
detailRows.value = mergeWithDictRows(xmData.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
@@ -349,16 +330,16 @@ const processCellFromClipboard = (params:any) => {
</script>
<template>
<div class="space-y-6">
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
@@ -1,42 +1,14 @@
<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, ColGroupDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import {
import { myTheme } from '@/lib/diyAgGridTheme'
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
@@ -56,8 +28,13 @@ interface DetailRow {
groupName: string
majorCode: string
majorName: string
amount: number | null
landArea: number | null
laborBudgetUnitPrice: number | null
compositeBudgetUnitPrice: number | null
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
serviceBudget: number | null
remark: string
path: string[]
}
@@ -67,9 +44,11 @@ interface XmInfoState {
}
const props = defineProps<{
contractId: string
contractId: string,
serviceId: string|number
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
const detailRows = ref<DetailRow[]>([])
@@ -140,8 +119,13 @@ const buildDefaultRows = (): DetailRow[] => {
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
laborBudgetUnitPrice: null,
compositeBudgetUnitPrice: null,
adoptedBudgetUnitPrice: null,
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [group.id, child.id]
})
}
@@ -161,73 +145,100 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
laborBudgetUnitPrice:
typeof fromDb.laborBudgetUnitPrice === 'number' ? fromDb.laborBudgetUnitPrice : null,
compositeBudgetUnitPrice:
typeof fromDb.compositeBudgetUnitPrice === 'number' ? fromDb.compositeBudgetUnitPrice : null,
adoptedBudgetUnitPrice:
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
const editableNumberCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
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 === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber,
...extra
})
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{
headerName: '人员名称',
minWidth: 200,
width: 220,
pinned: 'left',
valueGetter: params => {
if (params.node?.rowPinned) return ''
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
headerName: '预算参考单价',
marryChildren: true,
children: [
editableNumberCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
editableNumberCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
]
},
editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: 'sum' }),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: 'sum' }),
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: 'sum' }),
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
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' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
headerName: '编码',
minWidth: 150,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
width: 160,
cellRendererParams: {
suppressCount: true
@@ -237,7 +248,8 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
}
}
@@ -258,12 +270,16 @@ const gridOptions: GridOptions<DetailRow> = {
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
const totalPersonnelCount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.personnelCount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
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 pinnedTopRowData = computed(() => [
{
@@ -272,8 +288,13 @@ const pinnedTopRowData = computed(() => [
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
laborBudgetUnitPrice: null,
compositeBudgetUnitPrice: null,
adoptedBudgetUnitPrice: null,
personnelCount: totalPersonnelCount.value,
workdayCount: totalWorkdayCount.value,
serviceBudget: totalServiceBudget.value,
remark: '',
path: ['TOTAL']
}
])
@@ -349,16 +370,16 @@ const processCellFromClipboard = (params:any) => {
</script>
<template>
<div class="space-y-6">
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
@@ -5,38 +5,11 @@ import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import {
import { myTheme } from '@/lib/diyAgGridTheme'
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
@@ -57,7 +30,11 @@ interface DetailRow {
majorCode: string
majorName: string
amount: number | null
landArea: number | null
benchmarkBudget: number | null
consultCategoryFactor: number | null
majorFactor: number | null
budgetFee: number | null
remark: string
path: string[]
}
@@ -67,9 +44,10 @@ interface XmInfoState {
}
const props = defineProps<{
contractId: string
contractId: string,
serviceId: string|number
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const detailRows = ref<DetailRow[]>([])
@@ -141,7 +119,11 @@ const buildDefaultRows = (): DetailRow[] => {
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
benchmarkBudget: null,
consultCategoryFactor: null,
majorFactor: null,
budgetFee: null,
remark: '',
path: [group.id, child.id]
})
}
@@ -162,18 +144,46 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
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,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
const 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',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
@@ -182,24 +192,14 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '用地面积(亩)',
field: 'landArea',
headerName: '基准预算(元)',
field: 'benchmarkBudget',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
@@ -208,26 +208,75 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
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 === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '专业系数',
field: 'majorFactor',
minWidth: 130,
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: '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',
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
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 === '')
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
headerName: '专业编码',
minWidth: 160,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
width: 170,
cellRendererParams: {
suppressCount: true
@@ -237,7 +286,8 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
}
}
@@ -262,8 +312,12 @@ 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 totalBenchmarkBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalBudgetFee = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
@@ -273,7 +327,11 @@ const pinnedTopRowData = computed(() => [
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
benchmarkBudget: totalBenchmarkBudget.value,
consultCategoryFactor: null,
majorFactor: null,
budgetFee: totalBudgetFee.value,
remark: '',
path: ['TOTAL']
}
])
@@ -349,16 +407,16 @@ const processCellFromClipboard = (params:any) => {
</script>
<template>
<div class="space-y-6">
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
@@ -5,38 +5,11 @@ import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import {
import { myTheme } from '@/lib/diyAgGridTheme'
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
@@ -58,6 +31,11 @@ interface DetailRow {
majorName: string
amount: number | null
landArea: number | null
benchmarkBudget: number | null
consultCategoryFactor: number | null
majorFactor: number | null
budgetFee: number | null
remark: string
path: string[]
}
@@ -67,9 +45,10 @@ interface XmInfoState {
}
const props = defineProps<{
contractId: string
contractId: string,
serviceId: string|number
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const detailRows = ref<DetailRow[]>([])
@@ -142,6 +121,11 @@ const buildDefaultRows = (): DetailRow[] => {
majorName: child.name,
amount: null,
landArea: null,
benchmarkBudget: null,
consultCategoryFactor: null,
majorFactor: null,
budgetFee: null,
remark: '',
path: [group.id, child.id]
})
}
@@ -162,44 +146,47 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
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,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
const 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',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
@@ -208,26 +195,90 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '基准预算(元)',
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',
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
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 === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '专业系数',
field: 'majorFactor',
minWidth: 130,
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: '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',
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
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 === '')
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
headerName: '专业编码',
minWidth: 160,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
width: 170,
cellRendererParams: {
suppressCount: true
@@ -237,7 +288,8 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
}
}
@@ -265,6 +317,14 @@ const totalAmount = computed(() =>
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const totalBenchmarkBudget = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.benchmarkBudget || 0), 0)
)
const totalBudgetFee = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetFee || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@@ -274,6 +334,11 @@ const pinnedTopRowData = computed(() => [
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
benchmarkBudget: totalBenchmarkBudget.value,
consultCategoryFactor: null,
majorFactor: null,
budgetFee: totalBudgetFee.value,
remark: '',
path: ['TOTAL']
}
])
@@ -349,16 +414,16 @@ const processCellFromClipboard = (params:any) => {
</script>
<template>
<div class="space-y-6">
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
@@ -5,38 +5,10 @@ import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import {
import { myTheme } from '@/lib/diyAgGridTheme'
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
@@ -56,8 +28,13 @@ interface DetailRow {
groupName: string
majorCode: string
majorName: string
amount: number | null
landArea: number | null
workload: number | null
budgetBase: number | null
budgetReferenceUnitPrice: number | null
budgetAdoptedUnitPrice: number | null
consultCategoryFactor: number | null
serviceFee: number | null
remark: string
path: string[]
}
@@ -67,9 +44,11 @@ interface XmInfoState {
}
const props = defineProps<{
contractId: string
contractId: string,
serviceId: string|number
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const detailRows = ref<DetailRow[]>([])
@@ -140,8 +119,13 @@ const buildDefaultRows = (): DetailRow[] => {
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
workload: null,
budgetBase: null,
budgetReferenceUnitPrice: null,
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: null,
remark: '',
path: [group.id, child.id]
})
}
@@ -161,45 +145,65 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : 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:
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
const parseNumberOrNull = (value: unknown) => {
if (value === '' || value == null) return null
const v = Number(value)
return Number.isFinite(v) ? v : null
}
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
const 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',
headerName: '工作量',
field: 'workload',
minWidth: 140,
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: 'budgetBase',
minWidth: 170,
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
@@ -208,26 +212,89 @@ const columnDefs: ColDef<DetailRow>[] = [
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: 'sum',
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
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 === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
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 === '')
},
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '服务费用(元)',
field: 'serviceFee',
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',
valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
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 === '')
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
headerName: '编码',
minWidth: 160,
pinned: 'left',
flex:2, // 核心:开启弹性布局,自动占满剩余空间
width: 170,
cellRendererParams: {
suppressCount: true
@@ -237,7 +304,8 @@ const autoGroupColumnDef: ColDef = {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
const label = idLabelMap.get(nodeId) || nodeId
return label.includes(' ') ? label.split(' ')[0] : label
}
}
@@ -258,12 +326,16 @@ const gridOptions: GridOptions<DetailRow> = {
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
const totalBudgetBase = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.budgetBase || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 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(() => [
{
@@ -272,8 +344,13 @@ const pinnedTopRowData = computed(() => [
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
workload: totalWorkload.value,
budgetBase: totalBudgetBase.value,
budgetReferenceUnitPrice: null,
budgetAdoptedUnitPrice: null,
consultCategoryFactor: null,
serviceFee: totalServiceFee.value,
remark: '',
path: ['TOTAL']
}
])
@@ -349,16 +426,16 @@ const processCellFromClipboard = (params:any) => {
</script>
<template>
<div class="space-y-6">
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<h3 class="text-sm font-semibold text-foreground">工作流规模</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
+85 -32
View File
@@ -5,38 +5,12 @@ import type { ColDef, GridOptions } from 'ag-grid-community'
import localforage from 'localforage'
import { majorList } from '@/sql'
import 'ag-grid-enterprise'
import {
import { myTheme } from '@/lib/diyAgGridTheme'
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
@@ -71,6 +45,73 @@ const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const projectName = ref(DEFAULT_PROJECT_NAME)
const detailRows = ref<DetailRow[]>([])
const rootRef = ref<HTMLElement | null>(null)
const gridSectionRef = ref<HTMLElement | null>(null)
const agGridRef = ref<HTMLElement | null>(null)
const agGridHeight = ref(580)
let snapScrollHost: HTMLElement | null = null
let snapTimer: ReturnType<typeof setTimeout> | null = null
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
let isSnapping = false
let hostResizeObserver: ResizeObserver | null = null
const updateGridCardHeight = () => {
if (!snapScrollHost || !rootRef.value) return
const contentWrap = rootRef.value.parentElement
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight
}
const bindSnapScrollHost = () => {
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
if (!snapScrollHost) return
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
hostResizeObserver?.disconnect()
hostResizeObserver = new ResizeObserver(() => {
updateGridCardHeight()
})
hostResizeObserver.observe(snapScrollHost)
updateGridCardHeight()
}
const unbindSnapScrollHost = () => {
if (snapScrollHost) {
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
}
hostResizeObserver?.disconnect()
hostResizeObserver = null
snapScrollHost = null
}
const trySnapToGrid = () => {
if (isSnapping || !snapScrollHost || !agGridRef.value) return
const hostRect = snapScrollHost.getBoundingClientRect()
const gridRect = agGridRef.value.getBoundingClientRect()
const offsetTop = gridRect.top - hostRect.top
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
const inSnapRange = offsetTop > -120 && offsetTop < 180
if (!inVisibleBand || !inSnapRange) return
isSnapping = true
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (snapLockTimer) clearTimeout(snapLockTimer)
snapLockTimer = setTimeout(() => {
isSnapping = false
}, 420)
}
function handleSnapHostScroll() {
if (isSnapping) return
if (snapTimer) clearTimeout(snapTimer)
snapTimer = setTimeout(() => {
trySnapToGrid()
}, 90)
}
type MajorLite = { code: string; name: string }
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
@@ -330,13 +371,20 @@ watch(projectName, schedulePersist)
onMounted(async () => {
await loadFromIndexedDB()
bindSnapScrollHost()
requestAnimationFrame(() => {
updateGridCardHeight()
})
// window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload)
unbindSnapScrollHost()
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
if (snapTimer) clearTimeout(snapTimer)
if (snapLockTimer) clearTimeout(snapLockTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
@@ -358,9 +406,11 @@ const processCellFromClipboard = (params:any) => {
</script>
<template>
<div class="space-y-6">
<div class="rounded-lg border bg-card p-4 shadow-sm">
<label class="mb-2 block text-sm font-medium text-foreground">项目名称</label>
<div ref="rootRef" class="space-y-6">
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
<div class="mb-2 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-foreground">项目名称</label>
</div>
<input
v-model="projectName"
type="text"
@@ -369,13 +419,16 @@ const processCellFromClipboard = (params:any) => {
/>
</div>
<div class="rounded-lg border bg-card xmMx">
<div
ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3"
>
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">项目明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
+110 -27
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
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 { themeQuartz } from 'ag-grid-community'
import { myTheme } from '@/lib/diyAgGridTheme'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { Search } from 'lucide-vue-next'
import {
@@ -52,24 +52,6 @@ const props = defineProps<{
const tabStore = useTabStore()
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
const borderConfig = {
style: 'solid',
width: 0.5,
color: '#e5e7eb'
}
const myTheme = themeQuartz.withParams({
wrapperBorder: false,
headerBackgroundColor: '#f9fafb',
headerTextColor: '#374151',
headerFontSize: 15,
headerFontWeight: 'normal',
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
dataBackgroundColor: '#fefefe'
})
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
@@ -91,6 +73,73 @@ const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
const selectedIds = ref<string[]>([])
const detailRows = ref<DetailRow[]>([])
const rootRef = ref<HTMLElement | null>(null)
const gridSectionRef = ref<HTMLElement | null>(null)
const agGridRef = ref<HTMLElement | null>(null)
const agGridHeight = ref(580)
let snapScrollHost: HTMLElement | null = null
let snapTimer: ReturnType<typeof setTimeout> | null = null
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
let isSnapping = false
let hostResizeObserver: ResizeObserver | null = null
const updateGridCardHeight = () => {
if (!snapScrollHost || !rootRef.value) return
const contentWrap = rootRef.value.parentElement
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight
}
const bindSnapScrollHost = () => {
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
if (!snapScrollHost) return
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
hostResizeObserver?.disconnect()
hostResizeObserver = new ResizeObserver(() => {
updateGridCardHeight()
})
hostResizeObserver.observe(snapScrollHost)
updateGridCardHeight()
}
const unbindSnapScrollHost = () => {
if (snapScrollHost) {
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
}
hostResizeObserver?.disconnect()
hostResizeObserver = null
snapScrollHost = null
}
const trySnapToGrid = () => {
if (isSnapping || !snapScrollHost || !agGridRef.value) return
const hostRect = snapScrollHost.getBoundingClientRect()
const gridRect = agGridRef.value.getBoundingClientRect()
const offsetTop = gridRect.top - hostRect.top
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
const inSnapRange = offsetTop > -120 && offsetTop < 180
if (!inVisibleBand || !inSnapRange) return
isSnapping = true
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (snapLockTimer) clearTimeout(snapLockTimer)
snapLockTimer = setTimeout(() => {
isSnapping = false
}, 420)
}
function handleSnapHostScroll() {
if (isSnapping) return
if (snapTimer) clearTimeout(snapTimer)
snapTimer = setTimeout(() => {
trySnapToGrid()
}, 90)
}
const pickerOpen = ref(false)
const pickerTempIds = ref<string[]>([])
@@ -139,13 +188,35 @@ const numericParser = (newValue: any): number | null => {
const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
const getPricingPaneStorageKeys = (serviceId: string) => [
`tzGMF-${props.contractId}-${serviceId}`,
`ydGMF-${props.contractId}-${serviceId}`,
`gzlF-${props.contractId}-${serviceId}`,
`hourlyPricing-${props.contractId}-${serviceId}`
]
const clearPricingPaneValues = async (serviceId: string) => {
const keys = getPricingPaneStorageKeys(serviceId)
await Promise.all(keys.map(key => localforage.removeItem(key)))
}
const clearRowValues = async (row: DetailRow) => {
if (isFixedRow(row)) return
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓�? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick()
row.investScale = null
row.landScale = null
row.workload = null
row.hourly = null
row.subtotal = null
detailRows.value = [...detailRows.value]
await clearPricingPaneValues(row.id)
// 定时�?卸载保存可能产生回写,延迟再清一次更稳妥
await new Promise(resolve => setTimeout(resolve, 80))
await clearPricingPaneValues(row.id)
await saveToIndexedDB()
}
@@ -153,7 +224,7 @@ const clearRowValues = async (row: DetailRow) => {
const openEditTab = (row: DetailRow) => {
tabStore.openTab({
id: `zxfw-edit-${props.contractId}-${row.id}`,
title: `服务编辑:${row.code}${row.name}`,
title: `服务编辑-${row.code}${row.name}`,
componentName: 'ZxFwView',
props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id}
})
@@ -485,20 +556,29 @@ const handleCellValueChanged = () => {
onMounted(async () => {
await loadFromIndexedDB()
bindSnapScrollHost()
requestAnimationFrame(() => {
updateGridCardHeight()
})
})
onBeforeUnmount(() => {
unbindSnapScrollHost()
stopDragSelect()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
if (snapTimer) clearTimeout(snapTimer)
if (snapLockTimer) clearTimeout(snapLockTimer)
void saveToIndexedDB()
})
</script>
<template>
<div class="space-y-6">
<div ref="rootRef" class="space-y-6">
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
<div class="rounded-lg border bg-card p-4 shadow-sm">
<label class="mb-2 block text-sm font-medium text-foreground">选择服务</label>
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
<div class="mb-2 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-foreground">选择服务</label>
</div>
<div class="flex items-center gap-2">
<input :value="selectedServiceText" readonly placeholder="请点击右侧“浏览”选择服务"
class="h-10 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none" />
@@ -516,7 +596,7 @@ onBeforeUnmount(() => {
<DialogContent
class="fixed left-1/2 top-1/2 z-[60] w-[96vw] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background shadow-xl p-0">
<DialogTitle class="sr-only">选择服务词典</DialogTitle>
<DialogDescription class="sr-only">浏览并选择服务项</DialogDescription>
<DialogDescription class="sr-only">浏览并选择服务词典</DialogDescription>
<div class="flex items-center justify-between border-b px-5 py-4">
<h4 class="text-base font-semibold">选择服务词典</h4>
<DialogClose as-child>
@@ -561,13 +641,16 @@ onBeforeUnmount(() => {
</DialogPortal>
</DialogRoot>
<div class="rounded-lg border bg-card xmMx">
<div
ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3"
>
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">咨询服务明细</h3>
<div class="text-xs text-muted-foreground">按服务词典生成</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }">
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="gridOptions"
:theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"