'20260305修复bug'
This commit is contained in:
parent
53c1b2c0db
commit
75f293f877
19
AGENTS.md
Normal file
19
AGENTS.md
Normal 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.
|
||||||
@ -9,6 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script>
|
<script>
|
||||||
|
//上线前添加访问版本号,强制刷新缓存
|
||||||
;(() => {
|
;(() => {
|
||||||
const makeVisitVersion = () => {
|
const makeVisitVersion = () => {
|
||||||
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
|
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
|
||||||
|
|||||||
@ -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 |
@ -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>
|
|
||||||
@ -13,6 +13,7 @@ interface DictItem {
|
|||||||
defCoe: number | null
|
defCoe: number | null
|
||||||
desc?: string | null
|
desc?: string | null
|
||||||
notshowByzxflxs?: boolean
|
notshowByzxflxs?: boolean
|
||||||
|
order?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FactorRow {
|
interface FactorRow {
|
||||||
@ -61,10 +62,12 @@ const sortedDictEntries = () =>
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aNum = Number(a[0])
|
const aOrder = Number(a[1]?.order)
|
||||||
const bNum = Number(b[0])
|
const bOrder = Number(b[1]?.order)
|
||||||
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum
|
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
|
||||||
return String(a[0]).localeCompare(String(b[0]))
|
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>) => {
|
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||||
|
|||||||
244
src/components/common/xmCommonAgGrid.vue
Normal file
244
src/components/common/xmCommonAgGrid.vue
Normal 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>
|
||||||
@ -9,7 +9,7 @@ import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/
|
|||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
||||||
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
||||||
import { majorList } from '@/sql'
|
import { industryTypeList } from '@/sql'
|
||||||
import {
|
import {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@ -90,7 +90,7 @@ const contractListViewportRef = ref<HTMLElement | null>(null)
|
|||||||
const showScrollTopFab = ref(false)
|
const showScrollTopFab = ref(false)
|
||||||
const isDraggingContracts = ref(false)
|
const isDraggingContracts = ref(false)
|
||||||
const cardMotionState = ref<'enter' | 'ready'>('ready')
|
const cardMotionState = ref<'enter' | 'ready'>('ready')
|
||||||
const canManageContracts = ref(false)
|
const canManageContracts = ref(true)
|
||||||
let contractAutoScrollRaf = 0
|
let contractAutoScrollRaf = 0
|
||||||
let dragPointerClientY: number | null = null
|
let dragPointerClientY: number | null = null
|
||||||
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
|
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@ -196,10 +196,8 @@ const formatExportTimestamp = (date: Date): string => {
|
|||||||
|
|
||||||
const industryNameByCode = (() => {
|
const industryNameByCode = (() => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
for (const item of Object.values(majorList as Record<string, { code?: string; name?: string }>)) {
|
for (const item of industryTypeList) {
|
||||||
if (!item?.code || !item?.name) continue
|
map.set(item.id, item.name)
|
||||||
if (item.code.includes('-')) continue
|
|
||||||
map.set(item.code, item.name)
|
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
})()
|
})()
|
||||||
|
|||||||
@ -36,7 +36,7 @@ const clearAll = () => {
|
|||||||
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label>
|
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label>
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="clearAll"
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import TypeLine from '@/layout/typeLine.vue'
|
||||||
|
|
||||||
const infoView = markRaw(defineAsyncComponent(() => import('@/components/views/info.vue')))
|
const infoView = markRaw(defineAsyncComponent(() => import('@/components/views/info.vue')))
|
||||||
@ -22,12 +23,48 @@ const majorFactorView = markRaw(
|
|||||||
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
|
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: 'info', label: '基础信息', component: infoView },
|
||||||
{ key: 'contract', label: '合同段管理', component: htView },
|
{ key: 'contract', label: '合同段管理', component: htView },
|
||||||
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
|
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
|
||||||
|
|
||||||
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
|
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
|
||||||
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
|
{ 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>
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||||
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||||
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||||
|
|
||||||
@ -37,7 +37,9 @@ const loadProjectIndustry = async () => {
|
|||||||
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
|
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
|
||||||
const industry = projectIndustry.value
|
const industry = projectIndustry.value
|
||||||
if (!industry) return {}
|
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)
|
return Object.fromEntries(entries)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -51,15 +53,13 @@ onActivated(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MethodUnavailableNotice
|
|
||||||
v-if="!hasProjectBaseInfo"
|
|
||||||
title="请先在“基础信息”里新建项目"
|
|
||||||
message="完成新建后将自动加载工程专业系数。"
|
|
||||||
/>
|
|
||||||
<XmFactorGrid
|
<XmFactorGrid
|
||||||
v-else
|
|
||||||
title="工程专业系数明细"
|
title="工程专业系数明细"
|
||||||
storage-key="xm-major-factor-v1"
|
storage-key="xm-major-factor-v1"
|
||||||
:dict="filteredMajorDict"
|
:dict="filteredMajorDict"
|
||||||
|
:disable-budget-edit-when-standard-null="true"
|
||||||
|
:exclude-notshow-by-zxflxs="true"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
|
||||||
import CommonAgGrid from '@/components/common/CommonAgGrid.vue'
|
|
||||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
|
||||||
|
|
||||||
interface DictLeaf {
|
interface DictLeaf {
|
||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
interface DictGroup {
|
||||||
@ -27,6 +25,8 @@ interface DetailRow {
|
|||||||
groupName: string
|
groupName: string
|
||||||
majorCode: string
|
majorCode: string
|
||||||
majorName: string
|
majorName: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
amount: number | null
|
amount: number | null
|
||||||
landArea: number | null
|
landArea: number | null
|
||||||
path: string[]
|
path: string[]
|
||||||
@ -46,17 +46,13 @@ const props = defineProps<{
|
|||||||
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||||
const XM_DB_KEY = 'xm-info-v3'
|
const XM_DB_KEY = 'xm-info-v3'
|
||||||
const BASE_INFO_KEY = 'xm-base-info-v1'
|
const BASE_INFO_KEY = 'xm-base-info-v1'
|
||||||
const activeIndustryCode = ref('')
|
const activeIndustryId = ref('')
|
||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
|
||||||
type majorLite = { code: string; name: string }
|
type majorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||||
.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>()
|
const groupMap = new Map<string, DictGroup>()
|
||||||
@ -92,26 +88,20 @@ const detailDict: DictGroup[] = (() => {
|
|||||||
groupMap.get(parentCode)!.children.push({
|
groupMap.get(parentCode)!.children.push({
|
||||||
id: key,
|
id: key,
|
||||||
code,
|
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))
|
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 buildDefaultRows = (): DetailRow[] => {
|
||||||
if (!activeIndustryCode.value) return []
|
if (!activeIndustryId.value) return []
|
||||||
const rows: DetailRow[] = []
|
const rows: DetailRow[] = []
|
||||||
for (const group of detailDict) {
|
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) {
|
for (const child of group.children) {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: child.id,
|
id: child.id,
|
||||||
@ -119,9 +109,11 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
majorCode: child.code,
|
majorCode: child.code,
|
||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
|
hasCost: child.hasCost,
|
||||||
|
hasArea: child.hasArea,
|
||||||
amount: null,
|
amount: null,
|
||||||
landArea: 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 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.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 => {
|
return buildDefaultRows().map(row => {
|
||||||
@ -140,130 +137,16 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : 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 () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||||
activeIndustryCode.value =
|
activeIndustryId.value =
|
||||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||||
|
|
||||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
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 () => {
|
onMounted(async () => {
|
||||||
await loadFromIndexedDB()
|
await loadFromIndexedDB()
|
||||||
})
|
})
|
||||||
@ -308,53 +177,8 @@ onMounted(async () => {
|
|||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
void loadFromIndexedDB()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 min-w-0 flex flex-col">
|
<CommonAgGrid title="合同规模明细" :rowData="detailRows" :dbKey="DB_KEY" />
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { isMajorIndustrySelectable, majorList } from '@/sql'
|
import { industryTypeList } from '@/sql'
|
||||||
import { CircleHelp } from 'lucide-vue-next'
|
import { CircleHelp } from 'lucide-vue-next'
|
||||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
@ -14,12 +14,12 @@ interface XmInfoState {
|
|||||||
preparedDate?: string
|
preparedDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MajorLite = { code: string; name: string; hideInIndustrySelector?: boolean }
|
type MajorParentNode = { id: string; name: string }
|
||||||
type MajorParentNode = { id: string; code: string; name: string }
|
|
||||||
|
|
||||||
const DB_KEY = 'xm-base-info-v1'
|
const DB_KEY = 'xm-base-info-v1'
|
||||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||||
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
|
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
|
||||||
|
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||||
|
|
||||||
const isProjectInitialized = ref(false)
|
const isProjectInitialized = ref(false)
|
||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
@ -32,25 +32,12 @@ const reviewedBy = ref('')
|
|||||||
const preparedCompany = ref('')
|
const preparedCompany = ref('')
|
||||||
const preparedDate = ref('')
|
const preparedDate = ref('')
|
||||||
|
|
||||||
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
|
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
id: item.id,
|
||||||
.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,
|
|
||||||
name: item.name
|
name: item.name
|
||||||
}))
|
}))
|
||||||
|
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id))
|
||||||
const majorParentNodes = getMajorParentNodes(majorEntries)
|
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || ''
|
||||||
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.code))
|
|
||||||
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.code || ''
|
|
||||||
|
|
||||||
const saveToIndexedDB = async () => {
|
const saveToIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
@ -142,6 +129,7 @@ const createProject = async () => {
|
|||||||
isProjectInitialized.value = true
|
isProjectInitialized.value = true
|
||||||
showCreateDialog.value = false
|
showCreateDialog.value = false
|
||||||
await saveToIndexedDB()
|
await saveToIndexedDB()
|
||||||
|
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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">
|
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
|
||||||
<label
|
<label
|
||||||
v-for="item in majorParentNodes"
|
v-for="item in majorParentNodes"
|
||||||
:key="item.code"
|
:key="item.id"
|
||||||
class="inline-flex items-center gap-2 text-sm text-foreground/80"
|
class="inline-flex items-center gap-2 text-sm text-foreground/80"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="projectIndustry"
|
v-model="projectIndustry"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="item.code"
|
:value="item.id"
|
||||||
disabled
|
disabled
|
||||||
class="h-4 w-4 cursor-not-allowed accent-primary"
|
class="h-4 w-4 cursor-not-allowed accent-primary"
|
||||||
/>
|
/>
|
||||||
<span>{{ item.code }} {{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mt-4 max-h-72 space-y-2 overflow-auto rounded-lg border p-3">
|
||||||
<label
|
<label
|
||||||
v-for="item in majorParentNodes"
|
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"
|
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" />
|
<input v-model="pendingIndustry" type="radio" :value="item.id" class="h-4 w-4 accent-primary" />
|
||||||
<span class="text-sm text-foreground">{{ item.code }} {{ item.name }}</span>
|
<span class="text-sm text-foreground"> {{ item.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end gap-2">
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
@ -32,6 +32,8 @@ interface DictLeaf {
|
|||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
interface DictGroup {
|
||||||
@ -47,6 +49,8 @@ interface DetailRow {
|
|||||||
groupName: string
|
groupName: string
|
||||||
majorCode: string
|
majorCode: string
|
||||||
majorName: string
|
majorName: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
amount: number | null
|
amount: number | null
|
||||||
benchmarkBudget: number | null
|
benchmarkBudget: number | null
|
||||||
benchmarkBudgetBasic: number | null
|
benchmarkBudgetBasic: number | null
|
||||||
@ -139,13 +143,9 @@ const shouldForceDefaultLoad = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
type majorLite = { code: string; name: string; defCoe: number | null }
|
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
|
||||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||||
.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>()
|
const groupMap = new Map<string, DictGroup>()
|
||||||
@ -181,7 +181,9 @@ const detailDict: DictGroup[] = (() => {
|
|||||||
groupMap.get(parentCode)!.children.push({
|
groupMap.get(parentCode)!.children.push({
|
||||||
id: key,
|
id: key,
|
||||||
code,
|
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 []
|
if (!activeIndustryCode.value) return []
|
||||||
const rows: DetailRow[] = []
|
const rows: DetailRow[] = []
|
||||||
for (const group of detailDict) {
|
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) {
|
for (const child of group.children) {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: child.id,
|
id: child.id,
|
||||||
@ -208,6 +210,8 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
majorCode: child.code,
|
majorCode: child.code,
|
||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
|
hasCost: child.hasCost,
|
||||||
|
hasArea: child.hasArea,
|
||||||
amount: null,
|
amount: null,
|
||||||
benchmarkBudget: null,
|
benchmarkBudget: null,
|
||||||
benchmarkBudgetBasic: null,
|
benchmarkBudgetBasic: null,
|
||||||
@ -253,7 +257,12 @@ const mergeWithDictRows = (
|
|||||||
const includeFactorValues = options?.includeFactorValues ?? true
|
const includeFactorValues = options?.includeFactorValues ?? true
|
||||||
const dbValueMap = new Map<string, SourceRow>()
|
const dbValueMap = new Map<string, SourceRow>()
|
||||||
for (const row of rowsFromDb || []) {
|
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 => {
|
return buildDefaultRows().map(row => {
|
||||||
@ -264,7 +273,7 @@ const mergeWithDictRows = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...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,
|
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
||||||
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
|
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
|
||||||
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
|
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
|
||||||
@ -311,6 +320,9 @@ const formatMajorFactor = (params: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatEditableMoney = (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 === '')) {
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
return '点击输入'
|
return '点击输入'
|
||||||
}
|
}
|
||||||
@ -358,11 +370,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
|
|||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost),
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
cellClass: params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && params.data?.hasCost
|
||||||
|
? 'ag-right-aligned-cell editable-cell-line'
|
||||||
|
: 'ag-right-aligned-cell',
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params =>
|
'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,
|
aggFunc: decimalAggSum,
|
||||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||||
@ -549,6 +564,8 @@ const pinnedTopRowData = computed(() => [
|
|||||||
groupName: '',
|
groupName: '',
|
||||||
majorCode: '',
|
majorCode: '',
|
||||||
majorName: '',
|
majorName: '',
|
||||||
|
hasCost: false,
|
||||||
|
hasArea: false,
|
||||||
amount: totalAmount.value,
|
amount: totalAmount.value,
|
||||||
benchmarkBudget: totalBenchmarkBudget.value,
|
benchmarkBudget: totalBenchmarkBudget.value,
|
||||||
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
|
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
@ -32,6 +32,8 @@ interface DictLeaf {
|
|||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
interface DictGroup {
|
||||||
@ -47,6 +49,8 @@ interface DetailRow {
|
|||||||
groupName: string
|
groupName: string
|
||||||
majorCode: string
|
majorCode: string
|
||||||
majorName: string
|
majorName: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
amount: number | null
|
amount: number | null
|
||||||
landArea: number | null
|
landArea: number | null
|
||||||
benchmarkBudget: number | null
|
benchmarkBudget: number | null
|
||||||
@ -140,13 +144,9 @@ const shouldSkipPersist = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type majorLite = { code: string; name: string; defCoe: number | null }
|
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
|
||||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||||
.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>()
|
const groupMap = new Map<string, DictGroup>()
|
||||||
@ -182,7 +182,9 @@ const detailDict: DictGroup[] = (() => {
|
|||||||
groupMap.get(parentCode)!.children.push({
|
groupMap.get(parentCode)!.children.push({
|
||||||
id: key,
|
id: key,
|
||||||
code,
|
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 []
|
if (!activeIndustryCode.value) return []
|
||||||
const rows: DetailRow[] = []
|
const rows: DetailRow[] = []
|
||||||
for (const group of detailDict) {
|
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) {
|
for (const child of group.children) {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: child.id,
|
id: child.id,
|
||||||
@ -209,6 +211,8 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
majorCode: child.code,
|
majorCode: child.code,
|
||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
|
hasCost: child.hasCost,
|
||||||
|
hasArea: child.hasArea,
|
||||||
amount: null,
|
amount: null,
|
||||||
landArea: null,
|
landArea: null,
|
||||||
benchmarkBudget: null,
|
benchmarkBudget: null,
|
||||||
@ -256,7 +260,12 @@ const mergeWithDictRows = (
|
|||||||
const includeFactorValues = options?.includeFactorValues ?? true
|
const includeFactorValues = options?.includeFactorValues ?? true
|
||||||
const dbValueMap = new Map<string, SourceRow>()
|
const dbValueMap = new Map<string, SourceRow>()
|
||||||
for (const row of rowsFromDb || []) {
|
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 => {
|
return buildDefaultRows().map(row => {
|
||||||
@ -267,8 +276,8 @@ const mergeWithDictRows = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
amount: includeScaleValues && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
amount: includeScaleValues && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||||
landArea: includeScaleValues && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
|
landArea: includeScaleValues && row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
|
||||||
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
||||||
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
|
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
|
||||||
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : 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) => {
|
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 === '')) {
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
return '点击输入'
|
return '点击输入'
|
||||||
}
|
}
|
||||||
@ -362,14 +374,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
|
|||||||
minWidth: 170,
|
minWidth: 170,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
|
||||||
cellClass: params =>
|
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 editable-cell-line'
|
||||||
: 'ag-right-aligned-cell',
|
: 'ag-right-aligned-cell',
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params =>
|
'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 => {
|
valueParser: params => {
|
||||||
const value = parseNumberOrNull(params.newValue)
|
const value = parseNumberOrNull(params.newValue)
|
||||||
@ -556,6 +568,8 @@ const pinnedTopRowData = computed(() => [
|
|||||||
groupName: '',
|
groupName: '',
|
||||||
majorCode: '',
|
majorCode: '',
|
||||||
majorName: '',
|
majorName: '',
|
||||||
|
hasCost: false,
|
||||||
|
hasArea: false,
|
||||||
amount: totalAmount.value,
|
amount: totalAmount.value,
|
||||||
landArea: null,
|
landArea: null,
|
||||||
benchmarkBudget: totalBenchmarkBudget.value,
|
benchmarkBudget: totalBenchmarkBudget.value,
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { onActivated, onMounted, ref } from 'vue'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
|
||||||
import CommonAgGrid from '@/components/common/CommonAgGrid.vue'
|
|
||||||
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
|
||||||
|
|
||||||
interface DictLeaf {
|
interface DictLeaf {
|
||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DictGroup {
|
interface DictGroup {
|
||||||
@ -27,6 +25,8 @@ interface DetailRow {
|
|||||||
groupName: string
|
groupName: string
|
||||||
majorCode: string
|
majorCode: string
|
||||||
majorName: string
|
majorName: string
|
||||||
|
hasCost: boolean
|
||||||
|
hasArea: boolean
|
||||||
amount: number | null
|
amount: number | null
|
||||||
landArea: number | null
|
landArea: number | null
|
||||||
path: string[]
|
path: string[]
|
||||||
@ -41,87 +41,14 @@ interface XmBaseInfoState {
|
|||||||
|
|
||||||
const DB_KEY = 'xm-info-v3'
|
const DB_KEY = 'xm-info-v3'
|
||||||
const BASE_INFO_KEY = 'xm-base-info-v1'
|
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 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 activeIndustryCode = ref('')
|
||||||
const hasProjectBaseInfo = ref(false)
|
|
||||||
const detailDict = ref<DictGroup[]>([])
|
const detailDict = ref<DictGroup[]>([])
|
||||||
const idLabelMap = ref(new Map<string, string>())
|
|
||||||
|
|
||||||
const updateGridCardHeight = () => {
|
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
|
||||||
if (!snapScrollHost || !rootRef.value) return
|
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||||
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 buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
||||||
const groupMap = new Map<string, DictGroup>()
|
const groupMap = new Map<string, DictGroup>()
|
||||||
@ -156,7 +83,9 @@ const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
|||||||
groupMap.get(parentCode)!.children.push({
|
groupMap.get(parentCode)!.children.push({
|
||||||
id: key,
|
id: key,
|
||||||
code: item.code,
|
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) => {
|
const rebuildDictByIndustry = (industryCode: string) => {
|
||||||
if (!industryCode) {
|
if (!industryCode) {
|
||||||
detailDict.value = []
|
detailDict.value = []
|
||||||
idLabelMap.value = new Map()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const filteredEntries = majorEntries.filter(([, item]) => isMajorCodeInIndustryScope(item.code, industryCode))
|
const filteredEntries = majorEntries.filter(([id]) => isMajorIdInIndustryScope(id, industryCode))
|
||||||
const nextDict = buildDetailDict(filteredEntries)
|
detailDict.value = 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 buildDefaultRows = (): DetailRow[] => {
|
const buildDefaultRows = (): DetailRow[] => {
|
||||||
@ -192,9 +111,11 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
majorCode: child.code,
|
majorCode: child.code,
|
||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
|
hasCost: child.hasCost,
|
||||||
|
hasArea: child.hasArea,
|
||||||
amount: null,
|
amount: null,
|
||||||
landArea: 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 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.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 => {
|
return buildDefaultRows().map(row => {
|
||||||
@ -213,121 +139,15 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : 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 () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||||
hasProjectBaseInfo.value = Boolean(baseInfo)
|
|
||||||
activeIndustryCode.value =
|
activeIndustryCode.value =
|
||||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||||
rebuildDictByIndustry(activeIndustryCode.value)
|
rebuildDictByIndustry(activeIndustryCode.value)
|
||||||
@ -345,90 +165,19 @@ const loadFromIndexedDB = async () => {
|
|||||||
detailRows.value = buildDefaultRows()
|
detailRows.value = buildDefaultRows()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
hasProjectBaseInfo.value = false
|
|
||||||
detailRows.value = []
|
detailRows.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCellValueChanged = () => {
|
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
|
||||||
gridPersistTimer = setTimeout(() => {
|
|
||||||
void saveToIndexedDB()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadFromIndexedDB()
|
await loadFromIndexedDB()
|
||||||
bindSnapScrollHost()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
updateGridCardHeight()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
void loadFromIndexedDB()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="rootRef" class="h-full">
|
<CommonAgGrid title="项目明细" :rowData="detailRows" :dbKey="DB_KEY" />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { serviceList } from '@/sql'
|
import { getServiceDictEntries } from '@/sql'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||||
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
|
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
|
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
||||||
|
|
||||||
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
|
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
|
||||||
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
|
const serviceDict: ServiceItem[] = getServiceDictEntries()
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
.map(({ id, item }) => ({ id, item: item as ServiceListItem }))
|
||||||
.filter((entry): entry is [string, ServiceListItem] => {
|
.filter(({ item }) => {
|
||||||
const item = entry[1]
|
|
||||||
const itemCode = item?.code || item?.ref
|
const itemCode = item?.code || item?.ref
|
||||||
return Boolean(itemCode && item?.name) && item.defCoe !== null
|
return Boolean(itemCode && item?.name) && item.defCoe !== null
|
||||||
})
|
})
|
||||||
.map(([key, item]) => ({
|
.map(({ id, item }) => ({
|
||||||
id: key,
|
id,
|
||||||
code: item.code || item.ref || '',
|
code: item.code || item.ref || '',
|
||||||
name: item.name
|
name: item.name
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -1260,6 +1260,7 @@ const handleReset = async () => {
|
|||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
await localforage.clear()
|
await localforage.clear()
|
||||||
|
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('reset failed:', error)
|
console.error('reset failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -1427,7 +1428,7 @@ watch(
|
|||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
v-if="dataMenuOpen"
|
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
|
<button
|
||||||
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import localforage from 'localforage'
|
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 { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||||
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||||
@ -73,20 +79,23 @@ const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
|
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)
|
return toFiniteNumberOrNull(service?.defCoe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite]))
|
||||||
|
const majorIdAliasMap = getMajorIdAliasMap()
|
||||||
|
|
||||||
const getDefaultMajorFactorById = (id: string) => {
|
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)
|
return toFiniteNumberOrNull(major?.defCoe)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMajorLeafIds = () =>
|
const getMajorLeafIds = () =>
|
||||||
Object.entries(majorList as Record<string, MajorLite>)
|
getMajorDictEntries()
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
|
||||||
.filter(([, item]) => Boolean(item?.code && item.code.includes('-')))
|
.map(({ id }) => id)
|
||||||
.map(([id]) => id)
|
|
||||||
|
|
||||||
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => {
|
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => {
|
||||||
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||||
@ -104,6 +113,13 @@ const mergeScaleRows = (
|
|||||||
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
|
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
|
||||||
): ScaleRow[] => {
|
): ScaleRow[] => {
|
||||||
const dbValueMap = toRowMap(rowsFromDb)
|
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)
|
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||||
return buildDefaultScaleRows(serviceId).map(row => {
|
return buildDefaultScaleRows(serviceId).map(row => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { majorList, serviceList } from '@/sql'
|
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||||
|
|
||||||
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
||||||
@ -43,20 +43,22 @@ const resolveFactorValue = (
|
|||||||
|
|
||||||
const loadFactorMap = async (
|
const loadFactorMap = async (
|
||||||
storageKey: string,
|
storageKey: string,
|
||||||
dict: FactorDict
|
dict: FactorDict,
|
||||||
|
aliases?: Map<string, string>
|
||||||
): Promise<Map<string, number | null>> => {
|
): Promise<Map<string, number | null>> => {
|
||||||
const data = await localforage.getItem<XmFactorState>(storageKey)
|
const data = await localforage.getItem<XmFactorState>(storageKey)
|
||||||
const map = buildStandardFactorMap(dict)
|
const map = buildStandardFactorMap(dict)
|
||||||
for (const row of data?.detailRows || []) {
|
for (const row of data?.detailRows || []) {
|
||||||
if (!row?.id) continue
|
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))
|
map.set(id, resolveFactorValue(row, map.get(id) ?? null))
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadConsultCategoryFactorMap = async () =>
|
export const loadConsultCategoryFactorMap = async () =>
|
||||||
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, serviceList as FactorDict)
|
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, getServiceDictById() as FactorDict)
|
||||||
|
|
||||||
export const loadMajorFactorMap = async () =>
|
export const loadMajorFactorMap = async () =>
|
||||||
loadFactorMap(MAJOR_FACTOR_KEY, majorList as FactorDict)
|
loadFactorMap(MAJOR_FACTOR_KEY, getMajorDictById() as FactorDict, getMajorIdAliasMap())
|
||||||
|
|||||||
579
src/sql.ts
579
src/sql.ts
@ -2,9 +2,11 @@
|
|||||||
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
import ExcelJS from "ExcelJS";
|
import ExcelJS from "ExcelJS";
|
||||||
|
// 统一数字千分位格式化,默认保留 2 位小数。
|
||||||
const numberFormatter = (value: unknown, fractionDigits = 2) =>
|
const numberFormatter = (value: unknown, fractionDigits = 2) =>
|
||||||
formatThousands(value, fractionDigits)
|
formatThousands(value, fractionDigits)
|
||||||
|
|
||||||
|
// 将任意输入安全转为有限数字;无效值统一按 0 处理。
|
||||||
const toFiniteNumber = (value: unknown) => {
|
const toFiniteNumber = (value: unknown) => {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
return Number.isFinite(num) ? num : 0
|
return Number.isFinite(num) ? num : 0
|
||||||
@ -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 },
|
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 = {
|
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 },
|
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 },
|
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) => {
|
export const industryTypeList = [
|
||||||
const code = String(majorCode || '').trim()
|
{id:'0', name: '公路工程', type: 'isRoad' },
|
||||||
const industry = String(industryCode || '').trim()
|
{id:'1', name: '铁路工程', type: 'isRailway' },
|
||||||
if (!code || !industry) return false
|
{id:'2', name: '水运工程', type: 'isWaterway' }
|
||||||
if (code === industry || code.startsWith(`${industry}-`)) return true
|
] as const
|
||||||
if (!includeGeneric) return false
|
|
||||||
if (industry === GENERIC_MAJOR_CODE) return code === GENERIC_MAJOR_CODE || code.startsWith(`${GENERIC_MAJOR_CODE}-`)
|
export type IndustryType = (typeof industryTypeList)[number]['type']
|
||||||
return code === GENERIC_MAJOR_CODE || code.startsWith(`${GENERIC_MAJOR_CODE}-`)
|
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) =>
|
const industryTypeById = new Map(
|
||||||
Boolean(item?.code && !String(item.code).includes('-') && !item?.hideInIndustrySelector)
|
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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断专业ID(majorList 的 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]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 判断数值是否命中分段区间:(staLine, endLine]。
|
||||||
const infoServiceScaleCal: Array<{ staLine: number; endLine: number | null; staPrice: number; rate: number }> = []
|
|
||||||
|
|
||||||
const inRange = (sv: number, staLine: number, endLine: number | null) =>
|
const inRange = (sv: number, staLine: number, endLine: number | null) =>
|
||||||
staLine < sv && (endLine == null || sv <= endLine)
|
staLine < sv && (endLine == null || sv <= endLine)
|
||||||
|
|
||||||
|
// 按分段参数计算基础费用。
|
||||||
const calcScaleFee = (params: {
|
const calcScaleFee = (params: {
|
||||||
staPrice: number
|
staPrice: number
|
||||||
sv: number
|
sv: number
|
||||||
@ -210,8 +393,10 @@ const calcScaleFee = (params: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将费率转成千分数文本(例如 0.012 -> 12‰)。
|
||||||
const scaleRatePermillage = (rate: number) => roundTo(toDecimal(rate).mul(1000), 2)
|
const scaleRatePermillage = (rate: number) => roundTo(toDecimal(rate).mul(1000), 2)
|
||||||
|
|
||||||
|
// 生成“基础费用”计算公式字符串,供前端展示。
|
||||||
const buildScaleFormula = (params: {
|
const buildScaleFormula = (params: {
|
||||||
staPrice: number
|
staPrice: number
|
||||||
sv: 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)
|
const sv = Number(scaleValue)
|
||||||
if (!Number.isFinite(sv) || sv <= 0) return null
|
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))
|
return null
|
||||||
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: ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//demo
|
/**
|
||||||
let data1 = {
|
* 导出入口:生成 Excel 并触发保存(优先使用 File System Access API)。
|
||||||
name: 'test001',
|
* @returns 导出流程完成后的 Promise
|
||||||
writer: '张三',// 编制人
|
*/
|
||||||
reviewer: '李四',// 复核人
|
export async function exportFile(fileName: string, data: any): Promise<void> {
|
||||||
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) {
|
|
||||||
console.log(data)
|
console.log(data)
|
||||||
if (window.showSaveFilePicker) {
|
if (window.showSaveFilePicker) {
|
||||||
const handle = await window.showSaveFilePicker({
|
const handle = await window.showSaveFilePicker({
|
||||||
@ -669,6 +550,7 @@ export async function exportFile(fileName, data) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 按模板生成最终工作簿:填充封面、目录、各分表及汇总数据。
|
||||||
async function generateTemplate(data) {
|
async function generateTemplate(data) {
|
||||||
try {
|
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) => {
|
cusInsertRowFunc(4 + sindex, [sheet_1.getRow(3)], sheet_1, (targetRow) => {
|
||||||
targetRow.getCell(1).value = sindex + 1;
|
targetRow.getCell(1).value = sindex + 1;
|
||||||
targetRow.getCell(2).value = serviceX.code;
|
targetRow.getCell(2).value = serviceX.code;
|
||||||
@ -885,7 +767,17 @@ async function generateTemplate(data) {
|
|||||||
if (sobj.method1 || sobj.method2) {
|
if (sobj.method1 || sobj.method2) {
|
||||||
let det1 = sobj.method1 ? sobj.method1.det.map(m => m.major) : [];
|
let det1 = sobj.method1 ? sobj.method1.det.map(m => m.major) : [];
|
||||||
let det2 = sobj.method2 ? sobj.method2.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 {
|
return {
|
||||||
major: m,
|
major: m,
|
||||||
mth1: det1.includes(m) ? sobj.method1.det[det1.indexOf(m)] : null,
|
mth1: det1.includes(m) ? sobj.method1.det[det1.indexOf(m)] : null,
|
||||||
@ -931,7 +823,7 @@ async function generateTemplate(data) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
allDet.forEach((m, mindex) => {
|
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) => {
|
cusInsertRowFunc(4 + num_2, [sheet_2.getRow(4)], sheet_2, (targetRow) => {
|
||||||
targetRow.getCell(1).value = num_2++;
|
targetRow.getCell(1).value = num_2++;
|
||||||
targetRow.getCell(2).value = serviceX.code + '-' + (mindex + 1);
|
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) => {
|
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) => {
|
cusInsertRowFunc(3 + sindex, [yz01_sheet.getRow(2)], yz01_sheet, (targetRow) => {
|
||||||
let siSum = 0;
|
let siSum = 0;
|
||||||
for (let i = 0; i < yz01Num; i++) {
|
for (let i = 0; i < yz01Num; i++) {
|
||||||
@ -1148,6 +1048,7 @@ async function generateTemplate(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 在指定位置插入行,并按模板行复制样式,可选回调填充值。
|
||||||
function cusInsertRowFunc(insertRowNum, sourceRows, worksheet, RowFun, cellFun) {
|
function cusInsertRowFunc(insertRowNum, sourceRows, worksheet, RowFun, cellFun) {
|
||||||
// 插入行
|
// 插入行
|
||||||
let newRows = [];
|
let newRows = [];
|
||||||
@ -1179,6 +1080,7 @@ function cusInsertRowFunc(insertRowNum, sourceRows, worksheet, RowFun, cellFun)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 复制整张工作表(含页眉页脚、合并单元格、列宽样式与单元格值)。
|
||||||
function copyWorksheet(workbook, sourceName, targetName) {
|
function copyWorksheet(workbook, sourceName, targetName) {
|
||||||
const source = workbook.getWorksheet(sourceName);
|
const source = workbook.getWorksheet(sourceName);
|
||||||
if (!source) throw new Error("Source sheet not found");
|
if (!source) throw new Error("Source sheet not found");
|
||||||
@ -1228,6 +1130,7 @@ function copyWorksheet(workbook, sourceName, targetName) {
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在指定列位置批量插入新列,并从给定来源列复制样式与内容。
|
||||||
function insertAndCopyColumn(insertAt, cols, ws) {
|
function insertAndCopyColumn(insertAt, cols, ws) {
|
||||||
let insertAti = insertAt;
|
let insertAti = insertAt;
|
||||||
cols.forEach((col, index) => {
|
cols.forEach((col, index) => {
|
||||||
@ -1244,6 +1147,7 @@ function insertAndCopyColumn(insertAt, cols, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 复制单列:列级属性 + 该列所有单元格值和样式。
|
||||||
function copyColumn(toCol, fromCol, ws) {
|
function copyColumn(toCol, fromCol, ws) {
|
||||||
const srcCol = ws.getColumn(fromCol);
|
const srcCol = ws.getColumn(fromCol);
|
||||||
const dstCol = ws.getColumn(toCol);
|
const dstCol = ws.getColumn(toCol);
|
||||||
@ -1261,6 +1165,7 @@ function copyColumn(toCol, fromCol, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 深拷贝单元格值,避免对象类型(公式/富文本)引用同一实例。
|
||||||
function cloneCellValue(value) {
|
function cloneCellValue(value) {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user