chore: migrate remaining ECharts components to Chart.js

- 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
This commit is contained in:
SunCheng
2026-02-16 21:55:38 +08:00
parent a88556c784
commit 9921cd5fdf
77 changed files with 6964 additions and 1632 deletions

View File

@@ -0,0 +1,140 @@
/**
* 图表工具函数
* 提供数据格式化、颜色处理等通用功能
*/
/**
* 格式化金额
* @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
}