diff --git a/src/components/common/xmCommonAgGrid.vue b/src/components/common/xmCommonAgGrid.vue index 9951901..3344936 100644 --- a/src/components/common/xmCommonAgGrid.vue +++ b/src/components/common/xmCommonAgGrid.vue @@ -7,9 +7,207 @@ 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' +import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { SwitchRoot, SwitchThumb } from 'reka-ui' + + +interface DictLeaf { + id: string + code: string + name: string + hasCost: boolean + + hasArea: boolean +} + +interface DictGroup { + id: string + code: string + name: string + children: DictLeaf[] +} + +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 XmScaleState { + detailRows?: DetailRow[] + totalAmount: number + roughCalcEnabled: boolean + +} +interface XmBaseInfoState { + projectIndustry?: string +} + +const BASE_INFO_KEY = 'xm-base-info-v1' +type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean } + +const detailRows = ref([]) +const detailDict = ref([]) + +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() + const groupOrder: string[] = [] + const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, ...item }])) + + for (const [key, item] of entries) { + const isGroup = !item.code.includes('-') + if (isGroup) { + if (!groupMap.has(item.code)) groupOrder.push(item.code) + groupMap.set(item.code, { + id: key, + code: item.code, + name: item.name, + children: [] + }) + continue + } + + const parentCode = item.code.split('-')[0] + if (!groupMap.has(parentCode)) { + const parent = codeLookup.get(parentCode) + if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode) + groupMap.set(parentCode, { + id: parent?.id || `group-${parentCode}`, + code: parentCode, + name: parent?.name || parentCode, + children: [] + }) + } + groupMap.get(parentCode)!.children.push({ + id: key, + code: item.code, + name: item.name, + + hasCost: item.hasCost !== false, + hasArea: item.hasArea !== false + }) + } + + return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group)) +} + + + +const buildDefaultRows = (): DetailRow[] => { + const rows: DetailRow[] = [] + for (const group of detailDict.value) { + for (const child of group.children) { + + rows.push({ + id: child.id, + groupCode: group.code, + groupName: group.name, + majorCode: child.code, + majorName: child.name, + hasCost: child.hasCost, + hasArea: child.hasArea, + amount: null, + landArea: null, + path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`] + }) + } + } + return rows +} + +const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => { + const dbValueMap = new Map() + for (const row of rowsFromDb || []) { + 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 => { + const fromDb = dbValueMap.get(row.id) + if (!fromDb) return row + + return { + ...row, + hide: fromDb.hide, + amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null, + landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null + } + }) +} + + +const loadFromIndexedDB = async (gridApi: any) => { + + + try { + + const baseInfo = await localforage.getItem(BASE_INFO_KEY) + activeIndustryId.value = + typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' + if (!activeIndustryId.value) { + detailDict.value = [] + return + } + const filteredEntries = majorEntries.filter(([id]) => isMajorIdInIndustryScope(id, activeIndustryId.value)) + detailDict.value = buildDetailDict(filteredEntries) + if (!activeIndustryId.value) { + detailRows.value = [] + return + } + + const data = await localforage.getItem(props.dbKey) + + roughCalcEnabled.value = data?.roughCalcEnabled || false + const pinnedTopNode = gridApi.getPinnedTopRow(0) + if (pinnedTopNode) { + pinnedTopNode.setDataValue('amount', data?.totalAmount || null) + } + // + if (data?.detailRows) { + detailRows.value = mergeWithDictRows(data.detailRows) + return + } + + if (props.xmInfoKey) { + // 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐) + const xmData = + (await localforage.getItem(props.xmInfoKey)) + roughCalcEnabled.value = xmData?.roughCalcEnabled || false + if (pinnedTopNode) { + pinnedTopNode.setDataValue('amount', xmData?.totalAmount || null) + } + if (xmData?.detailRows) { + detailRows.value = mergeWithDictRows(xmData.detailRows) + return + } + + } + + detailRows.value = buildDefaultRows() + +saveToIndexedDB() + } catch (error) { + console.error('loadFromIndexedDB failed:', error) + activeIndustryId.value = '' + detailRows.value = [] + } +} + interface DetailRow { id: string groupCode: string @@ -36,11 +234,10 @@ interface GridPersistState { const props = defineProps<{ title: string - rowData: DetailRow[] dbKey: string + xmInfoKey?: string | null }>() -const BASE_INFO_KEY = 'xm-base-info-v1' let persistTimer: ReturnType | null = null const gridApi = ref | null>(null) const activeIndustryId = ref('') @@ -55,7 +252,7 @@ const totalLabel = computed(() => { return industryName ? `${industryName}总投资` : '总投资' }) const roughCalcEnabled = ref(false) -const visibleRowData = computed(() => props.rowData.filter(row => !row.hide)) +const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) }) const refreshPinnedTotalLabelCell = () => { if (!gridApi.value) return @@ -82,8 +279,8 @@ const columnDefs: ColDef[] = [ roughCalcEnabled.value && params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost - ? 'ag-right-aligned-cell editable-cell-line' - : 'ag-right-aligned-cell', + ? 'ag-right-aligned-cell editable-cell-line' + : 'ag-right-aligned-cell', cellClassRules: { 'editable-cell-empty': params => roughCalcEnabled.value && params.node?.rowPinned @@ -183,7 +380,7 @@ const pinnedTopRowData = ref([ const saveToIndexedDB = async () => { try { const payload: GridPersistState = { - detailRows: props.rowData.map(row => ({ + detailRows: detailRows.value.map(row => ({ ...JSON.parse(JSON.stringify(row)), hide: Boolean(row.hide) })) @@ -204,7 +401,7 @@ const schedulePersist = () => { } const setDetailRowsHidden = (hidden: boolean) => { - for (const row of props.rowData) { + for (const row of detailRows.value) { row.hide = hidden } } @@ -215,12 +412,12 @@ const onRoughCalcSwitch = (checked: boolean) => { setDetailRowsHidden(checked) if (!checked) { syncPinnedTotalForNormalMode() - }else{ - pinnedTopRowData.value[0].amount = null - const pinnedTopNode = gridApi.value?.getPinnedTopRow(0) - if (pinnedTopNode) { - pinnedTopNode.setDataValue('amount', null) - } + } else { + pinnedTopRowData.value[0].amount = null + const pinnedTopNode = gridApi.value?.getPinnedTopRow(0) + if (pinnedTopNode) { + pinnedTopNode.setDataValue('amount', null) + } } schedulePersist() } @@ -230,10 +427,10 @@ const onRoughCalcSwitch = (checked: boolean) => { const onCellValueChanged = (event: CellValueChangedEvent) => { if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') { if (typeof event.newValue === 'number') { - pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2) + pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2) } else { const parsed = Number(event.newValue) - pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null + pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null } } else if (!roughCalcEnabled.value) { @@ -244,11 +441,10 @@ const onCellValueChanged = (event: CellValueChangedEvent) => { const onGridReady = (event: GridReadyEvent) => { gridApi.value = event.api - void loadIndustryFromBaseInfo() + void loadFromIndexedDB(event.api) - void loadGridPersistState() - void refreshPinnedTotalLabelCell() + void refreshPinnedTotalLabelCell() } @@ -269,45 +465,9 @@ const processCellFromClipboard = (params: any) => { return params.value } -const loadIndustryFromBaseInfo = async () => { - try { - const baseInfo = await localforage.getItem(BASE_INFO_KEY) - activeIndustryId.value = - typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' - } catch (error) { - console.error('loadIndustryFromBaseInfo failed:', error) - activeIndustryId.value = '' - } -} -const loadGridPersistState = async () => { - try { - const data = await localforage.getItem(props.dbKey) - roughCalcEnabled.value = Boolean(data?.roughCalcEnabled) - const detailRows = Array.isArray(data?.detailRows) ? data.detailRows : [] - const detailRowById = new Map(detailRows.map(row => [row.id, row])) - for (const row of props.rowData) { - const persisted = detailRowById.get(row.id) - if (!persisted) { - row.hide = roughCalcEnabled.value - continue - } - row.amount = typeof persisted.amount === 'number' ? roundTo(persisted.amount, 2) : null - row.landArea = typeof persisted.landArea === 'number' ? roundTo(persisted.landArea, 3) : null - row.hide = typeof persisted.hide === 'boolean' ? persisted.hide : roughCalcEnabled.value - } - pinnedTopRowData.value[0].amount = typeof data?.totalAmount === 'number' ? data.totalAmount : null - const pinnedTopNode = (gridApi as any).value.getPinnedTopRow(0) - if (pinnedTopNode) { - pinnedTopNode.setDataValue('amount', pinnedTopRowData.value[0].amount) - } - } catch (error) { - console.error('loadGridPersistState failed:', error) - roughCalcEnabled.value = false - pinnedTopRowData.value[0].amount= null - } -} + @@ -319,14 +479,14 @@ const syncPinnedTotalForNormalMode = () => { if (roughCalcEnabled.value) return if (!gridApi.value) { - pinnedTopRowData.value[0].amount = sumByNumber(props.rowData, row => row.amount) + pinnedTopRowData.value[0].amount = sumByNumber(detailRows.value, row => row.amount) return } let total = 0 let hasValue = false - props.rowData.forEach(node => { - + detailRows.value.forEach(node => { + const amount = node.amount if (typeof amount === 'number' && Number.isFinite(amount)) { total += amount @@ -335,9 +495,9 @@ const syncPinnedTotalForNormalMode = () => { }) pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null const pinnedTopNode = gridApi.value.getPinnedTopRow(0) - if (pinnedTopNode) { - pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null) - } + if (pinnedTopNode) { + pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null) + } } onBeforeUnmount(() => { @@ -351,47 +511,29 @@ onBeforeUnmount(() => {
-

+

{{ props.title }}

粗略计算 + :modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch"> + class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
- +
diff --git a/src/components/views/Ht.vue b/src/components/views/Ht.vue index 3848438..1f3f7c0 100644 --- a/src/components/views/Ht.vue +++ b/src/components/views/Ht.vue @@ -69,8 +69,8 @@ const isListLayout = ref(false) const contractDataMenuOpen = ref(false) const contractDataMenuRef = ref(null) const contractImportFileRef = ref(null) -const isExportSelecting = ref(false) -const selectedExportContractIds = ref([]) +const selectionMode = ref<'none' | 'export' | 'delete'>('none') +const selectedContractIds = ref([]) const showCreateModal = ref(false) const contractNameInput = ref('') @@ -107,7 +107,8 @@ const buildDefaultContracts = (): ContractItem[] => [ ] const normalizedSearchKeyword = computed(() => contractSearchKeyword.value.trim().toLowerCase()) -const selectedExportCount = computed(() => selectedExportContractIds.value.length) +const isSelectingContracts = computed(() => selectionMode.value !== 'none') +const selectedContractCount = computed(() => selectedContractIds.value.length) const hasContracts = computed(() => contracts.value.length > 0) const filteredContracts = computed(() => { if (!normalizedSearchKeyword.value) return contracts.value @@ -208,28 +209,35 @@ const formatIndustryLabel = (code: string) => { return name ? `${trimmed} ${name}` : trimmed } -const isContractSelectedForExport = (contractId: string) => - selectedExportContractIds.value.includes(contractId) +const isContractSelected = (contractId: string) => + selectedContractIds.value.includes(contractId) -const toggleExportContractSelection = (contractId: string) => { - if (!isExportSelecting.value) return - if (isContractSelectedForExport(contractId)) { - selectedExportContractIds.value = selectedExportContractIds.value.filter(id => id !== contractId) +const toggleContractSelection = (contractId: string) => { + if (!isSelectingContracts.value) return + if (isContractSelected(contractId)) { + selectedContractIds.value = selectedContractIds.value.filter(id => id !== contractId) return } - selectedExportContractIds.value = [...selectedExportContractIds.value, contractId] + selectedContractIds.value = [...selectedContractIds.value, contractId] } -const exitContractExportMode = () => { - isExportSelecting.value = false - selectedExportContractIds.value = [] +const exitContractSelectionMode = () => { + selectionMode.value = 'none' + selectedContractIds.value = [] } const enterContractExportMode = () => { if (!hasContracts.value) return closeContractDataMenu() - isExportSelecting.value = true - selectedExportContractIds.value = [] + selectionMode.value = 'export' + selectedContractIds.value = [] +} + +const enterContractDeleteMode = () => { + if (!hasContracts.value) return + closeContractDataMenu() + selectionMode.value = 'delete' + selectedContractIds.value = [] } const triggerContractImport = () => { @@ -321,7 +329,7 @@ const getCardSelectStyle = (index: number) => ({ }) const getContractCardStyle = (index: number) => { - if (isExportSelecting.value) { + if (isSelectingContracts.value) { return getCardSelectStyle(index) } if (cardMotionState.value === 'enter') { @@ -417,13 +425,13 @@ const generateContractId = (usedIds: Set) => { } const exportSelectedContracts = async () => { - if (selectedExportContractIds.value.length === 0) { + if (selectedContractIds.value.length === 0) { window.alert('请先勾选至少一个合同段。') return } try { - const selectedSet = new Set(selectedExportContractIds.value) + const selectedSet = new Set(selectedContractIds.value) const selectedContracts = contracts.value .filter(item => selectedSet.has(item.id)) .map((item, index) => ({ @@ -464,7 +472,7 @@ const exportSelectedContracts = async () => { URL.revokeObjectURL(url) notify(`导出成功(${selectedContracts.length} 个合同段)`) - exitContractExportMode() + exitContractSelectionMode() } catch (error) { console.error('export selected contracts failed:', error) window.alert('导出失败,请重试。') @@ -679,11 +687,54 @@ const deleteContract = async (id: string) => { await cleanupContractRelatedData(id) contracts.value = contracts.value.filter(item => item.id !== id) - selectedExportContractIds.value = selectedExportContractIds.value.filter(item => item !== id) + selectedContractIds.value = selectedContractIds.value.filter(item => item !== id) await saveContracts() notify('删除成功') } +const deleteSelectedContracts = async () => { + if (selectedContractIds.value.length === 0) { + window.alert('请先勾选至少一个合同段。') + return + } + + const selectedSet = new Set(selectedContractIds.value) + const targets = contracts.value.filter(item => selectedSet.has(item.id)) + if (targets.length === 0) { + window.alert('未找到可删除的合同段。') + return + } + + const confirmed = window.confirm( + `即将删除 ${targets.length} 个合同段及其关联咨询服务和计价数据,是否继续?` + ) + if (!confirmed) return + + try { + const targetIds = targets.map(item => item.id) + for (const id of targetIds) { + removeRelatedTabsByContractId(id) + } + await nextTick() + for (const id of targetIds) { + await cleanupContractRelatedData(id) + } + await new Promise(resolve => setTimeout(resolve, 80)) + for (const id of targetIds) { + await cleanupContractRelatedData(id) + } + + contracts.value = contracts.value.filter(item => !selectedSet.has(item.id)) + selectedContractIds.value = selectedContractIds.value.filter(item => !selectedSet.has(item)) + await saveContracts() + notify(`删除成功(${targetIds.length} 个合同段)`) + exitContractSelectionMode() + } catch (error) { + console.error('delete selected contracts failed:', error) + window.alert('批量删除失败,请重试。') + } +} + const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => { stopContractAutoScroll() if ( @@ -763,8 +814,8 @@ const stopContractAutoScroll = () => { } const handleCardClick = (item: ContractItem) => { - if (isExportSelecting.value) { - toggleExportContractSelection(item.id) + if (isSelectingContracts.value) { + toggleContractSelection(item.id) return } tabStore.openTab({ @@ -839,16 +890,16 @@ onBeforeUnmount(() => {

合同段列表

-
@@ -1094,27 +1154,27 @@ onBeforeUnmount(() => { :key="element.id" :class="[ 'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card', - isExportSelecting + isSelectingContracts ? 'ht-contract-card--selecting' : cardMotionState === 'enter' && !isDraggingContracts ? 'ht-contract-card--enter' : 'ht-contract-card--ready', - isContractSelectedForExport(element.id) && 'ht-contract-card--selected', + isContractSelected(element.id) && 'ht-contract-card--selected', isListLayout && 'gap-0 py-0' ]" :style="getContractCardStyle(index)" @click="handleCardClick(element)" > {
diff --git a/src/components/views/htInfo.vue b/src/components/views/htInfo.vue index b9305cd..7918c62 100644 --- a/src/components/views/htInfo.vue +++ b/src/components/views/htInfo.vue @@ -4,181 +4,16 @@ import localforage from 'localforage' 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 { - id: string - code: string - name: string - children: DictLeaf[] -} - -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 XmInfoState { - projectName: string - detailRows: DetailRow[] -} -interface XmBaseInfoState { - projectIndustry?: string -} const props = defineProps<{ contractId: string }>() 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 activeIndustryId = ref('') -const detailRows = ref([]) -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() - const groupOrder: string[] = [] - const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }])) - - for (const [key, item] of serviceEntries) { - const code = item.code - const isGroup = !code.includes('-') - if (isGroup) { - if (!groupMap.has(code)) groupOrder.push(code) - groupMap.set(code, { - id: key, - code, - name: item.name, - children: [] - }) - continue - } - - const parentCode = code.split('-')[0] - if (!groupMap.has(parentCode)) { - const parent = codeLookup.get(parentCode) - if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode) - groupMap.set(parentCode, { - id: parent?.id || `group-${parentCode}`, - code: parentCode, - name: parent?.name || parentCode, - children: [] - }) - } - - groupMap.get(parentCode)!.children.push({ - id: key, - code, - name: item.name, - hasCost: item.hasCost !== false, - hasArea: item.hasArea !== false - }) - } - - return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group)) -})() - -const buildDefaultRows = (): DetailRow[] => { - if (!activeIndustryId.value) return [] - const rows: DetailRow[] = [] - for (const group of detailDict) { - if (activeIndustryId.value && !isMajorIdInIndustryScope(group.id, activeIndustryId.value)) continue - for (const child of group.children) { - rows.push({ - id: child.id, - groupCode: group.code, - groupName: group.name, - majorCode: child.code, - majorName: child.name, - hasCost: child.hasCost, - hasArea: child.hasArea, - amount: null, - landArea: null, - path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`] - }) - } - } - return rows -} - -const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => { - const dbValueMap = new Map() - for (const row of rowsFromDb || []) { - 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 => { - const fromDb = dbValueMap.get(row.id) - if (!fromDb) return row - - return { - ...row, - amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null, - landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null - } - }) -} - -const loadFromIndexedDB = async () => { - try { - const baseInfo = await localforage.getItem(BASE_INFO_KEY) - activeIndustryId.value = - typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' - - const data = await localforage.getItem(DB_KEY.value) - if (data) { - detailRows.value = mergeWithDictRows(data.detailRows) - return - } - - // 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐) - const xmData = - (await localforage.getItem(XM_DB_KEY)) - if (xmData?.detailRows) { - detailRows.value = mergeWithDictRows(xmData.detailRows) - return - } - - detailRows.value = buildDefaultRows() - } catch (error) { - console.error('loadFromIndexedDB failed:', error) - detailRows.value = buildDefaultRows() - } -} - -onMounted(async () => { - await loadFromIndexedDB() -}) - -onActivated(() => { - void loadFromIndexedDB() -}) diff --git a/src/components/views/xmInfo.vue b/src/components/views/xmInfo.vue index 64c1f5b..5cb9db1 100644 --- a/src/components/views/xmInfo.vue +++ b/src/components/views/xmInfo.vue @@ -1,183 +1,13 @@