This commit is contained in:
wintsa 2026-03-06 16:06:37 +08:00
parent d8f8b629d2
commit 626513bc21
4 changed files with 338 additions and 471 deletions

View File

@ -7,9 +7,207 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import localforage from 'localforage' import localforage from 'localforage'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
import { industryTypeList } from '@/sql' import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import { SwitchRoot, SwitchThumb } from 'reka-ui' 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<DetailRow[]>([])
const detailDict = ref<DictGroup[]>([])
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>()
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<string, DetailRow>()
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<XmBaseInfoState>(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<XmScaleState>(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<XmScaleState>(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 { interface DetailRow {
id: string id: string
groupCode: string groupCode: string
@ -36,11 +234,10 @@ interface GridPersistState {
const props = defineProps<{ const props = defineProps<{
title: string title: string
rowData: DetailRow[]
dbKey: string dbKey: string
xmInfoKey?: string | null
}>() }>()
const BASE_INFO_KEY = 'xm-base-info-v1'
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null) const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('') const activeIndustryId = ref('')
@ -55,7 +252,7 @@ const totalLabel = computed(() => {
return industryName ? `${industryName}总投资` : '总投资' return industryName ? `${industryName}总投资` : '总投资'
}) })
const roughCalcEnabled = ref(false) 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 = () => { const refreshPinnedTotalLabelCell = () => {
if (!gridApi.value) return if (!gridApi.value) return
@ -82,8 +279,8 @@ const columnDefs: ColDef<DetailRow>[] = [
roughCalcEnabled.value && params.node?.rowPinned roughCalcEnabled.value && params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line' ? 'ag-right-aligned-cell editable-cell-line'
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost : !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line' ? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell', : 'ag-right-aligned-cell',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => 'editable-cell-empty': params =>
roughCalcEnabled.value && params.node?.rowPinned roughCalcEnabled.value && params.node?.rowPinned
@ -183,7 +380,7 @@ const pinnedTopRowData = ref<DetailRow[]>([
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
try { try {
const payload: GridPersistState = { const payload: GridPersistState = {
detailRows: props.rowData.map(row => ({ detailRows: detailRows.value.map(row => ({
...JSON.parse(JSON.stringify(row)), ...JSON.parse(JSON.stringify(row)),
hide: Boolean(row.hide) hide: Boolean(row.hide)
})) }))
@ -204,7 +401,7 @@ const schedulePersist = () => {
} }
const setDetailRowsHidden = (hidden: boolean) => { const setDetailRowsHidden = (hidden: boolean) => {
for (const row of props.rowData) { for (const row of detailRows.value) {
row.hide = hidden row.hide = hidden
} }
} }
@ -215,12 +412,12 @@ const onRoughCalcSwitch = (checked: boolean) => {
setDetailRowsHidden(checked) setDetailRowsHidden(checked)
if (!checked) { if (!checked) {
syncPinnedTotalForNormalMode() syncPinnedTotalForNormalMode()
}else{ } else {
pinnedTopRowData.value[0].amount = null pinnedTopRowData.value[0].amount = null
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0) const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
if (pinnedTopNode) { if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', null) pinnedTopNode.setDataValue('amount', null)
} }
} }
schedulePersist() schedulePersist()
} }
@ -230,10 +427,10 @@ const onRoughCalcSwitch = (checked: boolean) => {
const onCellValueChanged = (event: CellValueChangedEvent) => { const onCellValueChanged = (event: CellValueChangedEvent) => {
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') { if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
if (typeof event.newValue === 'number') { if (typeof event.newValue === 'number') {
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2) pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
} else { } else {
const parsed = Number(event.newValue) 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) { } else if (!roughCalcEnabled.value) {
@ -244,11 +441,10 @@ const onCellValueChanged = (event: CellValueChangedEvent) => {
const onGridReady = (event: GridReadyEvent<DetailRow>) => { const onGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api 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 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 = ''
}
}
const loadGridPersistState = async () => {
try {
const data = await localforage.getItem<GridPersistState>(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,13 +479,13 @@ const syncPinnedTotalForNormalMode = () => {
if (roughCalcEnabled.value) return if (roughCalcEnabled.value) return
if (!gridApi.value) { if (!gridApi.value) {
pinnedTopRowData.value[0].amount = sumByNumber(props.rowData, row => row.amount) pinnedTopRowData.value[0].amount = sumByNumber(detailRows.value, row => row.amount)
return return
} }
let total = 0 let total = 0
let hasValue = false let hasValue = false
props.rowData.forEach(node => { detailRows.value.forEach(node => {
const amount = node.amount const amount = node.amount
if (typeof amount === 'number' && Number.isFinite(amount)) { if (typeof amount === 'number' && Number.isFinite(amount)) {
@ -335,9 +495,9 @@ const syncPinnedTotalForNormalMode = () => {
}) })
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
const pinnedTopNode = gridApi.value.getPinnedTopRow(0) const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (pinnedTopNode) { if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null) pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
} }
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -351,47 +511,29 @@ onBeforeUnmount(() => {
<div class="h-full"> <div class="h-full">
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden 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"> <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
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }} {{ props.title }}
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class=" text-xs text-muted-foreground">粗略计算</span> <span class=" text-xs text-muted-foreground">粗略计算</span>
<SwitchRoot <SwitchRoot
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary" class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
:modelValue="roughCalcEnabled" :modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
@update:modelValue="onRoughCalcSwitch"
>
<SwitchThumb <SwitchThumb
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
/>
</SwitchRoot> </SwitchRoot>
</div> </div>
</div> </div>
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full"> <div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<AgGridVue <AgGridVue :style="{ height: '100%' }" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
:style="{ height: '100%' }" :columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
:rowData="visibleRowData" @grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
:pinnedTopRowData="pinnedTopRowData" :suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:columnDefs="columnDefs" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
:autoGroupColumnDef="autoGroupColumnDef" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:gridOptions="gridOptions" :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
:theme="myTheme"
@grid-ready="onGridReady"
@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> </div>
</div> </div>

View File

@ -69,8 +69,8 @@ const isListLayout = ref(false)
const contractDataMenuOpen = ref(false) const contractDataMenuOpen = ref(false)
const contractDataMenuRef = ref<HTMLElement | null>(null) const contractDataMenuRef = ref<HTMLElement | null>(null)
const contractImportFileRef = ref<HTMLInputElement | null>(null) const contractImportFileRef = ref<HTMLInputElement | null>(null)
const isExportSelecting = ref(false) const selectionMode = ref<'none' | 'export' | 'delete'>('none')
const selectedExportContractIds = ref<string[]>([]) const selectedContractIds = ref<string[]>([])
const showCreateModal = ref(false) const showCreateModal = ref(false)
const contractNameInput = ref('') const contractNameInput = ref('')
@ -107,7 +107,8 @@ const buildDefaultContracts = (): ContractItem[] => [
] ]
const normalizedSearchKeyword = computed(() => contractSearchKeyword.value.trim().toLowerCase()) 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 hasContracts = computed(() => contracts.value.length > 0)
const filteredContracts = computed(() => { const filteredContracts = computed(() => {
if (!normalizedSearchKeyword.value) return contracts.value if (!normalizedSearchKeyword.value) return contracts.value
@ -208,28 +209,35 @@ const formatIndustryLabel = (code: string) => {
return name ? `${trimmed} ${name}` : trimmed return name ? `${trimmed} ${name}` : trimmed
} }
const isContractSelectedForExport = (contractId: string) => const isContractSelected = (contractId: string) =>
selectedExportContractIds.value.includes(contractId) selectedContractIds.value.includes(contractId)
const toggleExportContractSelection = (contractId: string) => { const toggleContractSelection = (contractId: string) => {
if (!isExportSelecting.value) return if (!isSelectingContracts.value) return
if (isContractSelectedForExport(contractId)) { if (isContractSelected(contractId)) {
selectedExportContractIds.value = selectedExportContractIds.value.filter(id => id !== contractId) selectedContractIds.value = selectedContractIds.value.filter(id => id !== contractId)
return return
} }
selectedExportContractIds.value = [...selectedExportContractIds.value, contractId] selectedContractIds.value = [...selectedContractIds.value, contractId]
} }
const exitContractExportMode = () => { const exitContractSelectionMode = () => {
isExportSelecting.value = false selectionMode.value = 'none'
selectedExportContractIds.value = [] selectedContractIds.value = []
} }
const enterContractExportMode = () => { const enterContractExportMode = () => {
if (!hasContracts.value) return if (!hasContracts.value) return
closeContractDataMenu() closeContractDataMenu()
isExportSelecting.value = true selectionMode.value = 'export'
selectedExportContractIds.value = [] selectedContractIds.value = []
}
const enterContractDeleteMode = () => {
if (!hasContracts.value) return
closeContractDataMenu()
selectionMode.value = 'delete'
selectedContractIds.value = []
} }
const triggerContractImport = () => { const triggerContractImport = () => {
@ -321,7 +329,7 @@ const getCardSelectStyle = (index: number) => ({
}) })
const getContractCardStyle = (index: number) => { const getContractCardStyle = (index: number) => {
if (isExportSelecting.value) { if (isSelectingContracts.value) {
return getCardSelectStyle(index) return getCardSelectStyle(index)
} }
if (cardMotionState.value === 'enter') { if (cardMotionState.value === 'enter') {
@ -417,13 +425,13 @@ const generateContractId = (usedIds: Set<string>) => {
} }
const exportSelectedContracts = async () => { const exportSelectedContracts = async () => {
if (selectedExportContractIds.value.length === 0) { if (selectedContractIds.value.length === 0) {
window.alert('请先勾选至少一个合同段。') window.alert('请先勾选至少一个合同段。')
return return
} }
try { try {
const selectedSet = new Set(selectedExportContractIds.value) const selectedSet = new Set(selectedContractIds.value)
const selectedContracts = contracts.value const selectedContracts = contracts.value
.filter(item => selectedSet.has(item.id)) .filter(item => selectedSet.has(item.id))
.map((item, index) => ({ .map((item, index) => ({
@ -464,7 +472,7 @@ const exportSelectedContracts = async () => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
notify(`导出成功(${selectedContracts.length} 个合同段)`) notify(`导出成功(${selectedContracts.length} 个合同段)`)
exitContractExportMode() exitContractSelectionMode()
} catch (error) { } catch (error) {
console.error('export selected contracts failed:', error) console.error('export selected contracts failed:', error)
window.alert('导出失败,请重试。') window.alert('导出失败,请重试。')
@ -679,11 +687,54 @@ const deleteContract = async (id: string) => {
await cleanupContractRelatedData(id) await cleanupContractRelatedData(id)
contracts.value = contracts.value.filter(item => item.id !== 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() await saveContracts()
notify('删除成功') 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 }) => { const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => {
stopContractAutoScroll() stopContractAutoScroll()
if ( if (
@ -763,8 +814,8 @@ const stopContractAutoScroll = () => {
} }
const handleCardClick = (item: ContractItem) => { const handleCardClick = (item: ContractItem) => {
if (isExportSelecting.value) { if (isSelectingContracts.value) {
toggleExportContractSelection(item.id) toggleContractSelection(item.id)
return return
} }
tabStore.openTab({ tabStore.openTab({
@ -839,16 +890,16 @@ onBeforeUnmount(() => {
<div class="mb-6 flex items-center justify-between pt-1"> <div class="mb-6 flex items-center justify-between pt-1">
<h3 class="text-lg font-bold">合同段列表</h3> <h3 class="text-lg font-bold">合同段列表</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<template v-if="isExportSelecting"> <template v-if="isSelectingContracts">
<div class="text-xs text-muted-foreground">已选 {{ selectedExportCount }} </div> <div class="text-xs text-muted-foreground">已选 {{ selectedContractCount }} </div>
<Button <Button
variant="outline" variant="outline"
:disabled="selectedExportCount === 0" :disabled="selectedContractCount === 0"
@click="exportSelectedContracts" @click="selectionMode === 'export' ? exportSelectedContracts() : deleteSelectedContracts()"
> >
导出已选 {{ selectionMode === 'export' ? '导出已选' : '删除已选' }}
</Button> </Button>
<Button variant="ghost" @click="exitContractExportMode"> <Button variant="ghost" @click="exitContractSelectionMode">
取消 取消
</Button> </Button>
</template> </template>
@ -870,6 +921,14 @@ onBeforeUnmount(() => {
v-if="contractDataMenuOpen" v-if="contractDataMenuOpen"
class="absolute right-0 top-full z-50 mt-1 min-w-[132px] rounded-md border bg-background p-1 shadow-md" class="absolute right-0 top-full z-50 mt-1 min-w-[132px] rounded-md border bg-background p-1 shadow-md"
> >
<button
class="w-full rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!hasContracts"
@click="enterContractDeleteMode"
>
批量删除
</button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm" class="w-full rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'" :class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
@ -878,6 +937,7 @@ onBeforeUnmount(() => {
> >
导出合同段 导出合同段
</button> </button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm" class="w-full rounded px-3 py-1.5 text-left text-sm"
:class="canManageContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'" :class="canManageContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
@ -921,8 +981,8 @@ onBeforeUnmount(() => {
<div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground"> <div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground">
搜索中{{ filteredContracts.length }} / {{ contracts.length }}已关闭拖拽排序 搜索中{{ filteredContracts.length }} / {{ contracts.length }}已关闭拖拽排序
</div> </div>
<div v-if="isExportSelecting" class="mt-1 text-xs text-muted-foreground"> <div v-if="isSelectingContracts" class="mt-1 text-xs text-muted-foreground">
导出选择模式勾选合同段后点击导出已选 {{ selectionMode === 'export' ? '导出选择模式:勾选合同段后点击“导出已选”' : '删除选择模式:勾选合同段后点击“删除已选”' }}
</div> </div>
<div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground"> <div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground">
请先在基础信息里新建项目并选择工程行业后再新增或导入合同段 请先在基础信息里新建项目并选择工程行业后再新增或导入合同段
@ -956,7 +1016,7 @@ onBeforeUnmount(() => {
:key="`contracts-${isListLayout ? 'list' : 'grid'}`" :key="`contracts-${isListLayout ? 'list' : 'grid'}`"
v-model="contracts" v-model="contracts"
item-key="id" item-key="id"
:disabled="isExportSelecting" :disabled="isSelectingContracts"
handle=".contract-drag-handle" handle=".contract-drag-handle"
ghost-class="ht-sortable-ghost" ghost-class="ht-sortable-ghost"
chosen-class="ht-sortable-chosen" chosen-class="ht-sortable-chosen"
@ -974,27 +1034,27 @@ onBeforeUnmount(() => {
<Card <Card
:class="[ :class="[
'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card', 'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
isExportSelecting isSelectingContracts
? 'ht-contract-card--selecting' ? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts : cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter' ? 'ht-contract-card--enter'
: 'ht-contract-card--ready', : 'ht-contract-card--ready',
isContractSelectedForExport(element.id) && 'ht-contract-card--selected', isContractSelected(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0' isListLayout && 'gap-0 py-0'
]" ]"
:style="getContractCardStyle(index)" :style="getContractCardStyle(index)"
@click="handleCardClick(element)" @click="handleCardClick(element)"
> >
<label <label
v-if="isExportSelecting" v-if="isSelectingContracts"
class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm" class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm"
@click.stop @click.stop
> >
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 cursor-pointer" class="h-4 w-4 cursor-pointer"
:checked="isContractSelectedForExport(element.id)" :checked="isContractSelected(element.id)"
@change.stop="toggleExportContractSelection(element.id)" @change.stop="toggleContractSelection(element.id)"
/> />
</label> </label>
<CardHeader <CardHeader
@ -1020,7 +1080,7 @@ onBeforeUnmount(() => {
</template> </template>
</CardTitle> </CardTitle>
<div <div
v-if="!isExportSelecting" v-if="!isSelectingContracts"
:class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']" :class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']"
> >
<TooltipRoot> <TooltipRoot>
@ -1094,27 +1154,27 @@ onBeforeUnmount(() => {
:key="element.id" :key="element.id"
:class="[ :class="[
'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card', 'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
isExportSelecting isSelectingContracts
? 'ht-contract-card--selecting' ? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts : cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter' ? 'ht-contract-card--enter'
: 'ht-contract-card--ready', : 'ht-contract-card--ready',
isContractSelectedForExport(element.id) && 'ht-contract-card--selected', isContractSelected(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0' isListLayout && 'gap-0 py-0'
]" ]"
:style="getContractCardStyle(index)" :style="getContractCardStyle(index)"
@click="handleCardClick(element)" @click="handleCardClick(element)"
> >
<label <label
v-if="isExportSelecting" v-if="isSelectingContracts"
class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm" class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm"
@click.stop @click.stop
> >
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 cursor-pointer" class="h-4 w-4 cursor-pointer"
:checked="isContractSelectedForExport(element.id)" :checked="isContractSelected(element.id)"
@change.stop="toggleExportContractSelection(element.id)" @change.stop="toggleContractSelection(element.id)"
/> />
</label> </label>
<CardHeader <CardHeader
@ -1140,7 +1200,7 @@ onBeforeUnmount(() => {
</template> </template>
</CardTitle> </CardTitle>
<div <div
v-if="!isExportSelecting" v-if="!isSelectingContracts"
:class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']" :class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']"
> >
<TooltipRoot> <TooltipRoot>

View File

@ -4,181 +4,16 @@ import localforage from 'localforage'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue' 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<{ const props = defineProps<{
contractId: string contractId: string
}>() }>()
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const XM_DB_KEY = 'xm-info-v3' const XM_DB_KEY = 'xm-info-v3'
const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryId = ref('')
const detailRows = ref<DetailRow[]>([])
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>()
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<string, DetailRow>()
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<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryId.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
// id
const xmData =
(await localforage.getItem<XmInfoState>(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()
})
</script> </script>
<template> <template>
<CommonAgGrid title="合同规模明细" :rowData="detailRows" :dbKey="DB_KEY" /> <CommonAgGrid title="合同规模明细" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY"/>
</template> </template>

View File

@ -1,183 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { onActivated, onMounted, ref } from 'vue'
import localforage from 'localforage'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue' 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 XmScaleState {
detailRows?: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
const DB_KEY = 'xm-info-v3' const DB_KEY = 'xm-info-v3'
const BASE_INFO_KEY = 'xm-base-info-v1'
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const detailRows = ref<DetailRow[]>([])
const activeIndustryCode = ref('')
const detailDict = ref<DictGroup[]>([])
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>()
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 rebuildDictByIndustry = (industryCode: string) => {
if (!industryCode) {
detailDict.value = []
return
}
const filteredEntries = majorEntries.filter(([id]) => isMajorIdInIndustryScope(id, industryCode))
detailDict.value = buildDetailDict(filteredEntries)
}
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<string, DetailRow>()
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<XmBaseInfoState>(BASE_INFO_KEY)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
rebuildDictByIndustry(activeIndustryCode.value)
if (!activeIndustryCode.value) {
detailRows.value = []
return
}
const data = await localforage.getItem<XmScaleState>(DB_KEY)
if (data?.detailRows) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = []
}
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
</script> </script>
<template> <template>
<CommonAgGrid title="项目明细" :rowData="detailRows" :dbKey="DB_KEY" /> <CommonAgGrid title="项目明细" :dbKey="DB_KEY" />
</template> </template>