- Migrated 4 components from ECharts to Chart.js: * MonthlyExpenseCard.vue (折线图) * DailyTrendChart.vue (双系列折线图) * ExpenseCategoryCard.vue (环形图) * BudgetChartAnalysis.vue (仪表盘 + 多种图表) - Removed all ECharts imports and environment variable switches - Unified all charts to use BaseChart.vue component - Build verified: pnpm build success ✓ - No echarts imports remaining ✓ Refs: openspec/changes/migrate-remaining-echarts-to-chartjs
141 lines
3.8 KiB
TypeScript
141 lines
3.8 KiB
TypeScript
/**
|
||
* 图表工具函数
|
||
* 提供数据格式化、颜色处理等通用功能
|
||
*/
|
||
|
||
/**
|
||
* 格式化金额
|
||
* @param amount 金额
|
||
* @param decimals 小数位数
|
||
*/
|
||
export function formatMoney(amount: number, decimals: number = 2): string {
|
||
return amount.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||
}
|
||
|
||
/**
|
||
* 格式化百分比
|
||
* @param value 值
|
||
* @param total 总数
|
||
* @param decimals 小数位数
|
||
*/
|
||
export function formatPercentage(value: number, total: number, decimals: number = 1): string {
|
||
if (total === 0) return '0%'
|
||
return ((value / total) * 100).toFixed(decimals) + '%'
|
||
}
|
||
|
||
/**
|
||
* 生成渐变色
|
||
* @param color 基础颜色
|
||
* @param alpha 透明度
|
||
*/
|
||
export function colorWithAlpha(color: string, alpha: number): string {
|
||
// 如果是 hex 颜色,转换为 rgba
|
||
if (color.startsWith('#')) {
|
||
const r = parseInt(color.slice(1, 3), 16)
|
||
const g = parseInt(color.slice(3, 5), 16)
|
||
const b = parseInt(color.slice(5, 7), 16)
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||
}
|
||
// 如果已经是 rgb/rgba,替换 alpha
|
||
return color.replace(/rgba?\(([^)]+)\)/, (match, values) => {
|
||
const parts = values.split(',').slice(0, 3)
|
||
return `rgba(${parts.join(',')}, ${alpha})`
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 创建渐变背景(用于折线图填充)
|
||
* @param ctx Canvas 上下文
|
||
* @param chartArea 图表区域
|
||
* @param color 颜色
|
||
*/
|
||
export function createGradient(ctx: CanvasRenderingContext2D, chartArea: any, color: string) {
|
||
const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top)
|
||
gradient.addColorStop(0, colorWithAlpha(color, 0.0))
|
||
gradient.addColorStop(0.5, colorWithAlpha(color, 0.1))
|
||
gradient.addColorStop(1, colorWithAlpha(color, 0.3))
|
||
return gradient
|
||
}
|
||
|
||
/**
|
||
* 截断文本(移动端长标签处理)
|
||
* @param text 文本
|
||
* @param maxLength 最大长度
|
||
*/
|
||
export function truncateText(text: string, maxLength: number = 12): string {
|
||
if (text.length <= maxLength) return text
|
||
return text.slice(0, maxLength) + '...'
|
||
}
|
||
|
||
/**
|
||
* 合并小分类为 "Others"
|
||
* @param data 数据数组 { label, value, color }
|
||
* @param threshold 阈值百分比(默认 3%)
|
||
* @param maxCategories 最大分类数(默认 8)
|
||
*/
|
||
export function mergeSmallCategories(
|
||
data: Array<{ label: string; value: number; color?: string }>,
|
||
threshold: number = 0.03,
|
||
maxCategories: number = 8
|
||
) {
|
||
const total = data.reduce((sum, item) => sum + item.value, 0)
|
||
|
||
// 按值降序排序
|
||
const sorted = [...data].sort((a, b) => b.value - a.value)
|
||
|
||
// 分离大分类和小分类
|
||
const main: typeof data = []
|
||
const others: typeof data = []
|
||
|
||
sorted.forEach((item) => {
|
||
const percentage = item.value / total
|
||
if (main.length < maxCategories && percentage >= threshold) {
|
||
main.push(item)
|
||
} else {
|
||
others.push(item)
|
||
}
|
||
})
|
||
|
||
// 如果有小分类,合并为 "Others"
|
||
if (others.length > 0) {
|
||
const othersValue = others.reduce((sum, item) => sum + item.value, 0)
|
||
main.push({
|
||
label: '其他',
|
||
value: othersValue,
|
||
color: '#bbb'
|
||
})
|
||
}
|
||
|
||
return main
|
||
}
|
||
|
||
/**
|
||
* 数据抽样(用于大数据量场景)
|
||
* @param data 数据数组
|
||
* @param maxPoints 最大点数
|
||
*/
|
||
export function decimateData<T>(data: T[], maxPoints: number = 100): T[] {
|
||
if (data.length <= maxPoints) return data
|
||
|
||
const step = Math.ceil(data.length / maxPoints)
|
||
return data.filter((_, index) => index % step === 0)
|
||
}
|
||
|
||
/**
|
||
* 检测是否为移动端
|
||
*/
|
||
export function isMobile(): boolean {
|
||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||
}
|
||
|
||
/**
|
||
* 根据屏幕宽度调整字体大小
|
||
*/
|
||
export function getResponsiveFontSize(baseSize: number): number {
|
||
const screenWidth = window.innerWidth
|
||
if (screenWidth < 375) {
|
||
return Math.max(baseSize - 2, 10)
|
||
}
|
||
return baseSize
|
||
}
|