249 lines
7.4 KiB
TypeScript
249 lines
7.4 KiB
TypeScript
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<ColumnStatMethod, string> = {
|
||
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<StatsRowColumn, 'align' | 'money'>,
|
||
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<Element>): 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<Record<string, ColumnStatMethod>>
|
||
|
||
export function loadColumnStatConfig(storageKey: string): ColumnStatConfigMap {
|
||
try {
|
||
const raw = localStorage.getItem(storageKey)
|
||
if (!raw) return {}
|
||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||
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> | 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
|
||
}
|