优化目录

This commit is contained in:
zwgczx committed 2026-03-17 12:05:22 +08:00
1 parent 4e343cccf1
commit 2b18fe9991
46 files changed
+1061 -1308

No files matched your search

+605
View File
@@ -0,0 +1,605 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { expertList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
interface DetailRow {
id: string
expertCode: string
expertName: string
laborBudgetUnitPrice: string
compositeBudgetUnitPrice: string
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
serviceBudget: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: DetailRow[]
}
const props = withDefaults(
defineProps<{
storageKey: string
title?: string
contractId?: string
serviceId?: string | number
enableZxFwSync?: boolean
syncField?: ZxFwPricingField
htMainStorageKey?: string
htRowId?: string
htMethodType?: HtFeeMethodType
}>(),
{
title: '工时法明细',
enableZxFwSync: false,
syncField: 'hourly'
}
)
const zxFwPricingStore = useZxFwPricingStore()
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const paneInstanceCreatedAt = Date.now()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const fallbackDetailRows = ref<DetailRow[]>([])
const gridApi = ref<GridApi<DetailRow> | null>(null)
const serviceMethod = computed<ServicePricingMethod | null>(() => {
if (props.syncField === 'investScale') return 'investScale'
if (props.syncField === 'landScale') return 'landScale'
if (props.syncField === 'workload') return 'workload'
if (props.syncField === 'hourly') return 'hourly'
return null
})
const useServicePricingState = computed(
() => Boolean(props.enableZxFwSync && props.contractId && props.serviceId != null && serviceMethod.value)
)
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const getServiceMethodState = () => {
if (!useServicePricingState.value || !serviceMethod.value) return null
return zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
}
const getHtMethodState = () => {
if (!useHtMethodState.value) return null
return zxFwPricingStore.getHtFeeMethodState<GridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
}
const detailRows = computed<DetailRow[]>({
get: () => {
if (useServicePricingState.value) {
const rows = getServiceMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
}
if (useHtMethodState.value) {
const rows = getHtMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
}
return fallbackDetailRows.value
},
set: rows => {
if (useServicePricingState.value && serviceMethod.value) {
const currentState = getServiceMethodState()
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, {
detailRows: rows,
projectCount: currentState?.projectCount ?? null
})
return
}
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
{ detailRows: rows }
)
return
}
fallbackDetailRows.value = rows
}
})
type ExpertLite = {
code: string
name: string
maxPrice: number | null
minPrice: number | null
defPrice: number | null
manageCoe: number | null
}
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ExpertLite] => {
const item = entry[1]
return Boolean(item?.code && item?.name)
})
const formatPriceRange = (min: number | null, max: number | null) => {
const hasMin = typeof min === 'number' && Number.isFinite(min)
const hasMax = typeof max === 'number' && Number.isFinite(max)
if (hasMin && hasMax) return `${min}-${max}`
if (hasMin) return String(min)
if (hasMax) return String(max)
return ''
}
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
if (typeof expert.manageCoe !== 'number' || !Number.isFinite(expert.manageCoe)) return ''
const min =
typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
: null
const max =
typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
: null
return formatPriceRange(min, max)
}
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
if (
typeof expert.defPrice !== 'number' ||
!Number.isFinite(expert.defPrice) ||
typeof expert.manageCoe !== 'number' ||
!Number.isFinite(expert.manageCoe)
) {
return null
}
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
const buildDefaultRows = (): DetailRow[] => {
const rows: DetailRow[] = []
for (const [expertId, expert] of expertEntries) {
const rowId = `expert-${expertId}`
rows.push({
id: rowId,
expertCode: expert.code,
expertName: expert.name,
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
personnelCount: null,
workdayCount: null,
serviceBudget: null,
remark: '',
path: [rowId]
})
}
return rows
}
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
const dbValueMap = new Map<string, DetailRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice:
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const parseNonNegativeIntegerOrNull = (value: unknown) => {
if (value === '' || value == null) return null
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null
const normalized = String(value).trim()
if (!/^\d+$/.test(normalized)) return null
const v = Number(normalized)
return Number.isSafeInteger(v) ? v : null
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const formatEditableInteger = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
}
const calcServiceBudget = (row: DetailRow | undefined) => {
const adopted = row?.adoptedBudgetUnitPrice
const personnel = row?.personnelCount
const workday = row?.workdayCount
if (
typeof adopted !== 'number' ||
!Number.isFinite(adopted) ||
typeof personnel !== 'number' ||
!Number.isFinite(personnel) ||
typeof workday !== 'number' ||
!Number.isFinite(workday)
) {
return null
}
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
}
const syncServiceBudgetToRows = () => {
for (const row of detailRows.value) {
row.serviceBudget = calcServiceBudget(row)
}
}
const editableNumberCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber,
...extra
})
const editableMoneyCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
},
...extra
})
const readonlyTextCol = <K extends keyof DetailRow>(
field: K,
headerName: string,
extra: Partial<ColDef<DetailRow>> = {}
): ColDef<DetailRow> => ({
headerName,
field,
minWidth: 170,
flex: 1,
editable: false,
valueFormatter: params => params.value || '',
...extra
})
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{
headerName: '编码',
field: 'expertCode',
minWidth: 120,
width: 140,
pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
},
{
headerName: '人员名称',
field: 'expertName',
minWidth: 200,
width: 220,
pinned: 'left',
tooltipField: 'expertName',
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
},
{
headerName: '预算参考单价',
marryChildren: true,
children: [
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
]
},
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
editableNumberCol('personnelCount', '人员数量(人)', {
aggFunc: decimalAggSum,
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
valueFormatter: formatEditableInteger
}),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
{
headerName: '服务预算(元)',
field: 'serviceBudget',
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
valueFormatter: params => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
},
{
headerName: '说明',
field: 'remark',
minWidth: 180,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
}
]
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
if (!Number.isFinite(value)) return null
hasValid = true
return value
})
return hasValid ? total : null
}
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value.filter(e => e.serviceBudget !== null && e.serviceBudget !== undefined), row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
expertCode: '总合计',
expertName: '',
laborBudgetUnitPrice: '',
compositeBudgetUnitPrice: '',
adoptedBudgetUnitPrice: null,
personnelCount: totalPersonnelCount.value,
workdayCount: totalWorkdayCount.value,
serviceBudget: totalServiceBudget.value,
remark: '',
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
syncServiceBudgetToRows()
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
if (useServicePricingState.value && serviceMethod.value) {
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, payload)
} else if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload)
}
if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: props.syncField,
value: totalServiceBudget.value
})
if (!synced) return
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
return
}
const data = useServicePricingState.value && serviceMethod.value
? await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
: useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<GridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<GridState>(props.storageKey)
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
syncServiceBudgetToRows()
return
}
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
syncServiceBudgetToRows()
}
}
const handleCellValueChanged = () => {
syncServiceBudgetToRows()
gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB()
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) return JSON.stringify(params.value)
return params.value
}
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(async () => {
await loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onDeactivated(() => {
gridApi.value?.stopEditing()
void saveToIndexedDB()
})
onBeforeUnmount(() => {
gridApi.value?.stopEditing()
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">{{ props.title }}</h3>
<div class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:gridOptions="gridOptions"
:theme="myTheme"
:treeData="false"
@cell-value-changed="handleCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
/>
</div>
</div>
</div>
</template>
+489
View File
@@ -0,0 +1,489 @@
<script setup lang="ts">
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
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 { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeRow {
id: string
feeItem: string
unit: string
quantity: number | null
unitPrice: number | null
budgetFee: number | null
remark: string
actions?: unknown
}
interface FeeGridState {
detailRows: FeeRow[]
}
const props = defineProps<{
title: string
storageKey: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'quantity-unit-price-fee'
}>()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
const createDefaultRow = (): FeeRow => ({
id: createRowId(),
feeItem: '',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: null,
remark: ''
})
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 fallbackDetailRows = ref<FeeRow[]>([])
const gridApi = ref<GridApi<FeeRow> | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const detailRows = computed<FeeRow[]>({
get: () => {
if (!useHtMethodState.value) return fallbackDetailRows.value
const state = zxFwPricingStore.getHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
const rows = state?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
if (!useHtMethodState.value) {
fallbackDetailRows.value = rows
return
}
zxFwPricingStore.setHtFeeMethodState(props.htMainStorageKey!, props.htRowId!, props.htMethodType!, {
detailRows: rows
})
}
})
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)
}
const formatReadonlyBudgetFee = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
const syncComputedValuesToRows = () => {
let totalBudgetFee = 0
let hasValidRow = false
for (const row of detailRows.value) {
if (isSubtotalRow(row)) continue
const hasFeeItem = typeof row.feeItem === 'string' && row.feeItem.trim() !== ''
const hasUnit = typeof row.unit === 'string' && row.unit.trim() !== ''
const quantity = typeof row.quantity === 'number' && Number.isFinite(row.quantity) ? row.quantity : null
const unitPrice = typeof row.unitPrice === 'number' && Number.isFinite(row.unitPrice) ? row.unitPrice : null
if (!hasFeeItem || !hasUnit || quantity == null || unitPrice == null) {
row.budgetFee = null
continue
}
row.budgetFee = roundTo(toDecimal(quantity).mul(unitPrice), 2)
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
hasValidRow = true
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 = hasValidRow ? totalBudgetFee : null
subtotalRow.remark = ''
}
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
return []
}
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 ensureSubtotalRow(rows)
}
const buildPersistDetailRows = () => {
syncComputedValuesToRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => {
try {
const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<FeeGridState>(props.storageKey)
detailRows.value = mergeWithStoredRows(data?.detailRows)
syncComputedValuesToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = []
syncComputedValuesToRows()
}
}
const columnDefs: ColDef<FeeRow>[] = [
{
headerName: '序号',
colId: 'rowNo',
minWidth: 68,
maxWidth: 80,
flex: 0.6,
editable: false,
sortable: false,
filter: false,
cellStyle: { textAlign: 'center' },
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: params => !isSubtotalRow(params.data),
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
valueFormatter: formatEditableText,
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '单位',
field: 'unit',
minWidth: 90,
flex: 0.9,
editable: params => !isSubtotalRow(params.data),
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: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
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: params => !isSubtotalRow(params.data),
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: params => !isSubtotalRow(params.data),
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 === ''
}
},
{
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', '删除')]
)
}
}
})
}
]
const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions,
treeData: false
}
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api
}
const handleCellValueChanged = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onBeforeUnmount(() => {
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>
<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">
<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>
<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>
+740
View File
@@ -0,0 +1,740 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { Pencil, Eraser } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeMethodRow {
id: string
name: string
rateFee: number | null
hourlyFee: number | null
quantityUnitPriceFee: number | null
subtotal?: number | null
actions?: unknown
}
interface FeeMethodState {
detailRows: FeeMethodRow[]
}
interface MethodRateState {
rate?: unknown
budgetFee?: unknown
}
interface MethodHourlyRowLike {
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
}
interface MethodHourlyState {
detailRows?: MethodHourlyRowLike[]
}
interface MethodQuantityRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface MethodQuantityState {
detailRows?: MethodQuantityRowLike[]
}
interface LegacyFeeRow {
id?: string
feeItem?: string
budgetFee?: number | null
quantity?: number | null
unitPrice?: number | null
}
const props = defineProps<{
title: string
storageKey: string
contractId?: string
contractName?: string
fixedNames?: string[]
}>()
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const createDefaultRow = (name = ''): FeeMethodRow => ({
id: createRowId(),
name,
rateFee: null,
hourlyFee: null,
quantityUnitPriceFee: null
})
const SUMMARY_ROW_ID = 'fee-method-summary'
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
const toFinite = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? value : 0
const round3 = (value: number) => Number(value.toFixed(3))
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
let hasValid = false
let total = 0
for (const row of rows) {
const value = pick(row)
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total += value
hasValid = true
}
return hasValid ? round3(total) : null
}
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
if (!row) return null
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value))
if (!hasValid) return null
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
}
const toFiniteUnknown = (value: unknown): number | null => {
if (value == null || value === '') return null
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
if (!Array.isArray(rows) || rows.length === 0) return null
return sumNullableField(rows, row => getRowSubtotal(row))
}
const loadAdditionalWorkFeeTotal = async (contractId: string): Promise<number | null> => {
try {
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
const additionalState = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(additionalStorageKey)
return sumMainStateSubtotal(additionalState?.detailRows)
} catch (error) {
console.error('loadAdditionalWorkFeeTotal failed:', error)
return null
}
}
const loadContractServiceFeeBase = async (): Promise<number | null> => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return null
try {
await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveStorageKey(props.storageKey)) {
return serviceBase == null ? null : round3(serviceBase)
}
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
if (!hasAnyBase) return null
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal))
} catch (error) {
console.error('loadContractServiceFeeBase failed:', error)
return null
}
}
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.serviceBudget!== null):[]
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
const rowBudget = toFiniteUnknown(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
hasValid = true
continue
}
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteUnknown(row?.personnelCount)
const workday = toFiniteUnknown(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? round3(total) : null
}
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.budgetFee!== null):[]
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteUnknown(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteUnknown(row?.quantity)
const unitPrice = toFiniteUnknown(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? round3(total) : null
}
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
if (!Array.isArray(rows) || rows.length === 0) return rows
const contractBase = await loadContractServiceFeeBase()
const hydratedRows = await Promise.all(
rows.map(async row => {
if (!row?.id) return row
const [rateData, hourlyData, quantityData] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<MethodRateState>(props.storageKey, row.id, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodHourlyState>(props.storageKey, row.id, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
])
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
const rateValue = toFiniteUnknown(rateData?.rate)
const rateFee =
contractBase != null && rateValue != null
? round3(contractBase * rateValue)
: storedRateFee != null
? round3(storedRateFee)
: null
const hourlyFee = sumHourlyMethodFee(hourlyData)
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
return {
...row,
rateFee,
hourlyFee,
quantityUnitPriceFee
}
})
)
return hydratedRows
}
const fixedNames = computed(() =>
Array.isArray(props.fixedNames)
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
: []
)
const hasFixedNames = computed(() => fixedNames.value.length > 0)
const detailRows = computed<FeeMethodRow[]>({
get: () => {
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setHtFeeMainState(props.storageKey, {
detailRows: rows
})
}
})
const summaryRow = computed<FeeMethodRow>(() => {
const rateFee = sumNullableField(detailRows.value, row => row.rateFee)
const hourlyFee = sumNullableField(detailRows.value, row => row.hourlyFee)
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
const result: FeeMethodRow = {
id: SUMMARY_ROW_ID,
name: '小计',
rateFee,
hourlyFee,
quantityUnitPriceFee
}
result.subtotal = getRowSubtotal(result)
return result
})
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
const clearConfirmOpen = ref(false)
const pendingClearRowId = ref<string | null>(null)
const pendingClearRowName = ref('')
const lastSavedSnapshot = ref('')
const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id
pendingClearRowName.value = String(name || '').trim() || '当前行'
clearConfirmOpen.value = true
}
const handleClearConfirmOpenChange = (open: boolean) => {
clearConfirmOpen.value = open
}
const confirmClearRow = async () => {
const id = pendingClearRowId.value
if (!id) return
await clearRow(id)
clearConfirmOpen.value = false
pendingClearRowId.value = null
pendingClearRowName.value = ''
}
const formatEditableText = (params: any) => {
if (params.value == null || params.value === '') {
if (isSummaryRow(params.data)) return ''
return ''
}
return String(params.value)
}
const formatEditableNumber = (params: any) => {
if (params.value == null || params.value === '') {
if (isSummaryRow(params.data)) return ''
return ''
}
return formatThousandsFlexible(params.value, 3)
}
const numericParser = (newValue: any): number | null =>
parseNumberOrNull(newValue, { precision: 3 })
const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) return row.budgetFee
if (
typeof row.quantity === 'number' &&
Number.isFinite(row.quantity) &&
typeof row.unitPrice === 'number' &&
Number.isFinite(row.unitPrice)
) {
return Number((row.quantity * row.unitPrice).toFixed(2))
}
return null
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
)
const rows = sourceRows.map(item => {
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
return {
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
name:
typeof row.name === 'string'
? row.name
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
quantityUnitPriceFee:
typeof row.quantityUnitPriceFee === 'number'
? row.quantityUnitPriceFee
: toLegacyQuantityUnitPriceFee(row)
} as FeeMethodRow
})
if (hasFixedNames.value) {
const byName = new Map(rows.map(row => [row.name, row]))
return fixedNames.value.map((name, index) => {
const fromDb = byName.get(name)
return {
id: fromDb?.id || `fee-method-fixed-${index}`,
name,
rateFee: fromDb?.rateFee ?? null,
hourlyFee: fromDb?.hourlyFee ?? null,
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
}
})
}
return rows.length > 0 ? rows : [createDefaultRow()]
}
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async (force = false) => {
try {
const payload: FeeMethodState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
const snapshot = JSON.stringify(payload.detailRows)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setHtFeeMainState(props.storageKey, payload, { force })
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
const mergedRows = mergeWithStoredRows(data?.detailRows)
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB(true)
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
const mergedRows = mergeWithStoredRows([])
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB(true)
}
}
const addRow = () => {
detailRows.value = [...detailRows.value, createDefaultRow()]
void saveToIndexedDB()
}
const clearRow = async (id: string) => {
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
await nextTick()
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'rate-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'hourly-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'quantity-unit-price-fee')
detailRows.value = detailRows.value.map(row =>
row.id !== id
? row
: {
...row,
rateFee: null,
hourlyFee: null,
quantityUnitPriceFee: null
}
)
await saveToIndexedDB()
}
const editRow = (id: string) => {
const row = detailRows.value.find(item => item.id === id)
if (!row) return
tabStore.openTab({
id: `ht-fee-edit-${props.storageKey}-${id}`,
title: `费用编辑-${row.name || '未命名'}`,
componentName: 'HtFeeMethodTypeLineView',
props: {
sourceTitle: props.title,
storageKey: props.storageKey,
rowId: id,
rowName: row.name || '',
contractId: props.contractId,
contractName: props.contractName
}
})
}
const ActionCellRenderer = defineComponent({
name: 'HtFeeMethodActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeMethodRow>>,
required: true
}
},
setup(props) {
return () => {
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const rowId = props.params.data?.id
if (!rowId) return
if (action === 'edit') {
props.params.context?.onActionEdit?.(rowId)
return
}
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
}
return h('div', { class: 'zxfw-action-wrap' }, [
h('div', { class: 'zxfw-action-group' }, [
h('button', {
class: 'zxfw-action-btn',
'data-action': 'edit',
type: 'button',
onClick: onActionClick('edit')
}, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑')
]),
h('button', {
class: 'zxfw-action-btn zxfw-action-btn--danger',
'data-action': 'clear',
type: 'button',
onClick: onActionClick('clear')
}, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '清空')
])
])
])
}
}
})
const columnDefs: ColDef<FeeMethodRow>[] = [
{
headerName: '名字',
field: 'name',
minWidth: 180,
flex: 1.8,
editable: false,
valueFormatter: formatEditableText,
cellClass: params =>
params.context?.fixedNames === true || isSummaryRow(params.data)
? ''
: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '费率计取',
field: 'rateFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '工时法',
field: 'hourlyFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '数量单价',
field: 'quantityUnitPriceFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '小计',
field: 'subtotal',
minWidth: 140,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueGetter: params => getRowSubtotal(params.data),
valueFormatter: formatEditableNumber
},
{
headerName: '操作',
field: 'actions',
minWidth: 220,
flex: 1.6,
maxWidth: 260,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: ActionCellRenderer
}
]
const detailGridOptions: GridOptions<FeeMethodRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
context: {
fixedNames: hasFixedNames.value,
onActionEdit: editRow,
onActionClear: clearRow,
onActionRequestClear: requestClearRow
},
onCellClicked: params => {
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
const target = params.event?.target as HTMLElement | null
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
const action = btn?.dataset.action
if (action === 'edit') {
editRow(params.data.id)
return
}
if (action === 'clear') {
requestClearRow(params.data.id, params.data.name)
return
}
}
}
let reloadTimer: ReturnType<typeof setTimeout> | null = null
const scheduleReloadFromStorage = () => {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
void loadFromIndexedDB()
}, 80)
}
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
gridApi.value = event.api
}
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
if (isSummaryRow(event.data)) return
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
scheduleReloadFromStorage()
})
const storageKeyRef = computed(() => props.storageKey)
const reserveAdditionalWorkKeyVersion = computed(() => {
if (!isReserveStorageKey(props.storageKey)) return 0
const contractId = String(props.contractId || '').trim()
if (!contractId) return 0
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
})
watch(storageKeyRef, () => {
scheduleReloadFromStorage()
})
watch(
() =>
detailRows.value
.map(row => {
const rateKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'rate-fee')
const hourlyKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'hourly-fee')
const quantityKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'quantity-unit-price-fee')
return `${row.id}:${zxFwPricingStore.getKeyVersion(rateKey)}:${zxFwPricingStore.getKeyVersion(hourlyKey)}:${zxFwPricingStore.getKeyVersion(quantityKey)}`
})
.join('|'),
(nextSignature, prevSignature) => {
if (!nextSignature && !prevSignature) return
if (nextSignature === prevSignature) return
scheduleReloadFromStorage()
}
)
watch(
() => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return 0
return zxFwPricingStore.contractVersions[contractId] || 0
},
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
scheduleReloadFromStorage()
}
)
watch(
reserveAdditionalWorkKeyVersion,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion) return
scheduleReloadFromStorage()
}
)
watch([hasFixedNames], () => {
if (!detailGridOptions.context) return
detailGridOptions.context.fixedNames = hasFixedNames.value
detailGridOptions.context.onActionEdit = editRow
detailGridOptions.context.onActionClear = clearRow
detailGridOptions.context.onActionRequestClear = requestClearRow
gridApi.value?.refreshCells({ force: true })
})
onBeforeUnmount(() => {
if (reloadTimer) clearTimeout(reloadTimer)
gridApi.value = null
void saveToIndexedDB(true)
})
</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>
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="displayRows"
: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>
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
<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">
将清空“{{ pendingClearRowName }}”及其编辑页面的可填和自动计算数据,是否继续?
</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="confirmClearRow">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
title?: string
message: string
}>(),
{
title: '该服务不适用当前计价方法'
}
)
</script>
<template>
<div
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
>
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title }}</p>
<p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
</div>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
interface ServiceItem {
id: string
code: string
name: string
}
const props = defineProps<{
services: ServiceItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const selectedSet = computed(() => new Set(props.modelValue))
const toggleService = (id: string, checked: boolean) => {
const next = new Set(props.modelValue)
if (checked) next.add(id)
else next.delete(id)
emit('update:modelValue', props.services.map(item => item.id).filter(itemId => next.has(itemId)))
}
const clearAll = () => {
emit('update:modelValue', [])
}
</script>
<template>
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
<div class="mb-1 flex items-center justify-between gap-2">
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label>
<button
type="button"
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
@click="clearAll"
>
清空
</button>
</div>
<div class="rounded-md border p-1.5">
<div class="flex flex-wrap items-start gap-1">
<label
v-for="item in props.services"
:key="item.id"
class="inline-flex w-fit max-w-full cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4 hover:bg-muted/60"
>
<input
type="checkbox"
class="mt-0.5"
:checked="selectedSet.has(item.id)"
@change="toggleService(item.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
<span class="text-foreground break-words">{{ item.name }}</span>
</label>
</div>
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
暂无服务
</div>
</div>
</div>
</template>
+320
View File
@@ -0,0 +1,320 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type {
CellValueChangedEvent,
ColDef,
GridApi,
GridReadyEvent,
ICellRendererParams,
ValueFormatterParams
} from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { Button } from '@/components/ui/button'
import { myTheme } from '@/lib/diyAgGridOptions'
import { workList } from '@/sql'
import type { WorkType } from '@/sql'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface WorkContentRow {
id: string
content: string
type: WorkType
remark: string
checked: boolean
custom: boolean
path: string[]
}
interface WorkContentState {
detailRows: WorkContentRow[]
}
// dictMode: 'service' 按serviceId筛选workList;'additional' 取serviceid=-1的数据;'none' 不加载词典
const props = withDefaults(defineProps<{
title?: string
storageKey: string
serviceId?: number | string
dictMode?: 'service' | 'additional' | 'none'
}>(), {
title: '工作内容',
dictMode: 'none'
})
const emit = defineEmits<{
checkedChange: [value: string[]]
}>()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = ref<GridApi<WorkContentRow> | null>(null)
const rowData = ref<WorkContentRow[]>([])
const TYPE_LABEL_MAP: Record<number, WorkType> = {
0: '基本工作',
1: '可选工作',
2: '日常顾问',
3: '专项顾问',
4: '附加工作'
}
const buildDefaultRowsFromDict = (): WorkContentRow[] => {
const rows: WorkContentRow[] = []
const entries = Object.values(workList) as Array<{ text: string; serviceid: number; order: number; type: number }>
let filtered: typeof entries
if (props.dictMode === 'service') {
const sid = Number(props.serviceId)
filtered = entries.filter(e => e.serviceid === sid)
} else if (props.dictMode === 'additional') {
filtered = entries.filter(e => e.serviceid === -1)
} else {
return []
}
filtered.sort((a, b) => a.order - b.order)
for (const entry of filtered) {
const content = String(entry.text || '').trim()
if (!content) continue
const typeLabel = TYPE_LABEL_MAP[entry.type] ?? '基本工作'
rows.push({
id: `dict-${entry.order}`,
content,
type: typeLabel,
remark: '',
checked: false,
custom: false,
path: [typeLabel, content]
})
}
return rows
}
const checkedIds = computed(() =>
rowData.value.filter(item => item.checked).map(item => item.id)
)
// 导出用:自定义内容全部包含,默认词典内容只含勾选的
const selectedTexts = computed(() =>
rowData.value
.filter(item => item.custom || item.checked)
.map(item => item.content)
.filter(Boolean)
)
defineExpose({ selectedTexts })
const emitCheckedChange = () => {
emit('checkedChange', [...checkedIds.value])
}
const saveToStore = () => {
const payload: WorkContentState = {
detailRows: rowData.value.map(item => ({ ...item }))
}
zxFwPricingStore.setKeyState(props.storageKey, payload)
emitCheckedChange()
}
const loadFromStore = async () => {
const state = await zxFwPricingStore.loadKeyState<WorkContentState>(props.storageKey)
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
rowData.value = state.detailRows.map(item => ({
...item,
type: item.custom ? '自定义' : (item.type || '基本工作'),
path: Array.isArray(item.path) && item.path.length ? item.path : ['自定义', item.content || '未命名']
})) as WorkContentRow[]
} else {
rowData.value = buildDefaultRowsFromDict()
saveToStore()
}
emitCheckedChange()
}
const handleCheckedToggle = (id: string, checked: boolean) => {
const target = rowData.value.find(item => item.id === id)
if (!target) return
target.checked = checked
gridApi.value?.refreshCells({ force: true })
saveToStore()
}
const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
const data = params.data
if (!data) return ''
const wrapper = document.createElement('div')
wrapper.className = 'work-content-cell'
// 自定义行不显示 checkbox,直接显示文本;空时显示 placeholder
if (data.custom) {
const label = document.createElement('span')
if (!data.content) {
label.className = 'work-content-placeholder'
label.textContent = '点击输入工作内容'
} else {
label.className = 'work-content-text'
label.textContent = data.content
}
wrapper.appendChild(label)
return wrapper
}
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'work-content-check'
checkbox.checked = Boolean(data.checked)
checkbox.addEventListener('change', () => {
handleCheckedToggle(data.id, checkbox.checked)
})
const label = document.createElement('span')
label.className = 'work-content-text'
label.textContent = String(data.content || '')
wrapper.appendChild(checkbox)
wrapper.appendChild(label)
return wrapper
}
const columnDefs: ColDef<WorkContentRow>[] = [
{
headerName: '序号',
minWidth: 60,
width: 70,
pinned: 'left',
suppressMovable: true,
editable: false,
valueGetter: params => (params.node?.rowIndex ?? 0) + 1
},
{
headerName: '工作内容',
field: 'content',
minWidth: 320,
flex: 2,
editable: params => Boolean(params.data?.custom),
cellClass: params => (params.data?.custom ? 'editable-cell-line' : ''),
valueParser: params => String(params.newValue || '').trim(),
cellRenderer: contentCellRenderer
},
{
headerName: '工作类型',
field: 'type',
minWidth: 100,
width: 120,
editable: false,
valueFormatter: (params: ValueFormatterParams<WorkContentRow>) => String(params.value || '')
},
{
headerName: '备注',
field: 'remark',
minWidth: 180,
flex: 1.2,
editable: true,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
cellClass: 'editable-cell-line remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
},
valueFormatter: params => params.value || '点击输入'
}
]
const addCustomRow = () => {
const ts = Date.now()
rowData.value.push({
id: `custom-${ts}`,
content: '',
type: '自定义' as WorkType,
remark: '',
checked: false,
custom: true,
path: ['自定义', `自定义-${ts}`]
})
saveToStore()
}
const onGridReady = (event: GridReadyEvent<WorkContentRow>) => {
gridApi.value = event.api
}
const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
const row = event.data
if (!row) return
if (event.colDef.field === 'content' && row.custom) {
row.path = ['自定义', row.content || `自定义-${row.id}`]
}
if (event.colDef.field === 'type' && row.custom) {
row.type = '自定义'
}
saveToStore()
}
onMounted(() => {
void loadFromStore()
})
onBeforeUnmount(() => {
saveToStore()
})
</script>
<template>
<div class="h-full min-h-0 xmMx">
<div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-border/60 px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3>
<Button type="button" size="sm" variant="outline" class="cursor-pointer" @click="addCustomRow">
添加自定义内容
</Button>
</div>
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="rowData"
:columnDefs="columnDefs"
:theme="myTheme"
:getRowId="(params: { data: WorkContentRow }) => params.data.id"
:animateRows="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
:defaultColDef="{ resizable: true, sortable: false, filter: false }"
:suppressColumnVirtualisation="false"
:suppressRowVirtualisation="false"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
/>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.work-content-placeholder) {
color: #94a3b8;
font-style: italic;
min-width: 0;
flex: 1;
}
:deep(.work-content-cell) {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
}
:deep(.work-content-text) {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.work-content-check) {
width: 14px;
height: 14px;
cursor: pointer;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { useKvStore } from '@/pinia/kv'
interface DictItem {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
order?: number | null
}
interface FactorRow {
id: string
code: string
name: string
standardFactor: number | null
budgetValue: number | null
remark: string
path: string[]
}
interface GridState {
detailRows: FactorRow[]
}
type DictSource = Record<string, DictItem>
const props = defineProps<{
title: string
storageKey: string
parentStorageKey?: string
dict: DictSource
disableBudgetEditWhenStandardNull?: boolean
excludeNotshowByZxflxs?: boolean
initBudgetValueFromStandard?: boolean
}>()
const kvStore = useKvStore()
const detailRows = ref<FactorRow[]>([])
const gridApi = ref<GridApi<FactorRow> | null>(null)
const formatReadonlyFactor = (value: unknown) => {
if (value == null || value === '') return ''
const parsed = parseNumberOrNull(value, { precision: 3 })
if (parsed == null) return ''
return String(Number(parsed))
}
const formatEditableFactor = (params: any) => {
if (params.value == null || params.value === '') return '点击输入'
const parsed = parseNumberOrNull(params.value, { precision: 3 })
if (parsed == null) return ''
return String(Number(parsed))
}
const sortedDictEntries = () =>
Object.entries(props.dict)
.filter((entry): entry is [string, DictItem] => {
const item = entry[1]
if (!item?.code || !item?.name) return false
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
return true
})
.sort((a, b) => {
const aOrder = Number(a[1]?.order)
const bOrder = Number(b[1]?.order)
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
})
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
const parts = code.split('-').filter(Boolean)
if (!parts.length) return [selfId]
const path: string[] = []
let currentCode = parts[0]
const firstId = codeIdMap.get(currentCode)
if (firstId) path.push(firstId)
for (let i = 1; i < parts.length; i += 1) {
currentCode = `${currentCode}-${parts[i]}`
const id = codeIdMap.get(currentCode)
if (id) path.push(id)
}
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
return path
}
const buildDefaultRows = (): FactorRow[] => {
const entries = sortedDictEntries()
const codeIdMap = new Map<string, string>()
for (const [id, item] of entries) {
codeIdMap.set(item.code, id)
}
return entries.map(([id, item]) => {
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
return {
id,
code: item.code,
name: item.name,
standardFactor,
budgetValue: props.initBudgetValueFromStandard ? standardFactor : null,
remark: '',
path: buildCodePath(item.code, id, codeIdMap)
}
})
}
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
const hasMeaningfulFactorValue = (rows: SourceRow[] | undefined) =>
Array.isArray(rows) &&
rows.some(row => {
const hasBudgetValue = typeof row?.budgetValue === 'number' && Number.isFinite(row.budgetValue)
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
return hasBudgetValue || hasRemark
})
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
}
return buildDefaultRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
return {
...row,
budgetValue:
typeof fromDb.budgetValue === 'number'
? fromDb.budgetValue
: hasBudgetValue
? null
: row.budgetValue,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
}
})
}
const columnDefs: ColDef<FactorRow>[] = [
{
headerName: '标准系数',
field: 'standardFactor',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
flex: 0.9,
valueFormatter: params => formatReadonlyFactor(params.value)
},
{
headerName: '预算取值',
field: 'budgetValue',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line'
},
flex: 0.9,
cellClassRules: {
'editable-cell-empty': params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return !disabled && (params.value == null || params.value === '')
}
},
editable: params => {
if (!props.disableBudgetEditWhenStandardNull) return true
return params.data?.standardFactor != null
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
if (disabled && (params.value == null || params.value === '')) return ''
return formatEditableFactor(params)
}
},
{
headerName: '说明',
field: 'remark',
minWidth: 170,
flex: 2.4,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: true,
valueFormatter: params => params.value || '点击输入',
cellClass: 'editable-cell-line remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
}
]
const autoGroupColumnDef: ColDef<FactorRow> = {
headerName: '专业编码以及工程专业名称',
minWidth: 220,
flex: 2.2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
const key = String(params.node?.key || '')
const dictItem = (props.dict as DictSource)[key]
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
}
}
const saveToIndexedDB = async () => {
try {
const payload: GridState = {
detailRows: JSON.parse(JSON.stringify(detailRows.value))
}
await kvStore.setItem(props.storageKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
if (!storageKey) return null
const data = await kvStore.getItem<GridState>(storageKey)
if (!data?.detailRows || !Array.isArray(data.detailRows)) return null
return data
}
const loadFromIndexedDB = async () => {
try {
const data = await loadGridState(props.storageKey)
if (data && hasMeaningfulFactorValue(data.detailRows)) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
const parentStorageKey = props.parentStorageKey?.trim()
if (parentStorageKey) {
const parentData = await loadGridState(parentStorageKey)
if (parentData && hasMeaningfulFactorValue(parentData.detailRows)) {
detailRows.value = mergeWithDictRows(parentData.detailRows)
await saveToIndexedDB()
return
}
}
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
await saveToIndexedDB()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 500)
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (_error) {
return params.value
}
return params.value
}
onMounted(async () => {
await loadFromIndexedDB()
})
watch(
() => props.dict,
() => {
void loadFromIndexedDB()
},
{ deep: true }
)
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 class="text-xs text-muted-foreground"></div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
:treeData="true"
@cell-value-changed="handleCellValueChanged"
: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>
</template>
+640
View File
@@ -0,0 +1,640 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { useKvStore } from '@/pinia/kv'
interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
id: string
code: string
name: string
children: DictLeaf[]
}
interface XmScaleState {
detailRows?: DetailRow[]
totalAmount: number
roughCalcEnabled: boolean
}
interface XmBaseInfoState {
projectIndustry?: string
}
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const kvStore = useKvStore()
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 || []) {
if (row?.isGroupRow === true) continue
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 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<DetailRow> | null | undefined,
totalAmount: number | null | undefined
) => {
const normalized = typeof totalAmount === 'number' && Number.isFinite(totalAmount)
? roundTo(totalAmount, 2)
: null
pinnedTopRowData.value[0].amount = normalized
const pinnedTopNode = api?.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', normalized)
}
}
const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
try {
const [baseInfo, contractData] = await Promise.all([
kvStore.getItem<XmBaseInfoState>(props.baseInfoKey || 'xm-base-info-v1'),
kvStore.getItem<XmScaleState>(props.dbKey)
])
activeIndustryId.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
if (!activeIndustryId.value) {
detailDict.value = []
detailRows.value = []
roughCalcEnabled.value = false
applyPinnedTotalAmount(api, null)
return
}
const filteredEntries = majorEntries.filter(([id]) =>
isMajorIdInIndustryScope(id, activeIndustryId.value)
)
detailDict.value = buildDetailDict(filteredEntries)
roughCalcEnabled.value = Boolean(contractData?.roughCalcEnabled)
applyPinnedTotalAmount(api, contractData?.totalAmount)
const contractRows = Array.isArray(contractData?.detailRows) ? contractData.detailRows : []
const hasContractRows = contractRows.length > 0
const hasContractScaleValue = hasContractRows
? contractRows.some(row => {
const amount = row?.amount
const landArea = row?.landArea
return (
(typeof amount === 'number' && Number.isFinite(amount)) ||
(typeof landArea === 'number' && Number.isFinite(landArea))
)
})
: false
const isLegacyEmptyScaleRows =
hasContractRows &&
!hasContractScaleValue &&
!roughCalcEnabled.value &&
typeof contractData?.totalAmount === 'number' &&
Number.isFinite(contractData.totalAmount)
if (hasContractRows && !isLegacyEmptyScaleRows) {
detailRows.value = mergeWithDictRows(contractRows)
return
}
if (props.xmInfoKey) {
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
const xmData = await kvStore.getItem<XmScaleState>(props.xmInfoKey)
roughCalcEnabled.value = Boolean(xmData?.roughCalcEnabled)
applyPinnedTotalAmount(api, xmData?.totalAmount)
if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) {
detailRows.value = mergeWithDictRows(xmData.detailRows)
return
}
}
detailRows.value = buildDefaultRows()
void saveToIndexedDB()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
activeIndustryId.value = ''
detailRows.value = []
roughCalcEnabled.value = false
applyPinnedTotalAmount(api, null)
}
}
interface DetailRow {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
path: string[]
hide?: boolean
isGroupRow?: boolean
}
interface XmBaseInfoState {
projectIndustry?: string
}
interface GridPersistState {
detailRows?: DetailRow[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
const props = defineProps<{
title: string
dbKey: string
xmInfoKey?: string | null
baseInfoKey?: string
}>()
let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('')
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const roughCalcEnabled = ref(false)
const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) })
const refreshPinnedTotalLabelCell = () => {
if (!gridApi.value) return
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (!pinnedTopNode) return
gridApi.value.refreshCells({
rowNodes: [pinnedTopNode],
force: true
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => {
if (roughCalcEnabled.value) return Boolean(params.node?.rowPinned)
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
},
cellClass: params =>
roughCalcEnabled.value && params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
roughCalcEnabled.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (roughCalcEnabled.value) {
if (!params.node?.rowPinned) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3)
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
},
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},
valueParser: params => {
if (params.newValue === '' || params.newValue == null) return null
const v = Number(params.newValue)
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (roughCalcEnabled.value) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 200,
flex: 2,
cellRendererParams: {
suppressCount: true
},
valueFormatter: params => {
if (params.node?.rowPinned) return totalLabel.value
return String(params.value || '')
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value
return String(params.value || '')
}
}
const pinnedTopRowData = ref<DetailRow[]>([
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: null,
landArea: null,
path: ['TOTAL']
}
])
const saveToIndexedDB = async () => {
try {
const leafRows = detailRows.value.map(row => ({
...JSON.parse(JSON.stringify(row)),
hide: Boolean(row.hide),
isGroupRow: false
}))
const totalAmountFromRows = (() => {
let hasValue = false
let total = 0
for (const row of leafRows) {
const amount = row?.amount
if (typeof amount !== 'number' || !Number.isFinite(amount)) continue
total += amount
hasValue = true
}
return hasValue ? roundTo(total, 2) : null
})()
const pinnedAmount = pinnedTopRowData.value[0].amount
const normalizedPinnedAmount =
typeof pinnedAmount === 'number' && Number.isFinite(pinnedAmount)
? roundTo(pinnedAmount, 2)
: null
const normalizedTotalAmount = roughCalcEnabled.value ? normalizedPinnedAmount : totalAmountFromRows
pinnedTopRowData.value[0].amount = normalizedTotalAmount
const payload: GridPersistState = {
detailRows: [...leafRows, ...buildGroupRows(leafRows)]
}
payload.roughCalcEnabled = roughCalcEnabled.value
payload.totalAmount = normalizedTotalAmount
await kvStore.setItem(props.dbKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 600)
}
const handleFlushPersistRequest = (event: Event) => {
const customEvent = event as CustomEvent<{ done?: () => void }>
const done = customEvent?.detail?.done
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
void saveToIndexedDB().finally(() => {
done?.()
})
}
const setDetailRowsHidden = (hidden: boolean) => {
for (const row of detailRows.value) {
row.hide = hidden
}
}
let oldValue:number|null
const onRoughCalcSwitch = (checked: boolean) => {
gridApi.value?.stopEditing(true)
roughCalcEnabled.value = checked
setDetailRowsHidden(checked)
if (!checked) {
oldValue=pinnedTopRowData.value[0].amount
syncPinnedTotalForNormalMode()
} else {
pinnedTopRowData.value[0].amount = oldValue
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', oldValue)
}
}
schedulePersist()
}
const onCellValueChanged = (event: CellValueChangedEvent) => {
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
if (typeof event.newValue === 'number') {
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
} else {
const parsed = Number(event.newValue)
pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
}
} else if (!roughCalcEnabled.value) {
syncPinnedTotalForNormalMode()
}
schedulePersist()
}
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
void loadFromIndexedDB(event.api)
void refreshPinnedTotalLabelCell()
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (error) {
// no-op
}
return params.value
}
watch(totalLabel, () => {
refreshPinnedTotalLabelCell()
})
const syncPinnedTotalForNormalMode = () => {
if (roughCalcEnabled.value) return
if (!gridApi.value) {
pinnedTopRowData.value[0].amount = sumByNumber(detailRows.value, row => row.amount)
return
}
let total = 0
let hasValue = false
detailRows.value.forEach(node => {
const amount = node.amount
if (typeof amount === 'number' && Number.isFinite(amount)) {
total += amount
hasValue = true
}
})
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
}
}
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
window.removeEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
gridApi.value = null
void saveToIndexedDB()
})
onMounted(() => {
window.addEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
})
</script>
<template>
<div class="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">
<h3
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }}
</h3>
<div class="flex items-center gap-2">
<span class=" text-xs text-muted-foreground">简要计算</span>
<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"
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
<SwitchThumb
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
</SwitchRoot>
</div>
</div>
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<AgGridVue :style="{ height: '100%' }" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :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>
</template>