279 lines
7.8 KiB
Vue
279 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import type { ColDef, GridApi, GridOptions, GridReadyEvent } 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 { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
|
import { roundTo, toDecimal } from '@/lib/decimal'
|
|
|
|
interface FeeRow {
|
|
id: string
|
|
feeItem: string
|
|
unit: string
|
|
quantity: number | null
|
|
unitPrice: number | null
|
|
budgetFee: number | null
|
|
remark: string
|
|
}
|
|
|
|
interface FeeGridState {
|
|
detailRows: FeeRow[]
|
|
}
|
|
|
|
const props = defineProps<{
|
|
title: string
|
|
storageKey: string
|
|
}>()
|
|
|
|
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
|
|
|
const createDefaultRow = (): FeeRow => ({
|
|
id: createRowId(),
|
|
feeItem: '',
|
|
unit: '',
|
|
quantity: null,
|
|
unitPrice: null,
|
|
budgetFee: null,
|
|
remark: ''
|
|
})
|
|
|
|
const detailRows = ref<FeeRow[]>([createDefaultRow()])
|
|
const gridApi = ref<GridApi<FeeRow> | null>(null)
|
|
|
|
const formatEditableText = (params: any) => {
|
|
if (params.value == null || params.value === '') return '点击输入'
|
|
return String(params.value)
|
|
}
|
|
|
|
const formatEditableQuantity = (params: any) => {
|
|
if (params.value == null || params.value === '') return '点击输入'
|
|
return formatThousandsFlexible(params.value, 4)
|
|
}
|
|
|
|
const formatEditableUnitPrice = (params: any) => {
|
|
if (params.value == null || params.value === '') return '点击输入'
|
|
return formatThousands(params.value, 2)
|
|
}
|
|
|
|
const formatReadonlyBudgetFee = (params: any) => {
|
|
if (params.value == null || params.value === '') return ''
|
|
return formatThousands(params.value, 2)
|
|
}
|
|
|
|
const syncComputedValuesToRows = () => {
|
|
for (const row of detailRows.value) {
|
|
if (row.quantity == null || row.unitPrice == null) {
|
|
row.budgetFee = null
|
|
continue
|
|
}
|
|
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
|
|
}
|
|
}
|
|
|
|
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
|
|
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
|
|
return [createDefaultRow()]
|
|
}
|
|
|
|
const rows: FeeRow[] = rowsFromDb.map(item => {
|
|
const row = item as Partial<FeeRow>
|
|
return {
|
|
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
|
feeItem: typeof row.feeItem === 'string' ? row.feeItem : '',
|
|
unit: typeof row.unit === 'string' ? row.unit : '',
|
|
quantity: typeof row.quantity === 'number' ? row.quantity : null,
|
|
unitPrice: typeof row.unitPrice === 'number' ? row.unitPrice : null,
|
|
budgetFee: typeof row.budgetFee === 'number' ? row.budgetFee : null,
|
|
remark: typeof row.remark === 'string' ? row.remark : ''
|
|
}
|
|
})
|
|
|
|
return rows.length > 0 ? rows : [createDefaultRow()]
|
|
}
|
|
|
|
const buildPersistDetailRows = () => {
|
|
syncComputedValuesToRows()
|
|
return detailRows.value.map(row => ({ ...row }))
|
|
}
|
|
|
|
const saveToIndexedDB = async () => {
|
|
try {
|
|
const payload: FeeGridState = {
|
|
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
|
}
|
|
await localforage.setItem(props.storageKey, payload)
|
|
} catch (error) {
|
|
console.error('saveToIndexedDB failed:', error)
|
|
}
|
|
}
|
|
|
|
const loadFromIndexedDB = async () => {
|
|
try {
|
|
const data = await localforage.getItem<FeeGridState>(props.storageKey)
|
|
detailRows.value = mergeWithStoredRows(data?.detailRows)
|
|
syncComputedValuesToRows()
|
|
} catch (error) {
|
|
console.error('loadFromIndexedDB failed:', error)
|
|
detailRows.value = [createDefaultRow()]
|
|
syncComputedValuesToRows()
|
|
}
|
|
}
|
|
|
|
const columnDefs: ColDef<FeeRow>[] = [
|
|
{
|
|
headerName: '费用项',
|
|
field: 'feeItem',
|
|
minWidth: 140,
|
|
flex: 1.4,
|
|
editable: true,
|
|
valueFormatter: formatEditableText,
|
|
cellClass: 'editable-cell-line',
|
|
cellClassRules: {
|
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
|
}
|
|
},
|
|
{
|
|
headerName: '单位',
|
|
field: 'unit',
|
|
minWidth: 90,
|
|
flex: 0.9,
|
|
editable: true,
|
|
valueFormatter: formatEditableText,
|
|
cellClass: 'editable-cell-line',
|
|
cellClassRules: {
|
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
|
}
|
|
},
|
|
{
|
|
headerName: '数量',
|
|
field: 'quantity',
|
|
minWidth: 100,
|
|
flex: 1,
|
|
headerClass: 'ag-right-aligned-header',
|
|
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
|
editable: true,
|
|
valueParser: params => parseNumberOrNull(params.newValue, { precision: 4 }),
|
|
valueFormatter: formatEditableQuantity,
|
|
cellClassRules: {
|
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
|
}
|
|
},
|
|
{
|
|
headerName: '单价(元)',
|
|
field: 'unitPrice',
|
|
minWidth: 120,
|
|
flex: 1.1,
|
|
headerClass: 'ag-right-aligned-header',
|
|
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
|
editable: true,
|
|
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
|
valueFormatter: formatEditableUnitPrice,
|
|
cellClassRules: {
|
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
|
}
|
|
},
|
|
{
|
|
headerName: '预算费用(元)',
|
|
field: 'budgetFee',
|
|
minWidth: 130,
|
|
flex: 1.2,
|
|
headerClass: 'ag-right-aligned-header',
|
|
cellClass: 'ag-right-aligned-cell',
|
|
editable: false,
|
|
valueFormatter: formatReadonlyBudgetFee
|
|
},
|
|
{
|
|
headerName: '说明',
|
|
field: 'remark',
|
|
minWidth: 170,
|
|
flex: 2,
|
|
editable: true,
|
|
cellEditor: 'agLargeTextCellEditor',
|
|
wrapText: true,
|
|
autoHeight: true,
|
|
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
|
valueFormatter: formatEditableText,
|
|
cellClass: 'editable-cell-line remark-wrap-cell',
|
|
cellClassRules: {
|
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
|
}
|
|
}
|
|
]
|
|
|
|
const detailGridOptions: GridOptions<FeeRow> = {
|
|
...gridOptions,
|
|
treeData: false
|
|
}
|
|
|
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
|
gridApi.value = event.api
|
|
}
|
|
|
|
const handleCellValueChanged = () => {
|
|
syncComputedValuesToRows()
|
|
gridApi.value?.refreshCells({ columns: ['budgetFee'], force: true })
|
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
|
gridPersistTimer = setTimeout(() => {
|
|
void saveToIndexedDB()
|
|
}, 300)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadFromIndexedDB()
|
|
})
|
|
|
|
onActivated(() => {
|
|
void loadFromIndexedDB()
|
|
})
|
|
|
|
watch(
|
|
() => props.storageKey,
|
|
() => {
|
|
void loadFromIndexedDB()
|
|
}
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
|
gridApi.value = null
|
|
void saveToIndexedDB()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-full min-h-0 flex flex-col">
|
|
<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>
|
|
</div>
|
|
|
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
|
<AgGridVue
|
|
:style="{ height: '100%' }"
|
|
:rowData="detailRows"
|
|
:columnDefs="columnDefs"
|
|
:gridOptions="detailGridOptions"
|
|
:theme="myTheme"
|
|
:treeData="false"
|
|
:localeText="AG_GRID_LOCALE_CN"
|
|
:tooltipShowDelay="500"
|
|
:headerHeight="50"
|
|
:suppressColumnVirtualisation="true"
|
|
:suppressRowVirtualisation="true"
|
|
:cellSelection="{ handle: { mode: 'range' } }"
|
|
:enableClipboard="true"
|
|
:undoRedoCellEditing="true"
|
|
:undoRedoCellEditingLimit="20"
|
|
@grid-ready="handleGridReady"
|
|
@cell-value-changed="handleCellValueChanged"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|