'20260305修复bug'

This commit is contained in:
wintsa 2026-03-05 17:58:38 +08:00
parent 53c1b2c0db
commit 75f293f877
20 changed files with 1167 additions and 1400 deletions

19
AGENTS.md Normal file
View File

@ -0,0 +1,19 @@
# AGENTS.md — Encoding & Chinese Safety Rules
## Absolute rules (must follow)
1. Never corrupt non-ASCII text (Chinese, emoji, etc.). Preserve exact Unicode characters.
2. NEVER rewrite entire files when only small edits are needed. Always apply minimal diffs/patches.
3. If a file contains Chinese characters, do not “normalize”, “escape”, “re-encode”, or “replace” them.
4. When reading/writing files via scripts/tools, always use UTF-8 explicitly (no platform default encoding).
## Windows / PowerShell rules
- If you need to run PowerShell, force UTF-8 output/input:
- Use: `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()`
- Prefer `Set-Content -Encoding utf8` / `Out-File -Encoding utf8`
- Avoid commands that may round-trip through ANSI/CP936/CP1252 without explicit encoding.
## Workflow
- Before editing: inspect the target lines only.
- Apply changes as a patch (line-level edits), not full-file regeneration.
- After editing: verify the edited lines still show correct Chinese.
- If uncertain: stop and ask rather than guessing and corrupting text.

View File

@ -9,6 +9,7 @@
<body>
<div id="app"></div>
<script>
//上线前添加访问版本号,强制刷新缓存
;(() => {
const makeVisitVersion = () => {
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,49 +0,0 @@
<script setup lang="ts">
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
const props = defineProps<{
rowData: unknown[]
pinnedTopRowData?: unknown[]
columnDefs: ColDef[]
autoGroupColumnDef: ColDef
processCellForClipboard?: (params: any) => unknown
processCellFromClipboard?: (params: any) => unknown
headerHeight?: number
}>()
const emit = defineEmits<{
(event: 'cell-value-changed', payload: CellValueChangedEvent): void
}>()
const onCellValueChanged = (event: CellValueChangedEvent) => {
emit('cell-value-changed', event)
}
</script>
<template>
<AgGridVue
:style="{ height: '100%' }"
:rowData="props.rowData"
:pinnedTopRowData="props.pinnedTopRowData"
:columnDefs="props.columnDefs"
:autoGroupColumnDef="props.autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="onCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="props.headerHeight ?? 50"
:suppressHorizontalScroll="true"
:processCellForClipboard="props.processCellForClipboard"
:processCellFromClipboard="props.processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</template>

View File

@ -13,6 +13,7 @@ interface DictItem {
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
order?: number | null
}
interface FactorRow {
@ -61,10 +62,12 @@ const sortedDictEntries = () =>
return true
})
.sort((a, b) => {
const aNum = Number(a[0])
const bNum = Number(b[0])
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum
return String(a[0]).localeCompare(String(b[0]))
const aOrder = Number(a[1]?.order)
const bOrder = Number(b[1]?.order)
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
})
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {

View File

@ -0,0 +1,244 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import localforage from 'localforage'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { industryTypeList } from '@/sql'
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
path: string[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const props = defineProps<{
title: string
rowData: DetailRow[]
dbKey: string
}>()
const BASE_INFO_KEY = 'xm-base-info-v1'
let persistTimer: ReturnType<typeof setTimeout> | null = null
const activeIndustryId = ref('')
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value, 3)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 200,
flex: 2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) return totalLabel.value
return String(params.value || '')
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value
return String(params.value || '')
}
}
const totalAmount = computed(() => sumByNumber(props.rowData, row => row.amount))
const pinnedTopRowData = computed<DetailRow[]>(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: totalLabel.value,
hasCost: false,
hasArea: false,
amount: totalAmount.value,
landArea: null,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
try {
await localforage.setItem(props.dbKey, {
detailRows: JSON.parse(JSON.stringify(props.rowData))
})
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const onCellValueChanged = (_event: CellValueChangedEvent) => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}
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 (error) {
// no-op
}
return params.value
}
const loadIndustryFromBaseInfo = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryId.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
} catch (error) {
console.error('loadIndustryFromBaseInfo failed:', error)
activeIndustryId.value = ''
}
}
onMounted(() => {
void loadIndustryFromBaseInfo()
})
onActivated(() => {
void loadIndustryFromBaseInfo()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
void saveToIndexedDB()
})
</script>
<template>
<div class="h-full">
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }}
</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="props.rowData"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@cell-value-changed="onCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressHorizontalScroll="true"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
/>
</div>
</div>
</div>
</template>

View File

@ -9,7 +9,7 @@ import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/
import { useTabStore } from '@/pinia/tab'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import { majorList } from '@/sql'
import { industryTypeList } from '@/sql'
import {
AlertDialogAction,
AlertDialogCancel,
@ -90,7 +90,7 @@ const contractListViewportRef = ref<HTMLElement | null>(null)
const showScrollTopFab = ref(false)
const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready')
const canManageContracts = ref(false)
const canManageContracts = ref(true)
let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
@ -196,10 +196,8 @@ const formatExportTimestamp = (date: Date): string => {
const industryNameByCode = (() => {
const map = new Map<string, string>()
for (const item of Object.values(majorList as Record<string, { code?: string; name?: string }>)) {
if (!item?.code || !item?.name) continue
if (item.code.includes('-')) continue
map.set(item.code, item.name)
for (const item of industryTypeList) {
map.set(item.id, item.name)
}
return map
})()

View File

@ -36,7 +36,7 @@ const clearAll = () => {
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label>
<button
type="button"
class="h-6 rounded-md border px-2 text-[11px] text-muted-foreground transition hover:bg-accent"
class="h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
@click="clearAll"
>
清空

View File

@ -9,7 +9,8 @@
</template>
<script setup lang="ts">
import { markRaw, defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
import localforage from 'localforage'
import TypeLine from '@/layout/typeLine.vue'
const infoView = markRaw(defineAsyncComponent(() => import('@/components/views/info.vue')))
@ -22,12 +23,48 @@ const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
)
const xmCategories = [
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const hasProjectBaseInfo = ref(false)
const fullXmCategories = [
{ key: 'info', label: '基础信息', component: infoView },
{ key: 'contract', label: '合同段管理', component: htView },
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
]
const xmCategories = computed(() =>
hasProjectBaseInfo.value ? fullXmCategories : [fullXmCategories[0]]
)
const refreshProjectBaseInfoState = async () => {
try {
const data = await localforage.getItem(PROJECT_INFO_KEY)
hasProjectBaseInfo.value = Boolean(data)
} catch (error) {
console.error('read project base info failed:', error)
hasProjectBaseInfo.value = false
}
}
const handleProjectInitChanged = (event: Event) => {
const detail = (event as CustomEvent<boolean>).detail
if (typeof detail === 'boolean') {
hasProjectBaseInfo.value = detail
return
}
void refreshProjectBaseInfoState()
}
onMounted(() => {
void refreshProjectBaseInfoState()
window.addEventListener(PROJECT_INIT_CHANGED_EVENT, handleProjectInitChanged as EventListener)
})
onBeforeUnmount(() => {
window.removeEventListener(PROJECT_INIT_CHANGED_EVENT, handleProjectInitChanged as EventListener)
})
</script>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import localforage from 'localforage'
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
@ -37,7 +37,9 @@ const loadProjectIndustry = async () => {
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = Object.entries(majorList as Record<string, MajorItem>).filter(([, item]) => isMajorCodeInIndustryScope(item.code, industry))
const entries = getMajorDictEntries()
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
.map(({ id, item }) => [id, item as MajorItem] as const)
return Object.fromEntries(entries)
})
@ -51,15 +53,13 @@ onActivated(() => {
</script>
<template>
<MethodUnavailableNotice
v-if="!hasProjectBaseInfo"
title="请先在“基础信息”里新建项目"
message="完成新建后将自动加载工程专业系数。"
/>
<XmFactorGrid
v-else
title="工程专业系数明细"
storage-key="xm-major-factor-v1"
:dict="filteredMajorDict"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
/>
</template>

View File

@ -1,17 +1,15 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import type { ColDef } from 'ag-grid-community'
import { computed, onActivated, onMounted, ref } from 'vue'
import localforage from 'localforage'
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import CommonAgGrid from '@/components/common/CommonAgGrid.vue'
// 线+线
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
@ -27,6 +25,8 @@ interface DetailRow {
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
path: string[]
@ -46,17 +46,13 @@ const props = defineProps<{
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = 'xm-info-v3'
const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('')
const activeIndustryId = ref('')
const detailRows = ref<DetailRow[]>([])
type majorLite = { code: string; name: string }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
type majorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
@ -92,26 +88,20 @@ const detailDict: DictGroup[] = (() => {
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name
name: item.name,
hasCost: item.hasCost !== false,
hasArea: item.hasArea !== false
})
}
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[] => {
if (!activeIndustryCode.value) return []
if (!activeIndustryId.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && !isMajorCodeInIndustryScope(group.code, activeIndustryCode.value)) continue
if (activeIndustryId.value && !isMajorIdInIndustryScope(group.id, activeIndustryId.value)) continue
for (const child of group.children) {
rows.push({
id: child.id,
@ -119,9 +109,11 @@ const buildDefaultRows = (): DetailRow[] => {
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
path: [group.id, child.id]
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
})
}
}
@ -131,7 +123,12 @@ const buildDefaultRows = (): DetailRow[] => {
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
const rowId = String(row.id)
dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row)
}
}
return buildDefaultRows().map(row => {
@ -140,130 +137,16 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, //
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1, //
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value, 3)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 200,
maxWidth: 300,
flex:2, //
wrapText: true,
autoHeight: true,
// cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
}
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: null,
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 baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
activeIndustryId.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
@ -287,20 +170,6 @@ const loadFromIndexedDB = async () => {
}
}
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()
})
@ -308,53 +177,8 @@ onMounted(async () => {
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
if (gridPersistTimer) clearTimeout(gridPersistTimer)
void saveToIndexedDB()
})
const processCellForClipboard = (params:any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); //
}
return params.value;
};
const processCellFromClipboard = (params:any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
//
}
return params.value;
};
</script>
<template>
<div class="h-full min-h-0 min-w-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden">
<CommonAgGrid
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
@cell-value-changed="handleCellValueChanged"
/>
</div>
</div>
</div>
<CommonAgGrid title="合同规模明细" :rowData="detailRows" :dbKey="DB_KEY" />
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import localforage from 'localforage'
import { isMajorIndustrySelectable, majorList } from '@/sql'
import { industryTypeList } from '@/sql'
import { CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
@ -14,12 +14,12 @@ interface XmInfoState {
preparedDate?: string
}
type MajorLite = { code: string; name: string; hideInIndustrySelector?: boolean }
type MajorParentNode = { id: string; code: string; name: string }
type MajorParentNode = { id: string; name: string }
const DB_KEY = 'xm-base-info-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const isProjectInitialized = ref(false)
const showCreateDialog = ref(false)
@ -32,25 +32,12 @@ const reviewedBy = ref('')
const preparedCompany = ref('')
const preparedDate = ref('')
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 getMajorParentNodes = (entries: Array<[string, MajorLite]>): MajorParentNode[] =>
entries
.filter(([, item]) => isMajorIndustrySelectable(item))
.map(([id, item]) => ({
id,
code: item.code,
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
id: item.id,
name: item.name
}))
const majorParentNodes = getMajorParentNodes(majorEntries)
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.code))
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.code || ''
}))
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id))
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || ''
const saveToIndexedDB = async () => {
try {
@ -142,6 +129,7 @@ const createProject = async () => {
isProjectInitialized.value = true
showCreateDialog.value = false
await saveToIndexedDB()
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
}
watch(
@ -203,17 +191,17 @@ onMounted(async () => {
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
<label
v-for="item in majorParentNodes"
:key="item.code"
:key="item.id"
class="inline-flex items-center gap-2 text-sm text-foreground/80"
>
<input
v-model="projectIndustry"
type="radio"
:value="item.code"
:value="item.id"
disabled
class="h-4 w-4 cursor-not-allowed accent-primary"
/>
<span>{{ item.code }} {{ item.name }}</span>
<span>{{ item.name }}</span>
</label>
</div>
</div>
@ -271,11 +259,11 @@ onMounted(async () => {
<div class="mt-4 max-h-72 space-y-2 overflow-auto rounded-lg border p-3">
<label
v-for="item in majorParentNodes"
:key="item.code"
:key="item.id"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/60"
>
<input v-model="pendingIndustry" type="radio" :value="item.code" class="h-4 w-4 accent-primary" />
<span class="text-sm text-foreground">{{ item.code }} {{ item.name }}</span>
<input v-model="pendingIndustry" type="radio" :value="item.id" class="h-4 w-4 accent-primary" />
<span class="text-sm text-foreground"> {{ item.name }}</span>
</label>
</div>
<div class="mt-5 flex justify-end gap-2">

View File

@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
@ -32,6 +32,8 @@ interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
@ -47,6 +49,8 @@ interface DetailRow {
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
benchmarkBudget: number | null
benchmarkBudgetBasic: number | null
@ -139,13 +143,9 @@ const shouldForceDefaultLoad = () => {
}
const detailRows = ref<DetailRow[]>([])
type majorLite = { code: string; name: string; defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
@ -181,7 +181,9 @@ const detailDict: DictGroup[] = (() => {
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name
name: item.name,
hasCost: item.hasCost !== false,
hasArea: item.hasArea !== false
})
}
@ -200,7 +202,7 @@ const buildDefaultRows = (): DetailRow[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && !isMajorCodeInIndustryScope(group.code, activeIndustryCode.value)) continue
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
for (const child of group.children) {
rows.push({
id: child.id,
@ -208,6 +210,8 @@ const buildDefaultRows = (): DetailRow[] => {
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
@ -253,7 +257,12 @@ const mergeWithDictRows = (
const includeFactorValues = options?.includeFactorValues ?? true
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
const rowId = String(row.id)
dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row)
}
}
return buildDefaultRows().map(row => {
@ -264,7 +273,7 @@ const mergeWithDictRows = (
return {
...row,
amount: includeAmount && typeof fromDb.amount === 'number' ? fromDb.amount : null,
amount: includeAmount && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
@ -311,6 +320,9 @@ const formatMajorFactor = (params: any) => {
}
const formatEditableMoney = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
@ -358,11 +370,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 100,
flex: 2,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
@ -549,6 +564,8 @@ const pinnedTopRowData = computed(() => [
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: totalAmount.value,
benchmarkBudget: totalBenchmarkBudget.value,
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,

View File

@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import localforage from 'localforage'
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
@ -32,6 +32,8 @@ interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
@ -47,6 +49,8 @@ interface DetailRow {
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
benchmarkBudget: number | null
@ -140,13 +144,9 @@ const shouldSkipPersist = () => {
return false
}
type majorLite = { code: string; name: string; defCoe: number | null }
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, majorLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
@ -182,7 +182,9 @@ const detailDict: DictGroup[] = (() => {
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name
name: item.name,
hasCost: item.hasCost !== false,
hasArea: item.hasArea !== false
})
}
@ -201,7 +203,7 @@ const buildDefaultRows = (): DetailRow[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (const group of detailDict) {
if (activeIndustryCode.value && !isMajorCodeInIndustryScope(group.code, activeIndustryCode.value)) continue
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
for (const child of group.children) {
rows.push({
id: child.id,
@ -209,6 +211,8 @@ const buildDefaultRows = (): DetailRow[] => {
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
benchmarkBudget: null,
@ -256,7 +260,12 @@ const mergeWithDictRows = (
const includeFactorValues = options?.includeFactorValues ?? true
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
const rowId = String(row.id)
dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row)
}
}
return buildDefaultRows().map(row => {
@ -267,8 +276,8 @@ const mergeWithDictRows = (
return {
...row,
amount: includeScaleValues && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: includeScaleValues && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
amount: includeScaleValues && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: includeScaleValues && row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
@ -347,6 +356,9 @@ const getBudgetFeeSplit = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'c
}
const formatEditableFlexibleNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
@ -362,14 +374,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},
valueParser: params => {
const value = parseNumberOrNull(params.newValue)
@ -556,6 +568,8 @@ const pinnedTopRowData = computed(() => [
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: totalAmount.value,
landArea: null,
benchmarkBudget: totalBenchmarkBudget.value,

View File

@ -1,17 +1,15 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import type { ColDef } from 'ag-grid-community'
import { onActivated, onMounted, ref } from 'vue'
import localforage from 'localforage'
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import CommonAgGrid from '@/components/common/CommonAgGrid.vue'
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
@ -27,6 +25,8 @@ interface DetailRow {
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
path: string[]
@ -41,87 +41,14 @@ interface XmBaseInfoState {
const DB_KEY = 'xm-info-v3'
const BASE_INFO_KEY = 'xm-base-info-v1'
type MajorLite = { code: string; name: string }
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const detailRows = ref<DetailRow[]>([])
const rootRef = ref<HTMLElement | null>(null)
const agGridRef = ref<HTMLElement | null>(null)
const agGridHeight = ref(580)
let snapScrollHost: HTMLElement | null = null
let snapTimer: ReturnType<typeof setTimeout> | null = null
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
let isSnapping = false
let hostResizeObserver: ResizeObserver | null = null
const activeIndustryCode = ref('')
const hasProjectBaseInfo = ref(false)
const detailDict = ref<DictGroup[]>([])
const idLabelMap = ref(new Map<string, string>())
const updateGridCardHeight = () => {
if (!snapScrollHost || !rootRef.value) return
const contentWrap = rootRef.value.parentElement
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight + 10
}
const bindSnapScrollHost = () => {
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
if (!snapScrollHost) return
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
hostResizeObserver?.disconnect()
hostResizeObserver = new ResizeObserver(() => {
updateGridCardHeight()
})
hostResizeObserver.observe(snapScrollHost)
updateGridCardHeight()
}
const unbindSnapScrollHost = () => {
if (snapScrollHost) {
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
}
hostResizeObserver?.disconnect()
hostResizeObserver = null
snapScrollHost = null
}
const trySnapToGrid = () => {
if (isSnapping || !snapScrollHost || !agGridRef.value) return
const hostRect = snapScrollHost.getBoundingClientRect()
const gridRect = agGridRef.value.getBoundingClientRect()
const offsetTop = gridRect.top - hostRect.top
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
const inSnapRange = offsetTop > -120 && offsetTop < 180
if (!inVisibleBand || !inSnapRange) return
isSnapping = true
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
if (snapLockTimer) clearTimeout(snapLockTimer)
snapLockTimer = setTimeout(() => {
isSnapping = false
}, 420)
}
function handleSnapHostScroll() {
if (isSnapping) return
if (snapTimer) clearTimeout(snapTimer)
snapTimer = setTimeout(() => {
trySnapToGrid()
}, 90)
}
const 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 majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const groupMap = new Map<string, DictGroup>()
@ -156,7 +83,9 @@ const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
groupMap.get(parentCode)!.children.push({
id: key,
code: item.code,
name: item.name
name: item.name,
hasCost: item.hasCost !== false,
hasArea: item.hasArea !== false
})
}
@ -166,20 +95,10 @@ const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const rebuildDictByIndustry = (industryCode: string) => {
if (!industryCode) {
detailDict.value = []
idLabelMap.value = new Map()
return
}
const filteredEntries = majorEntries.filter(([, item]) => isMajorCodeInIndustryScope(item.code, industryCode))
const nextDict = buildDetailDict(filteredEntries)
const nextLabelMap = new Map<string, string>()
for (const group of nextDict) {
nextLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
nextLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
detailDict.value = nextDict
idLabelMap.value = nextLabelMap
const filteredEntries = majorEntries.filter(([id]) => isMajorIdInIndustryScope(id, industryCode))
detailDict.value = buildDetailDict(filteredEntries)
}
const buildDefaultRows = (): DetailRow[] => {
@ -192,9 +111,11 @@ const buildDefaultRows = (): DetailRow[] => {
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
path: [group.id, child.id]
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
})
}
}
@ -204,7 +125,12 @@ const buildDefaultRows = (): DetailRow[] => {
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
const rowId = String(row.id)
dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row)
}
}
return buildDefaultRows().map(row => {
@ -213,121 +139,15 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
}
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousands(params.value, 3)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 200,
flex: 2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
}
const nodeId = String(params.value || '')
return idLabelMap.value.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
const nodeId = String(params.value || '')
return idLabelMap.value.get(nodeId) || nodeId
}
}
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
amount: totalAmount.value,
landArea: null,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (!activeIndustryCode.value) return
try {
const payload: XmScaleState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await localforage.setItem(DB_KEY, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
hasProjectBaseInfo.value = Boolean(baseInfo)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
rebuildDictByIndustry(activeIndustryCode.value)
@ -345,90 +165,19 @@ const loadFromIndexedDB = async () => {
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
hasProjectBaseInfo.value = false
detailRows.value = []
}
}
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}
onMounted(async () => {
await loadFromIndexedDB()
bindSnapScrollHost()
requestAnimationFrame(() => {
updateGridCardHeight()
})
})
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
unbindSnapScrollHost()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
if (snapTimer) clearTimeout(snapTimer)
if (snapLockTimer) clearTimeout(snapLockTimer)
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) {
// no-op
}
return params.value
}
</script>
<template>
<div ref="rootRef" class="h-full">
<MethodUnavailableNotice
v-if="!hasProjectBaseInfo"
title="请先在“基础信息”里新建项目"
message="完成新建后将自动加载规模信息。"
/>
<div
v-else
class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full"
>
<div class="flex items-center justify-between border-b px-4 py-3 ">
<h3
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"
>
项目明细
</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<CommonAgGrid
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
@cell-value-changed="handleCellValueChanged"
/>
</div>
</div>
</div>
<CommonAgGrid title="项目明细" :rowData="detailRows" :dbKey="DB_KEY" />
</template>

View File

@ -32,7 +32,7 @@ import {
} from 'reka-ui'
import { Button } from '@/components/ui/button'
import { TooltipProvider } from '@/components/ui/tooltip'
import { serviceList } from '@/sql'
import { getServiceDictEntries } from '@/sql'
import { useTabStore } from '@/pinia/tab'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
@ -73,15 +73,14 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const PRICING_CLEAR_SKIP_TTL_MS = 5000
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ServiceListItem] => {
const item = entry[1]
const serviceDict: ServiceItem[] = getServiceDictEntries()
.map(({ id, item }) => ({ id, item: item as ServiceListItem }))
.filter(({ item }) => {
const itemCode = item?.code || item?.ref
return Boolean(itemCode && item?.name) && item.defCoe !== null
})
.map(([key, item]) => ({
id: key,
.map(({ id, item }) => ({
id,
code: item.code || item.ref || '',
name: item.name
}))

View File

@ -1260,6 +1260,7 @@ const handleReset = async () => {
localStorage.clear()
sessionStorage.clear()
await localforage.clear()
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
} catch (error) {
console.error('reset failed:', error)
} finally {
@ -1427,7 +1428,7 @@ watch(
</Button>
<div
v-if="dataMenuOpen"
class="absolute right-0 top-full mt-1 z-50 min-w-[108px] rounded-md border bg-background p-1 shadow-md"
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md"
>
<button
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"

View File

@ -1,5 +1,11 @@
import localforage from 'localforage'
import { expertList, majorList, serviceList, taskList } from '@/sql'
import {
expertList,
getMajorDictEntries,
getMajorIdAliasMap,
getServiceDictById,
taskList
} from '@/sql'
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
@ -73,20 +79,23 @@ const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
}
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
const service = (serviceList as Record<string, ServiceLite | undefined>)[String(serviceId)]
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
return toFiniteNumberOrNull(service?.defCoe)
}
const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite]))
const majorIdAliasMap = getMajorIdAliasMap()
const getDefaultMajorFactorById = (id: string) => {
const major = (majorList as Record<string, MajorLite | undefined>)[id]
const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id
const major = majorById.get(resolvedId)
return toFiniteNumberOrNull(major?.defCoe)
}
const getMajorLeafIds = () =>
Object.entries(majorList as Record<string, MajorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter(([, item]) => Boolean(item?.code && item.code.includes('-')))
.map(([id]) => id)
getMajorDictEntries()
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
.map(({ id }) => id)
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => {
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
@ -104,6 +113,13 @@ const mergeScaleRows = (
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
): ScaleRow[] => {
const dbValueMap = toRowMap(rowsFromDb)
for (const row of rowsFromDb || []) {
const rowId = String(row.id)
const nextId = majorIdAliasMap.get(rowId)
if (nextId && !dbValueMap.has(nextId)) {
dbValueMap.set(nextId, row as ScaleRow)
}
}
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
return buildDefaultScaleRows(serviceId).map(row => {

View File

@ -1,5 +1,5 @@
import localforage from 'localforage'
import { majorList, serviceList } from '@/sql'
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
import { toFiniteNumberOrNull } from '@/lib/number'
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
@ -43,20 +43,22 @@ const resolveFactorValue = (
const loadFactorMap = async (
storageKey: string,
dict: FactorDict
dict: FactorDict,
aliases?: Map<string, string>
): Promise<Map<string, number | null>> => {
const data = await localforage.getItem<XmFactorState>(storageKey)
const map = buildStandardFactorMap(dict)
for (const row of data?.detailRows || []) {
if (!row?.id) continue
const id = String(row.id)
const rowId = String(row.id)
const id = map.has(rowId) ? rowId : aliases?.get(rowId) || rowId
map.set(id, resolveFactorValue(row, map.get(id) ?? null))
}
return map
}
export const loadConsultCategoryFactorMap = async () =>
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, serviceList as FactorDict)
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, getServiceDictById() as FactorDict)
export const loadMajorFactorMap = async () =>
loadFactorMap(MAJOR_FACTOR_KEY, majorList as FactorDict)
loadFactorMap(MAJOR_FACTOR_KEY, getMajorDictById() as FactorDict, getMajorIdAliasMap())

View File

@ -2,15 +2,17 @@
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import ExcelJS from "ExcelJS";
// 统一数字千分位格式化,默认保留 2 位小数。
const numberFormatter = (value: unknown, fractionDigits = 2) =>
formatThousands(value, fractionDigits)
// 将任意输入安全转为有限数字;无效值统一按 0 处理。
const toFiniteNumber = (value: unknown) => {
const num = Number(value)
return Number.isFinite(num) ? num : 0
}
export const majorList = {
0: { code: 'E1', name: '交通运输工程通用专业',hideInIndustrySelector: true , maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false },
0: { code: 'E1', name: '交通运输工程通用专业',hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false },
1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true },
2: { code: 'E1-2', name: '拆迁补偿', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于交通建设项目拆迁补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 3, hasCost: true, hasArea: true },
3: { code: 'E1-3', name: '迁改工程', maxCoe: null, minCoe: null, defCoe: 2, desc: '适用于交通建设项目迁改工程的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 4, hasCost: true, hasArea: false },
@ -46,6 +48,9 @@ export const majorList = {
33: { code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房屋建筑与水运附属工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: false, isWaterway: true, order: 34, hasCost: true, hasArea: false },
};
export const serviceList = {
0: { code: 'D1', name: '全过程造价咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 1, scale: true, onlyCostScale: true, amount: false, workDay: true },
1: { code: 'D2', name: '分阶段造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 2, scale: null, onlyCostScale: null, amount: null, workDay: null },
@ -170,28 +175,206 @@ let areaScaleCal = [
export const GENERIC_MAJOR_CODE = 'E1'
export const isMajorCodeInIndustryScope = (majorCode, industryCode, includeGeneric = true) => {
const code = String(majorCode || '').trim()
const industry = String(industryCode || '').trim()
if (!code || !industry) return false
if (code === industry || code.startsWith(`${industry}-`)) return true
if (!includeGeneric) return false
if (industry === GENERIC_MAJOR_CODE) return code === GENERIC_MAJOR_CODE || code.startsWith(`${GENERIC_MAJOR_CODE}-`)
return code === GENERIC_MAJOR_CODE || code.startsWith(`${GENERIC_MAJOR_CODE}-`)
export const industryTypeList = [
{id:'0', name: '公路工程', type: 'isRoad' },
{id:'1', name: '铁路工程', type: 'isRailway' },
{id:'2', name: '水运工程', type: 'isWaterway' }
] as const
export type IndustryType = (typeof industryTypeList)[number]['type']
type DictItem = Record<string, any>
type DictEntry = { id: string; rawId: string; item: DictItem }
type DictByIdMap = Record<string, DictItem>
type BasicFeeFromScaleResult = {
basic: number
optional: number
basicFormula: string
optionalFormula: string
}
export const isMajorIndustrySelectable = (item) =>
Boolean(item?.code && !String(item.code).includes('-') && !item?.hideInIndustrySelector)
const industryTypeById = new Map(
industryTypeList.flatMap(item => {
return [[String(item.id).trim(), item.type]]
})
)
/**
* id获取对应专业字段isRoad/isRailway/isWaterway
* @returns null
*/
export const getIndustryTypeValue = (industryId: unknown): IndustryType | null =>
industryTypeById.get(String(industryId || '').trim()) || null
/**
*
* @returns
*/
export const isIndustryEnabledByType = (
item: DictItem | undefined,
typeValue: IndustryType | null
): boolean => Boolean(typeValue && item?.[typeValue] === true)
// 判断是否为“通用项”(三种行业都适用)。
const isGenericIndustryItem = (item: Record<string, any> | undefined) =>
Boolean(item?.isRoad && item?.isRailway && item?.isWaterway)
/**
* IDmajorList key
* @returns
*/
export const isMajorIdInIndustryScope = (
majorId: unknown,
industryCode: unknown,
includeGeneric = true
): boolean => {
const industryType = getIndustryTypeValue(industryCode)
if (!majorId || !industryType) return false
const id = String(majorId).trim()
const majorItem = getMajorDictEntries().find(entry => String(entry.id).trim() === id)?.item
if (isIndustryEnabledByType(majorItem, industryType)) return true
if (!includeGeneric) return false
return isGenericIndustryItem(majorItem)
}
const isPlainObject = (value: unknown): value is Record<string, any> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const hasCodeLike = (value: Record<string, any>) =>
typeof value.code === 'string'
const isDictLeafNode = (value: unknown): value is Record<string, any> =>
isPlainObject(value) && hasCodeLike(value) && typeof value.name === 'string'
// 递归提取字典树中的叶子节点(具有 code + name 的业务项)。
const collectDictLeafEntries = (
source: Record<string, any>,
prefix = ''
): Array<{ rawId: string; item: Record<string, any> }> => {
const result: Array<{ rawId: string; item: Record<string, any> }> = []
if (!isPlainObject(source)) return result
for (const [key, value] of Object.entries(source)) {
const rawId = prefix ? `${prefix}.${key}` : String(key)
if (isDictLeafNode(value)) {
result.push({ rawId, item: value })
continue
}
if (isPlainObject(value)) {
result.push(...collectDictLeafEntries(value, rawId))
}
}
return result
}
// 计算排序权重:优先用 order再退化为 rawId 的数值。
const getItemSortValue = (item: Record<string, any>, rawId: string) => {
const order = Number(item?.order)
if (Number.isFinite(order)) return order
const rawIdNum = Number(rawId)
if (Number.isFinite(rawIdNum)) return rawIdNum
return Number.POSITIVE_INFINITY
}
// 按权重与编码排序,保证下拉展示顺序稳定。
const sortDictEntries = (
entries: Array<{ id: string; rawId: string; item: Record<string, any> }>
) =>
entries.sort((a, b) => {
const orderDiff =
getItemSortValue(a.item, a.rawId) - getItemSortValue(b.item, b.rawId)
if (orderDiff !== 0) return orderDiff
const codeA = String(a.item?.code || a.id)
const codeB = String(b.item?.code || b.id)
return codeA.localeCompare(codeB)
})
// 将原始字典对象转换为扁平 entries 列表。
const buildDictEntries = (source: Record<string, any>) =>
sortDictEntries(
collectDictLeafEntries(source).map(({ rawId, item }) => {
return {
id: rawId,
rawId,
item
}
})
)
/**
*
* @returns
*/
export const getMajorDictEntries = (): DictEntry[] => buildDictEntries(majorList as Record<string, any>)
/**
*
* @returns
*/
export const getServiceDictEntries = (): DictEntry[] => buildDictEntries(serviceList as Record<string, any>)
/**
* isRoad/isRailway/isWaterway
* @returns
*/
export const isDictItemInIndustryScope = (item: DictItem | undefined, industryCode: unknown): boolean =>
isIndustryEnabledByType(item, getIndustryTypeValue(industryCode))
/**
* ID ->
* @returns
*/
export const getMajorDictById = (): DictByIdMap =>
Object.fromEntries(getMajorDictEntries().map(entry => [entry.id, entry.item]))
/**
* ID ->
* @returns
*/
export const getServiceDictById = (): DictByIdMap =>
Object.fromEntries(getServiceDictEntries().map(entry => [entry.id, entry.item]))
/**
* (code) -> ID
* @returns ID的别名映射
*/
export const getMajorIdAliasMap = (): Map<string, string> =>
new Map(
getMajorDictEntries().flatMap(entry => {
const code = String(entry.item?.code || '')
return code ? [[code, entry.id]] : []
})
)
/**
* ID code ID code
* @returns undefined
*/
export const getMajorDictItemById = (id: string | number): DictItem | undefined => {
const key = String(id)
const dict = getMajorDictById() as DictByIdMap
if (dict[key]) return dict[key]
const alias = getMajorIdAliasMap().get(key)
return alias ? dict[alias] : undefined
}
/**
* ID
* @returns undefined
*/
export const getServiceDictItemById = (id: string | number): DictItem | undefined => {
const key = String(id)
const dict = getServiceDictById() as DictByIdMap
return dict[key]
}
const infoServiceScaleCal: Array<{ staLine: number; endLine: number | null; staPrice: number; rate: number }> = []
// 判断数值是否命中分段区间:(staLine, endLine]。
const inRange = (sv: number, staLine: number, endLine: number | null) =>
staLine < sv && (endLine == null || sv <= endLine)
// 按分段参数计算基础费用。
const calcScaleFee = (params: {
staPrice: number
sv: number
@ -210,8 +393,10 @@ const calcScaleFee = (params: {
)
}
// 将费率转成千分数文本(例如 0.012 -> 12‰
const scaleRatePermillage = (rate: number) => roundTo(toDecimal(rate).mul(1000), 2)
// 生成“基础费用”计算公式字符串,供前端展示。
const buildScaleFormula = (params: {
staPrice: number
sv: number
@ -232,7 +417,14 @@ const buildScaleFormula = (params: {
export function getBasicFeeFromScale(scaleValue: unknown, scaleType: 'cost' | 'area' | 'amount') {
/**
* /
* @returns null
*/
export function getBasicFeeFromScale(
scaleValue: unknown,
scaleType: 'cost' | 'area'
): BasicFeeFromScaleResult | null {
const sv = Number(scaleValue)
if (!Number.isFinite(sv) || sv <= 0) return null
@ -304,326 +496,15 @@ export function getBasicFeeFromScale(scaleValue: unknown, scaleType: 'cost' | 'a
}
}
const targetRange = infoServiceScaleCal.find(f => inRange(sv, f.staLine, f.endLine))
if (!targetRange) return null
return {
basic: calcScaleFee({
staPrice: targetRange.staPrice,
sv,
staLine: targetRange.staLine,
rate: targetRange.rate
}),
optional: 0,
basicFormula: buildScaleFormula({
staPrice: targetRange.staPrice,
sv,
staLine: targetRange.staLine,
rate: targetRange.rate
}),
optionalFormula: ''
}
return null
}
//demo
let data1 = {
name: 'test001',
writer: '张三',// 编制人
reviewer: '李四',// 复核人
date: '2021-09-24',// 编制日期
industry: 0,// 0为公路工程1为铁路工程2为水运工程
fee: 10000,
scaleCost: 100000,// scale的cost的合计数
scale: [// 规模信息
{
major: 0,
cost: 100000,
area: 200,
},
{
major: 1,
cost: 100000,
area: 200,
},
],
serviceCoes: [// 项目咨询分类系数
{
serviceid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
serviceid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
majorCoes: [// 项目工程专业系数
{
majorid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
majorid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
contracts: [// 合同段信息
{
name: 'A合同段',
serviceFee: 100000,
addtionalFee: 0,
reserveFee: 0,
fee: 10000,
scale: [
{
major: 0,
cost: 100000,
area: 200,
},
{
major: 1,
cost: 100000,
area: 200,
},
],
serviceCoes: [// 合同段咨询分类系数
{
serviceid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
serviceid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
majorCoes: [// 合同段工程专业系数
{
majorid: 0,
coe: 1.1,
remark: '',// 用户输入的说明
},
{
majorid: 1,
coe: 1.2,
remark: '',// 用户输入的说明
},
],
services: [
{
id: 0,
fee: 100000,
process: 0,// 工作环节0为编制1为审核
method1: { // 投资规模法
cost: 100000,
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,
fee: 250000,
det: [
{
major: 0,
cost: 100000,
basicFee: 200,
basicFormula: '856,000+(1,000,000,000-500,000,000)×1‰',
basicFee_basic: 200,
optionalFormula: '171,200+(1,000,000,000-500,000,000)×0.2‰',
basicFee_optional: 0,
serviceCoe: 1.1,
majorCoe: 1.2,
processCoe: 1,// 工作环节系数(编审系数)
proportion: 0.5,// 工作占比
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method2: { // 用地规模法
area: 1200,
basicFee: 200,
basicFee_basic: 200,
basicFee_optional: 0,
fee: 250000,
det: [
{
major: 0,
area: 1200,
basicFee: 200,
basicFormula: '106,000+(1,200-1,000)×60',
basicFee_basic: 200,
optionalFormula: '21,200+(1,200-1,000)×12',
basicFee_optional: 0,
serviceCoe: 1.1,
majorCoe: 1.2,
processCoe: 1,// 工作环节系数(编审系数)
proportion: 0.5,// 工作占比
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method3: { // 工作量法
basicFee: 200,
fee: 250000,
det: [
{
task: 0,
price: 100000,
amount: 10,
basicFee: 200,
serviceCoe: 1.1,
fee: 100000,
remark: '',// 用户输入的说明
},
{
task: 1,
price: 100000,
amount: 10,
basicFee: 200,
serviceCoe: 1.1,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
method4: { // 工时法
person_num: 10,
work_day: 10,
fee: 250000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
},
],
addtional: [// 附加工作费
{
type: 0,// 0为费率计取1为工时法2为数量单价
coe: 0.03,
fee: 10000,
},
{
type: 1,// 0为费率计取1为工时法2为数量单价
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
{
type: 2,// 0为费率计取1为工时法2为数量单价
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
}
],
reserve: [// 预留费
{
type: 0,// 0为费率计取1为工时法2为数量单价
coe: 0.03,
fee: 10000,
},
{
type: 1,// 0为费率计取1为工时法2为数量单价
fee: 10000,
det: [
{
expert: 0,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
{
expert: 1,
price: 100000,
person_num: 10,
work_day: 3,
fee: 100000,
remark: '',// 用户输入的说明
},
],
},
{
type: 2,// 0为费率计取1为工时法2为数量单价
fee: 10000,
det: [
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
{
name: '×××项',
unit: '项',
amount: 10,
price: 100000,
fee: 100000,
remark: '',// 用户输入的说明
},
],
}
],
},
],
};
export async function exportFile(fileName, data) {
/**
* Excel 使 File System Access API
* @returns Promise
*/
export async function exportFile(fileName: string, data: any): Promise<void> {
console.log(data)
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({
@ -669,6 +550,7 @@ export async function exportFile(fileName, data) {
// 按模板生成最终工作簿:填充封面、目录、各分表及汇总数据。
async function generateTemplate(data) {
try {
// 获取模板
@ -871,7 +753,7 @@ async function generateTemplate(data) {
},
});
}
let serviceX = serviceList[sobj.id];
let serviceX = getServiceDictItemById(sobj.id) || { code: '', name: '' };
cusInsertRowFunc(4 + sindex, [sheet_1.getRow(3)], sheet_1, (targetRow) => {
targetRow.getCell(1).value = sindex + 1;
targetRow.getCell(2).value = serviceX.code;
@ -885,7 +767,17 @@ async function generateTemplate(data) {
if (sobj.method1 || sobj.method2) {
let det1 = sobj.method1 ? sobj.method1.det.map(m => m.major) : [];
let det2 = sobj.method2 ? sobj.method2.det.map(m => m.major) : [];
let allDet = [...(new Set([...det1, ...det2]))].sort((a, b) => a - b).map(m => {
const majorOrderMap = new Map(getMajorDictEntries().map((entry, idx) => [entry.id, idx]));
let allDet = [...(new Set([...det1, ...det2]))].sort((a, b) => {
const aId = String(a);
const bId = String(b);
const ao = majorOrderMap.get(aId) ?? majorOrderMap.get(getMajorIdAliasMap().get(aId) || '');
const bo = majorOrderMap.get(bId) ?? majorOrderMap.get(getMajorIdAliasMap().get(bId) || '');
if (ao != null && bo != null) return ao - bo;
if (ao != null) return -1;
if (bo != null) return 1;
return aId.localeCompare(bId);
}).map(m => {
return {
major: m,
mth1: det1.includes(m) ? sobj.method1.det[det1.indexOf(m)] : null,
@ -931,7 +823,7 @@ async function generateTemplate(data) {
}
});
allDet.forEach((m, mindex) => {
let majorX = majorList[m.major];
let majorX = getMajorDictItemById(m.major) || { name: '' };
cusInsertRowFunc(4 + num_2, [sheet_2.getRow(4)], sheet_2, (targetRow) => {
targetRow.getCell(1).value = num_2++;
targetRow.getCell(2).value = serviceX.code + '-' + (mindex + 1);
@ -1042,9 +934,17 @@ async function generateTemplate(data) {
});
});
allServices.sort((a, b) => a.id - b.id);
const serviceOrderMap = new Map(getServiceDictEntries().map((entry, index) => [entry.id, index]));
allServices.sort((a, b) => {
const ao = serviceOrderMap.get(String(a.id));
const bo = serviceOrderMap.get(String(b.id));
if (ao != null && bo != null) return ao - bo;
if (ao != null) return -1;
if (bo != null) return 1;
return String(a.id).localeCompare(String(b.id));
});
allServices.forEach((s, sindex) => {
const serviceX = serviceList[s.id];
const serviceX = getServiceDictItemById(s.id) || { code: '', name: '' };
cusInsertRowFunc(3 + sindex, [yz01_sheet.getRow(2)], yz01_sheet, (targetRow) => {
let siSum = 0;
for (let i = 0; i < yz01Num; i++) {
@ -1148,6 +1048,7 @@ async function generateTemplate(data) {
}
// 在指定位置插入行,并按模板行复制样式,可选回调填充值。
function cusInsertRowFunc(insertRowNum, sourceRows, worksheet, RowFun, cellFun) {
// 插入行
let newRows = [];
@ -1179,6 +1080,7 @@ function cusInsertRowFunc(insertRowNum, sourceRows, worksheet, RowFun, cellFun)
}
}
// 复制整张工作表(含页眉页脚、合并单元格、列宽样式与单元格值)。
function copyWorksheet(workbook, sourceName, targetName) {
const source = workbook.getWorksheet(sourceName);
if (!source) throw new Error("Source sheet not found");
@ -1228,6 +1130,7 @@ function copyWorksheet(workbook, sourceName, targetName) {
return target;
}
// 在指定列位置批量插入新列,并从给定来源列复制样式与内容。
function insertAndCopyColumn(insertAt, cols, ws) {
let insertAti = insertAt;
cols.forEach((col, index) => {
@ -1244,6 +1147,7 @@ function insertAndCopyColumn(insertAt, cols, ws) {
});
}
// 复制单列:列级属性 + 该列所有单元格值和样式。
function copyColumn(toCol, fromCol, ws) {
const srcCol = ws.getColumn(fromCol);
const dstCol = ws.getColumn(toCol);
@ -1261,6 +1165,7 @@ function copyColumn(toCol, fromCol, ws) {
});
}
// 深拷贝单元格值,避免对象类型(公式/富文本)引用同一实例。
function cloneCellValue(value) {
if (value == null) return value;