export type ColumnStatMethod = 'sum' | 'avg' | 'max' | 'min' export type StatsCellAlign = 'left' | 'center' | 'right' export type StatsRowColumn = { key: string interactive: boolean prop?: string money?: boolean /** 显式对齐;缺省时由统计行从主表表头同步 */ align?: StatsCellAlign } export const COLUMN_STAT_METHOD_OPTIONS: { value: ColumnStatMethod | ''; label: string }[] = [ { value: '', label: '无' }, { value: 'sum', label: '求和' }, { value: 'avg', label: '平均' }, { value: 'max', label: '最大值' }, { value: 'min', label: '最小值' } ] export const COLUMN_STAT_METHOD_LABEL: Record = { sum: '求和', avg: '平均', max: '最大值', min: '最小值' } export function normalizeCssTextAlign(value: string | null | undefined): StatsCellAlign { const ta = String(value || 'left').toLowerCase() if (ta === 'right' || ta === 'end') return 'right' if (ta === 'center') return 'center' return 'left' } /** * 优先列配置 align;金额列默认 right; * 其余同步主表列对齐(表体 `align`,无数据时回退表头)。 * 注意:多数业务表 `header-align=center` 且 `align=right`,不可只读表头。 */ export function resolveStatsCellAlign( col: Pick, columnAlign?: string | null ): StatsCellAlign { if (col.align === 'left' || col.align === 'center' || col.align === 'right') return col.align if (col.money) return 'right' if (columnAlign) return normalizeCssTextAlign(columnAlign) return 'left' } /** 从主表读取各列 text-align:优先首行表体(对应 `align`),无行时回退表头 */ export function getTableColumnTextAligns(table: HTMLElement | null | undefined): string[] { if (!table) return [] const collect = (cells: NodeListOf): string[] => { const aligns: string[] = [] cells.forEach((node) => { const el = node as HTMLElement if (el.classList.contains('gutter')) return aligns.push(getComputedStyle(el).textAlign || 'left') }) return aligns } const firstRow = table.querySelector('.el-table__body-wrapper tbody tr') if (firstRow) { const fromBody = collect(firstRow.querySelectorAll('td.el-table__cell')) if (fromBody.length) return fromBody } return collect(table.querySelectorAll('.el-table__header-wrapper th.el-table__cell')) } export function applyStatsCellAlignStyles( cellEl: HTMLElement, align: StatsCellAlign, triggerSelector: string ) { cellEl.style.justifyContent = align === 'right' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start' cellEl.classList.remove('is-align-left', 'is-align-center', 'is-align-right') cellEl.classList.add(`is-align-${align}`) const trigger = cellEl.querySelector(triggerSelector) as HTMLElement | null if (trigger) trigger.style.textAlign = align } export function parseStatNumber(raw: unknown): number | null { if (raw == null || raw === '') return null if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null let s = String(raw).trim() if (!s) return null s = s.replace(/^¥/, '').replace(/%$/, '').replace(/,/g, '') const n = Number(s) return Number.isFinite(n) ? n : null } function nonEmptyString(raw: unknown): string | null { if (raw == null) return null const s = String(raw).trim() return s ? s : null } export function aggregateColumnValues( values: unknown[], method: ColumnStatMethod ): number | string | null { const nums: number[] = [] const strs: string[] = [] for (const v of values) { const n = parseStatNumber(v) if (n != null) nums.push(n) else { const s = nonEmptyString(v) if (s != null) strs.push(s) } } if (method === 'sum') { if (!nums.length) return null return nums.reduce((a, b) => a + b, 0) } if (method === 'avg') { if (!nums.length) return null return nums.reduce((a, b) => a + b, 0) / nums.length } if (method === 'max') { if (nums.length) return Math.max(...nums) if (strs.length) return strs.reduce((a, b) => (a >= b ? a : b)) return null } if (nums.length) return Math.min(...nums) if (strs.length) return strs.reduce((a, b) => (a <= b ? a : b)) return null } export function formatStatNumberResult( value: number, opts: { money?: boolean } = {} ): string { if (opts.money) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } if (Number.isInteger(value)) return String(value) return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 }) } export function formatColumnStatDisplay( method: ColumnStatMethod | '' | null | undefined, values: unknown[], opts: { money?: boolean } = {} ): string { if (!method) return '' const label = COLUMN_STAT_METHOD_LABEL[method] const agg = aggregateColumnValues(values, method) if (agg == null) return `${label} -` if (typeof agg === 'number') { return `${label} ${formatStatNumberResult(agg, opts)}` } return `${label} ${agg}` } export type ColumnStatConfigMap = Partial> export function loadColumnStatConfig(storageKey: string): ColumnStatConfigMap { try { const raw = localStorage.getItem(storageKey) if (!raw) return {} const parsed = JSON.parse(raw) as Record if (!parsed || typeof parsed !== 'object') return {} const out: ColumnStatConfigMap = {} for (const [k, v] of Object.entries(parsed)) { if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') out[k] = v } return out } catch { return {} } } export function saveColumnStatConfig( config: ColumnStatConfigMap, storageKey: string ): void { const cleaned: ColumnStatConfigMap = {} for (const [k, v] of Object.entries(config)) { if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') cleaned[k] = v } localStorage.setItem(storageKey, JSON.stringify(cleaned)) } export function setColumnStatMethod( config: ColumnStatConfigMap, prop: string, method: ColumnStatMethod | '' ): ColumnStatConfigMap { const next = { ...config } if (!method) delete next[prop] else next[prop] = method return next } /** 构建与主表列顺序一致的统计行列(前导占位 + 固定列 + 可见可选列) */ export function buildStatsRowColumns(options: { leadingSpacers?: number alwaysColumns?: { prop: string; money?: boolean }[] optionalDefs?: { prop: string }[] isColumnVisible?: (prop: string) => boolean moneyProps?: Set | string[] }): StatsRowColumn[] { const { leadingSpacers = 0, alwaysColumns = [], optionalDefs = [], isColumnVisible = () => true, moneyProps } = options const moneySet = moneyProps instanceof Set ? moneyProps : new Set(moneyProps || []) const cols: StatsRowColumn[] = [] for (let i = 0; i < leadingSpacers; i++) { cols.push({ key: `__spacer_${i}`, interactive: false }) } for (const col of alwaysColumns) { const money = col.money ?? moneySet.has(col.prop) cols.push({ key: col.prop, interactive: true, prop: col.prop, money, ...(money ? { align: 'right' as const } : {}) }) } for (const def of optionalDefs) { if (!isColumnVisible(def.prop)) continue const money = moneySet.has(def.prop) cols.push({ key: def.prop, interactive: true, prop: def.prop, money, ...(money ? { align: 'right' as const } : {}) }) } return cols }