This commit is contained in:
wintsa 2026-03-26 14:54:26 +08:00
parent 35f06746fe
commit be2662d579
14 changed files with 493 additions and 35 deletions

View File

@ -56,6 +56,7 @@ interface DetailRow {
hourly: number | null
subtotal?: number | null
finalFee?: number | null
remark?: string
actions?: unknown
}
@ -180,6 +181,7 @@ const normalizeDetailRow = (row: Partial<DetailRow>): DetailRow => ({
hourly: typeof row.hourly === 'number' ? row.hourly : null,
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
finalFee: typeof row.finalFee === 'number' ? row.finalFee : null,
remark: String(row.remark || ''),
actions: row.actions
})
@ -408,11 +410,11 @@ const createMethodColumn = (
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth,
flex: 1.5,
editable: false,
cellClassRules: {
'zxfw-number-cell': () => true,
'ag-right-aligned-cell': () => true
},
valueGetter: params => {
@ -666,11 +668,11 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: t('htZxFw.columns.subtotal'),
field: 'subtotal',
headerClass: 'ag-right-aligned-header',
flex: 2,
minWidth: 100,
editable: false,
cellClassRules: {
'zxfw-number-cell': () => true,
'ag-right-aligned-cell': () => true
},
valueGetter: params => {
@ -682,12 +684,12 @@ const columnDefs: ColDef<DetailRow>[] = [
{
headerName: t('htZxFw.columns.finalFee'),
field: 'finalFee',
headerClass: 'ag-right-aligned-header',
headerTooltip: t('htZxFw.columns.finalFeeTooltip'),
flex: 2,
minWidth: 110,
editable: params => !isFixedRow(params.data),
cellClassRules: {
'zxfw-number-cell': () => true,
'ag-right-aligned-cell': () => true
},
valueGetter: params => {
@ -707,6 +709,32 @@ const columnDefs: ColDef<DetailRow>[] = [
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: t('htZxFw.columns.remark'),
field: 'remark',
minWidth: 160,
flex: 1.8,
editable: params => !isFixedRow(params.data),
cellEditor: 'agLargeTextCellEditor',
cellEditorPopup: true,
cellEditorParams: {
maxLength: 500,
rows: 8,
cols: 48
},
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
valueGetter: params => {
if (!params.data || isFixedRow(params.data)) return ''
return String(params.data.remark || '')
},
valueFormatter: params => (params.value == null ? '' : String(params.value)),
cellClass: params => (!isFixedRow(params.data) ? 'remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params => !isFixedRow(params.data) && (params.value == null || String(params.value).trim() === '')
}
},
{
headerName: t('htZxFw.columns.actions'),
field: 'actions',
@ -931,7 +959,8 @@ const applySelection = async (codes: string[]) => {
hourly: nextValues.hourly,
subtotal: typeof old?.subtotal === 'number' ? old.subtotal : null,
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null,
remark: String(old?.remark || '')
}
})
.filter((row): row is DetailRow => row !== null)
@ -951,6 +980,7 @@ const applySelection = async (codes: string[]) => {
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
subtotal: typeof fixedOld?.subtotal === 'number' ? fixedOld.subtotal : null,
finalFee: typeof fixedOld?.finalFee === 'number' ? fixedOld.finalFee : null,
remark: '',
actions: null
}
@ -1000,9 +1030,9 @@ const initializeContractState = async () => {
}
} catch (error) {
console.error('initializeContractState failed:', error)
await setCurrentContractState({
selectedIds: [],
detailRows: applyFixedRowTotals([{
await setCurrentContractState({
selectedIds: [],
detailRows: applyFixedRowTotals([{
id: fixedBudgetRow.id,
code: fixedBudgetRow.code,
name: fixedBudgetRow.name,
@ -1011,11 +1041,12 @@ const initializeContractState = async () => {
landScale: null,
workload: null,
hourly: null,
subtotal: null,
finalFee: null,
actions: null
}])
})
subtotal: null,
finalFee: null,
remark: '',
actions: null
}])
})
}
}
@ -1197,15 +1228,20 @@ const commitFinalFeeGridChanges = async () => {
const handleCellValueChanged = async (event: any) => {
if (isBulkClipboardMutation) return
if (event.colDef?.field !== 'finalFee') return
const row = event.data as DetailRow | undefined
if (!row || isFixedRow(row)) return
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
const field = String(event.colDef?.field || '')
if (field !== 'finalFee' && field !== 'remark') return
const currentState = getCurrentContractState()
const nextRows = currentState.detailRows.map(item =>
item.id === row.id ? { ...item, finalFee: newValue } : item
)
const nextRows = currentState.detailRows.map(item => {
if (item.id !== row.id) return item
if (field === 'remark') {
return { ...item, remark: String(event.newValue || '') }
}
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
return { ...item, finalFee: newValue }
})
const finalRows = applyFixedRowSummary(nextRows)
await setCurrentContractState({
...currentState,
@ -1256,7 +1292,7 @@ onActivated(async () => {
</h3>
</div>
<p class="text-[11px] text-muted-foreground leading-none leading-4 text-amber-700/90">{{ t('htZxFw.warning') }}</p>
<p class="text-[11px] leading-none leading-4 !text-[brown]">{{ t('htZxFw.warning') }}</p>
</div>
<div :class="agGridWrapClass">
@ -1319,6 +1355,14 @@ onActivated(async () => {
</template>
<style scoped>
:deep(.xmMx .ag-header-cell-label) {
justify-content: center;
}
:deep(.xmMx .ag-header-cell-text) {
text-align: center;
}
:deep(.zxfw-process-header .ag-header-cell-label) {
justify-content: center;
}
@ -1332,6 +1376,11 @@ onActivated(async () => {
align-items: center;
}
:deep(.zxfw-number-cell) {
justify-content: flex-end;
text-align: right;
}
:deep(.zxfw-name-cell.ag-cell-auto-height) {
display: flex;
align-items: center;

View File

@ -30,6 +30,9 @@ import {
createScaleBudgetCellRendererToggleFactory,
formatScaleEditableConditionalNumber,
restoreScaleColumnDefaults,
type ScaleBudgetCheckField,
type ScaleBudgetHeaderCheckState,
ScaleBudgetToggleHeader,
} from '@/lib/pricingScaleGrid'
import {
getCheckedBenchmarkBudgetSplitByRow,
@ -538,6 +541,32 @@ const createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFac
() => handleCellValueChanged()
)
const getBenchmarkBudgetHeaderCheckState = (field: ScaleBudgetCheckField): ScaleBudgetHeaderCheckState => {
const targetRows = detailRows.value.filter(row => row.hasCost)
if (!targetRows.length) return 'all'
const checkedCount = targetRows.reduce((count, row) => (row[field] !== false ? count + 1 : count), 0)
if (checkedCount === 0) return 'none'
if (checkedCount === targetRows.length) return 'all'
return 'partial'
}
const toggleBenchmarkBudgetColumnChecked = (field: ScaleBudgetCheckField, checked: boolean) => {
const targetRows = detailRows.value.filter(row => row.hasCost)
if (!targetRows.length) return
for (const row of targetRows) {
row[field] = checked
}
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
handleCellValueChanged()
}
const getBenchmarkBudgetHeaderParams = (field: ScaleBudgetCheckField) => ({
field,
getHeaderCheckState: getBenchmarkBudgetHeaderCheckState,
onToggleAll: toggleBenchmarkBudgetColumnChecked
})
const getBenchmarkBudgetSplitByAmount = (row?: Pick<DetailRow, 'amount'>) =>
getCheckedBenchmarkBudgetSplitByRow(
{
@ -661,7 +690,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
}),
createScaleBenchmarkBudgetColumnGroup<DetailRow>({
getCheckedSplit: getCheckedBenchmarkBudgetSplitByAmount,
createBudgetCellRendererWithCheck
createBudgetCellRendererWithCheck,
getHeaderComponent: () => ScaleBudgetToggleHeader,
getHeaderComponentParams: getBenchmarkBudgetHeaderParams
}),
createScaleBudgetFeeColumnGroup<DetailRow>({
headerComponent: AgGridResetHeader,
@ -877,7 +908,13 @@ const buildEmptyRows = (targetProjectCount: number) =>
: buildDefaultRows(targetProjectCount)
const applyDetailRows = (rows: DetailRow[]) => {
detailRows.value = rows
detailRows.value = rows.map(row => ({
...row,
benchmarkBudgetBasicChecked:
typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked:
typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true
}))
}
const relabelDetailRowsFromDict = async () => {
@ -1039,9 +1076,11 @@ let isBulkClipboardMutation = false
const commitGridChanges = async (source: string) => {
await nextTick()
syncComputedValuesToDetailRows()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
await saveToIndexedDB({ skipComputedSync: true })
await nextTick()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
}

View File

@ -30,6 +30,9 @@ import {
createScaleBudgetCellRendererToggleFactory,
formatScaleEditableConditionalNumber,
restoreScaleColumnDefaults,
type ScaleBudgetCheckField,
type ScaleBudgetHeaderCheckState,
ScaleBudgetToggleHeader,
} from '@/lib/pricingScaleGrid'
import {
getCheckedBenchmarkBudgetSplitByRow,
@ -410,6 +413,32 @@ const createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFac
() => handleCellValueChanged()
)
const getBenchmarkBudgetHeaderCheckState = (field: ScaleBudgetCheckField): ScaleBudgetHeaderCheckState => {
const targetRows = detailRows.value.filter(row => row.hasArea)
if (!targetRows.length) return 'all'
const checkedCount = targetRows.reduce((count, row) => (row[field] !== false ? count + 1 : count), 0)
if (checkedCount === 0) return 'none'
if (checkedCount === targetRows.length) return 'all'
return 'partial'
}
const toggleBenchmarkBudgetColumnChecked = (field: ScaleBudgetCheckField, checked: boolean) => {
const targetRows = detailRows.value.filter(row => row.hasArea)
if (!targetRows.length) return
for (const row of targetRows) {
row[field] = checked
}
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
handleCellValueChanged()
}
const getBenchmarkBudgetHeaderParams = (field: ScaleBudgetCheckField) => ({
field,
getHeaderCheckState: getBenchmarkBudgetHeaderCheckState,
onToggleAll: toggleBenchmarkBudgetColumnChecked
})
const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
getCheckedBenchmarkBudgetSplitByRow(
{
@ -538,7 +567,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
}),
createScaleBenchmarkBudgetColumnGroup<DetailRow>({
getCheckedSplit: getCheckedBenchmarkBudgetSplitByLandArea,
createBudgetCellRendererWithCheck
createBudgetCellRendererWithCheck,
getHeaderComponent: () => ScaleBudgetToggleHeader,
getHeaderComponentParams: getBenchmarkBudgetHeaderParams
}),
createScaleBudgetFeeColumnGroup<DetailRow>({
headerComponent: AgGridResetHeader,
@ -737,7 +768,13 @@ const buildRowsFromStoredState = (rows: DetailRow[]) =>
const buildEmptyRows = (targetProjectCount: number) => buildDefaultRows(targetProjectCount)
const applyDetailRows = (rows: DetailRow[]) => {
detailRows.value = rows
detailRows.value = rows.map(row => ({
...row,
benchmarkBudgetBasicChecked:
typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked:
typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true
}))
}
const relabelDetailRowsFromDict = async () => {
@ -897,9 +934,11 @@ let isBulkClipboardMutation = false
const commitGridChanges = async (source: string) => {
await nextTick()
syncComputedValuesToDetailRows()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
await saveToIndexedDB({ skipComputedSync: true })
await nextTick()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
}

View File

@ -341,6 +341,7 @@ const handleCheckedToggle = (id: string, checked: boolean) => {
if (!target || isAddTriggerRow(target)) return
target.checked = checked
gridApi.value?.refreshCells({ force: true })
gridApi.value?.redrawRows()
saveToStore()
}

View File

@ -167,6 +167,8 @@ const columnDefs: ColDef<FactorRow>[] = [
{
headerName: t('xmFactorGrid.columns.standardFactor'),
field: 'standardFactor',
type: 'numericColumn',
cellClass: 'ag-right-aligned-cell',
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
@ -493,3 +495,4 @@ onBeforeUnmount(() => {
</div>
</div>
</template>

View File

@ -328,6 +328,7 @@ export const enUS = {
subtotal: 'Subtotal',
finalFee: 'Final Fee ✎',
finalFeeTooltip: 'This column supports manual edits and will auto-sync to the fixed subtotal row.',
remark: 'Remark',
actions: 'Actions'
},
dialog: {

View File

@ -328,6 +328,7 @@ export const zhCN = {
subtotal: '小计',
finalFee: '确认金额 ✎',
finalFeeTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行',
remark: '备注',
actions: '操作'
},
dialog: {

View File

@ -1048,6 +1048,28 @@ const createRichTextCode = (...parts: string[]): unknown => ({
.map(text => ({ text }))
})
const resolveMethodEnabled = (value: unknown, fallback: boolean) =>
typeof value === 'boolean' ? value : fallback
const getServiceMethodAvailability = (serviceIdText: string) => {
const dict = getServiceDictItemById(serviceIdText) as {
scale?: boolean | null
onlyCostScale?: boolean | null
amount?: boolean | null
workDay?: boolean | null
} | null | undefined
const scale = resolveMethodEnabled(dict?.scale, true)
const onlyCostScale = resolveMethodEnabled(dict?.onlyCostScale, false)
const amount = resolveMethodEnabled(dict?.amount, true)
const workDay = resolveMethodEnabled(dict?.workDay, true)
return {
investmentScale: scale,
landScale: scale && !onlyCostScale,
workload: amount,
hourly: workDay
}
}
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string; subtotal?: unknown }>
const normalized = rows.map(row => {
@ -1148,11 +1170,20 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
if (rows.length === 0) return null
const reserveCodeTemplate = getReserveListEntries(locale.value)?.[0]?.code
?? {
richText: [
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' },
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }
]
}
for (const row of rows) {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
const rowSubtotal = getHtMainRowSubtotal(row)
if (!methodPayload && rowSubtotal == null) continue
const reserve: ExportReserve = {
code: reserveCodeTemplate,
name: row.name || t('htSummary.reservePrefix'),
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
tasks: []
@ -1261,26 +1292,87 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const sourceRow = serviceRowMap.get(serviceIdText)
const methodAvailability = getServiceMethodAvailability(serviceIdText)
const [method1State, method2State, method3State, method4State] = await Promise.all([
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale'),
zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload'),
zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
methodAvailability.investmentScale
? zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale')
: Promise.resolve(null),
methodAvailability.landScale
? zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale')
: Promise.resolve(null),
methodAvailability.workload
? zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload')
: Promise.resolve(null),
methodAvailability.hourly
? zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
: Promise.resolve(null)
])
const method1Raw = method1State ? { detailRows: method1State.detailRows } : null
const method2Raw = method2State ? { detailRows: method2State.detailRows } : null
const method3Raw = method3State ? { detailRows: method3State.detailRows } : null
const method4Raw = method4State ? { detailRows: method4State.detailRows } : null
const method1 = buildMethod1(method1Raw?.detailRows)
const method2 = buildMethod2(method2Raw?.detailRows)
const method3 = buildMethod3(method3Raw?.detailRows)
const method4 = buildMethod4(method4Raw?.detailRows)
const fee = buildServiceFee(sourceRow, method1, method2, method3, method4)
const finalFee = buildServiceFinalFee(sourceRow, method1, method2, method3, method4)
const method1 = methodAvailability.investmentScale ? buildMethod1(method1Raw?.detailRows) : null
const method2 = methodAvailability.landScale ? buildMethod2(method2Raw?.detailRows) : null
const method3 = methodAvailability.workload ? buildMethod3(method3Raw?.detailRows) : null
const method4 = methodAvailability.hourly ? buildMethod4(method4Raw?.detailRows) : null
const sanitizedSourceRow = sourceRow
? {
...sourceRow,
investScale: methodAvailability.investmentScale ? sourceRow.investScale : null,
landScale: methodAvailability.landScale ? sourceRow.landScale : null,
workload: methodAvailability.workload ? sourceRow.workload : null,
hourly: methodAvailability.hourly ? sourceRow.hourly : null,
subtotal: sumNumbers([
methodAvailability.investmentScale ? toFiniteNumber(sourceRow.investScale) : 0,
methodAvailability.landScale ? toFiniteNumber(sourceRow.landScale) : 0,
methodAvailability.workload ? toFiniteNumber(sourceRow.workload) : 0,
methodAvailability.hourly ? toFiniteNumber(sourceRow.hourly) : 0
])
}
: sourceRow
const fee = buildServiceFee(sanitizedSourceRow, method1, method2, method3, method4)
const finalFee = buildServiceFinalFee(sanitizedSourceRow, method1, method2, method3, method4)
const tasks = await buildServiceTasks(contractId, serviceIdText)
const process = Number(sourceRow?.process) === 1 ? 1 : 0
const disabledMethodLeak = {
investScale: !methodAvailability.investmentScale && (toFiniteNumber(sourceRow?.investScale) != null || Boolean(method1Raw)),
landScale: !methodAvailability.landScale && (toFiniteNumber(sourceRow?.landScale) != null || Boolean(method2Raw)),
workload: !methodAvailability.workload && (toFiniteNumber(sourceRow?.workload) != null || Boolean(method3Raw)),
hourly: !methodAvailability.hourly && (toFiniteNumber(sourceRow?.hourly) != null || Boolean(method4Raw))
}
if (disabledMethodLeak.investScale || disabledMethodLeak.landScale || disabledMethodLeak.workload || disabledMethodLeak.hourly) {
console.warn('[export][method-disabled-leak-detected]', {
contractId,
serviceId: serviceIdText,
methodAvailability,
sourceRow,
hasMethodPayload: {
method1: Boolean(method1Raw),
method2: Boolean(method2Raw),
method3: Boolean(method3Raw),
method4: Boolean(method4Raw)
},
disabledMethodLeak
})
}
console.log('[export][service-methods]', {
contractId,
serviceId: serviceIdText,
methodAvailability,
exported: {
method1: Boolean(method1),
method2: Boolean(method2),
method3: Boolean(method3),
method4: Boolean(method4)
},
fee,
finalFee
})
const service: ExportService = {
id: serviceId,
process,

View File

@ -2,6 +2,107 @@ import type { ColDef, ColGroupDef } from 'ag-grid-community'
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
const numericFieldKeywords = [
'amount',
'area',
'cost',
'price',
'fee',
'budget',
'subtotal',
'total',
'ratio',
'rate',
'quantity',
'count',
'num',
'workday',
'workload',
'hourly',
'scale',
'value',
'coe',
'factor'
]
const isFiniteNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
const isNumericLikeString = (value: unknown) => {
if (typeof value !== 'string') return false
const normalized = value.replace(/[,\s]/g, '').replace(/%$/, '')
if (!normalized) return false
const asNumber = Number(normalized)
return Number.isFinite(asNumber)
}
const looksNumericByColumnDef = <TRow>(col: ColDef<TRow>) => {
const fieldLike = String(col.field ?? col.colId ?? '').toLowerCase()
const hasNumericFieldHint = fieldLike
? numericFieldKeywords.some(keyword => fieldLike.includes(keyword))
: false
const type = col.type
const typeList = Array.isArray(type) ? type : type ? [type] : []
const hasNumericType = typeList.some(item => {
const normalized = String(item).toLowerCase()
return normalized.includes('numeric') || normalized.includes('rightaligned')
})
return hasNumericFieldHint || col.cellDataType === 'number' || hasNumericType
}
const hasRightAlignedHeaderClass = (value: unknown) => {
if (typeof value === 'string') return value.includes('ag-right-aligned-header')
if (Array.isArray(value)) return value.some(item => String(item).includes('ag-right-aligned-header'))
return false
}
const mergeHeaderClass = <TRow>(col: ColDef<TRow>, mustRightAlign: boolean): ColDef<TRow>['headerClass'] => {
if (!mustRightAlign) return col.headerClass
const base = col.headerClass
if (!base) return 'ag-right-aligned-header'
if (hasRightAlignedHeaderClass(base)) return base
if (typeof base === 'function') {
return params => {
const result = base(params)
if (!result) return 'ag-right-aligned-header'
if (typeof result === 'string') {
return result.includes('ag-right-aligned-header')
? result
: `${result} ag-right-aligned-header`
}
if (Array.isArray(result)) {
return hasRightAlignedHeaderClass(result)
? result
: [...result, 'ag-right-aligned-header']
}
return 'ag-right-aligned-header'
}
}
if (typeof base === 'string') return `${base} ag-right-aligned-header`
if (Array.isArray(base)) return [...base, 'ag-right-aligned-header']
return base
}
const mergeNumericCellClassRules = <TRow>(
col: ColDef<TRow>,
mustRightAlign: boolean
): ColDef<TRow>['cellClassRules'] => {
if (!mustRightAlign) return col.cellClassRules
const baseRules = col.cellClassRules ? { ...col.cellClassRules } : {}
const existing = baseRules['ag-right-aligned-cell']
if (existing) return baseRules
baseRules['ag-right-aligned-cell'] = params =>
looksNumericByColumnDef(params.colDef as ColDef<TRow>) ||
isFiniteNumber(params.value) ||
isNumericLikeString(params.value)
return baseRules
}
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
if (!cellStyle) return baseStyle
@ -29,9 +130,18 @@ const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] =>
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
const editable = col.editable
const isReadonlyColumn = editable == null || editable === false
if (!isReadonlyColumn) return { ...col }
const shouldRightAlign = looksNumericByColumnDef(col)
if (!isReadonlyColumn) {
return {
...col,
headerClass: mergeHeaderClass(col, shouldRightAlign),
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign)
}
}
return {
...col,
headerClass: mergeHeaderClass(col, shouldRightAlign),
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign),
wrapText: true,
autoHeight: true,
cellStyle: mergeCellStyle(col.cellStyle)

View File

@ -36,6 +36,41 @@ export const agGridWrapClass = 'ag-theme-quartz h-full min-h-0 w-full flex-1'
// AG Grid 组件通用 style撑满容器 div
export const agGridStyle = { height: '100%' }
const numericFieldKeywords = [
'amount',
'area',
'cost',
'price',
'fee',
'budget',
'subtotal',
'total',
'ratio',
'rate',
'quantity',
'count',
'num',
'workday',
'workload',
'hourly',
'investscale',
'landscale',
'scale',
'finalfee',
'value',
'coe',
'factor'
]
const isLikelyNumericColumn = (params: any) => {
const value = params?.value
if (typeof value === 'number' && Number.isFinite(value)) return true
const field = String(params?.colDef?.field || params?.column?.getColId?.() || '').toLowerCase()
if (!field) return false
return numericFieldKeywords.some(keyword => field.includes(keyword))
}
const isPlainEnterKey = (event: KeyboardEvent) =>
event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey
@ -112,7 +147,7 @@ export const agGridDefaultColDef: ColDef = {
suppressKeyboardEvent: suppressExcelLikeEnter,
// 默认把数值型单元格右对齐,减少每个列重复配置。
cellClassRules: {
'ag-right-aligned-cell': params => typeof params.value === 'number' && Number.isFinite(params.value)
'ag-right-aligned-cell': params => isLikelyNumericColumn(params)
}
}

View File

@ -52,6 +52,8 @@ export const createScaleValueColumn = <TRow>(options: {
export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null
createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
getHeaderComponent?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
getHeaderComponentParams?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => Record<string, unknown>
}) : ColGroupDef<TRow> => ({
headerName: scaleT('columns.benchmarkBudget'),
marryChildren: true,
@ -61,6 +63,8 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
field: 'benchmarkBudgetBasic' as any,
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
headerComponent: options.getHeaderComponent?.('benchmarkBudgetBasicChecked'),
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetBasicChecked'),
minWidth: 130,
flex: 1,
cellClassRules: {
@ -78,6 +82,8 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
field: 'benchmarkBudgetOptional' as any,
colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header',
headerComponent: options.getHeaderComponent?.('benchmarkBudgetOptionalChecked'),
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetOptionalChecked'),
minWidth: 130,
flex: 1,
cellClassRules: {

View File

@ -4,6 +4,7 @@ import type { GridApi } from 'ag-grid-community'
import { nextTick } from 'vue'
export type ScaleBudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
export type ScaleBudgetHeaderCheckState = 'all' | 'none' | 'partial'
type BudgetCheckRow = {
id: string
@ -13,6 +14,71 @@ type BudgetCheckRow = {
benchmarkBudgetOptional?: number | null
}
export interface ScaleBudgetToggleHeaderParams {
displayName?: string
field: ScaleBudgetCheckField
getHeaderCheckState?: (field: ScaleBudgetCheckField) => ScaleBudgetHeaderCheckState
onToggleAll?: (field: ScaleBudgetCheckField, checked: boolean) => void
}
export class ScaleBudgetToggleHeader {
private params!: ScaleBudgetToggleHeaderParams
private eGui!: HTMLDivElement
private checkbox!: HTMLInputElement
private label!: HTMLSpanElement
init(params: ScaleBudgetToggleHeaderParams) {
this.params = params
const root = document.createElement('div')
root.style.display = 'inline-flex'
root.style.alignItems = 'center'
root.style.gap = '6px'
root.style.width = '100%'
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('mousedown', event => event.stopPropagation())
checkbox.addEventListener('change', event => {
event.stopPropagation()
this.params.onToggleAll?.(this.params.field, checkbox.checked)
})
const label = document.createElement('span')
label.textContent = String(params.displayName || '')
label.style.userSelect = 'none'
label.addEventListener('click', event => event.stopPropagation())
root.append(checkbox, label)
this.eGui = root
this.checkbox = checkbox
this.label = label
this.applyCheckState()
}
getGui() {
return this.eGui
}
refresh(params: ScaleBudgetToggleHeaderParams) {
this.params = params
this.label.textContent = String(params.displayName || '')
this.applyCheckState()
return true
}
destroy() {
// noop
}
private applyCheckState() {
const state = this.params.getHeaderCheckState?.(this.params.field) || 'none'
this.checkbox.indeterminate = state === 'partial'
this.checkbox.checked = state === 'all'
}
}
export const formatScaleEditableNumber = (params: any, precision = 3, emptyText = '请输入') => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return emptyText

View File

@ -22,6 +22,7 @@ export interface ZxFwDetailRow {
id: string
code?: string
name?: string
remark?: string
process?: number | null
investScale: number | null
landScale: number | null
@ -126,6 +127,7 @@ const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
id: rowId,
code: typeof row.code === 'string' ? row.code : '',
name: typeof row.name === 'string' ? row.name : '',
remark: typeof row.remark === 'string' ? row.remark : '',
process: normalizeProcessValue(row.process, rowId),
investScale: toFiniteNumberOrNull(row.investScale),
landScale: toFiniteNumberOrNull(row.landScale),
@ -214,6 +216,7 @@ const isSameRows = (a: ZxFwDetailRow[] | undefined, b: ZxFwDetailRow[] | undefin
if (l.id !== r.id) return false
if ((l.code || '') !== (r.code || '')) return false
if ((l.name || '') !== (r.name || '')) return false
if ((l.remark || '') !== (r.remark || '')) return false
if (normalizeProcessValue(l.process, l.id) !== normalizeProcessValue(r.process, r.id)) return false
if (!isSameNullableNumber(l.investScale, r.investScale)) return false
if (!isSameNullableNumber(l.landScale, r.landScale)) return false

View File

@ -175,10 +175,23 @@ input[inputmode='numeric'] {
}
.ag-theme-quartz .ag-cell.ag-right-aligned-cell .ag-cell-value {
display: flex;
width: 100%;
justify-content: flex-end;
text-align: right;
}
/* Global AG Grid header alignment: center all header text. */
.ag-theme-quartz .ag-header-cell-label,
.ag-theme-quartz .ag-header-group-cell-label {
justify-content: center;
}
.ag-theme-quartz .ag-header-cell-text,
.ag-theme-quartz .ag-header-group-text {
text-align: center;
}
.app-toolbar-btn {
height: var(--app-toolbar-btn-h) !important;
min-height: var(--app-toolbar-btn-h) !important;