diff --git a/src/components/common/xmCommonAgGrid.vue b/src/components/common/xmCommonAgGrid.vue index 999b6e9..43e6606 100644 --- a/src/components/common/xmCommonAgGrid.vue +++ b/src/components/common/xmCommonAgGrid.vue @@ -119,6 +119,7 @@ const buildDefaultRows = (): DetailRow[] => { const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => { const dbValueMap = new Map() for (const row of rowsFromDb || []) { + if (row?.isGroupRow === true) continue const rowId = String(row.id) dbValueMap.set(rowId, row) const aliasId = majorIdAliasMap.get(rowId) @@ -140,6 +141,45 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => }) } +const buildGroupRows = (rows: DetailRow[]): DetailRow[] => { + const rowById = new Map(rows.map(row => [String(row.id || ''), row] as const)) + const groupRows: DetailRow[] = [] + for (const group of detailDict.value) { + let amountTotal = 0 + let hasAmount = false + let landAreaTotal = 0 + let hasLandArea = false + for (const child of group.children) { + const leaf = rowById.get(String(child.id || '')) + const amount = leaf?.amount + if (typeof amount === 'number' && Number.isFinite(amount)) { + amountTotal += amount + hasAmount = true + } + const landArea = leaf?.landArea + if (typeof landArea === 'number' && Number.isFinite(landArea)) { + landAreaTotal += landArea + hasLandArea = true + } + } + groupRows.push({ + id: group.id, + groupCode: group.code, + groupName: group.name, + majorCode: group.code, + majorName: group.name, + hasCost: true, + hasArea: true, + amount: hasAmount ? roundTo(amountTotal, 3) : null, + landArea: hasLandArea ? roundTo(landAreaTotal, 3) : null, + path: [`${group.code} ${group.name}`], + hide: false, + isGroupRow: true + }) + } + return groupRows +} + const applyPinnedTotalAmount = ( api: GridApi | null | undefined, @@ -239,6 +279,7 @@ interface DetailRow { landArea: number | null path: string[] hide?: boolean + isGroupRow?: boolean } interface XmBaseInfoState { @@ -398,11 +439,13 @@ const pinnedTopRowData = ref([ const saveToIndexedDB = async () => { try { + const leafRows = detailRows.value.map(row => ({ + ...JSON.parse(JSON.stringify(row)), + hide: Boolean(row.hide), + isGroupRow: false + })) const payload: GridPersistState = { - detailRows: detailRows.value.map(row => ({ - ...JSON.parse(JSON.stringify(row)), - hide: Boolean(row.hide) - })) + detailRows: [...leafRows, ...buildGroupRows(leafRows)] } payload.roughCalcEnabled = roughCalcEnabled.value payload.totalAmount = pinnedTopRowData.value[0].amount diff --git a/src/components/views/zxFw.vue b/src/components/views/zxFw.vue index 6facd78..cd328b4 100644 --- a/src/components/views/zxFw.vue +++ b/src/components/views/zxFw.vue @@ -2,7 +2,7 @@ import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import type { ComponentPublicInstance, PropType } from 'vue' import { AgGridVue } from 'ag-grid-vue3' -import type { ColDef, GridOptions, ICellRendererParams, IHeaderParams } from 'ag-grid-community' +import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { addNumbers } from '@/lib/decimal' @@ -14,7 +14,7 @@ import { getPricingMethodTotalsForServices, type PricingMethodTotals } from '@/lib/pricingMethodTotals' -import { Pencil, Eraser, Trash2, CircleHelp } from 'lucide-vue-next' +import { Pencil, Eraser, Trash2 } from 'lucide-vue-next' import { AlertDialogAction, AlertDialogCancel, @@ -554,45 +554,37 @@ const ProcessCellRenderer = defineComponent({ return () => { const row = props.params.data if (!row || isFixedRow(row)) return null - const checked = row.process === 1 - const onToggle = (event: Event) => { + const processValue = row.process === 1 ? 1 : 0 + const onSelect = (event: Event, value: 0 | 1) => { event.stopPropagation() - const target = event.target as HTMLInputElement | null - void props.params.context?.onToggleProcess?.(row.id, Boolean(target?.checked)) + void props.params.context?.onSetProcess?.(row.id, value) } - return h('div', { class: 'flex items-center justify-center w-full' }, [ - h('input', { - type: 'checkbox', - checked, - class: 'cursor-pointer', - onChange: onToggle - }) - ]) - } - } -}) - -const ProcessHeaderRenderer = defineComponent({ - name: 'ProcessHeaderRenderer', - props: { - params: { - type: Object as PropType, - required: true - } - }, - setup(props) { - const tooltipText = '默认为编制,勾选为审核' - props.params.setTooltip?.(tooltipText, () => true) - return () => - h('div', { class: 'flex items-center justify-center gap-1 w-full' }, [ - h('span', props.params.displayName || '工作环节'), - h('span', { class: 'inline-flex items-center pointer-events-auto' }, [ - h(CircleHelp, { - size: 20, - class: 'text-muted-foreground pointer-events-none' - }) + const radioName = `process-${row.id}` + return h('div', { class: 'flex items-center justify-center gap-4 w-full text-sm' }, [ + h('label', { class: 'inline-flex items-center gap-1.5 cursor-pointer' }, [ + h('input', { + type: 'radio', + name: radioName, + checked: processValue === 0, + class: 'cursor-pointer h-4 w-4', + onClick: (event: Event) => event.stopPropagation(), + onChange: (event: Event) => onSelect(event, 0) + }), + h('span', '编制') + ]), + h('label', { class: 'inline-flex items-center gap-1.5 cursor-pointer' }, [ + h('input', { + type: 'radio', + name: radioName, + checked: processValue === 1, + class: 'cursor-pointer h-4 w-4', + onClick: (event: Event) => event.stopPropagation(), + onChange: (event: Event) => onSelect(event, 1) + }), + h('span', '审核') ]) ]) + } } }) @@ -624,10 +616,10 @@ const columnDefs: ColDef[] = [ { headerName: '工作环节', field: 'process', - headerClass: 'ag-center-header', - minWidth: 90, - maxWidth: 110, - flex: 1, + headerClass: 'ag-center-header zxfw-process-header', + minWidth: 170, + maxWidth: 220, + flex: 1.2, editable: false, sortable: false, filter: false, @@ -641,7 +633,6 @@ const columnDefs: ColDef[] = [ if (!params.data || isFixedRow(params.data)) return null return params.data.process === 1 ? 1 : 0 }, - headerComponent: ProcessHeaderRenderer, cellRenderer: ProcessCellRenderer }, { @@ -754,15 +745,17 @@ const detailGridOptions: GridOptions = { treeData: false, getDataPath: undefined, context: { - onToggleProcess: async (rowId: string, checked: boolean) => { + onSetProcess: async (rowId: string, value: 0 | 1) => { const currentState = getCurrentContractState() let changed = false const nextRows = currentState.detailRows.map(row => { if (isFixedRow(row) || String(row.id) !== String(rowId)) return row + const nextValue = value === 1 ? 1 : 0 + if ((row.process === 1 ? 1 : 0) === nextValue) return row changed = true return { ...row, - process: checked ? 1 : 0 + process: nextValue } }) if (!changed) return @@ -1208,3 +1201,13 @@ onBeforeUnmount(() => { + + diff --git a/src/layout/tab.vue b/src/layout/tab.vue index 57f757a..ed7a77f 100644 --- a/src/layout/tab.vue +++ b/src/layout/tab.vue @@ -20,6 +20,11 @@ import { AlertDialogRoot, AlertDialogTitle, AlertDialogTrigger, + ToastDescription, + ToastProvider, + ToastRoot, + ToastTitle, + ToastViewport, } from 'reka-ui' import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive' import { addNumbers, roundTo } from '@/lib/decimal' @@ -62,6 +67,7 @@ interface ScaleRowLike { id: string amount: number | null landArea: number | null + isGroupRow?: boolean } interface XmInfoStorageLike extends XmInfoLike { @@ -457,6 +463,10 @@ const pendingImportPayload = shallowRef(null) const pendingImportFileName = ref('') const userGuideOpen = ref(false) const userGuideStepIndex = ref(0) +const reportExportToastOpen = ref(false) +const reportExportProgress = ref(0) +const reportExportStatus = ref<'running' | 'success' | 'error'>('running') +const reportExportText = ref('') const tabItemElMap = new Map() const tabTitleElMap = new Map() const tabPanelElMap = new Map() @@ -468,6 +478,7 @@ const isTabDragging = ref(false) const tabTitleOverflowMap = ref>({}) let tabStripViewportEl: HTMLElement | null = null let tabTitleOverflowRafId: number | null = null +let reportExportToastTimer: ReturnType | null = null const tabsModel = computed({ get: () => tabStore.tabs, @@ -502,6 +513,31 @@ const closeMenus = () => { dataMenuOpen.value = false } +const clearReportExportToastTimer = () => { + if (!reportExportToastTimer) return + clearTimeout(reportExportToastTimer) + reportExportToastTimer = null +} + +const showReportExportProgress = (progress: number, text: string) => { + clearReportExportToastTimer() + reportExportStatus.value = 'running' + reportExportProgress.value = Math.max(0, Math.min(100, progress)) + reportExportText.value = text + reportExportToastOpen.value = true +} + +const finishReportExportProgress = (success: boolean, text: string) => { + clearReportExportToastTimer() + reportExportStatus.value = success ? 'success' : 'error' + reportExportProgress.value = 100 + reportExportText.value = text + reportExportToastOpen.value = true + reportExportToastTimer = setTimeout(() => { + reportExportToastOpen.value = false + }, success ? 1200 : 1800) +} + const markGuideCompleted = () => { try { localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1') @@ -1016,6 +1052,16 @@ const toExportScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] = .filter((item): item is ExportScaleRow => Boolean(item)) } +const sumLeafScaleCost = (rows: ScaleRowLike[] | undefined) => { + if (!Array.isArray(rows)) return 0 + return sumNumbers( + rows.map(row => { + if (row?.isGroupRow === true) return null + return toFiniteNumber(row?.amount) + }) + ) +} + const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => { @@ -1365,9 +1411,8 @@ const buildExportReportPayload = async (): Promise => { const projectInfo = projectInfoRaw || {} const projectScaleSource = projectScaleRaw || {} - const projectScale = projectScaleSource.roughCalcEnabled ? [] : toExportScaleRows(projectScaleSource.detailRows) - const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost)) + const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumLeafScaleCost(projectScaleSource.detailRows) projectScale.push({ major: -1, cost: projectScaleCost, area: null @@ -1572,14 +1617,17 @@ const exportData = async () => { const exportReport = async () => { try { + showReportExportProgress(10, '正在准备报表导出...') const now = new Date() + showReportExportProgress(40, '正在汇总报表数据...') const payload = await buildExportReportPayload() + showReportExportProgress(80, '正在生成并写出报表文件...') const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}` - console.log(payload) await exportFile(fileName, payload) + finishReportExportProgress(true, '报表导出完成') } catch (error) { console.error('export report failed:', error) - // window.alert('导出报表失败,请重试。') + finishReportExportProgress(false, '报表导出失败,请重试') } finally { dataMenuOpen.value = false } @@ -1710,6 +1758,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + clearReportExportToastTimer() window.removeEventListener('mousedown', handleGlobalMouseDown) window.removeEventListener('keydown', handleGlobalKeyDown) window.removeEventListener('resize', scheduleUpdateTabTitleOverflow) @@ -1761,6 +1810,7 @@ watch(