Compare commits
2 Commits
5734cfa534
...
37f4a99914
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f4a99914 | |||
| 1609f19b9c |
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./public/favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>造价计算工具</title>
|
<title>造价计算工具</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<TypeLine
|
<TypeLine
|
||||||
scene="ht-tab"
|
scene="ht-tab"
|
||||||
:title="`合同段:${contractName}`"
|
:title="`合同段:${contractName}`"
|
||||||
storage-key="project-active-cat"
|
:storage-key="`project-active-cat-${contractId}`"
|
||||||
default-category="info"
|
default-category="info"
|
||||||
:categories="xmCategories"
|
:categories="xmCategories"
|
||||||
/>
|
/>
|
||||||
@ -43,11 +43,16 @@ const htView = markRaw(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const zxfwView = markRaw(
|
const zxfwView = markRaw(
|
||||||
defineAsyncComponent({
|
defineComponent({
|
||||||
loader: () => import('@/components/views/zxFw.vue'),
|
name: 'ZxFwWithProps',
|
||||||
// 可选:加载失败时的兜底组件
|
setup() {
|
||||||
onError: (err) => {
|
const AsyncZxFw = defineAsyncComponent({
|
||||||
console.error('加载 Ht 组件失败:', err);
|
loader: () => import('@/components/views/zxFw.vue'),
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('加载 zxFw 组件失败:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => h(AsyncZxFw, { contractId: props.contractId });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { Edit3, Plus, Trash2, X } from 'lucide-vue-next'
|
import { Edit3, Plus, Trash2, X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
ToastAction,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastRoot,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
interface ContractItem {
|
interface ContractItem {
|
||||||
id: string
|
id: string
|
||||||
@ -15,6 +23,10 @@ interface ContractItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'ht-card-v1'
|
const STORAGE_KEY = 'ht-card-v1'
|
||||||
|
const formStore = localforage.createInstance({
|
||||||
|
name: 'jgjs-pricing-db',
|
||||||
|
storeName: 'form_state'
|
||||||
|
})
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
|
|
||||||
@ -25,9 +37,9 @@ const contracts = ref<ContractItem[]>([])
|
|||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
const contractNameInput = ref('')
|
const contractNameInput = ref('')
|
||||||
const editingContractId = ref<string | null>(null)
|
const editingContractId = ref<string | null>(null)
|
||||||
|
const toastOpen = ref(false)
|
||||||
|
const toastTitle = ref('操作成功')
|
||||||
const toastText = ref('')
|
const toastText = ref('')
|
||||||
const showToast = ref(false)
|
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const modalOffset = ref({ x: 0, y: 0 })
|
const modalOffset = ref({ x: 0, y: 0 })
|
||||||
let dragStartX = 0
|
let dragStartX = 0
|
||||||
let dragStartY = 0
|
let dragStartY = 0
|
||||||
@ -54,12 +66,12 @@ const formatDateTime = (value: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notify = (text: string) => {
|
const notify = (text: string) => {
|
||||||
|
toastTitle.value = '操作成功'
|
||||||
toastText.value = text
|
toastText.value = text
|
||||||
showToast.value = true
|
toastOpen.value = false
|
||||||
if (toastTimer) clearTimeout(toastTimer)
|
requestAnimationFrame(() => {
|
||||||
toastTimer = setTimeout(() => {
|
toastOpen.value = true
|
||||||
showToast.value = false
|
})
|
||||||
}, 1600)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveContracts = async () => {
|
const saveContracts = async () => {
|
||||||
@ -71,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 () => {
|
const loadContracts = async () => {
|
||||||
try {
|
try {
|
||||||
const saved = await localforage.getItem<ContractItem[]>(STORAGE_KEY)
|
const saved = await localforage.getItem<ContractItem[]>(STORAGE_KEY)
|
||||||
@ -114,6 +160,13 @@ const createContract = async () => {
|
|||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
if (editingContractId.value) {
|
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 =>
|
contracts.value = contracts.value.map(item =>
|
||||||
item.id === editingContractId.value ? { ...item, name } : item
|
item.id === editingContractId.value ? { ...item, name } : item
|
||||||
)
|
)
|
||||||
@ -139,12 +192,28 @@ const createContract = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteContract = async (id: string) => {
|
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)
|
contracts.value = contracts.value.filter(item => item.id !== id)
|
||||||
await saveContracts()
|
await saveContracts()
|
||||||
notify('删除成功')
|
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()
|
await saveContracts()
|
||||||
notify('排序完成')
|
notify('排序完成')
|
||||||
}
|
}
|
||||||
@ -185,12 +254,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopDrag()
|
stopDrag()
|
||||||
if (toastTimer) clearTimeout(toastTimer)
|
|
||||||
void saveContracts()
|
void saveContracts()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ToastProvider>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold">合同段列表</h3>
|
<h3 class="text-lg font-bold">合同段列表</h3>
|
||||||
@ -242,13 +311,6 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showToast"
|
|
||||||
class="fixed left-1/2 top-6 z-[60] -translate-x-1/2 rounded-full border border-sky-100 bg-white/95 px-4 py-2 text-sm text-slate-700 shadow-md backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{{ toastText }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showCreateModal"
|
v-if="showCreateModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
@ -289,4 +351,23 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ToastRoot
|
||||||
|
v-model:open="toastOpen"
|
||||||
|
:duration="1800"
|
||||||
|
class="group pointer-events-auto flex items-center gap-3 rounded-xl border border-slate-800/90 bg-slate-900 px-4 py-3 text-white shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<ToastTitle class="text-sm font-semibold text-white">{{ toastTitle }}</ToastTitle>
|
||||||
|
<ToastDescription class="text-xs text-slate-100">{{ toastText }}</ToastDescription>
|
||||||
|
</div>
|
||||||
|
<ToastAction
|
||||||
|
alt-text="知道了"
|
||||||
|
class="ml-auto inline-flex h-7 items-center rounded-md border border-white/30 bg-white/10 px-2 text-xs text-white hover:bg-white/20"
|
||||||
|
@click="toastOpen = false"
|
||||||
|
>
|
||||||
|
知道了
|
||||||
|
</ToastAction>
|
||||||
|
</ToastRoot>
|
||||||
|
<ToastViewport class="fixed bottom-5 right-5 z-[80] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
|
||||||
|
</ToastProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
55
src/components/views/ZxFwView.vue
Normal file
55
src/components/views/ZxFwView.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<TypeLine
|
||||||
|
scene="zxfw-pricing-tab"
|
||||||
|
title="咨询服务计算"
|
||||||
|
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
|
||||||
|
default-category="investment-scale-method"
|
||||||
|
:categories="pricingCategories"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
|
||||||
|
import TypeLine from '@/layout/typeLine.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string
|
||||||
|
contractName?: string
|
||||||
|
serviceId: string|number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface PricingCategoryItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
component: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPricingPane = (name: string) =>
|
||||||
|
markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name,
|
||||||
|
setup() {
|
||||||
|
const AsyncPricingView = defineAsyncComponent({
|
||||||
|
loader: () => import(`@/components/views/pricingView/${name}.vue`),
|
||||||
|
onError: err => {
|
||||||
|
console.error('加载 PricingMethodView 组件失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => h(AsyncPricingView, { contractId: props.contractId, serviceId: props.serviceId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
|
||||||
|
const landScaleView = createPricingPane('LandScalePricingPane')
|
||||||
|
const workloadView = createPricingPane('WorkloadPricingPane')
|
||||||
|
const hourlyView = createPricingPane('HourlyPricingPane')
|
||||||
|
|
||||||
|
const pricingCategories: PricingCategoryItem[] = [
|
||||||
|
{ key: 'investment-scale-method', label: '投资规模法', component: investmentScaleView },
|
||||||
|
{ key: 'land-scale-method', label: '用地规模法', component: landScaleView },
|
||||||
|
{ key: 'workload-method', label: '工作量法', component: workloadView },
|
||||||
|
{ key: 'hourly-method', label: '工时法', component: hourlyView }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@ -3,45 +3,21 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
import { majorList } from '@/sql'
|
||||||
import 'ag-grid-enterprise'
|
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';
|
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 {
|
interface DictLeaf {
|
||||||
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
interface DictGroup {
|
||||||
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
children: DictLeaf[]
|
children: DictLeaf[]
|
||||||
@ -67,69 +43,64 @@ const props = defineProps<{
|
|||||||
contractId: string
|
contractId: string
|
||||||
}>()
|
}>()
|
||||||
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||||
|
const XM_DB_KEY = 'xm-info-v3'
|
||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
|
||||||
const detailDict: DictGroup[] = [
|
type majorLite = { code: string; name: string }
|
||||||
{
|
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||||
code: 'E1',
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
name: '交通运输工程通用专业',
|
.filter((entry): entry is [string, majorLite] => {
|
||||||
children: [
|
const item = entry[1]
|
||||||
{ code: 'E1-1', name: '征地(用海)补偿' },
|
return Boolean(item?.code && item?.name)
|
||||||
{ code: 'E1-2', name: '拆迁补偿' },
|
})
|
||||||
{ code: 'E1-3', name: '迁改工程' },
|
|
||||||
{ code: 'E1-4', name: '工程建设其他费' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'E2',
|
|
||||||
name: '公路工程专业',
|
|
||||||
children: [
|
|
||||||
{ code: 'E2-1', name: '临时工程' },
|
|
||||||
{ code: 'E2-2', name: '路基工程' },
|
|
||||||
{ code: 'E2-3', name: '路面工程' },
|
|
||||||
{ code: 'E2-4', name: '桥涵工程' },
|
|
||||||
{ code: 'E2-5', name: '隧道工程' },
|
|
||||||
{ code: 'E2-6', name: '交叉工程' },
|
|
||||||
{ code: 'E2-7', name: '机电工程' },
|
|
||||||
{ code: 'E2-8', name: '交通安全设施工程' },
|
|
||||||
{ code: 'E2-9', name: '绿化及环境保护工程' },
|
|
||||||
{ code: 'E2-10', name: '房建工程' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'E3',
|
|
||||||
name: '铁路工程专业',
|
|
||||||
children: [
|
|
||||||
{ code: 'E3-1', name: '大型临时设施和过渡工程' },
|
|
||||||
{ code: 'E3-2', name: '路基工程' },
|
|
||||||
{ code: 'E3-3', name: '桥涵工程' },
|
|
||||||
{ code: 'E3-4', name: '隧道及明洞工程' },
|
|
||||||
{ code: 'E3-5', name: '轨道工程' },
|
|
||||||
{ code: 'E3-6', name: '通信、信号、信息及灾害监测工程' },
|
|
||||||
{ code: 'E3-7', name: '电力及电力牵引供电工程' },
|
|
||||||
{ code: 'E3-8', name: '房建工程(房屋建筑及附属工程)' },
|
|
||||||
{ code: 'E3-9', name: '装饰装修工程' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'E4',
|
|
||||||
name: '水运工程专业',
|
|
||||||
children: [
|
|
||||||
{ code: 'E4-1', name: '临时工程' },
|
|
||||||
{ code: 'E4-2', name: '土建工程' },
|
|
||||||
{ code: 'E4-3', name: '机电与金属结构工程' },
|
|
||||||
{ code: 'E4-4', name: '设备工程' },
|
|
||||||
{ code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const codeNameMap = new Map<string, string>()
|
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) {
|
for (const group of detailDict) {
|
||||||
codeNameMap.set(group.code, group.name)
|
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
codeNameMap.set(child.code, child.name)
|
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,14 +109,14 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
for (const group of detailDict) {
|
for (const group of detailDict) {
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `row-${child.code}`,
|
id: child.id,
|
||||||
groupCode: group.code,
|
groupCode: group.code,
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
majorCode: child.code,
|
majorCode: child.code,
|
||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
amount: null,
|
amount: null,
|
||||||
landArea: null,
|
landArea: null,
|
||||||
path: [group.code, child.code]
|
path: [group.id, child.id]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,11 +126,11 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
const dbValueMap = new Map<string, DetailRow>()
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
for (const row of rowsFromDb || []) {
|
for (const row of rowsFromDb || []) {
|
||||||
dbValueMap.set(row.majorCode, row)
|
dbValueMap.set(row.id, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildDefaultRows().map(row => {
|
return buildDefaultRows().map(row => {
|
||||||
const fromDb = dbValueMap.get(row.majorCode)
|
const fromDb = dbValueMap.get(row.id)
|
||||||
if (!fromDb) return row
|
if (!fromDb) return row
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -239,9 +210,8 @@ const autoGroupColumnDef: ColDef = {
|
|||||||
if (params.node?.rowPinned) {
|
if (params.node?.rowPinned) {
|
||||||
return '总合计'
|
return '总合计'
|
||||||
}
|
}
|
||||||
const code = String(params.value || '')
|
const nodeId = String(params.value || '')
|
||||||
const name = codeNameMap.get(code) || ''
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
return name ? `${code} ${name}` : code
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,6 +259,7 @@ const saveToIndexedDB = async () => {
|
|||||||
const payload = {
|
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)
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
@ -303,6 +274,13 @@ const loadFromIndexedDB = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
|
||||||
|
const xmData = await localforage.getItem<XmInfoState>(XM_DB_KEY)
|
||||||
|
if (xmData?.detailRows) {
|
||||||
|
detailRows.value = mergeWithDictRows(xmData.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
detailRows.value = buildDefaultRows()
|
detailRows.value = buildDefaultRows()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
@ -311,16 +289,9 @@ const loadFromIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const schedulePersist = () => {
|
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
|
||||||
persistTimer = setTimeout(() => {
|
|
||||||
void saveToIndexedDB()
|
|
||||||
}, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
// const handleBeforeUnload = () => {
|
|
||||||
// void saveToIndexedDB()
|
|
||||||
// }
|
|
||||||
|
|
||||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const handleCellValueChanged = () => {
|
const handleCellValueChanged = () => {
|
||||||
@ -333,11 +304,9 @@ const handleCellValueChanged = () => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadFromIndexedDB()
|
await loadFromIndexedDB()
|
||||||
// window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
void saveToIndexedDB()
|
void saveToIndexedDB()
|
||||||
@ -361,16 +330,16 @@ const processCellFromClipboard = (params:any) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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 class="text-xs text-muted-foreground">导入导出</div>
|
||||||
</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
|
<AgGridVue
|
||||||
:style="{ height: '100%' }"
|
:style="{ height: '100%' }"
|
||||||
:rowData="detailRows"
|
:rowData="detailRows"
|
||||||
|
|||||||
437
src/components/views/pricingView/HourlyPricingPane.vue
Normal file
437
src/components/views/pricingView/HourlyPricingPane.vue
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
<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 localforage from 'localforage'
|
||||||
|
import { majorList } from '@/sql'
|
||||||
|
import 'ag-grid-enterprise'
|
||||||
|
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||||
|
|
||||||
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
|
|
||||||
|
interface DictLeaf {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictGroup {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
children: DictLeaf[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
id: string
|
||||||
|
groupCode: string
|
||||||
|
groupName: string
|
||||||
|
majorCode: string
|
||||||
|
majorName: string
|
||||||
|
laborBudgetUnitPrice: number | null
|
||||||
|
compositeBudgetUnitPrice: number | null
|
||||||
|
adoptedBudgetUnitPrice: number | null
|
||||||
|
personnelCount: number | null
|
||||||
|
workdayCount: number | null
|
||||||
|
serviceBudget: number | null
|
||||||
|
remark: string
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XmInfoState {
|
||||||
|
projectName: string
|
||||||
|
detailRows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string,
|
||||||
|
|
||||||
|
serviceId: string|number
|
||||||
|
}>()
|
||||||
|
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.id, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
laborBudgetUnitPrice:
|
||||||
|
typeof fromDb.laborBudgetUnitPrice === 'number' ? fromDb.laborBudgetUnitPrice : null,
|
||||||
|
compositeBudgetUnitPrice:
|
||||||
|
typeof fromDb.compositeBudgetUnitPrice === 'number' ? fromDb.compositeBudgetUnitPrice : null,
|
||||||
|
adoptedBudgetUnitPrice:
|
||||||
|
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||||
|
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||||
|
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
|
||||||
|
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : 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 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: '预算参考单价',
|
||||||
|
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 === '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const autoGroupColumnDef: ColDef = {
|
||||||
|
headerName: '编码',
|
||||||
|
minWidth: 150,
|
||||||
|
pinned: 'left',
|
||||||
|
width: 160,
|
||||||
|
|
||||||
|
cellRendererParams: {
|
||||||
|
suppressCount: true
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (params.node?.rowPinned) {
|
||||||
|
return '总合计'
|
||||||
|
}
|
||||||
|
const nodeId = String(params.value || '')
|
||||||
|
const label = idLabelMap.get(nodeId) || nodeId
|
||||||
|
return label.includes(' ') ? label.split(' ')[0] : label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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(() =>
|
||||||
|
detailRows.value.reduce((sum, row) => sum + (row.personnelCount || 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(() => [
|
||||||
|
{
|
||||||
|
id: 'pinned-total-row',
|
||||||
|
groupCode: '',
|
||||||
|
groupName: '',
|
||||||
|
majorCode: '',
|
||||||
|
majorName: '',
|
||||||
|
laborBudgetUnitPrice: null,
|
||||||
|
compositeBudgetUnitPrice: null,
|
||||||
|
adoptedBudgetUnitPrice: null,
|
||||||
|
personnelCount: totalPersonnelCount.value,
|
||||||
|
workdayCount: totalWorkdayCount.value,
|
||||||
|
serviceBudget: totalServiceBudget.value,
|
||||||
|
remark: '',
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
console.log('Saving to IndexedDB:', payload)
|
||||||
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||||
|
if (data) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFromIndexedDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
})
|
||||||
|
const processCellForClipboard = (params:any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value); // 数组转字符串复制
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params:any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时返回原始值,无需额外处理
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">工时法明细</h3>
|
||||||
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<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>
|
||||||
474
src/components/views/pricingView/InvestmentScalePricingPane.vue
Normal file
474
src/components/views/pricingView/InvestmentScalePricingPane.vue
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
<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 localforage from 'localforage'
|
||||||
|
import { majorList } from '@/sql'
|
||||||
|
import 'ag-grid-enterprise'
|
||||||
|
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||||
|
|
||||||
|
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
|
||||||
|
amount: number | null
|
||||||
|
benchmarkBudget: number | null
|
||||||
|
consultCategoryFactor: number | null
|
||||||
|
majorFactor: number | null
|
||||||
|
budgetFee: number | null
|
||||||
|
remark: string
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XmInfoState {
|
||||||
|
projectName: string
|
||||||
|
detailRows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string,
|
||||||
|
serviceId: string|number
|
||||||
|
}>()
|
||||||
|
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
|
||||||
|
|
||||||
|
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,
|
||||||
|
amount: null,
|
||||||
|
benchmarkBudget: null,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
majorFactor: null,
|
||||||
|
budgetFee: null,
|
||||||
|
remark: '',
|
||||||
|
path: [group.id, child.id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.id, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
|
||||||
|
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: '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) 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: 160,
|
||||||
|
pinned: 'left',
|
||||||
|
width: 170,
|
||||||
|
|
||||||
|
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 totalAmount = computed(() =>
|
||||||
|
detailRows.value.reduce((sum, row) => sum + (row.amount || 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',
|
||||||
|
groupCode: '',
|
||||||
|
groupName: '',
|
||||||
|
majorCode: '',
|
||||||
|
majorName: '',
|
||||||
|
amount: totalAmount.value,
|
||||||
|
benchmarkBudget: totalBenchmarkBudget.value,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
majorFactor: null,
|
||||||
|
budgetFee: totalBudgetFee.value,
|
||||||
|
remark: '',
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
console.log('Saving to IndexedDB:', payload)
|
||||||
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||||
|
if (data) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFromIndexedDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
})
|
||||||
|
const processCellForClipboard = (params:any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value); // 数组转字符串复制
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params:any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时返回原始值,无需额外处理
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
|
||||||
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<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>
|
||||||
481
src/components/views/pricingView/LandScalePricingPane.vue
Normal file
481
src/components/views/pricingView/LandScalePricingPane.vue
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
<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 localforage from 'localforage'
|
||||||
|
import { majorList } from '@/sql'
|
||||||
|
import 'ag-grid-enterprise'
|
||||||
|
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||||
|
|
||||||
|
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
|
||||||
|
amount: number | null
|
||||||
|
landArea: number | null
|
||||||
|
benchmarkBudget: number | null
|
||||||
|
consultCategoryFactor: number | null
|
||||||
|
majorFactor: number | null
|
||||||
|
budgetFee: number | null
|
||||||
|
remark: string
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XmInfoState {
|
||||||
|
projectName: string
|
||||||
|
detailRows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string,
|
||||||
|
serviceId: string|number
|
||||||
|
}>()
|
||||||
|
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
|
||||||
|
|
||||||
|
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,
|
||||||
|
amount: null,
|
||||||
|
landArea: null,
|
||||||
|
benchmarkBudget: null,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
majorFactor: null,
|
||||||
|
budgetFee: null,
|
||||||
|
remark: '',
|
||||||
|
path: [group.id, child.id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.id, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
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: 'landArea',
|
||||||
|
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: '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) 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: 160,
|
||||||
|
pinned: 'left',
|
||||||
|
width: 170,
|
||||||
|
|
||||||
|
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 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(() => [
|
||||||
|
{
|
||||||
|
id: 'pinned-total-row',
|
||||||
|
groupCode: '',
|
||||||
|
groupName: '',
|
||||||
|
majorCode: '',
|
||||||
|
majorName: '',
|
||||||
|
amount: totalAmount.value,
|
||||||
|
landArea: totalLandArea.value,
|
||||||
|
benchmarkBudget: totalBenchmarkBudget.value,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
majorFactor: null,
|
||||||
|
budgetFee: totalBudgetFee.value,
|
||||||
|
remark: '',
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
console.log('Saving to IndexedDB:', payload)
|
||||||
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||||
|
if (data) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFromIndexedDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
})
|
||||||
|
const processCellForClipboard = (params:any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value); // 数组转字符串复制
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params:any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时返回原始值,无需额外处理
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
|
||||||
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<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>
|
||||||
493
src/components/views/pricingView/WorkloadPricingPane.vue
Normal file
493
src/components/views/pricingView/WorkloadPricingPane.vue
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
<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 localforage from 'localforage'
|
||||||
|
import { majorList } from '@/sql'
|
||||||
|
import 'ag-grid-enterprise'
|
||||||
|
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||||
|
|
||||||
|
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
|
||||||
|
workload: number | null
|
||||||
|
budgetBase: number | null
|
||||||
|
budgetReferenceUnitPrice: number | null
|
||||||
|
budgetAdoptedUnitPrice: number | null
|
||||||
|
consultCategoryFactor: number | null
|
||||||
|
serviceFee: number | null
|
||||||
|
remark: string
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XmInfoState {
|
||||||
|
projectName: string
|
||||||
|
detailRows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string,
|
||||||
|
|
||||||
|
serviceId: string|number
|
||||||
|
}>()
|
||||||
|
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
||||||
|
|
||||||
|
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,
|
||||||
|
workload: null,
|
||||||
|
budgetBase: null,
|
||||||
|
budgetReferenceUnitPrice: null,
|
||||||
|
budgetAdoptedUnitPrice: null,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
serviceFee: null,
|
||||||
|
remark: '',
|
||||||
|
path: [group.id, child.id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.id, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
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:
|
||||||
|
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
|
||||||
|
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : 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: '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,
|
||||||
|
|
||||||
|
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 === '')
|
||||||
|
},
|
||||||
|
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) 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: 160,
|
||||||
|
pinned: 'left',
|
||||||
|
width: 170,
|
||||||
|
|
||||||
|
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 pinnedTopRowData = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'pinned-total-row',
|
||||||
|
groupCode: '',
|
||||||
|
groupName: '',
|
||||||
|
majorCode: '',
|
||||||
|
majorName: '',
|
||||||
|
workload: totalWorkload.value,
|
||||||
|
budgetBase: totalBudgetBase.value,
|
||||||
|
budgetReferenceUnitPrice: null,
|
||||||
|
budgetAdoptedUnitPrice: null,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
serviceFee: totalServiceFee.value,
|
||||||
|
remark: '',
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
console.log('Saving to IndexedDB:', payload)
|
||||||
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||||
|
if (data) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFromIndexedDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
})
|
||||||
|
const processCellForClipboard = (params:any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value); // 数组转字符串复制
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params:any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时返回原始值,无需额外处理
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">工作流规模</h3>
|
||||||
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<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>
|
||||||
@ -3,45 +3,22 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
import { majorList } from '@/sql'
|
||||||
import 'ag-grid-enterprise'
|
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';
|
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 {
|
interface DictLeaf {
|
||||||
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
interface DictGroup {
|
||||||
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
children: DictLeaf[]
|
children: DictLeaf[]
|
||||||
@ -68,67 +45,126 @@ const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
|||||||
|
|
||||||
const projectName = ref(DEFAULT_PROJECT_NAME)
|
const projectName = ref(DEFAULT_PROJECT_NAME)
|
||||||
const detailRows = ref<DetailRow[]>([])
|
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 detailDict: DictGroup[] = [
|
const updateGridCardHeight = () => {
|
||||||
{
|
if (!snapScrollHost || !rootRef.value) return
|
||||||
code: 'E1',
|
const contentWrap = rootRef.value.parentElement
|
||||||
name: '交通运输工程通用专业',
|
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
|
||||||
children: [
|
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||||
{ code: 'E1-1', name: '征地(用海)补偿' },
|
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||||||
{ code: 'E1-2', name: '拆迁补偿' },
|
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||||||
{ code: 'E1-3', name: '迁改工程' },
|
agGridHeight.value = nextHeight
|
||||||
{ code: 'E1-4', name: '工程建设其他费' }
|
}
|
||||||
]
|
|
||||||
},
|
const bindSnapScrollHost = () => {
|
||||||
{
|
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
|
||||||
code: 'E2',
|
if (!snapScrollHost) return
|
||||||
name: '公路工程专业',
|
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
|
||||||
children: [
|
hostResizeObserver?.disconnect()
|
||||||
{ code: 'E2-1', name: '临时工程' },
|
hostResizeObserver = new ResizeObserver(() => {
|
||||||
{ code: 'E2-2', name: '路基工程' },
|
updateGridCardHeight()
|
||||||
{ code: 'E2-3', name: '路面工程' },
|
})
|
||||||
{ code: 'E2-4', name: '桥涵工程' },
|
hostResizeObserver.observe(snapScrollHost)
|
||||||
{ code: 'E2-5', name: '隧道工程' },
|
updateGridCardHeight()
|
||||||
{ code: 'E2-6', name: '交叉工程' },
|
}
|
||||||
{ code: 'E2-7', name: '机电工程' },
|
|
||||||
{ code: 'E2-8', name: '交通安全设施工程' },
|
const unbindSnapScrollHost = () => {
|
||||||
{ code: 'E2-9', name: '绿化及环境保护工程' },
|
if (snapScrollHost) {
|
||||||
{ code: 'E2-10', name: '房建工程' }
|
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'E3',
|
|
||||||
name: '铁路工程专业',
|
|
||||||
children: [
|
|
||||||
{ code: 'E3-1', name: '大型临时设施和过渡工程' },
|
|
||||||
{ code: 'E3-2', name: '路基工程' },
|
|
||||||
{ code: 'E3-3', name: '桥涵工程' },
|
|
||||||
{ code: 'E3-4', name: '隧道及明洞工程' },
|
|
||||||
{ code: 'E3-5', name: '轨道工程' },
|
|
||||||
{ code: 'E3-6', name: '通信、信号、信息及灾害监测工程' },
|
|
||||||
{ code: 'E3-7', name: '电力及电力牵引供电工程' },
|
|
||||||
{ code: 'E3-8', name: '房建工程(房屋建筑及附属工程)' },
|
|
||||||
{ code: 'E3-9', name: '装饰装修工程' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'E4',
|
|
||||||
name: '水运工程专业',
|
|
||||||
children: [
|
|
||||||
{ code: 'E4-1', name: '临时工程' },
|
|
||||||
{ code: 'E4-2', name: '土建工程' },
|
|
||||||
{ code: 'E4-3', name: '机电与金属结构工程' },
|
|
||||||
{ code: 'E4-4', name: '设备工程' },
|
|
||||||
{ code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)' }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
hostResizeObserver?.disconnect()
|
||||||
|
hostResizeObserver = null
|
||||||
|
snapScrollHost = null
|
||||||
|
}
|
||||||
|
|
||||||
const codeNameMap = new Map<string, string>()
|
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]))
|
||||||
|
.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(majorEntries.map(([key, item]) => [item.code, { id: key, ...item }]))
|
||||||
|
|
||||||
|
for (const [key, item] of majorEntries) {
|
||||||
|
const isGroup = !item.code.includes('-')
|
||||||
|
if (isGroup) {
|
||||||
|
if (!groupMap.has(item.code)) groupOrder.push(item.code)
|
||||||
|
groupMap.set(item.code, {
|
||||||
|
id: key,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
children: []
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentCode = item.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: item.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) {
|
for (const group of detailDict) {
|
||||||
codeNameMap.set(group.code, group.name)
|
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
codeNameMap.set(child.code, child.name)
|
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,14 +173,14 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
for (const group of detailDict) {
|
for (const group of detailDict) {
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `row-${child.code}`,
|
id: child.id,
|
||||||
groupCode: group.code,
|
groupCode: group.code,
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
majorCode: child.code,
|
majorCode: child.code,
|
||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
amount: null,
|
amount: null,
|
||||||
landArea: null,
|
landArea: null,
|
||||||
path: [group.code, child.code]
|
path: [group.id, child.id]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,11 +190,11 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
const dbValueMap = new Map<string, DetailRow>()
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
for (const row of rowsFromDb || []) {
|
for (const row of rowsFromDb || []) {
|
||||||
dbValueMap.set(row.majorCode, row)
|
dbValueMap.set(row.id, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildDefaultRows().map(row => {
|
return buildDefaultRows().map(row => {
|
||||||
const fromDb = dbValueMap.get(row.majorCode)
|
const fromDb = dbValueMap.get(row.id)
|
||||||
if (!fromDb) return row
|
if (!fromDb) return row
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -238,9 +274,8 @@ const autoGroupColumnDef: ColDef = {
|
|||||||
if (params.node?.rowPinned) {
|
if (params.node?.rowPinned) {
|
||||||
return '总合计'
|
return '总合计'
|
||||||
}
|
}
|
||||||
const code = String(params.value || '')
|
const nodeId = String(params.value || '')
|
||||||
const name = codeNameMap.get(code) || ''
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
return name ? `${code} ${name}` : code
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,6 +324,7 @@ const saveToIndexedDB = async () => {
|
|||||||
projectName: projectName.value,
|
projectName: projectName.value,
|
||||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
}
|
}
|
||||||
|
console.log(payload)
|
||||||
await localforage.setItem(DB_KEY, payload)
|
await localforage.setItem(DB_KEY, payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
@ -298,7 +334,6 @@ const saveToIndexedDB = async () => {
|
|||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
||||||
console.log(data)
|
|
||||||
if (data) {
|
if (data) {
|
||||||
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
||||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
@ -336,13 +371,20 @@ watch(projectName, schedulePersist)
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadFromIndexedDB()
|
await loadFromIndexedDB()
|
||||||
|
bindSnapScrollHost()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateGridCardHeight()
|
||||||
|
})
|
||||||
// window.addEventListener('beforeunload', handleBeforeUnload)
|
// window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
unbindSnapScrollHost()
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
if (snapTimer) clearTimeout(snapTimer)
|
||||||
|
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||||
void saveToIndexedDB()
|
void saveToIndexedDB()
|
||||||
})
|
})
|
||||||
const processCellForClipboard = (params:any) => {
|
const processCellForClipboard = (params:any) => {
|
||||||
@ -364,9 +406,11 @@ const processCellFromClipboard = (params:any) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div ref="rootRef" class="space-y-6">
|
||||||
<div class="rounded-lg border bg-card p-4 shadow-sm">
|
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
|
||||||
<label class="mb-2 block text-sm font-medium text-foreground">项目名称</label>
|
<div class="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<label class="block text-sm font-medium text-foreground">项目名称</label>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="projectName"
|
v-model="projectName"
|
||||||
type="text"
|
type="text"
|
||||||
@ -375,13 +419,16 @@ const processCellFromClipboard = (params:any) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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 class="text-xs text-muted-foreground">导入导出</div>
|
||||||
</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
|
<AgGridVue
|
||||||
:style="{ height: '100%' }"
|
:style="{ height: '100%' }"
|
||||||
:rowData="detailRows"
|
:rowData="detailRows"
|
||||||
|
|||||||
@ -1,239 +1,512 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import 'ag-grid-enterprise'
|
import 'ag-grid-enterprise'
|
||||||
import {
|
import { myTheme } from '@/lib/diyAgGridTheme'
|
||||||
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { serviceList } from '@/sql'
|
||||||
|
import { useTabStore } from '@/pinia/tab'
|
||||||
|
|
||||||
themeQuartz
|
interface ServiceItem {
|
||||||
} from "ag-grid-community"
|
id: string
|
||||||
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 {
|
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
children: DictLeaf[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailRow {
|
interface DetailRow {
|
||||||
id: string
|
id: string
|
||||||
groupCode: string
|
code: string
|
||||||
groupName: string
|
name: string
|
||||||
majorCode: string
|
investScale: number | null
|
||||||
majorName: string
|
landScale: number | null
|
||||||
amount: number | null
|
workload: number | null
|
||||||
landArea: number | null
|
hourly: number | null
|
||||||
path: string[]
|
subtotal?: number | null
|
||||||
|
actions?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface XmInfoState {
|
interface ZxFwState {
|
||||||
|
selectedIds?: string[]
|
||||||
|
selectedCodes?: string[]
|
||||||
detailRows: DetailRow[]
|
detailRows: DetailRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contractId: string
|
contractId: string
|
||||||
}>()
|
}>()
|
||||||
|
const tabStore = useTabStore()
|
||||||
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
|
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
|
||||||
|
|
||||||
|
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]))
|
||||||
|
.filter((entry): entry is [string, ServiceListItem] => {
|
||||||
|
const item = entry[1]
|
||||||
|
const itemCode = item?.code || item?.ref
|
||||||
|
return Boolean(itemCode && item?.name) && item.defCoe !== null
|
||||||
|
})
|
||||||
|
.map(([key, item]) => ({
|
||||||
|
id: key,
|
||||||
|
code: item.code || item.ref || '',
|
||||||
|
name: item.name
|
||||||
|
}))
|
||||||
|
|
||||||
|
const serviceById = new Map(serviceDict.map(item => [item.id, item]))
|
||||||
|
const serviceIdByCode = new Map(serviceDict.map(item => [item.code, item.id]))
|
||||||
|
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: 'C', name: '合同预算' }
|
||||||
|
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
||||||
|
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
const detailRows = ref<DetailRow[]>([])
|
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 detailDict: DictGroup[] =[]
|
const updateGridCardHeight = () => {
|
||||||
|
if (!snapScrollHost || !rootRef.value) return
|
||||||
const codeNameMap = new Map<string, string>()
|
const contentWrap = rootRef.value.parentElement
|
||||||
for (const group of detailDict) {
|
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
|
||||||
codeNameMap.set(group.code, group.name)
|
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||||
for (const child of group.children) {
|
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||||||
codeNameMap.set(child.code, child.name)
|
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||||||
}
|
agGridHeight.value = nextHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildDefaultRows = (): DetailRow[] => {
|
const bindSnapScrollHost = () => {
|
||||||
const rows: DetailRow[] = []
|
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
|
||||||
for (const group of detailDict) {
|
if (!snapScrollHost) return
|
||||||
for (const child of group.children) {
|
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
|
||||||
rows.push({
|
hostResizeObserver?.disconnect()
|
||||||
id: `row-${child.code}`,
|
hostResizeObserver = new ResizeObserver(() => {
|
||||||
groupCode: group.code,
|
updateGridCardHeight()
|
||||||
groupName: group.name,
|
})
|
||||||
majorCode: child.code,
|
hostResizeObserver.observe(snapScrollHost)
|
||||||
majorName: child.name,
|
updateGridCardHeight()
|
||||||
amount: null,
|
|
||||||
landArea: null,
|
|
||||||
path: [group.code, child.code]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
const unbindSnapScrollHost = () => {
|
||||||
const dbValueMap = new Map<string, DetailRow>()
|
if (snapScrollHost) {
|
||||||
for (const row of rowsFromDb || []) {
|
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
|
||||||
dbValueMap.set(row.majorCode, row)
|
|
||||||
}
|
}
|
||||||
|
hostResizeObserver?.disconnect()
|
||||||
|
hostResizeObserver = null
|
||||||
|
snapScrollHost = null
|
||||||
|
}
|
||||||
|
|
||||||
return buildDefaultRows().map(row => {
|
const trySnapToGrid = () => {
|
||||||
const fromDb = dbValueMap.get(row.majorCode)
|
if (isSnapping || !snapScrollHost || !agGridRef.value) return
|
||||||
if (!fromDb) return row
|
|
||||||
|
|
||||||
return {
|
const hostRect = snapScrollHost.getBoundingClientRect()
|
||||||
...row,
|
const gridRect = agGridRef.value.getBoundingClientRect()
|
||||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
const offsetTop = gridRect.top - hostRect.top
|
||||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
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[]>([])
|
||||||
|
const pickerSearch = ref('')
|
||||||
|
const dragSelecting = ref(false)
|
||||||
|
let dragSelectChecked = false
|
||||||
|
const dragAppliedCodes = new Set<string>()
|
||||||
|
const dragStartPoint = ref({ x: 0, y: 0 })
|
||||||
|
const dragCurrentPoint = ref({ x: 0, y: 0 })
|
||||||
|
const pickerItemElMap = new Map<string, HTMLElement>()
|
||||||
|
|
||||||
|
const selectedServiceText = computed(() => {
|
||||||
|
if (selectedIds.value.length === 0) return ''
|
||||||
|
const names = selectedIds.value
|
||||||
|
.map(id => serviceById.get(id)?.name || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
if (names.length <= 2) return names.join('、')
|
||||||
|
return `${names.slice(0, 2).join('、')} 等 ${names.length} 项`
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredServiceDict = computed(() => {
|
||||||
|
const keyword = pickerSearch.value.trim()
|
||||||
|
if (!keyword) return serviceDict
|
||||||
|
return serviceDict.filter(item => item.code.includes(keyword) || item.name.includes(keyword))
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragRectStyle = computed(() => {
|
||||||
|
if (!dragSelecting.value) return {}
|
||||||
|
const left = Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x)
|
||||||
|
const top = Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
||||||
|
const width = Math.abs(dragCurrentPoint.value.x - dragStartPoint.value.x)
|
||||||
|
const height = Math.abs(dragCurrentPoint.value.y - dragStartPoint.value.y)
|
||||||
|
return {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const numericParser = (newValue: any): number | null => {
|
||||||
|
if (newValue === '' || newValue == null) return null
|
||||||
|
const num = Number(newValue)
|
||||||
|
return Number.isFinite(num) ? num : 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
|
||||||
|
|
||||||
|
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓<EFBFBD>? 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)
|
||||||
|
// 定时<EFBFBD>?卸载保存可能产生回写,延迟再清一次更稳妥
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 80))
|
||||||
|
await clearPricingPaneValues(row.id)
|
||||||
|
|
||||||
|
await saveToIndexedDB()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditTab = (row: DetailRow) => {
|
||||||
|
tabStore.openTab({
|
||||||
|
id: `zxfw-edit-${props.contractId}-${row.id}`,
|
||||||
|
title: `服务编辑-${row.code}${row.name}`,
|
||||||
|
componentName: 'ZxFwView',
|
||||||
|
props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnDefs: ColDef<DetailRow>[] = [
|
const columnDefs: ColDef<DetailRow>[] = [
|
||||||
|
{ headerName: '编码', field: 'code', minWidth: 80, flex: 1 },
|
||||||
|
{ headerName: '名称', field: 'name', minWidth: 320, flex: 2 },
|
||||||
{
|
{
|
||||||
headerName: '造价金额(万元)',
|
headerName: '投资规模法',
|
||||||
field: 'amount',
|
field: 'investScale',
|
||||||
minWidth: 170,
|
minWidth: 140,
|
||||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
flex: 2,
|
||||||
|
editable: false,
|
||||||
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
valueParser: params => numericParser(params.newValue),
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '用地面积(亩)',
|
headerName: '用地规模法',
|
||||||
field: 'landArea',
|
field: 'landScale',
|
||||||
minWidth: 170,
|
minWidth: 140,
|
||||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
flex: 2,
|
||||||
|
editable: false,
|
||||||
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
valueParser: params => numericParser(params.newValue),
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||||
cellClassRules: {
|
},
|
||||||
'editable-cell-empty': params =>
|
{
|
||||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
headerName: '工作量法',
|
||||||
|
field: 'workload',
|
||||||
|
minWidth: 120,
|
||||||
|
flex: 2,
|
||||||
|
editable: false,
|
||||||
|
|
||||||
|
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||||||
|
valueParser: params => numericParser(params.newValue),
|
||||||
|
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: '工时法',
|
||||||
|
field: 'hourly',
|
||||||
|
minWidth: 120,
|
||||||
|
flex: 2,
|
||||||
|
editable: false,
|
||||||
|
|
||||||
|
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||||||
|
valueParser: params => numericParser(params.newValue),
|
||||||
|
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: '小计',
|
||||||
|
field: 'subtotal',
|
||||||
|
flex: 3,
|
||||||
|
|
||||||
|
minWidth: 120,
|
||||||
|
editable: false,
|
||||||
|
valueGetter: params => {
|
||||||
|
if (!params.data) return null
|
||||||
|
return (
|
||||||
|
valueOrZero(params.data.investScale) +
|
||||||
|
valueOrZero(params.data.landScale) +
|
||||||
|
valueOrZero(params.data.workload) +
|
||||||
|
valueOrZero(params.data.hourly)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
aggFunc: 'sum',
|
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
||||||
valueParser: params => {
|
},
|
||||||
if (params.newValue === '' || params.newValue == null) return null
|
{
|
||||||
const v = Number(params.newValue)
|
headerName: '操作',
|
||||||
return Number.isFinite(v) ? v : null
|
field: 'actions',
|
||||||
},
|
minWidth: 88,
|
||||||
valueFormatter: params => {
|
flex: 1,
|
||||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
||||||
return '点击输入'
|
editable: false,
|
||||||
}
|
sortable: false,
|
||||||
if (params.value == null) return ''
|
filter: false,
|
||||||
return Number(params.value).toFixed(2)
|
suppressMovable: true,
|
||||||
}
|
cellRenderer: (params: ICellRendererParams<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>
|
||||||
|
</div>`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const autoGroupColumnDef: ColDef = {
|
|
||||||
headerName: '专业编码以及工程专业名称',
|
|
||||||
minWidth: 320,
|
|
||||||
pinned: 'left',
|
|
||||||
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
|
||||||
|
|
||||||
cellRendererParams: {
|
|
||||||
suppressCount: true
|
|
||||||
},
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.node?.rowPinned) {
|
|
||||||
return '总合计'
|
|
||||||
}
|
|
||||||
const code = String(params.value || '')
|
|
||||||
const name = codeNameMap.get(code) || ''
|
|
||||||
return name ? `${code} ${name}` : code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridOptions: GridOptions<DetailRow> = {
|
const gridOptions: GridOptions<DetailRow> = {
|
||||||
treeData: true,
|
|
||||||
animateRows: true,
|
animateRows: true,
|
||||||
singleClickEdit: true,
|
singleClickEdit: true,
|
||||||
suppressClickEdit: false,
|
suppressClickEdit: false,
|
||||||
suppressContextMenu: false,
|
suppressContextMenu: false,
|
||||||
groupDefaultExpanded: -1,
|
|
||||||
suppressFieldDotNotation: true,
|
|
||||||
getDataPath: data => data.path,
|
|
||||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
|
||||||
defaultColDef: {
|
defaultColDef: {
|
||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filter: false
|
filter: false
|
||||||
|
},
|
||||||
|
onCellClicked: async params => {
|
||||||
|
if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return
|
||||||
|
const target = params.event?.target as HTMLElement | null
|
||||||
|
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||||||
|
const action = btn?.dataset.action
|
||||||
|
if (action === 'clear') {
|
||||||
|
await clearRowValues(params.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'edit') {
|
||||||
|
openEditTab(params.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalAmount = computed(() =>
|
const applySelection = (codes: string[]) => {
|
||||||
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
const prevSelectedSet = new Set(selectedIds.value)
|
||||||
)
|
const uniqueIds = Array.from(new Set(codes)).filter(
|
||||||
|
id => serviceById.has(id) && id !== fixedBudgetRow.id
|
||||||
|
)
|
||||||
|
const existingMap = new Map(detailRows.value.map(row => [row.id, row]))
|
||||||
|
|
||||||
const totalLandArea = computed(() =>
|
const baseRows: DetailRow[] = uniqueIds
|
||||||
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
|
.map(id => {
|
||||||
)
|
const dictItem = serviceById.get(id)
|
||||||
const pinnedTopRowData = computed(() => [
|
if (!dictItem) return null
|
||||||
{
|
|
||||||
id: 'pinned-total-row',
|
const old = existingMap.get(id)
|
||||||
groupCode: '',
|
return {
|
||||||
groupName: '',
|
id: old?.id || id,
|
||||||
majorCode: '',
|
code: dictItem.code,
|
||||||
majorName: '',
|
name: dictItem.name,
|
||||||
amount: totalAmount.value,
|
investScale: old?.investScale ?? null,
|
||||||
landArea: totalLandArea.value,
|
landScale: old?.landScale ?? null,
|
||||||
path: ['TOTAL']
|
workload: old?.workload ?? null,
|
||||||
|
hourly: old?.hourly ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((row): row is DetailRow => Boolean(row))
|
||||||
|
|
||||||
|
const orderMap = new Map(serviceDict.map((item, index) => [item.id, index]))
|
||||||
|
baseRows.sort((a, b) => (orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0))
|
||||||
|
|
||||||
|
const fixedOld = existingMap.get(fixedBudgetRow.id)
|
||||||
|
const fixedRow: DetailRow = {
|
||||||
|
id: fixedOld?.id || fixedBudgetRow.id,
|
||||||
|
code: fixedBudgetRow.code,
|
||||||
|
name: fixedBudgetRow.name,
|
||||||
|
investScale: typeof fixedOld?.investScale === 'number' ? fixedOld.investScale : null,
|
||||||
|
landScale: typeof fixedOld?.landScale === 'number' ? fixedOld.landScale : null,
|
||||||
|
workload: typeof fixedOld?.workload === 'number' ? fixedOld.workload : null,
|
||||||
|
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
|
||||||
|
subtotal: null,
|
||||||
|
actions: null
|
||||||
}
|
}
|
||||||
])
|
|
||||||
|
|
||||||
|
const removedIds = Array.from(prevSelectedSet).filter(id => !uniqueIds.includes(id))
|
||||||
|
for (const id of removedIds) {
|
||||||
|
tabStore.removeTab(`zxfw-edit-${props.contractId}-${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIds.value = uniqueIds
|
||||||
|
detailRows.value = [...baseRows, fixedRow]
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparePickerOpen = () => {
|
||||||
|
pickerTempIds.value = [...selectedIds.value]
|
||||||
|
pickerSearch.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePicker = () => {
|
||||||
|
stopDragSelect()
|
||||||
|
pickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePickerOpenChange = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
preparePickerOpen()
|
||||||
|
} else {
|
||||||
|
stopDragSelect()
|
||||||
|
}
|
||||||
|
pickerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPicker = () => {
|
||||||
|
applySelection(pickerTempIds.value)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPickerSelection = () => {
|
||||||
|
pickerTempIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleServiceCode = (code: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
if (!pickerTempIds.value.includes(code)) {
|
||||||
|
pickerTempIds.value = [...pickerTempIds.value, code]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTempChecked = (code: string, checked: boolean) => {
|
||||||
|
const exists = pickerTempIds.value.includes(code)
|
||||||
|
if (checked && !exists) {
|
||||||
|
pickerTempIds.value = [...pickerTempIds.value, code]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!checked && exists) {
|
||||||
|
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPickerItemRef = (
|
||||||
|
code: string,
|
||||||
|
el: Element | ComponentPublicInstance | null
|
||||||
|
) => {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
pickerItemElMap.set(code, el)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pickerItemElMap.delete(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRectIntersect = (
|
||||||
|
a: { left: number; right: number; top: number; bottom: number },
|
||||||
|
b: { left: number; right: number; top: number; bottom: number }
|
||||||
|
) => !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom)
|
||||||
|
|
||||||
|
const applyDragSelectionByRect = () => {
|
||||||
|
const rect = {
|
||||||
|
left: Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
||||||
|
right: Math.max(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
||||||
|
top: Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y),
|
||||||
|
bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDragSelect = () => {
|
||||||
|
dragSelecting.value = false
|
||||||
|
dragAppliedCodes.clear()
|
||||||
|
window.removeEventListener('mousemove', onDragSelectingMove)
|
||||||
|
window.removeEventListener('mouseup', stopDragSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragSelectingMove = (event: MouseEvent) => {
|
||||||
|
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
||||||
|
applyDragSelectionByRect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDragSelect = (event: MouseEvent, code: string) => {
|
||||||
|
dragSelecting.value = true
|
||||||
|
dragAppliedCodes.clear()
|
||||||
|
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 saveToIndexedDB = async () => {
|
const saveToIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const payload: XmInfoState = {
|
const payload: ZxFwState = {
|
||||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
selectedIds: [...selectedIds.value],
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
}
|
}
|
||||||
await localforage.setItem(DB_KEY.value, payload)
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -243,31 +516,36 @@ const saveToIndexedDB = async () => {
|
|||||||
|
|
||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
const data = await localforage.getItem<ZxFwState>(DB_KEY.value)
|
||||||
if (data) {
|
if (!data) {
|
||||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
selectedIds.value = []
|
||||||
|
detailRows.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
detailRows.value = buildDefaultRows()
|
const idsFromStorage = data.selectedIds
|
||||||
|
|| (data.selectedCodes || []).map(code => serviceIdByCode.get(code)).filter((id): id is string => Boolean(id))
|
||||||
|
applySelection(idsFromStorage)
|
||||||
|
|
||||||
|
const savedRowMap = new Map((data.detailRows || []).map(row => [row.id, row]))
|
||||||
|
detailRows.value = detailRows.value.map(row => {
|
||||||
|
const old = savedRowMap.get(row.id)
|
||||||
|
if (!old) return row
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
investScale: typeof old.investScale === 'number' ? old.investScale : null,
|
||||||
|
landScale: typeof old.landScale === 'number' ? old.landScale : null,
|
||||||
|
workload: typeof old.workload === 'number' ? old.workload : null,
|
||||||
|
hourly: typeof old.hourly === 'number' ? old.hourly : null
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
detailRows.value = buildDefaultRows()
|
selectedIds.value = []
|
||||||
|
detailRows.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const schedulePersist = () => {
|
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
|
||||||
persistTimer = setTimeout(() => {
|
|
||||||
void saveToIndexedDB()
|
|
||||||
}, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
// const handleBeforeUnload = () => {
|
|
||||||
// void saveToIndexedDB()
|
|
||||||
// }
|
|
||||||
|
|
||||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const handleCellValueChanged = () => {
|
const handleCellValueChanged = () => {
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
@ -276,102 +554,132 @@ const handleCellValueChanged = () => {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadFromIndexedDB()
|
await loadFromIndexedDB()
|
||||||
// window.addEventListener('beforeunload', handleBeforeUnload)
|
bindSnapScrollHost()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateGridCardHeight()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
unbindSnapScrollHost()
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
stopDragSelect()
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
if (snapTimer) clearTimeout(snapTimer)
|
||||||
|
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||||
void saveToIndexedDB()
|
void saveToIndexedDB()
|
||||||
})
|
})
|
||||||
const processCellForClipboard = (params:any) => {
|
|
||||||
if (Array.isArray(params.value)) {
|
|
||||||
return JSON.stringify(params.value); // 数组转字符串复制
|
|
||||||
}
|
|
||||||
return params.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const processCellFromClipboard = (params:any) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(params.value);
|
|
||||||
if (Array.isArray(parsed)) return parsed;
|
|
||||||
} catch (e) {
|
|
||||||
// 解析失败时返回原始值,无需额外处理
|
|
||||||
}
|
|
||||||
return params.value;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div ref="rootRef" class="space-y-6">
|
||||||
<div class="rounded-lg border bg-card p-4 shadow-sm">
|
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
|
||||||
<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" />
|
||||||
|
<DialogTrigger as-child>
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex h-10 w-10 items-center justify-center rounded-md border text-sm hover:bg-accent cursor-pointer"
|
||||||
|
title="浏览服务词典">
|
||||||
|
<Search class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay class="fixed inset-0 z-50 bg-black/40" />
|
||||||
|
<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>
|
||||||
|
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||||
|
<h4 class="text-base font-semibold">选择服务词典</h4>
|
||||||
|
<DialogClose as-child>
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex cursor-pointer h-8 items-center rounded-md border px-3 text-sm hover:bg-accent">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
<div class="max-h-[420px] overflow-auto px-5 py-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input v-model="pickerSearch" type="text" placeholder="输入编码或名称过滤"
|
||||||
|
class="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||||
|
</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"
|
||||||
|
@mousedown.prevent="startDragSelect($event, item.id)" @mouseenter="handleDragHover(item.id)"
|
||||||
|
@click.prevent>
|
||||||
|
<input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" />
|
||||||
|
<span class="text-muted-foreground">{{ item.code }}</span>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border bg-card xmMx">
|
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
|
||||||
|
<Button type="button" variant="outline" @click="clearPickerSelection">
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
<DialogClose as-child>
|
||||||
|
<Button type="button" @click="confirmPicker">
|
||||||
|
确认选择
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<div v-if="dragSelecting"
|
||||||
|
class="pointer-events-none fixed z-[70] rounded-sm border border-sky-500/90 bg-sky-400/10"
|
||||||
|
:style="dragRectStyle" />
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
|
||||||
|
<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">
|
<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 class="text-xs text-muted-foreground">按服务词典生成</div>
|
||||||
</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
|
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="gridOptions"
|
||||||
:style="{ height: '100%' }"
|
:theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true"
|
||||||
:rowData="detailRows"
|
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"
|
||||||
:pinnedTopRowData="pinnedTopRowData"
|
:undoRedoCellEditingLimit="20" />
|
||||||
: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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style >
|
<style>
|
||||||
.ag-floating-top{
|
.ag-floating-top {
|
||||||
overflow-y:auto !important
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xmMx .editable-cell-line .ag-cell-value {
|
.zxfw-action-wrap {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
min-width: 84%;
|
align-items: center;
|
||||||
padding: 2px 4px;
|
justify-content: center;
|
||||||
border-bottom: 1px solid #cbd5e1;
|
gap: 6px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
.zxfw-action-btn {
|
||||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
border: 0;
|
||||||
border-bottom-color: #2563eb;
|
background: transparent;
|
||||||
}
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
.xmMx .editable-cell-empty .ag-cell-value {
|
line-height: 1;
|
||||||
color: #94a3b8 !important;
|
padding: 0;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@ -35,6 +36,7 @@ interface DataPackage {
|
|||||||
const componentMap: Record<string, any> = {
|
const componentMap: Record<string, any> = {
|
||||||
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
|
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
|
||||||
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
|
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
|
||||||
|
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
@ -53,6 +55,7 @@ const tabContextRef = ref<HTMLElement | null>(null)
|
|||||||
const dataMenuOpen = ref(false)
|
const dataMenuOpen = ref(false)
|
||||||
const dataMenuRef = ref<HTMLElement | null>(null)
|
const dataMenuRef = ref<HTMLElement | null>(null)
|
||||||
const importFileRef = ref<HTMLInputElement | null>(null)
|
const importFileRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const tabItemElMap = new Map<string, HTMLElement>()
|
||||||
|
|
||||||
const tabsModel = computed({
|
const tabsModel = computed({
|
||||||
get: () => tabStore.tabs,
|
get: () => tabStore.tabs,
|
||||||
@ -114,6 +117,21 @@ const canMoveTab = (event: any) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null) => {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
tabItemElMap.set(id, el)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tabItemElMap.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureActiveTabVisible = () => {
|
||||||
|
const activeId = tabStore.activeTabId
|
||||||
|
const el = tabItemElMap.get(activeId)
|
||||||
|
if (!el) return
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
const readWebStorage = (storageObj: Storage): DataEntry[] => {
|
const readWebStorage = (storageObj: Storage): DataEntry[] => {
|
||||||
const entries: DataEntry[] = []
|
const entries: DataEntry[] = []
|
||||||
for (let i = 0; i < storageObj.length; i++) {
|
for (let i = 0; i < storageObj.length; i++) {
|
||||||
@ -174,7 +192,7 @@ const exportData = async () => {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `jgjs-data-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`
|
link.download = `造价项目-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
@ -231,27 +249,40 @@ const handleReset = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||||
|
void nextTick(() => {
|
||||||
|
ensureActiveTabVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('mousedown', handleGlobalMouseDown)
|
window.removeEventListener('mousedown', handleGlobalMouseDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => tabStore.activeTabId,
|
||||||
|
() => {
|
||||||
|
void nextTick(() => {
|
||||||
|
ensureActiveTabVisible()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||||||
<div class="flex items-center border-b bg-muted/30 px-2 pt-2 flex-none">
|
<div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-2 flex-none">
|
||||||
<ScrollArea class="flex-1 whitespace-nowrap">
|
<ScrollArea type="auto" class="min-w-0 flex-1 whitespace-nowrap pb-2">
|
||||||
<draggable
|
<draggable
|
||||||
v-model="tabsModel"
|
v-model="tabsModel"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
tag="div"
|
tag="div"
|
||||||
class="flex gap-1"
|
class="flex w-max gap-1"
|
||||||
:animation="180"
|
:animation="180"
|
||||||
:move="canMoveTab"
|
:move="canMoveTab"
|
||||||
>
|
>
|
||||||
<template #item="{ element: tab }">
|
<template #item="{ element: tab }">
|
||||||
<div
|
<div
|
||||||
|
:ref="el => setTabItemRef(tab.id, el)"
|
||||||
@click="tabStore.activeTabId = tab.id"
|
@click="tabStore.activeTabId = tab.id"
|
||||||
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
||||||
:class="[
|
:class="[
|
||||||
@ -276,10 +307,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<ScrollBar orientation="horizontal" class="invisible" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div ref="dataMenuRef" class="relative ml-2 mb-2">
|
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
|
||||||
<Button variant="outline" size="sm" @click="dataMenuOpen = !dataMenuOpen">
|
<Button variant="outline" size="sm" @click="dataMenuOpen = !dataMenuOpen">
|
||||||
<ChevronDown class="h-4 w-4 mr-1" />
|
<ChevronDown class="h-4 w-4 mr-1" />
|
||||||
导入/导出
|
导入/导出
|
||||||
@ -312,7 +343,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<AlertDialogRoot>
|
<AlertDialogRoot>
|
||||||
<AlertDialogTrigger as-child>
|
<AlertDialogTrigger as-child>
|
||||||
<Button variant="destructive" size="sm" class="ml-2 mb-2">
|
<Button variant="destructive" size="sm" class="mb-2 shrink-0">
|
||||||
<RotateCcw class="h-4 w-4 mr-1" />
|
<RotateCcw class="h-4 w-4 mr-1" />
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
@ -322,7 +353,7 @@ onBeforeUnmount(() => {
|
|||||||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||||
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
||||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
将清空本地缓存(IndexDB / LocalStorage / SessionStorage)并恢复默认页面,确认继续吗?
|
将清空所有项目数据,并恢复默认页面,确认继续吗?
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
<div class="mt-4 flex items-center justify-end gap-2">
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
<AlertDialogCancel as-child>
|
<AlertDialogCancel as-child>
|
||||||
|
|||||||
@ -56,7 +56,7 @@ const activeComponent = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full w-full bg-background">
|
<div class="flex h-full w-full bg-background">
|
||||||
<div class="w-1/5 border-r p-6 flex flex-col gap-8 relative">
|
<div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
|
||||||
<!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div> -->
|
<!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div> -->
|
||||||
|
|
||||||
<div class="flex flex-col gap-10 relative">
|
<div class="flex flex-col gap-10 relative">
|
||||||
@ -92,9 +92,9 @@ const activeComponent = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-4/5 min-h-0">
|
<div class="w-88/100 min-h-0 h-full flex flex-col">
|
||||||
<ScrollArea class="h-full w-full">
|
<ScrollArea class="h-full w-full min-h-0 rightMain">
|
||||||
<div class="p-3">
|
<div class="p-3 h-full min-h-0 flex flex-col">
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<component :is="activeComponent" />
|
<component :is="activeComponent" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
@ -103,3 +103,11 @@ const activeComponent = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
/* 核心修改:添加 :deep() 穿透 scoped 作用域 */
|
||||||
|
:deep(.rightMain > div > div) {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
30
src/lib/diyAgGridTheme.ts
Normal file
30
src/lib/diyAgGridTheme.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
|
||||||
|
themeQuartz
|
||||||
|
} from "ag-grid-community"
|
||||||
|
const borderConfig = {
|
||||||
|
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
|
||||||
|
width: 0.5, // 更细的边框,减少视觉干扰
|
||||||
|
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简洁清新风格的主题配置
|
||||||
|
export const myTheme = themeQuartz.withParams({
|
||||||
|
// 核心:移除外边框,减少视觉包裹感
|
||||||
|
wrapperBorder: false,
|
||||||
|
|
||||||
|
// 表头样式(柔和浅蓝,无加粗,更轻盈)
|
||||||
|
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
|
||||||
|
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
|
||||||
|
headerFontSize: 15, // 字体稍大一点,更易读
|
||||||
|
headerFontWeight: "normal", // 取消加粗,降低视觉重量
|
||||||
|
|
||||||
|
// 行/列/表头边框(统一浅灰细边框)
|
||||||
|
rowBorder: borderConfig,
|
||||||
|
columnBorder: borderConfig,
|
||||||
|
headerRowBorder: borderConfig,
|
||||||
|
|
||||||
|
|
||||||
|
// 可选:偶数行背景色(轻微区分,更清新)
|
||||||
|
dataBackgroundColor: "#fefefe"
|
||||||
|
});
|
||||||
144
src/sql.ts
Normal file
144
src/sql.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
export const majorList = {
|
||||||
|
0: { code: 'E1', name: '交通运输工程通用专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '' },
|
||||||
|
1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
2: { code: 'E1-2', name: '拆迁补偿', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于交通建设项目拆迁补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
3: { code: 'E1-3', name: '迁改工程', maxCoe: null, minCoe: null, defCoe: 2, desc: '适用于交通建设项目迁改工程的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
4: { code: 'E1-4', name: '工程建设其他费', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目的工程建设其他费的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
5: { code: 'E2', name: '公路工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于公路工程的全过程造价咨询、分阶段造价咨询、投资估算、初步设计概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)' },
|
||||||
|
6: { code: 'E2-1', name: '临时工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于临时工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
7: { code: 'E2-2', name: '路基工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于路基工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
8: { code: 'E2-3', name: '路面工程', maxCoe: null, minCoe: null, defCoe: 0.8, desc: '适用于路面工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
9: { code: 'E2-4', name: '桥涵工程', maxCoe: null, minCoe: null, defCoe: 0.9, desc: '适用于桥梁涵洞工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
10: { code: 'E2-5', name: '隧道工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于隧道工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
11: { code: 'E2-6', name: '交叉工程', maxCoe: null, minCoe: null, defCoe: 1.1, desc: '适用于交叉工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
12: { code: 'E2-7', name: '机电工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于机电工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
13: { code: 'E2-8', name: '交通安全设施工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于交通安全设施工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
14: { code: 'E2-9', name: '绿化及环境保护工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于绿化工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
15: { code: 'E2-10', name: '房建工程', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房建工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
16: { code: 'E3', name: '铁路工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于铁路工程的投资估算、初步设计概算、清理概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)' },
|
||||||
|
17: { code: 'E3-1', name: '大型临时设施和过渡工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于大型临时设施和过渡工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
18: { code: 'E3-2', name: '路基工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于路基工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
19: { code: 'E3-3', name: '桥涵工程', maxCoe: null, minCoe: null, defCoe: 0.9, desc: '适用于桥涵工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
20: { code: 'E3-4', name: '隧道及明洞工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于隧道及明洞工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算、竣工决算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
21: { code: 'E3-5', name: '轨道工程', maxCoe: null, minCoe: null, defCoe: 0.3, desc: '适用于轨道工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
22: { code: 'E3-6', name: '通信、信号、信息及灾害监测工程', maxCoe: null, minCoe: null, defCoe: 2, desc: '适用于通信、信号、信息及防灾监测工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
23: { code: 'E3-7', name: '电力及电力牵引供电工程', maxCoe: null, minCoe: null, defCoe: 1.5, desc: '适用于电力及电力牵引供电工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
24: { code: 'E3-8', name: '房建工程(房屋建筑及附属工程)', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房屋建筑及附属工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
25: { code: 'E3-9', name: '装饰装修工程', maxCoe: null, minCoe: null, defCoe: 2.7, desc: '适用于装饰装修工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
26: { code: 'E4', name: '水运工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于水运工程的投资估算、初步设计概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)' },
|
||||||
|
27: { code: 'E4-1', name: '临时工程', maxCoe: null, minCoe: null, defCoe: 1.1, desc: '适用于临时工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算、竣工决算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
28: { code: 'E4-2', name: '土建工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于土建工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算、竣工决算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
29: { code: 'E4-3', name: '机电与金属结构工程', maxCoe: null, minCoe: null, defCoe: 1.5, desc: '适用于机电与金属结构专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
30: { code: 'E4-4', name: '设备工程', maxCoe: null, minCoe: null, defCoe: 1.5, desc: '适用于设备工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
31: { code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房屋建筑与水运附属工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算' },
|
||||||
|
}
|
||||||
|
export const serviceList = {
|
||||||
|
0: { code: 'D1', name: '全过程造价咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: null },
|
||||||
|
1: { code: 'D2', name: '分阶段造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', taskList: null },
|
||||||
|
2: { code: 'D2-1', name: '前期阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '', taskList: null },
|
||||||
|
3: { code: 'D2-2-1', name: '实施阶段造价咨询(公路、水运)', maxCoe: null, minCoe: null, defCoe: 0.55, desc: '本系数适用于公路和水运工程。', taskList: null },
|
||||||
|
4: { code: 'D2-2-2', name: '实施阶段造价咨询(铁路)', maxCoe: null, minCoe: null, defCoe: 0.6, desc: '本系数适用于铁路工程。', taskList: null },
|
||||||
|
5: { code: 'D3', name: '基本造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', taskList: null },
|
||||||
|
6: { code: 'D3-1', name: '投资估算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '委托同一咨询人同时负责D3-1和D3-2时,D3-1和D3-2的合计调整系数为0.25。', taskList: null },
|
||||||
|
7: { code: 'D3-2', name: '设计概算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '', taskList: null },
|
||||||
|
8: { code: 'D3-3', name: '施工图预算', maxCoe: null, minCoe: null, defCoe: 0.25, desc: '委托同一咨询人同时负责D3-3和D3-4时,D3-3和D3-4的合计调整系数为0.3。', taskList: null },
|
||||||
|
9: { code: 'D3-4', name: '招标工程量清单及清单预算(或最高投标限价)', maxCoe: null, minCoe: null, defCoe: 0.15, desc: '', taskList: null },
|
||||||
|
10: { code: 'D3-5', name: '清理概算(仅限铁路)', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '本系数适用于铁路工程。', taskList: null },
|
||||||
|
11: { code: 'D3-6-1', name: '合同(工程)结算', maxCoe: null, minCoe: null, defCoe: 0.3, desc: '本系数适用于公路和水运工程。', taskList: null },
|
||||||
|
12: { code: 'D3-6-2', name: '合同(工程)结算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '本系数适用于铁路工程。', taskList: null },
|
||||||
|
13: { code: 'D3-7', name: '竣工决算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '', taskList: null },
|
||||||
|
14: { code: 'D4', name: '专项造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', taskList: null },
|
||||||
|
15: { code: 'D4-1', name: '工程造价顾问', maxCoe: null, minCoe: null, defCoe: 1, desc: '本表系数适用于采用工作量计价法基准预算的调整系数。', taskList: [0, 1] },
|
||||||
|
16: { code: 'D4-2', name: '造价政策制(修)订', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: [2, 3, 4, 5, 6, 7] },
|
||||||
|
17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21] },
|
||||||
|
18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: [22, 23, 24, 25, 26, 27, 28, 22, 23, 24, 25, 26, 27, 28] },
|
||||||
|
19: { code: 'D4-5', name: '造价信息咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '', taskList: null },
|
||||||
|
20: { code: 'D4-6', name: '造价鉴定', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '本表系数适用于采用规模计价法基准预算的调整系数。', taskList: null },
|
||||||
|
21: { code: 'D4-7', name: '工程成本测算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '', taskList: null },
|
||||||
|
22: { code: 'D4-8', name: '工程成本核算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '', taskList: null },
|
||||||
|
23: { code: 'D4-9', name: '计算工程量', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '', taskList: null },
|
||||||
|
24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '', taskList: null },
|
||||||
|
25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '', taskList: null },
|
||||||
|
26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。', taskList: null },
|
||||||
|
27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。', taskList: null },
|
||||||
|
28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。', taskList: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
let taskList = {
|
||||||
|
0: { serviceID: 15, ref: 'C4-1', name: '工程造价日常顾问', basicParam: '服务月份数', required: true, unit: '万元/月', conversion: 10000, maxPrice: 0.5, minPrice: 0.3, defPrice: 0.4, desc: '' },
|
||||||
|
1: { serviceID: 15, ref: 'C4-2', name: '工程造价专项顾问', basicParam: '服务项目的造价金额', required: true, unit: '%', conversion: 0.01, maxPrice: null, minPrice: null, defPrice: 0.01, desc: '适用于涉及造价费用类的顾问' },
|
||||||
|
2: { serviceID: 16, ref: 'C5-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
|
||||||
|
3: { serviceID: 16, ref: 'C5-2-1', name: '文件编写工作', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 5, minPrice: 3, defPrice: 4, desc: '主编' },
|
||||||
|
4: { serviceID: 16, ref: 'C5-2-2', name: '文件编写工作', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '参编' },
|
||||||
|
5: { serviceID: 16, ref: 'C5-3-1', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
|
||||||
|
6: { serviceID: 16, ref: 'C5-3-2', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
|
||||||
|
7: { serviceID: 16, ref: 'C5-3-3', name: '评审工作', basicParam: '评审次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
|
||||||
|
8: { serviceID: 17, ref: 'C6-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
|
||||||
|
9: { serviceID: 17, ref: 'C6-2-1', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 50, minPrice: 20, defPrice: 35, desc: '国家级' },
|
||||||
|
10: { serviceID: 17, ref: 'C6-2-2', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 20, minPrice: 10, defPrice: 15, desc: '省部级' },
|
||||||
|
11: { serviceID: 17, ref: 'C6-2-3', name: '研究及编写报告', basicParam: '文件份数', required: true, unit: '万元/份', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '其他级' },
|
||||||
|
12: { serviceID: 17, ref: 'C6-3-1', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 80, minPrice: 50, defPrice: 65, desc: '复杂标准' },
|
||||||
|
13: { serviceID: 17, ref: 'C6-3-2', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 50, minPrice: 20, defPrice: 35, desc: '较复杂标准' },
|
||||||
|
14: { serviceID: 17, ref: 'C6-3-3', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 20, minPrice: 10, defPrice: 15, desc: '一般标准' },
|
||||||
|
15: { serviceID: 17, ref: 'C6-3-4', name: '标准与技术性指导文件的编制', basicParam: '文件与标准的数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '简单标准' },
|
||||||
|
16: { serviceID: 17, ref: 'C6-4-1', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
|
||||||
|
17: { serviceID: 17, ref: 'C6-4-2', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
|
||||||
|
18: { serviceID: 17, ref: 'C6-4-3', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
|
||||||
|
19: { serviceID: 17, ref: 'C6-5-1', name: '培训与宣贯工作', basicParam: '项目数量', required: false, unit: '万元/次', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '培训与宣贯材料' },
|
||||||
|
20: { serviceID: 17, ref: 'C6-5-2', name: '培训与宣贯工作', basicParam: '培训与宣贯次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 1, minPrice: 0.5, defPrice: 0.75, desc: '组织培训与宣贯' },
|
||||||
|
21: { serviceID: 17, ref: 'C6-6', name: '测试与验证工作', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
|
||||||
|
22: { serviceID: 18, ref: 'C7-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' },
|
||||||
|
23: { serviceID: 18, ref: 'C7-2', name: '编制大纲', basicParam: '项目数量', required: true, unit: '万元/个', conversion: 10000, maxPrice: 3, minPrice: 2, defPrice: 2.5, desc: '包括技术与定额子目研究' },
|
||||||
|
24: { serviceID: 18, ref: 'C7-3', name: '数据采集与测定', basicParam: '采集组数', required: true, unit: '万元/组', conversion: 10000, maxPrice: 0.8, minPrice: 0.2, defPrice: 0.5, desc: '现场采集方式时计' },
|
||||||
|
25: { serviceID: 18, ref: 'C7-4-1', name: '数据整理与分析', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 0.3, minPrice: 0.1, defPrice: 0.2, desc: '简单定额' },
|
||||||
|
26: { serviceID: 18, ref: 'C7-4-2', name: '数据整理与分析', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 3, minPrice: 0.3, defPrice: 1.65, desc: '复杂定额' },
|
||||||
|
27: { serviceID: 18, ref: 'C7-5', name: '编写定额测定报告', basicParam: '项目数量', required: true, unit: '万元/份', conversion: 10000, maxPrice: 5, minPrice: 2, defPrice: 3.5, desc: '' },
|
||||||
|
28: { serviceID: 18, ref: 'C7-6-1', name: '编制定额文本和释义', basicParam: '基本费用', required: true, unit: '万元/份', conversion: 10000, maxPrice: 1, minPrice: 0.5, defPrice: 0.75, desc: '20条定额子目内' },
|
||||||
|
29: { serviceID: 18, ref: 'C7-6-2', name: '编制定额文本和释义', basicParam: '定额子目条数', required: true, unit: '万元/条', conversion: 10000, maxPrice: 0.2, minPrice: 0.1, defPrice: 0.15, desc: '超过20条每增加1条' },
|
||||||
|
30: { serviceID: 18, ref: 'C7-7-1', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 20, minPrice: 8, defPrice: 14, desc: '大型评审' },
|
||||||
|
31: { serviceID: 18, ref: 'C7-7-2', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 10, minPrice: 5, defPrice: 7.5, desc: '中型评审' },
|
||||||
|
32: { serviceID: 18, ref: 'C7-7-3', name: '评审与验收工作', basicParam: '评审与验收次数', required: false, unit: '万元/次', conversion: 10000, maxPrice: 6, minPrice: 3, defPrice: 4.5, desc: '小型评审' },
|
||||||
|
33: { serviceID: 18, ref: 'C7-8-1', name: '培训与宣贯工作', basicParam: '项目数量', required: false, unit: '万元/次', conversion: 10000, maxPrice: 3, minPrice: 1, defPrice: 2, desc: '培训与宣贯材料' },
|
||||||
|
34:{ serviceID :18 ,ref :'C7-8-2' ,name :'培训与宣贯工作' ,basicParam :'培训与宣贯次数' ,required :false ,unit :'万元/次' ,conversion :10000 ,maxPrice :1 ,minPrice :0.5 ,defPrice :0.75 ,desc :'组织培训与宣贯'},
|
||||||
|
35: { serviceID: 18, ref: 'C7-9', name: '定额测试与验证', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expertList = {
|
||||||
|
0: { ref: 'C9-1-1', name: '技术员及其他', maxPrice: 800, minPrice: 600, defPrice: 700, manageCoe: 2.3 },
|
||||||
|
1: { ref: 'C9-1-2', name: '助理工程师', maxPrice: 1000, minPrice: 800, defPrice: 900, manageCoe: 2.3 },
|
||||||
|
2: { ref: 'C9-1-3', name: '中级工程师或二级造价工程师', maxPrice: 1500, minPrice: 1000, defPrice: 1250, manageCoe: 2.2 },
|
||||||
|
3: { ref: 'C9-1-4', name: '高级工程师或一级造价工程师', maxPrice: 1800, minPrice: 1500, defPrice: 1650, manageCoe: 2.1 },
|
||||||
|
4: { ref: 'C9-1-5', name: '正高级工程师或资深专家', maxPrice: 2500, minPrice: 2000, defPrice: 2250, manageCoe: 2 },
|
||||||
|
5: { ref: 'C9-2-1', name: '二级造价工程师且具备中级工程师资格', maxPrice: 1500, minPrice: 1200, defPrice: 1350, manageCoe: 2.2 },
|
||||||
|
6: { ref: 'C9-3-1', name: '一级造价工程师且具备中级工程师资格', maxPrice: 1800, minPrice: 1500, defPrice: 1650, manageCoe: 2.1 },
|
||||||
|
7: { ref: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const costScaleCal = [
|
||||||
|
{ ref: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } },
|
||||||
|
{ ref: 'C1-2', staLine: 100, endLine: 300, basic: { staPrice: 10000, rate: 0.008 }, optional: { staPrice: 2000, rate: 0.0016 } },
|
||||||
|
{ ref: 'C1-3', staLine: 300, endLine: 500, basic: { staPrice: 26000, rate: 0.005 }, optional: { staPrice: 5200, rate: 0.001 } },
|
||||||
|
{ ref: 'C1-4', staLine: 500, endLine: 1000, basic: { staPrice: 36000, rate: 0.004 }, optional: { staPrice: 7200, rate: 0.0008 } },
|
||||||
|
{ ref: 'C1-5', staLine: 1000, endLine: 5000, basic: { staPrice: 56000, rate: 0.003 }, optional: { staPrice: 11200, rate: 0.0006 } },
|
||||||
|
{ ref: 'C1-6', staLine: 5000, endLine: 10000, basic: { staPrice: 176000, rate: 0.002 }, optional: { staPrice: 35200, rate: 0.0004 } },
|
||||||
|
{ ref: 'C1-7', staLine: 10000, endLine: 30000, basic: { staPrice: 276000, rate: 0.0016 }, optional: { staPrice: 55200, rate: 0.00032 } },
|
||||||
|
{ ref: 'C1-8', staLine: 30000, endLine: 50000, basic: { staPrice: 596000, rate: 0.0013 }, optional: { staPrice: 119200, rate: 0.00026 } },
|
||||||
|
{ ref: 'C1-9', staLine: 50000, endLine: 100000, basic: { staPrice: 856000, rate: 0.001 }, optional: { staPrice: 171200, rate: 0.0002 } },
|
||||||
|
{ ref: 'C1-10', staLine: 100000, endLine: 150000, basic: { staPrice: 1356000, rate: 0.0009 }, optional: { staPrice: 271200, rate: 0.00018 } },
|
||||||
|
{ ref: 'C1-11', staLine: 150000, endLine: 200000, basic: { staPrice: 1806000, rate: 0.0008 }, optional: { staPrice: 361200, rate: 0.00016 } },
|
||||||
|
{ ref: 'C1-12', staLine: 200000, endLine: 300000, basic: { staPrice: 2206000, rate: 0.0007 }, optional: { staPrice: 441200, rate: 0.00014 } },
|
||||||
|
{ ref: 'C1-13', staLine: 300000, endLine: 400000, basic: { staPrice: 2906000, rate: 0.0006 }, optional: { staPrice: 581200, rate: 0.00012 } },
|
||||||
|
{ ref: 'C1-14', staLine: 400000, endLine: 600000, basic: { staPrice: 3506000, rate: 0.0005 }, optional: { staPrice: 701200, rate: 0.0001 } },
|
||||||
|
{ ref: 'C1-15', staLine: 600000, endLine: 800000, basic: { staPrice: 4506000, rate: 0.0004 }, optional: { staPrice: 901200, rate: 0.00008 } },
|
||||||
|
{ ref: 'C1-16', staLine: 800000, endLine: 1000000, basic: { staPrice: 5306000, rate: 0.0003 }, optional: { staPrice: 1061200, rate: 0.00006 } },
|
||||||
|
{ ref: 'C1-17', staLine: 1000000, endLine: null, basic: { staPrice: 5906000, rate: 0.00025 }, optional: { staPrice: 1181200, rate: 0.00005 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const areaScaleCal = [
|
||||||
|
{ ref: 'C2-1', staLine: 0, endLine: 50, basic: { staPrice: 0, rate: 200 }, optional: { staPrice: 0, rate: 40 } },
|
||||||
|
{ ref: 'C2-2', staLine: 50, endLine: 100, basic: { staPrice: 10000, rate: 160 }, optional: { staPrice: 2000, rate: 32 } },
|
||||||
|
{ ref: 'C2-3', staLine: 100, endLine: 500, basic: { staPrice: 18000, rate: 120 }, optional: { staPrice: 3600, rate: 24 } },
|
||||||
|
{ ref: 'C2-4', staLine: 500, endLine: 1000, basic: { staPrice: 66000, rate: 80 }, optional: { staPrice: 13200, rate: 16 } },
|
||||||
|
{ ref: 'C2-5', staLine: 1000, endLine: 5000, basic: { staPrice: 106000, rate: 60 }, optional: { staPrice: 21200, rate: 12 } },
|
||||||
|
{ ref: 'C2-6', staLine: 5000, endLine: null, basic: { staPrice: 346000, rate: 20 }, optional: { staPrice: 69200, rate: 4 } },
|
||||||
|
];
|
||||||
@ -111,12 +111,23 @@
|
|||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
.ag-horizontal-left-spacer{
|
||||||
|
overflow-x: auto
|
||||||
|
}
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
[contenteditable='true'] {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user