773 lines
24 KiB
Vue
773 lines
24 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import type {
|
|
ColDef,
|
|
ColGroupDef,
|
|
FirstDataRenderedEvent,
|
|
GridApi,
|
|
GridReadyEvent,
|
|
RowDataUpdatedEvent
|
|
} from 'ag-grid-community'
|
|
import { expertList } from '@/sql'
|
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
|
import { decimalAggSum, roundTo, sumByNumber, sumNullableNumbers, 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'
|
|
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
|
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
|
|
|
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: undefined,
|
|
enableZxFwSync: false,
|
|
syncField: 'hourly'
|
|
}
|
|
)
|
|
const zxFwPricingStore = useZxFwPricingStore()
|
|
const { t, locale } = useI18n()
|
|
|
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
|
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
|
const paneInstanceCreatedAt = Date.now()
|
|
|
|
const shouldSkipPersist = () => {
|
|
const storageKey = buildProjectScopedSessionKey(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 = buildProjectScopedSessionKey(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 getExpertDisplayName = (expert: ExpertLite | undefined) => {
|
|
if (!expert) return ''
|
|
return String(locale.value).toLowerCase().startsWith('en')
|
|
? (expert as ExpertLite & { nameEn?: string }).nameEn || expert.name
|
|
: expert.name
|
|
}
|
|
|
|
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: getExpertDisplayName(expert),
|
|
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
|
|
const parsed = parseNumberOrNull(value, { sanitize: true, precision: 0 })
|
|
if (parsed == null) return null
|
|
if (!Number.isSafeInteger(parsed) || parsed < 0) return null
|
|
return parsed
|
|
}
|
|
|
|
const formatEditableNumber = (params: any) => {
|
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
|
return t('hourlyFeeGrid.clickToInput')
|
|
}
|
|
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 t('hourlyFeeGrid.clickToInput')
|
|
}
|
|
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: 120,
|
|
flex: 1,
|
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
|
cellClassRules: {
|
|
'ag-right-aligned-cell':()=>true,
|
|
'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: 120,
|
|
flex: 1,
|
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
|
cellClass: params =>
|
|
!params.node?.group && !params.node?.rowPinned
|
|
? 'editable-cell-line'
|
|
: '',
|
|
cellClassRules: {
|
|
'ag-right-aligned-cell': () => true,
|
|
'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 t('hourlyFeeGrid.clickToInput')
|
|
}
|
|
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: 120,
|
|
flex: 1,
|
|
editable: false,
|
|
valueFormatter: params => params.value || '',
|
|
...extra
|
|
})
|
|
|
|
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
|
{
|
|
headerName: t('hourlyFeeGrid.columns.code'),
|
|
field: 'expertCode',
|
|
minWidth: 90,
|
|
width: 100,
|
|
pinned: 'left',
|
|
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
|
cellClassRules: {
|
|
'ag-summary-label-cell': params => Boolean(params.node?.rowPinned)
|
|
},
|
|
valueFormatter: params => (params.node?.rowPinned ? t('hourlyFeeGrid.total') : params.value || '')
|
|
},
|
|
{
|
|
headerName: t('hourlyFeeGrid.columns.name'),
|
|
field: 'expertName',
|
|
minWidth: 210,
|
|
width: 230,
|
|
pinned: 'left',
|
|
tooltipField: 'expertName',
|
|
wrapText: true,
|
|
autoHeight: true,
|
|
cellClass: 'hourly-fee-name-cell',
|
|
cellStyle: { whiteSpace: 'normal', lineHeight: '1.2' },
|
|
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
|
},
|
|
{
|
|
headerName: t('hourlyFeeGrid.columns.referenceUnitPrice'),
|
|
marryChildren: true,
|
|
children: [
|
|
readonlyTextCol('laborBudgetUnitPrice', t('hourlyFeeGrid.columns.laborBudgetUnitPrice'), {
|
|
colSpan: params => (params.node?.rowPinned ? 3 : 1),
|
|
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
|
}),
|
|
readonlyTextCol('compositeBudgetUnitPrice', t('hourlyFeeGrid.columns.compositeBudgetUnitPrice'))
|
|
]
|
|
},
|
|
editableMoneyCol('adoptedBudgetUnitPrice', t('hourlyFeeGrid.columns.adoptedBudgetUnitPrice')),
|
|
editableNumberCol('personnelCount', t('hourlyFeeGrid.columns.personnelCount'), {
|
|
aggFunc: decimalAggSum,
|
|
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
|
|
valueFormatter: formatEditableInteger
|
|
}),
|
|
editableNumberCol('workdayCount', t('hourlyFeeGrid.columns.workdayCount'), { aggFunc: decimalAggSum }),
|
|
{
|
|
headerName: t('hourlyFeeGrid.columns.serviceBudget'),
|
|
field: 'serviceBudget',
|
|
headerClass: 'ag-right-aligned-header',
|
|
minWidth: 120,
|
|
flex: 1,
|
|
editable: false,
|
|
cellClassRules: {
|
|
'ag-right-aligned-cell': () => true
|
|
},
|
|
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: t('hourlyFeeGrid.columns.remark'),
|
|
field: 'remark',
|
|
minWidth: 120,
|
|
flex: 1,
|
|
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 t('hourlyFeeGrid.clickToInput')
|
|
return params.value || ''
|
|
},
|
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
|
|
cellClassRules: {
|
|
'editable-cell-empty': params =>
|
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
|
}
|
|
}
|
|
]
|
|
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
|
|
|
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
|
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
|
const totalServiceBudget = computed(() => sumNullableNumbers(detailRows.value.map(row => calcServiceBudget(row))))
|
|
const pinnedTopRowData = computed(() => [
|
|
{
|
|
id: 'pinned-total-row',
|
|
expertCode: t('hourlyFeeGrid.total'),
|
|
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,
|
|
{ force: true }
|
|
)
|
|
} else if (useHtMethodState.value) {
|
|
zxFwPricingStore.setHtFeeMethodState(
|
|
props.htMainStorageKey!,
|
|
props.htRowId!,
|
|
props.htMethodType!,
|
|
payload,
|
|
{ force: true }
|
|
)
|
|
} 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 relabelRowsFromExpertDict = async () => {
|
|
if (detailRows.value.length === 0) return
|
|
let changed = false
|
|
detailRows.value = detailRows.value.map(row => {
|
|
const match = String(row.id || '').match(/^expert-(\d+)$/)
|
|
if (!match) return row
|
|
const expert = (expertList as Record<string, ExpertLite | undefined>)[match[1]]
|
|
if (!expert) return row
|
|
const nextName = getExpertDisplayName(expert)
|
|
if (row.expertName === nextName) return row
|
|
changed = true
|
|
return {
|
|
...row,
|
|
expertName: nextName
|
|
}
|
|
})
|
|
gridApi.value?.refreshCells({ force: true })
|
|
if (!changed) return
|
|
await saveToIndexedDB()
|
|
}
|
|
|
|
let isBulkClipboardMutation = false
|
|
|
|
const commitGridChanges = (source: string) => {
|
|
syncServiceBudgetToRows()
|
|
gridApi.value?.refreshCells({ force: true })
|
|
scheduleAutoRowHeights()
|
|
void saveToIndexedDB()
|
|
}
|
|
|
|
const handleCellValueChanged = (event?: any) => {
|
|
if (isBulkClipboardMutation) return
|
|
commitGridChanges('cell-value-changed')
|
|
}
|
|
|
|
const handleBulkMutationStart = () => {
|
|
isBulkClipboardMutation = true
|
|
}
|
|
|
|
const handleBulkMutationEnd = (event?: any) => {
|
|
isBulkClipboardMutation = false
|
|
commitGridChanges(event?.type || 'bulk-end')
|
|
}
|
|
|
|
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
|
gridApi.value = event.api
|
|
scheduleAutoRowHeights()
|
|
}
|
|
|
|
let autoHeightSyncTimer: ReturnType<typeof setTimeout> | null = null
|
|
const isGridApiAlive = (api: GridApi<DetailRow> | null | undefined): api is GridApi<DetailRow> =>
|
|
Boolean(api && !api.isDestroyed?.())
|
|
|
|
const forceRefreshCellsOnLiveApi = () => {
|
|
// 再次触发一轮强制刷新,覆盖 AG Grid 异步布局后的高度计算。
|
|
setTimeout(() => {
|
|
const liveApi = gridApi.value
|
|
if (!isGridApiAlive(liveApi)) return
|
|
liveApi.refreshCells({ force: true })
|
|
liveApi.redrawRows()
|
|
}, 16)
|
|
}
|
|
|
|
const syncAutoRowHeights = async () => {
|
|
await nextTick()
|
|
const api = gridApi.value
|
|
if (!isGridApiAlive(api)) return
|
|
api.onRowHeightChanged()
|
|
api.refreshCells({ force: true })
|
|
api.redrawRows()
|
|
forceRefreshCellsOnLiveApi()
|
|
}
|
|
const scheduleAutoRowHeights = () => {
|
|
if (autoHeightSyncTimer) clearTimeout(autoHeightSyncTimer)
|
|
autoHeightSyncTimer = setTimeout(() => {
|
|
autoHeightSyncTimer = null
|
|
if (!isGridApiAlive(gridApi.value)) return
|
|
void syncAutoRowHeights()
|
|
}, 0)
|
|
}
|
|
|
|
const onGridSizeChanged = () => {
|
|
scheduleAutoRowHeights()
|
|
}
|
|
|
|
const onColumnResized = () => {
|
|
scheduleAutoRowHeights()
|
|
}
|
|
|
|
const onFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
|
|
scheduleAutoRowHeights()
|
|
}
|
|
|
|
const onRowDataUpdated = (_event: RowDataUpdatedEvent<DetailRow>) => {
|
|
scheduleAutoRowHeights()
|
|
}
|
|
|
|
const processCellForClipboard = (params: any) => {
|
|
if (Array.isArray(params.value)) return JSON.stringify(params.value)
|
|
return params.value
|
|
}
|
|
|
|
const processCellFromClipboard = (params: any) => {
|
|
const field = String(params.column?.getColDef?.().field || '')
|
|
if (field === 'personnelCount') {
|
|
return parseNonNegativeIntegerOrNull(params.value)
|
|
}
|
|
if (field === 'adoptedBudgetUnitPrice' || field === 'workdayCount') {
|
|
return parseNumberOrNull(params.value, { precision: 3 })
|
|
}
|
|
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()
|
|
scheduleAutoRowHeights()
|
|
})
|
|
|
|
onActivated(async () => {
|
|
await loadFromIndexedDB()
|
|
scheduleAutoRowHeights()
|
|
})
|
|
|
|
watch(
|
|
() => props.storageKey,
|
|
() => {
|
|
void loadFromIndexedDB()
|
|
scheduleAutoRowHeights()
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => detailRows.value.length,
|
|
() => {
|
|
scheduleAutoRowHeights()
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => locale.value,
|
|
() => {
|
|
void relabelRowsFromExpertDict()
|
|
}
|
|
)
|
|
|
|
onDeactivated(() => {
|
|
gridApi.value?.stopEditing()
|
|
void saveToIndexedDB()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
gridApi.value?.stopEditing()
|
|
gridApi.value = null
|
|
if (autoHeightSyncTimer) {
|
|
clearTimeout(autoHeightSyncTimer)
|
|
autoHeightSyncTimer = 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 || t('hourlyFeeGrid.title') }}</h3>
|
|
<div class="text-xs text-muted-foreground"></div>
|
|
</div>
|
|
|
|
<div :class="agGridWrapClass">
|
|
<AgGridVue
|
|
:style="agGridStyle"
|
|
:rowData="detailRows"
|
|
:pinnedTopRowData="pinnedTopRowData"
|
|
:columnDefs="gridColumnDefs"
|
|
:gridOptions="gridOptions"
|
|
:theme="myTheme"
|
|
:animateRows="true"
|
|
:treeData="false"
|
|
@cell-value-changed="handleCellValueChanged"
|
|
@paste-start="handleBulkMutationStart"
|
|
@paste-end="handleBulkMutationEnd"
|
|
@fill-start="handleBulkMutationStart"
|
|
@fill-end="handleBulkMutationEnd"
|
|
: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"
|
|
@first-data-rendered="onFirstDataRendered"
|
|
@row-data-updated="onRowDataUpdated"
|
|
@grid-size-changed="onGridSizeChanged"
|
|
@column-resized="onColumnResized"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.hourly-fee-name-cell.ag-cell-auto-height) {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
:deep(.hourly-fee-name-cell.ag-cell-auto-height .ag-cell-wrapper),
|
|
:deep(.hourly-fee-name-cell.ag-cell-auto-height .ag-cell-value) {
|
|
display: flex;
|
|
align-items: center;
|
|
width: 100%;
|
|
white-space: normal;
|
|
}
|
|
</style>
|