import type { TableColumnsType, FilterDropdownProps } from 'antd';
// ============ 类型 ============
type ProposalData = any;
type ColConfig = {
key: string;
title: string;
dataIndex?: string | string[];
monthly?: boolean;
numeric?: boolean;
};
type CommonBuilderParams = {
visibleCols: ColConfig[];
hasData: boolean;
leftFixedKey?: string[];
rightFixedKey?: string[];
currentMonthKey?: string[];
CURRENT_MONTH: string;
dataSource: ProposalData[];
renderOperation: (...args: any[]) => React.ReactNode;
calcTextWidth: (txt: string) => number;
localeCompareJa: (a: string, b: string) => number;
monthLabel: (v: string) => string;
generateColumnFilters: (data: ProposalData[], dataIndex: string | string[]) => any[];
matchColumnFilter: (val: any[], recordField: any) => boolean;
VirtualFilterDropdown: React.ComponentType<FilterDropdownProps>;
};
// ============ 公共小工具(全部复用) ============
/** 静态列 */
function buildStaticCol(params: {
col: ColConfig;
fixed: 'left' | 'right' | undefined;
common: CommonBuilderParams;
}) {
const { col, fixed, common } = params;
const { hasData, dataSource, calcTextWidth, localeCompareJa, generateColumnFilters, matchColumnFilter, VirtualFilterDropdown } = common;
const dataIndex = col.dataIndex as keyof ProposalData;
const headerWidth = calcTextWidth(col.title);
const dataWidth = dataSource.reduce((max, row) => Math.max(max, calcTextWidth(String(row[dataIndex] ?? ''))), 0);
const colWidth = Math.max(headerWidth, dataWidth) + 56;
return {
title: col.title,
dataIndex: col.dataIndex,
key: col.key,
width: colWidth,
...(fixed ? { fixed } : {}),
onCell: () => ({ style: { whiteSpace: 'nowrap' } }),
onHeaderCell: () => ({ style: { whiteSpace: 'nowrap' } }),
sorter: hasData
? (a: ProposalData, b: ProposalData) =>
localeCompareJa(String(a[dataIndex] ?? ''), String(b[dataIndex] ?? ''))
: (false as const),
...(hasData
? {
filterDropdown: (props: FilterDropdownProps) => (
<VirtualFilterDropdown {...props} filters={generateColumnFilters(dataSource, dataIndex)} />
),
onFilter: (value: React.Key | boolean, record: ProposalData) =>
matchColumnFilter(record[dataIndex], [value as React.Key]),
}
: {}),
};
}
/** 当月表头badge */
function MonthHeaderLabel({ m, isCurrent, monthLabel }: { m: string; isCurrent: boolean; monthLabel: (v: string) => string }) {
if (!isCurrent) return monthLabel(m);
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 600 }}>{monthLabel(m)}</span>
<span
style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
color: '#1677ff',
background: '#f0f7ff',
borderRadius: 8,
fontWeight: 400,
}}
>
当月
</span>
</span>
);
}
/** 【公共】生成operation操作列 */
function getOperationColumn(renderOperation: CommonBuilderParams['renderOperation']) {
return {
title: '操作',
key: 'operation',
width: 57,
fixed: 'right' as const,
align: 'center' as const,
onCell: () => ({ style: { textAlign: 'center' as const } }),
onHeaderCell: () => ({ style: { textAlign: 'center' as const } }),
render: renderOperation,
};
}
/** 【公共】生成单个月度子单元格列(period / item 都调用) */
function buildMonthChildCol(params: {
col: ColConfig;
m: string;
isCurrent: boolean;
common: CommonBuilderParams;
extraWidth?: number; // item模式需要额外宽度偏移
}) {
const { col, m, isCurrent, common, extraWidth = 0 } = params;
const { hasData, dataSource, calcTextWidth, localeCompareJa, rightFixedKey } = common;
const monthKey = `${col.key}__${m}`;
const isNumeric = col.numeric;
const headerWidth = calcTextWidth(col.title);
const dataWidth = dataSource.reduce(
(max, row) => Math.max(max, calcTextWidth(String(row.monthly?.[monthKey] ?? ''))),
0
);
const colWidth = Math.max(headerWidth, dataWidth) + 40 + extraWidth;
const isRightFixed = !!rightFixedKey?.includes(col.key);
return {
title: <MonthHeaderLabel m={m} isCurrent={isCurrent} monthLabel={common.monthLabel} />,
key: monthKey,
dataIndex: ['monthly', monthKey],
width: colWidth,
fixed: isRightFixed ? ('right' as const) : undefined,
align: isNumeric ? ('right' as const) : undefined,
onCell: () => ({
style: {
whiteSpace: 'nowrap',
...(isNumeric ? { textAlign: 'right' as const } : {}),
...(isCurrent ? { backgroundColor: '#fafcff' } : {}),
},
}),
onHeaderCell: () => ({
style: { whiteSpace: 'nowrap', ...(isCurrent ? { backgroundColor: '#fafcff' } : {}) },
}),
render: (_: unknown, record: ProposalData) => record.monthly?.[monthKey] ?? '',
sorter: hasData
? (a: ProposalData, b: ProposalData) =>
localeCompareJa(String(a.monthly?.[monthKey] ?? ''), String(b.monthly?.[monthKey] ?? ''))
: false,
};
}
// ============ 期間軸:月份作为父分组,指标作为子列 ============
function buildPeriodColumns(common: CommonBuilderParams, months: string[]) {
const { visibleCols, leftFixedKey, rightFixedKey, currentMonthKey, CURRENT_MONTH } = common;
const staticVisibleCols = visibleCols.filter((col) => !col.monthly);
const monthlyVisibleCols = visibleCols.filter((col) => col.monthly);
const leftStaticCols: TableColumnsType<ProposalData> = [];
const normalStaticCols: TableColumnsType<ProposalData> = [];
staticVisibleCols.forEach((col) => {
if (leftFixedKey?.includes(col.key)) {
leftStaticCols.push(buildStaticCol({ col, fixed: 'left', common }));
} else {
normalStaticCols.push(buildStaticCol({ col, fixed: undefined, common }));
}
});
// 构建月份父分组
const monthGroups = months.map((m) => {
const isCurrent = m === CURRENT_MONTH;
const childrenCols = monthlyVisibleCols
.filter((col) => !currentMonthKey?.includes(col.key))
.map((col) => buildMonthChildCol({ col, m, isCurrent, common }));
return {
title: common.monthLabel(m),
key: `group_${m}`,
onHeaderCell: () => ({ style: { ...(isCurrent ? { backgroundColor: '#fafcff' } : {}) } }),
children: childrenCols,
};
});
const currentMonthGroups =
currentMonthKey && currentMonthKey.length > 0
? [CURRENT_MONTH].map((m) => {
const isCurrent = m === CURRENT_MONTH;
const childrenCols = monthlyVisibleCols
.filter((col) => currentMonthKey?.includes(col.key))
.map((col) => buildMonthChildCol({ col, m, isCurrent, common }));
return {
title: common.monthLabel(m),
key: `group_${m}`,
onHeaderCell: () => ({ style: { ...(isCurrent ? { backgroundColor: '#fafcff' } : {}) } }),
children: childrenCols,
};
})
: [];
// 方案B:剥离右固定子列,拆分分组,过滤空分组
const rightItemsCols =
rightFixedKey && rightFixedKey.length > 0
? monthGroups
.map((item) => ({
...item,
children: item.children.filter((child) => child.fixed === 'right'),
}))
.filter((g) => g.children.length > 0)
: [];
const monthlyItemsColsWithoutRight = monthGroups.map((item) => ({
...item,
children: item.children.filter((child) => !child.fixed),
}));
const operationCol = getOperationColumn(common.renderOperation);
return [
...leftStaticCols,
...normalStaticCols,
...currentMonthGroups,
...monthlyItemsColsWithoutRight,
...rightItemsCols,
operationCol,
];
}
// ============ 項目軸:指标作为父分组,月份作为子列 ============
function buildItemColumns(common: CommonBuilderParams, effectiveMonths: string[]) {
const { visibleCols, leftFixedKey, rightFixedKey, currentMonthKey, CURRENT_MONTH } = common;
const leftItemCols: TableColumnsType<ProposalData> = [];
const normalStaticItemCols: TableColumnsType<ProposalData> = [];
const monthlyItemCols: TableColumnsType<ProposalData> = [];
visibleCols.forEach((col) => {
if (col.monthly) {
const subMonths = currentMonthKey?.includes(col.key) ? [CURRENT_MONTH] : effectiveMonths;
monthlyItemCols.push({
title: col.title,
key: col.key,
fixed: rightFixedKey?.includes(col.key) ? 'right' : undefined,
children: subMonths.map((m) => {
// item模式宽度需要额外偏移,传extraWidth
return buildMonthChildCol({ col, m, isCurrent: m === CURRENT_MONTH, common, extraWidth: 44 });
}),
});
return;
}
if (leftFixedKey?.includes(col.key)) {
leftItemCols.push(buildStaticCol({ col, fixed: 'left', common }));
} else {
normalStaticItemCols.push(buildStaticCol({ col, fixed: undefined, common }));
}
});
const operationCol = getOperationColumn(common.renderOperation);
const currentMonthCols = monthlyItemCols.filter((item) => currentMonthKey?.includes(item.key as string));
const totalMonthCols = monthlyItemCols.filter(
(item) => !currentMonthKey?.includes(item.key as string) && !item.fixed
);
const rightItemsCols = monthlyItemCols.filter((item) => item.fixed === 'right') ?? [];
return [
...leftItemCols,
...normalStaticItemCols,
...currentMonthCols,
...totalMonthCols,
...rightItemsCols,
operationCol,
];
}
const columns = useMemo<TableColumnsType<ProposalData>>(() => {
const visibleCols = PROPOSAL_COLUMNS.filter((col) => visibleColumnKeys.includes(col.key));
const hasData = dataSource.length > 0;
const common: CommonBuilderParams = {
visibleCols,
hasData,
leftFixedKey,
rightFixedKey,
currentMonthKey,
CURRENT_MONTH,
dataSource,
renderOperation,
calcTextWidth,
localeCompareJa,
monthLabel,
generateColumnFilters,
matchColumnFilter,
VirtualFilterDropdown,
};
if (layout === 'period') {
return buildPeriodColumns(common, effectiveMonths);
} else {
return buildItemColumns(common, effectiveMonths);
}
}, [
visibleColumnKeys,
leftFixedKey,
rightFixedKey,
currentMonthKey,
dataSource,
effectiveMonths,
layout,
renderOperation,
PROPOSAL_COLUMNS,
CURRENT_MONTH,
calcTextWidth,
localeCompareJa,
generateColumnFilters,
matchColumnFilter,
monthLabel,
]);