'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>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
//上线前添加访问版本号,强制刷新缓存
|
||||
;(() => {
|
||||
const makeVisitVersion = () => {
|
||||
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
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
order?: number | null
|
||||
}
|
||||
|
||||
interface FactorRow {
|
||||
@ -61,10 +62,12 @@ const sortedDictEntries = () =>
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aNum = Number(a[0])
|
||||
const bNum = Number(b[0])
|
||||
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum
|
||||
return String(a[0]).localeCompare(String(b[0]))
|
||||
const aOrder = Number(a[1]?.order)
|
||||
const bOrder = Number(b[1]?.order)
|
||||
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
|
||||
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
|
||||
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
|
||||
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
|
||||
})
|
||||
|
||||
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||
|
||||
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 { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
||||
import { majorList } from '@/sql'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
@ -90,7 +90,7 @@ const contractListViewportRef = ref<HTMLElement | null>(null)
|
||||
const showScrollTopFab = ref(false)
|
||||
const isDraggingContracts = ref(false)
|
||||
const cardMotionState = ref<'enter' | 'ready'>('ready')
|
||||
const canManageContracts = ref(false)
|
||||
const canManageContracts = ref(true)
|
||||
let contractAutoScrollRaf = 0
|
||||
let dragPointerClientY: number | null = null
|
||||
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@ -196,10 +196,8 @@ const formatExportTimestamp = (date: Date): string => {
|
||||
|
||||
const industryNameByCode = (() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const item of Object.values(majorList as Record<string, { code?: string; name?: string }>)) {
|
||||
if (!item?.code || !item?.name) continue
|
||||
if (item.code.includes('-')) continue
|
||||
map.set(item.code, item.name)
|
||||
for (const item of industryTypeList) {
|
||||
map.set(item.id, item.name)
|
||||
}
|
||||
return map
|
||||
})()
|
||||
|
||||
@ -36,7 +36,7 @@ const clearAll = () => {
|
||||
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label>
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 rounded-md border px-2 text-[11px] text-muted-foreground transition hover:bg-accent"
|
||||
class="h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
|
||||
@click="clearAll"
|
||||
>
|
||||
清空
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { markRaw, defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
|
||||
const infoView = markRaw(defineAsyncComponent(() => import('@/components/views/info.vue')))
|
||||
@ -22,12 +23,48 @@ const majorFactorView = markRaw(
|
||||
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
|
||||
)
|
||||
|
||||
const xmCategories = [
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||
|
||||
const hasProjectBaseInfo = ref(false)
|
||||
|
||||
const fullXmCategories = [
|
||||
{ key: 'info', label: '基础信息', component: infoView },
|
||||
{ key: 'contract', label: '合同段管理', component: htView },
|
||||
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
|
||||
|
||||
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
|
||||
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
|
||||
]
|
||||
|
||||
const xmCategories = computed(() =>
|
||||
hasProjectBaseInfo.value ? fullXmCategories : [fullXmCategories[0]]
|
||||
)
|
||||
|
||||
const refreshProjectBaseInfoState = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem(PROJECT_INFO_KEY)
|
||||
hasProjectBaseInfo.value = Boolean(data)
|
||||
} catch (error) {
|
||||
console.error('read project base info failed:', error)
|
||||
hasProjectBaseInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleProjectInitChanged = (event: Event) => {
|
||||
const detail = (event as CustomEvent<boolean>).detail
|
||||
if (typeof detail === 'boolean') {
|
||||
hasProjectBaseInfo.value = detail
|
||||
return
|
||||
}
|
||||
void refreshProjectBaseInfoState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshProjectBaseInfoState()
|
||||
window.addEventListener(PROJECT_INIT_CHANGED_EVENT, handleProjectInitChanged as EventListener)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(PROJECT_INIT_CHANGED_EVENT, handleProjectInitChanged as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||
|
||||
@ -37,7 +37,9 @@ const loadProjectIndustry = async () => {
|
||||
const filteredMajorDict = computed<Record<string, MajorItem>>(() => {
|
||||
const industry = projectIndustry.value
|
||||
if (!industry) return {}
|
||||
const entries = Object.entries(majorList as Record<string, MajorItem>).filter(([, item]) => isMajorCodeInIndustryScope(item.code, industry))
|
||||
const entries = getMajorDictEntries()
|
||||
.filter(({ id }) => isMajorIdInIndustryScope(id, industry))
|
||||
.map(({ id, item }) => [id, item as MajorItem] as const)
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
@ -51,15 +53,13 @@ onActivated(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MethodUnavailableNotice
|
||||
v-if="!hasProjectBaseInfo"
|
||||
title="请先在“基础信息”里新建项目"
|
||||
message="完成新建后将自动加载工程专业系数。"
|
||||
/>
|
||||
|
||||
<XmFactorGrid
|
||||
v-else
|
||||
title="工程专业系数明细"
|
||||
storage-key="xm-major-factor-v1"
|
||||
:dict="filteredMajorDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { ColDef } from 'ag-grid-community'
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
import CommonAgGrid from '@/components/common/CommonAgGrid.vue'
|
||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
@ -27,6 +25,8 @@ interface DetailRow {
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
path: string[]
|
||||
@ -46,17 +46,13 @@ const props = defineProps<{
|
||||
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const XM_DB_KEY = 'xm-info-v3'
|
||||
const BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
const activeIndustryCode = ref('')
|
||||
const activeIndustryId = ref('')
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
|
||||
type majorLite = { code: string; name: string }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
type majorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
|
||||
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
@ -92,26 +88,20 @@ const detailDict: DictGroup[] = (() => {
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
name: item.name,
|
||||
hasCost: item.hasCost !== false,
|
||||
hasArea: item.hasArea !== false
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
})()
|
||||
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
if (!activeIndustryCode.value) return []
|
||||
if (!activeIndustryId.value) return []
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
if (activeIndustryCode.value && !isMajorCodeInIndustryScope(group.code, activeIndustryCode.value)) continue
|
||||
if (activeIndustryId.value && !isMajorIdInIndustryScope(group.id, activeIndustryId.value)) continue
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
@ -119,9 +109,11 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
hasCost: child.hasCost,
|
||||
hasArea: child.hasArea,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: [group.id, child.id]
|
||||
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -131,7 +123,12 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
const rowId = String(row.id)
|
||||
dbValueMap.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||
dbValueMap.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
@ -140,130 +137,16 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
|
||||
{
|
||||
headerName: '造价金额(万元)',
|
||||
field: 'amount',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 2) : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousands(params.value)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '用地面积(亩)',
|
||||
field: 'landArea',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 3) : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousands(params.value, 3)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 200,
|
||||
maxWidth: 300,
|
||||
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
// cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
return idLabelMap.get(nodeId) || nodeId
|
||||
},
|
||||
tooltipValueGetter: params => {
|
||||
if (params.node?.rowPinned) return '总合计'
|
||||
const nodeId = String(params.value || '')
|
||||
return idLabelMap.get(nodeId) || nodeId
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
|
||||
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
amount: totalAmount.value,
|
||||
landArea: null,
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
console.log('Saving to IndexedDB:', payload)
|
||||
await localforage.setItem(DB_KEY.value, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||
activeIndustryCode.value =
|
||||
activeIndustryId.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||
@ -287,20 +170,6 @@ const loadFromIndexedDB = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
@ -308,53 +177,8 @@ onMounted(async () => {
|
||||
onActivated(() => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
const processCellForClipboard = (params:any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value); // 数组转字符串复制
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params:any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// 解析失败时返回原始值,无需额外处理
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 min-w-0 flex flex-col">
|
||||
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden">
|
||||
<CommonAgGrid
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommonAgGrid title="合同规模明细" :rowData="detailRows" :dbKey="DB_KEY" />
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import { isMajorIndustrySelectable, majorList } from '@/sql'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { CircleHelp } from 'lucide-vue-next'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
@ -14,12 +14,12 @@ interface XmInfoState {
|
||||
preparedDate?: string
|
||||
}
|
||||
|
||||
type MajorLite = { code: string; name: string; hideInIndustrySelector?: boolean }
|
||||
type MajorParentNode = { id: string; code: string; name: string }
|
||||
type MajorParentNode = { id: string; name: string }
|
||||
|
||||
const DB_KEY = 'xm-base-info-v1'
|
||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
|
||||
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||
|
||||
const isProjectInitialized = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
@ -32,25 +32,12 @@ const reviewedBy = ref('')
|
||||
const preparedCompany = ref('')
|
||||
const preparedDate = ref('')
|
||||
|
||||
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, MajorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const getMajorParentNodes = (entries: Array<[string, MajorLite]>): MajorParentNode[] =>
|
||||
entries
|
||||
.filter(([, item]) => isMajorIndustrySelectable(item))
|
||||
.map(([id, item]) => ({
|
||||
id,
|
||||
code: item.code,
|
||||
name: item.name
|
||||
}))
|
||||
|
||||
const majorParentNodes = getMajorParentNodes(majorEntries)
|
||||
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.code))
|
||||
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.code || ''
|
||||
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name
|
||||
}))
|
||||
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id))
|
||||
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || ''
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
@ -142,6 +129,7 @@ const createProject = async () => {
|
||||
isProjectInitialized.value = true
|
||||
showCreateDialog.value = false
|
||||
await saveToIndexedDB()
|
||||
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
|
||||
}
|
||||
|
||||
watch(
|
||||
@ -203,17 +191,17 @@ onMounted(async () => {
|
||||
<div class="mt-2 flex flex-wrap gap-3 rounded-lg border bg-background px-3 py-2">
|
||||
<label
|
||||
v-for="item in majorParentNodes"
|
||||
:key="item.code"
|
||||
:key="item.id"
|
||||
class="inline-flex items-center gap-2 text-sm text-foreground/80"
|
||||
>
|
||||
<input
|
||||
v-model="projectIndustry"
|
||||
type="radio"
|
||||
:value="item.code"
|
||||
:value="item.id"
|
||||
disabled
|
||||
class="h-4 w-4 cursor-not-allowed accent-primary"
|
||||
/>
|
||||
<span>{{ item.code }} {{ item.name }}</span>
|
||||
<span>{{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,11 +259,11 @@ onMounted(async () => {
|
||||
<div class="mt-4 max-h-72 space-y-2 overflow-auto rounded-lg border p-3">
|
||||
<label
|
||||
v-for="item in majorParentNodes"
|
||||
:key="item.code"
|
||||
:key="item.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/60"
|
||||
>
|
||||
<input v-model="pendingIndustry" type="radio" :value="item.code" class="h-4 w-4 accent-primary" />
|
||||
<span class="text-sm text-foreground">{{ item.code }} {{ item.name }}</span>
|
||||
<input v-model="pendingIndustry" type="radio" :value="item.id" class="h-4 w-4 accent-primary" />
|
||||
<span class="text-sm text-foreground"> {{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
|
||||
@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
@ -32,6 +32,8 @@ interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
@ -47,6 +49,8 @@ interface DetailRow {
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount: number | null
|
||||
benchmarkBudget: number | null
|
||||
benchmarkBudgetBasic: number | null
|
||||
@ -139,13 +143,9 @@ const shouldForceDefaultLoad = () => {
|
||||
}
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
type majorLite = { code: string; name: string; defCoe: number | null }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
|
||||
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
|
||||
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
@ -181,7 +181,9 @@ const detailDict: DictGroup[] = (() => {
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
name: item.name,
|
||||
hasCost: item.hasCost !== false,
|
||||
hasArea: item.hasArea !== false
|
||||
})
|
||||
}
|
||||
|
||||
@ -200,7 +202,7 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
if (!activeIndustryCode.value) return []
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
if (activeIndustryCode.value && !isMajorCodeInIndustryScope(group.code, activeIndustryCode.value)) continue
|
||||
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
@ -208,6 +210,8 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
hasCost: child.hasCost,
|
||||
hasArea: child.hasArea,
|
||||
amount: null,
|
||||
benchmarkBudget: null,
|
||||
benchmarkBudgetBasic: null,
|
||||
@ -253,7 +257,12 @@ const mergeWithDictRows = (
|
||||
const includeFactorValues = options?.includeFactorValues ?? true
|
||||
const dbValueMap = new Map<string, SourceRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
const rowId = String(row.id)
|
||||
dbValueMap.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||
dbValueMap.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
@ -264,7 +273,7 @@ const mergeWithDictRows = (
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: includeAmount && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
amount: includeAmount && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
||||
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
|
||||
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
|
||||
@ -311,6 +320,9 @@ const formatMajorFactor = (params: any) => {
|
||||
}
|
||||
|
||||
const formatEditableMoney = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
@ -358,11 +370,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
|
||||
minWidth: 100,
|
||||
flex: 2,
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost),
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned && params.data?.hasCost
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||
@ -549,6 +564,8 @@ const pinnedTopRowData = computed(() => [
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
hasCost: false,
|
||||
hasArea: false,
|
||||
amount: totalAmount.value,
|
||||
benchmarkBudget: totalBenchmarkBudget.value,
|
||||
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
|
||||
|
||||
@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
@ -32,6 +32,8 @@ interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
@ -47,6 +49,8 @@ interface DetailRow {
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
benchmarkBudget: number | null
|
||||
@ -140,13 +144,9 @@ const shouldSkipPersist = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
type majorLite = { code: string; name: string; defCoe: number | null }
|
||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, majorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
|
||||
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
|
||||
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||
|
||||
const detailDict: DictGroup[] = (() => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
@ -182,7 +182,9 @@ const detailDict: DictGroup[] = (() => {
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name
|
||||
name: item.name,
|
||||
hasCost: item.hasCost !== false,
|
||||
hasArea: item.hasArea !== false
|
||||
})
|
||||
}
|
||||
|
||||
@ -201,7 +203,7 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
if (!activeIndustryCode.value) return []
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict) {
|
||||
if (activeIndustryCode.value && !isMajorCodeInIndustryScope(group.code, activeIndustryCode.value)) continue
|
||||
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
|
||||
for (const child of group.children) {
|
||||
rows.push({
|
||||
id: child.id,
|
||||
@ -209,6 +211,8 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
hasCost: child.hasCost,
|
||||
hasArea: child.hasArea,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
benchmarkBudget: null,
|
||||
@ -256,7 +260,12 @@ const mergeWithDictRows = (
|
||||
const includeFactorValues = options?.includeFactorValues ?? true
|
||||
const dbValueMap = new Map<string, SourceRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
const rowId = String(row.id)
|
||||
dbValueMap.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||
dbValueMap.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
@ -267,8 +276,8 @@ const mergeWithDictRows = (
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: includeScaleValues && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: includeScaleValues && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
|
||||
amount: includeScaleValues && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: includeScaleValues && row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
|
||||
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
|
||||
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
|
||||
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
|
||||
@ -347,6 +356,9 @@ const getBudgetFeeSplit = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'c
|
||||
}
|
||||
|
||||
const formatEditableFlexibleNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
@ -362,14 +374,14 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => {
|
||||
const value = parseNumberOrNull(params.newValue)
|
||||
@ -556,6 +568,8 @@ const pinnedTopRowData = computed(() => [
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
hasCost: false,
|
||||
hasArea: false,
|
||||
amount: totalAmount.value,
|
||||
landArea: null,
|
||||
benchmarkBudget: totalBenchmarkBudget.value,
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { ColDef } from 'ag-grid-community'
|
||||
import { onActivated, onMounted, ref } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import { isMajorCodeInIndustryScope, majorList } from '@/sql'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
import CommonAgGrid from '@/components/common/CommonAgGrid.vue'
|
||||
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
|
||||
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
@ -27,6 +25,8 @@ interface DetailRow {
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
path: string[]
|
||||
@ -41,87 +41,14 @@ interface XmBaseInfoState {
|
||||
|
||||
const DB_KEY = 'xm-info-v3'
|
||||
const BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
type MajorLite = { code: string; name: string }
|
||||
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const agGridRef = ref<HTMLElement | null>(null)
|
||||
const agGridHeight = ref(580)
|
||||
let snapScrollHost: HTMLElement | null = null
|
||||
let snapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isSnapping = false
|
||||
let hostResizeObserver: ResizeObserver | null = null
|
||||
const activeIndustryCode = ref('')
|
||||
const hasProjectBaseInfo = ref(false)
|
||||
const detailDict = ref<DictGroup[]>([])
|
||||
const idLabelMap = ref(new Map<string, string>())
|
||||
|
||||
const updateGridCardHeight = () => {
|
||||
if (!snapScrollHost || !rootRef.value) return
|
||||
const contentWrap = rootRef.value.parentElement
|
||||
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
|
||||
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||||
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||||
agGridHeight.value = nextHeight + 10
|
||||
}
|
||||
|
||||
const bindSnapScrollHost = () => {
|
||||
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
|
||||
if (!snapScrollHost) return
|
||||
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
|
||||
hostResizeObserver?.disconnect()
|
||||
hostResizeObserver = new ResizeObserver(() => {
|
||||
updateGridCardHeight()
|
||||
})
|
||||
hostResizeObserver.observe(snapScrollHost)
|
||||
updateGridCardHeight()
|
||||
}
|
||||
|
||||
const unbindSnapScrollHost = () => {
|
||||
if (snapScrollHost) {
|
||||
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
|
||||
}
|
||||
hostResizeObserver?.disconnect()
|
||||
hostResizeObserver = null
|
||||
snapScrollHost = null
|
||||
}
|
||||
|
||||
const trySnapToGrid = () => {
|
||||
if (isSnapping || !snapScrollHost || !agGridRef.value) return
|
||||
|
||||
const hostRect = snapScrollHost.getBoundingClientRect()
|
||||
const gridRect = agGridRef.value.getBoundingClientRect()
|
||||
const offsetTop = gridRect.top - hostRect.top
|
||||
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
|
||||
const inSnapRange = offsetTop > -120 && offsetTop < 180
|
||||
|
||||
if (!inVisibleBand || !inSnapRange) return
|
||||
|
||||
isSnapping = true
|
||||
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||
snapLockTimer = setTimeout(() => {
|
||||
isSnapping = false
|
||||
}, 420)
|
||||
}
|
||||
|
||||
function handleSnapHostScroll() {
|
||||
if (isSnapping) return
|
||||
if (snapTimer) clearTimeout(snapTimer)
|
||||
snapTimer = setTimeout(() => {
|
||||
trySnapToGrid()
|
||||
}, 90)
|
||||
}
|
||||
|
||||
const majorEntries = Object.entries(majorList as Record<string, MajorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, MajorLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
|
||||
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||
|
||||
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
@ -156,7 +83,9 @@ const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name
|
||||
name: item.name,
|
||||
hasCost: item.hasCost !== false,
|
||||
hasArea: item.hasArea !== false
|
||||
})
|
||||
}
|
||||
|
||||
@ -166,20 +95,10 @@ const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
||||
const rebuildDictByIndustry = (industryCode: string) => {
|
||||
if (!industryCode) {
|
||||
detailDict.value = []
|
||||
idLabelMap.value = new Map()
|
||||
return
|
||||
}
|
||||
const filteredEntries = majorEntries.filter(([, item]) => isMajorCodeInIndustryScope(item.code, industryCode))
|
||||
const nextDict = buildDetailDict(filteredEntries)
|
||||
const nextLabelMap = new Map<string, string>()
|
||||
for (const group of nextDict) {
|
||||
nextLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
nextLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
detailDict.value = nextDict
|
||||
idLabelMap.value = nextLabelMap
|
||||
const filteredEntries = majorEntries.filter(([id]) => isMajorIdInIndustryScope(id, industryCode))
|
||||
detailDict.value = buildDetailDict(filteredEntries)
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
@ -192,9 +111,11 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
hasCost: child.hasCost,
|
||||
hasArea: child.hasArea,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: [group.id, child.id]
|
||||
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -204,7 +125,12 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
const rowId = String(row.id)
|
||||
dbValueMap.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||
dbValueMap.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
@ -213,121 +139,15 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
||||
|
||||
return {
|
||||
...row,
|
||||
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: '造价金额(万元)',
|
||||
field: 'amount',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 2) : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousands(params.value)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '用地面积(亩)',
|
||||
field: 'landArea',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 3) : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousands(params.value, 3)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 200,
|
||||
flex: 2,
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return '总合计'
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
return idLabelMap.value.get(nodeId) || nodeId
|
||||
},
|
||||
tooltipValueGetter: params => {
|
||||
if (params.node?.rowPinned) return '总合计'
|
||||
const nodeId = String(params.value || '')
|
||||
return idLabelMap.value.get(nodeId) || nodeId
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
|
||||
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
amount: totalAmount.value,
|
||||
landArea: null,
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
if (!activeIndustryCode.value) return
|
||||
try {
|
||||
const payload: XmScaleState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
await localforage.setItem(DB_KEY, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||
hasProjectBaseInfo.value = Boolean(baseInfo)
|
||||
activeIndustryCode.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
rebuildDictByIndustry(activeIndustryCode.value)
|
||||
@ -345,90 +165,19 @@ const loadFromIndexedDB = async () => {
|
||||
detailRows.value = buildDefaultRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
hasProjectBaseInfo.value = false
|
||||
detailRows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
bindSnapScrollHost()
|
||||
requestAnimationFrame(() => {
|
||||
updateGridCardHeight()
|
||||
})
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindSnapScrollHost()
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
if (snapTimer) clearTimeout(snapTimer)
|
||||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="h-full">
|
||||
<MethodUnavailableNotice
|
||||
v-if="!hasProjectBaseInfo"
|
||||
title="请先在“基础信息”里新建项目"
|
||||
message="完成新建后将自动加载规模信息。"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3 ">
|
||||
<h3
|
||||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"
|
||||
|
||||
>
|
||||
项目明细
|
||||
</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
|
||||
<CommonAgGrid
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommonAgGrid title="项目明细" :rowData="detailRows" :dbKey="DB_KEY" />
|
||||
</template>
|
||||
|
||||
@ -32,7 +32,7 @@ import {
|
||||
} from 'reka-ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { serviceList } from '@/sql'
|
||||
import { getServiceDictEntries } from '@/sql'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue'
|
||||
@ -73,15 +73,14 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
||||
|
||||
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
|
||||
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, ServiceListItem] => {
|
||||
const item = entry[1]
|
||||
const serviceDict: ServiceItem[] = getServiceDictEntries()
|
||||
.map(({ id, item }) => ({ id, item: item as ServiceListItem }))
|
||||
.filter(({ item }) => {
|
||||
const itemCode = item?.code || item?.ref
|
||||
return Boolean(itemCode && item?.name) && item.defCoe !== null
|
||||
})
|
||||
.map(([key, item]) => ({
|
||||
id: key,
|
||||
.map(({ id, item }) => ({
|
||||
id,
|
||||
code: item.code || item.ref || '',
|
||||
name: item.name
|
||||
}))
|
||||
|
||||
@ -1260,6 +1260,7 @@ const handleReset = async () => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
await localforage.clear()
|
||||
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||||
} catch (error) {
|
||||
console.error('reset failed:', error)
|
||||
} finally {
|
||||
@ -1427,7 +1428,7 @@ watch(
|
||||
</Button>
|
||||
<div
|
||||
v-if="dataMenuOpen"
|
||||
class="absolute right-0 top-full mt-1 z-50 min-w-[108px] rounded-md border bg-background p-1 shadow-md"
|
||||
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md"
|
||||
>
|
||||
<button
|
||||
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import localforage from 'localforage'
|
||||
import { expertList, majorList, serviceList, taskList } from '@/sql'
|
||||
import {
|
||||
expertList,
|
||||
getMajorDictEntries,
|
||||
getMajorIdAliasMap,
|
||||
getServiceDictById,
|
||||
taskList
|
||||
} from '@/sql'
|
||||
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||
@ -73,20 +79,23 @@ const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
|
||||
}
|
||||
|
||||
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
|
||||
const service = (serviceList as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||||
return toFiniteNumberOrNull(service?.defCoe)
|
||||
}
|
||||
|
||||
const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite]))
|
||||
const majorIdAliasMap = getMajorIdAliasMap()
|
||||
|
||||
const getDefaultMajorFactorById = (id: string) => {
|
||||
const major = (majorList as Record<string, MajorLite | undefined>)[id]
|
||||
const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id
|
||||
const major = majorById.get(resolvedId)
|
||||
return toFiniteNumberOrNull(major?.defCoe)
|
||||
}
|
||||
|
||||
const getMajorLeafIds = () =>
|
||||
Object.entries(majorList as Record<string, MajorLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter(([, item]) => Boolean(item?.code && item.code.includes('-')))
|
||||
.map(([id]) => id)
|
||||
getMajorDictEntries()
|
||||
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
|
||||
.map(({ id }) => id)
|
||||
|
||||
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => {
|
||||
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||
@ -104,6 +113,13 @@ const mergeScaleRows = (
|
||||
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
|
||||
): ScaleRow[] => {
|
||||
const dbValueMap = toRowMap(rowsFromDb)
|
||||
for (const row of rowsFromDb || []) {
|
||||
const rowId = String(row.id)
|
||||
const nextId = majorIdAliasMap.get(rowId)
|
||||
if (nextId && !dbValueMap.has(nextId)) {
|
||||
dbValueMap.set(nextId, row as ScaleRow)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||
return buildDefaultScaleRows(serviceId).map(row => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import localforage from 'localforage'
|
||||
import { majorList, serviceList } from '@/sql'
|
||||
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
|
||||
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
||||
@ -43,20 +43,22 @@ const resolveFactorValue = (
|
||||
|
||||
const loadFactorMap = async (
|
||||
storageKey: string,
|
||||
dict: FactorDict
|
||||
dict: FactorDict,
|
||||
aliases?: Map<string, string>
|
||||
): Promise<Map<string, number | null>> => {
|
||||
const data = await localforage.getItem<XmFactorState>(storageKey)
|
||||
const map = buildStandardFactorMap(dict)
|
||||
for (const row of data?.detailRows || []) {
|
||||
if (!row?.id) continue
|
||||
const id = String(row.id)
|
||||
const rowId = String(row.id)
|
||||
const id = map.has(rowId) ? rowId : aliases?.get(rowId) || rowId
|
||||
map.set(id, resolveFactorValue(row, map.get(id) ?? null))
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const loadConsultCategoryFactorMap = async () =>
|
||||
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, serviceList as FactorDict)
|
||||
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, getServiceDictById() as FactorDict)
|
||||
|
||||
export const loadMajorFactorMap = async () =>
|
||||
loadFactorMap(MAJOR_FACTOR_KEY, majorList as FactorDict)
|
||||
loadFactorMap(MAJOR_FACTOR_KEY, getMajorDictById() as FactorDict, getMajorIdAliasMap())
|
||||
|
||||
1437
src/sql.ts
1437
src/sql.ts
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user