This commit is contained in:
zwgczx committed 2026-03-10 15:50:07 +08:00
1 parent 1910f15564
commit 5bb6609ef8
18 files changed
+1447 -979

No files matched your search

+173 -14
View File
@@ -1,13 +1,26 @@
<script setup lang="ts">
import { onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import localforage from 'localforage'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal'
import { Button } from '@/components/ui/button'
import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
import { Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeRow {
id: string
@@ -17,6 +30,7 @@ interface FeeRow {
unitPrice: number | null
budgetFee: number | null
remark: string
actions?: unknown
}
interface FeeGridState {
@@ -26,9 +40,13 @@ interface FeeGridState {
const props = defineProps<{
title: string
storageKey: string
syncMainStorageKey?: string
syncRowId?: string
}>()
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
const createDefaultRow = (): FeeRow => ({
id: createRowId(),
@@ -40,20 +58,77 @@ const createDefaultRow = (): FeeRow => ({
remark: ''
})
const detailRows = ref<FeeRow[]>([createDefaultRow()])
const createSubtotalRow = (): FeeRow => ({
id: SUBTOTAL_ROW_ID,
feeItem: '小计',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: 0,
remark: ''
})
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
const ensureSubtotalRow = (rows: FeeRow[]) => {
const normalRows = rows.filter(row => !isSubtotalRow(row))
if (normalRows.length === 0) return []
return [...normalRows, createSubtotalRow()]
}
const detailRows = ref<FeeRow[]>([])
const gridApi = ref<GridApi<FeeRow> | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const addRow = () => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
syncComputedValuesToRows()
void saveToIndexedDB()
}
const deleteRow = (id: string) => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
detailRows.value = ensureSubtotalRow(normalRows)
syncComputedValuesToRows()
void saveToIndexedDB()
}
const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
deleteConfirmOpen.value = true
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const confirmDeleteRow = () => {
const id = pendingDeleteRowId.value
if (!id) return
deleteRow(id)
deleteConfirmOpen.value = false
pendingDeleteRowId.value = null
pendingDeleteRowName.value = ''
}
const formatEditableText = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入'
return String(params.value)
}
const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3)
}
const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3)
}
@@ -64,18 +139,32 @@ const formatReadonlyBudgetFee = (params: any) => {
}
const syncComputedValuesToRows = () => {
let totalBudgetFee = 0
for (const row of detailRows.value) {
if (isSubtotalRow(row)) continue
if (row.quantity == null || row.unitPrice == null) {
row.budgetFee = null
continue
}
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
}
}
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
if (subtotalRow) {
subtotalRow.feeItem = '小计'
subtotalRow.unit = ''
subtotalRow.quantity = null
subtotalRow.unitPrice = null
subtotalRow.budgetFee = totalBudgetFee
subtotalRow.remark = ''
}
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
return [createDefaultRow()]
return []
}
const rows: FeeRow[] = rowsFromDb.map(item => {
@@ -91,7 +180,7 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
}
})
return rows.length > 0 ? rows : [createDefaultRow()]
return ensureSubtotalRow(rows)
}
const buildPersistDetailRows = () => {
@@ -104,7 +193,11 @@ const saveToIndexedDB = async () => {
const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
await localforage.setItem(props.storageKey, payload)
if (props.syncMainStorageKey && props.syncRowId) {
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
@@ -117,7 +210,7 @@ const loadFromIndexedDB = async () => {
syncComputedValuesToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = [createDefaultRow()]
detailRows.value = []
syncComputedValuesToRows()
}
}
@@ -136,18 +229,22 @@ const columnDefs: ColDef<FeeRow>[] = [
valueGetter: params =>
params.node?.rowPinned
? ''
: isSubtotalRow(params.data)
? '小计'
: typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1
: ''
: '',
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
},
{
headerName: '费用项',
field: 'feeItem',
minWidth: 140,
flex: 1.4,
editable: true,
editable: params => !isSubtotalRow(params.data),
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line',
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
@@ -157,7 +254,7 @@ const columnDefs: ColDef<FeeRow>[] = [
field: 'unit',
minWidth: 90,
flex: 0.9,
editable: true,
editable: params => !isSubtotalRow(params.data),
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line',
cellClassRules: {
@@ -171,7 +268,7 @@ const columnDefs: ColDef<FeeRow>[] = [
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: true,
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity,
cellClassRules: {
@@ -185,7 +282,7 @@ const columnDefs: ColDef<FeeRow>[] = [
flex: 1.1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: true,
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice,
cellClassRules: {
@@ -207,7 +304,7 @@ const columnDefs: ColDef<FeeRow>[] = [
field: 'remark',
minWidth: 170,
flex: 2,
editable: true,
editable: params => !isSubtotalRow(params.data),
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
@@ -217,6 +314,47 @@ const columnDefs: ColDef<FeeRow>[] = [
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '操作',
field: 'actions',
minWidth: 92,
maxWidth: 110,
flex: 0.8,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: defineComponent({
name: 'HtFeeGridActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeRow>>,
required: true
}
},
setup(rendererProps) {
return () => {
const row = rendererProps.params.data
if (!row || isSubtotalRow(row)) return null
const onDelete = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
requestDeleteRow(row.id, row.feeItem)
}
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete
},
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
)
}
}
})
}
]
@@ -233,7 +371,7 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
const handleCellValueChanged = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ columns: ['budgetFee'], force: true })
gridApi.value?.refreshCells({ force: true })
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
@@ -267,6 +405,7 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
@@ -292,4 +431,24 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将删除“{{ pendingDeleteRowName }}”这条明细,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>