fix
This commit is contained in:
parent
d8f8b629d2
commit
626513bc21
@ -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
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -244,10 +441,9 @@ 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)) {
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user