This commit is contained in:
wintsa 2026-02-25 17:59:10 +08:00
parent 5734cfa534
commit 1609f19b9c
14 changed files with 2481 additions and 433 deletions

View File

@ -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>

View File

@ -43,11 +43,16 @@ const htView = markRaw(
); );
const zxfwView = markRaw( const zxfwView = markRaw(
defineAsyncComponent({ defineComponent({
name: 'ZxFwWithProps',
setup() {
const AsyncZxFw = defineAsyncComponent({
loader: () => import('@/components/views/zxFw.vue'), loader: () => import('@/components/views/zxFw.vue'),
//
onError: (err) => { onError: (err) => {
console.error('加载 Ht 组件失败:', err); console.error('加载 zxFw 组件失败:', err);
}
});
return () => h(AsyncZxFw, { contractId: props.contractId });
} }
}) })
); );

View File

@ -6,6 +6,14 @@ 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
@ -25,9 +33,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 +62,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 () => {
@ -141,6 +149,7 @@ const createContract = async () => {
const deleteContract = async (id: string) => { const deleteContract = async (id: string) => {
contracts.value = contracts.value.filter(item => item.id !== id) contracts.value = contracts.value.filter(item => item.id !== id)
await saveContracts() await saveContracts()
tabStore.removeTab(`contract-${id}`)
notify('删除成功') notify('删除成功')
} }
@ -185,12 +194,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 +251,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 +291,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>

View File

@ -0,0 +1,54 @@
<template>
<TypeLine
scene="zxfw-pricing-tab"
title="咨询服务计算"
storage-key="zxfw-pricing-active-cat"
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
}>()
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 })
}
})
)
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>

View File

@ -3,6 +3,7 @@ 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 {
@ -37,11 +38,13 @@ const myTheme = themeQuartz.withParams({
dataBackgroundColor: "#fefefe" 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[]
@ -70,66 +73,60 @@ const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
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 +135,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 +152,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 +236,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 +285,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)
@ -311,16 +308,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 +323,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()

View File

@ -0,0 +1,416 @@
<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 {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线
const borderConfig = {
style: "solid", // 线线 dotted solid
width: 0.5, //
color: "#e5e7eb" //
};
//
const myTheme = themeQuartz.withParams({
//
wrapperBorder: false,
//
headerBackgroundColor: "#f9fafb", // #e7f3fc
headerTextColor: "#374151", //
headerFontSize: 15, //
headerFontWeight: "normal", //
// //
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
//
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
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
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
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,
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
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
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 => {
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: '用地面积(亩)',
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, //
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
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="space-y-6">
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<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>

View File

@ -0,0 +1,416 @@
<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 {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线
const borderConfig = {
style: "solid", // 线线 dotted solid
width: 0.5, //
color: "#e5e7eb" //
};
//
const myTheme = themeQuartz.withParams({
//
wrapperBorder: false,
//
headerBackgroundColor: "#f9fafb", // #e7f3fc
headerTextColor: "#374151", //
headerFontSize: 15, //
headerFontWeight: "normal", //
// //
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
//
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
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
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
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,
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
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
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 => {
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: '用地面积(亩)',
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, //
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
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="space-y-6">
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<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>

View File

@ -0,0 +1,416 @@
<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 {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线
const borderConfig = {
style: "solid", // 线线 dotted solid
width: 0.5, //
color: "#e5e7eb" //
};
//
const myTheme = themeQuartz.withParams({
//
wrapperBorder: false,
//
headerBackgroundColor: "#f9fafb", // #e7f3fc
headerTextColor: "#374151", //
headerFontSize: 15, //
headerFontWeight: "normal", //
// //
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
//
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
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
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
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,
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
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
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 => {
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: '用地面积(亩)',
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, //
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
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="space-y-6">
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<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>

View File

@ -0,0 +1,416 @@
<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 {
themeQuartz
} from "ag-grid-community"
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 线+线
const borderConfig = {
style: "solid", // 线线 dotted solid
width: 0.5, //
color: "#e5e7eb" //
};
//
const myTheme = themeQuartz.withParams({
//
wrapperBorder: false,
//
headerBackgroundColor: "#f9fafb", // #e7f3fc
headerTextColor: "#374151", //
headerFontSize: 15, //
headerFontWeight: "normal", //
// //
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
//
dataBackgroundColor: "#fefefe"
});
interface DictLeaf {
id: string
code: string
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
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
const props = defineProps<{
contractId: string
}>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
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,
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
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
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 => {
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: '用地面积(亩)',
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 => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? v : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return Number(params.value).toFixed(2)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 320,
pinned: 'left',
flex:2, //
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const gridOptions: GridOptions<DetailRow> = {
treeData: true,
animateRows: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,
sortable: false,
filter: false
}
}
const totalAmount = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
)
const totalLandArea = computed(() =>
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: totalLandArea.value,
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="space-y-6">
<div class="rounded-lg border bg-card xmMx">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div>
</div>
<div class="ag-theme-quartz h-[580px] w-full">
<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>

View File

@ -3,6 +3,7 @@ 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 {
@ -37,11 +38,13 @@ const myTheme = themeQuartz.withParams({
dataBackgroundColor: "#fefefe" 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 +71,59 @@ 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[]>([])
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 detailDict: DictGroup[] = (() => {
{ const groupMap = new Map<string, DictGroup>()
code: 'E1', const groupOrder: string[] = []
name: '交通运输工程通用专业', const codeLookup = new Map(majorEntries.map(([key, item]) => [item.code, { id: key, ...item }]))
children: [
{ code: 'E1-1', name: '征地(用海)补偿' }, for (const [key, item] of majorEntries) {
{ code: 'E1-2', name: '拆迁补偿' }, const isGroup = !item.code.includes('-')
{ code: 'E1-3', name: '迁改工程' }, if (isGroup) {
{ code: 'E1-4', name: '工程建设其他费' } if (!groupMap.has(item.code)) groupOrder.push(item.code)
] groupMap.set(item.code, {
}, id: key,
{ code: item.code,
code: 'E2', name: item.name,
name: '公路工程专业', children: []
children: [ })
{ code: 'E2-1', name: '临时工程' }, continue
{ 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 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 +132,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 +149,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 +233,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 +283,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 +293,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)

View File

@ -1,238 +1,440 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, 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 { themeQuartz } from 'ag-grid-community'
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}`)
const borderConfig = {
style: 'solid',
width: 0.5,
color: '#e5e7eb'
}
const myTheme = themeQuartz.withParams({
wrapperBorder: false,
headerBackgroundColor: '#f9fafb',
headerTextColor: '#374151',
headerFontSize: 15,
headerFontWeight: 'normal',
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
dataBackgroundColor: '#fefefe'
})
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.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 detailDict: DictGroup[] =[] 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 codeNameMap = new Map<string, string>() const selectedServiceText = computed(() => {
for (const group of detailDict) { if (selectedIds.value.length === 0) return ''
codeNameMap.set(group.code, group.name) const names = selectedIds.value
for (const child of group.children) { .map(id => serviceById.get(id)?.name || '')
codeNameMap.set(child.code, child.name) .filter(Boolean)
} if (names.length <= 2) return names.join('、')
} return `${names.slice(0, 2).join('、')}${names.length}`
})
const buildDefaultRows = (): DetailRow[] => { const filteredServiceDict = computed(() => {
const rows: DetailRow[] = [] const keyword = pickerSearch.value.trim()
for (const group of detailDict) { if (!keyword) return serviceDict
for (const child of group.children) { return serviceDict.filter(item => item.code.includes(keyword) || item.name.includes(keyword))
rows.push({ })
id: `row-${child.code}`,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
amount: null,
landArea: null,
path: [group.code, child.code]
})
}
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.majorCode, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.majorCode)
if (!fromDb) return row
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 { return {
...row, left: `${left}px`,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null, top: `${top}px`,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null 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 clearRowValues = async (row: DetailRow) => {
if (isFixedRow(row)) return
row.investScale = null
row.landScale = null
row.workload = null
row.hourly = null
row.subtotal = null
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 === '')
}, },
aggFunc: 'sum', {
valueParser: params => { headerName: '工作量法',
if (params.newValue === '' || params.newValue == null) return null field: 'workload',
const v = Number(params.newValue) minWidth: 120,
return Number.isFinite(v) ? v : null 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))
}, },
valueFormatter: params => { {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { headerName: '工时法',
return '点击输入' field: 'hourly',
} minWidth: 120,
if (params.value == null) return '' flex: 2,
return Number(params.value).toFixed(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)
)
},
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
},
{
headerName: '操作',
field: 'actions',
minWidth: 88,
flex: 1,
editable: false,
sortable: false,
filter: false,
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 = {
selectedIds: [...selectedIds.value],
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
} }
await localforage.setItem(DB_KEY.value, payload) await localforage.setItem(DB_KEY.value, payload)
@ -243,31 +445,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 +483,120 @@ const handleCellValueChanged = () => {
}, 1000) }, 1000)
} }
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
// window.addEventListener('beforeunload', handleBeforeUnload)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
// window.removeEventListener('beforeunload', handleBeforeUnload) stopDragSelect()
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
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 class="space-y-6">
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
<div class="rounded-lg border bg-card p-4 shadow-sm"> <div class="rounded-lg border bg-card p-4 shadow-sm">
<label class="mb-2 block text-sm font-medium text-foreground">选择服务</label> <label class="mb-2 block text-sm font-medium text-foreground">选择服务</label>
<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>
</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 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="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 class="rounded-lg border bg-card xmMx"> <div class="rounded-lg border bg-card xmMx">
<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-[580px] w-full">
<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" </div>
:autoGroupColumnDef="autoGroupColumnDef" </div>
: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>
</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>

View File

@ -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>

View File

@ -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,7 +92,7 @@ const activeComponent = computed(() => {
</div> </div>
</div> </div>
<div class="w-4/5 min-h-0"> <div class="w-88/100 min-h-0">
<ScrollArea class="h-full w-full"> <ScrollArea class="h-full w-full">
<div class="p-3"> <div class="p-3">
<keep-alive> <keep-alive>

66
src/sql.ts Normal file
View File

@ -0,0 +1,66 @@
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 },
};