diff --git a/.doc/chart-grid-lines-issue.md b/.doc/chart-grid-lines-issue.md new file mode 100644 index 0000000..01f31d1 --- /dev/null +++ b/.doc/chart-grid-lines-issue.md @@ -0,0 +1,103 @@ +--- +title: Doughnut/Pie 图表显示网格线问题修复 +author: AI Assistant +date: 2026-02-19 +status: final +category: 技术修复 +--- + +# Doughnut/Pie 图表显示网格线问题修复 + +## 问题描述 + +在使用 Chart.js 的 Doughnut(环形图)或 Pie(饼图)时,图表中不应该显示笛卡尔坐标系的网格线,但在某些情况下会错误地显示出来。 + +## 问题根源 + +`useChartTheme.ts` 中的 `baseChartOptions` 包含了 `scales.x` 和 `scales.y` 配置(第 82-108 行),这些配置适用于折线图、柱状图等**笛卡尔坐标系图表**,但不适用于 Doughnut/Pie 这类**极坐标图表**。 + +当使用 `getChartOptions()` 合并配置时,这些默认的 `scales` 配置会被带入到圆形图表中,导致显示网格线。 + +## 修复方案 + +### 方案 1:在具体组件中显式禁用(已应用) + +在使用 Doughnut/Pie 图表的组件中,调用 `getChartOptions()` 时显式传入 `scales` 配置: + +```javascript +const chartOptions = computed(() => { + return getChartOptions({ + cutout: '65%', + // 显式禁用笛卡尔坐标系(Doughnut 图表不需要) + scales: { + x: { display: false }, + y: { display: false } + }, + plugins: { + // ...其他插件配置 + } + }) +}) +``` + +### 方案 2:BaseChart 组件自动处理(已优化) + +优化 `BaseChart.vue` 组件(第 106-128 行),使其能够自动检测圆形图表并强制禁用坐标轴: + +```javascript +const mergedOptions = computed(() => { + const isCircularChart = props.type === 'pie' || props.type === 'doughnut' + + const merged = getChartOptions(props.options) + + if (isCircularChart) { + if (!props.options?.scales) { + // 用户完全没传 scales,直接删除 + delete merged.scales + } else { + // 用户传了 scales,确保 display 设置为 false + if (merged.scales) { + if (merged.scales.x) merged.scales.x.display = false + if (merged.scales.y) merged.scales.y.display = false + } + } + } + + return merged +}) +``` + +## 已修复的文件 + +1. **Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue** + - 在 `chartOptions` 中添加了显式的 `scales` 禁用配置(第 321-324 行) + +2. **Web/src/components/Charts/BaseChart.vue** + - 优化了圆形图表的 `scales` 处理逻辑(第 106-128 行) + +## 已验证的文件(无需修改) + +1. **Web/src/components/Budget/BudgetChartAnalysis.vue** + - `monthGaugeOptions` 和 `yearGaugeOptions` 已经包含正确的 `scales` 配置 + +## 预防措施 + +1. **新增 Doughnut/Pie 图表时**:始终显式设置 `scales: { x: { display: false }, y: { display: false } }` +2. **使用 BaseChart 组件**:依赖其自动处理逻辑(已优化) +3. **代码审查**:检查所有圆形图表配置,确保不包含笛卡尔坐标系配置 + +## Chart.js 图表类型说明 + +| 图表类型 | 坐标系 | 是否需要 scales | +|---------|--------|----------------| +| Line | 笛卡尔 | ✓ 需要 x/y | +| Bar | 笛卡尔 | ✓ 需要 x/y | +| Pie | 极坐标 | ✗ 不需要 | +| Doughnut| 极坐标 | ✗ 不需要 | +| Radar | 极坐标 | ✗ 不需要 | + +## 相关资源 + +- Chart.js 官方文档:https://www.chartjs.org/docs/latest/ +- 项目主题配置:`Web/src/composables/useChartTheme.ts` +- 图表基础组件:`Web/src/components/Charts/BaseChart.vue` diff --git a/Web/src/components/Bill/BillListComponent.vue b/Web/src/components/Bill/BillListComponent.vue index 1f75e15..50980af 100644 --- a/Web/src/components/Bill/BillListComponent.vue +++ b/Web/src/components/Bill/BillListComponent.vue @@ -445,7 +445,7 @@ const getIconByClassify = (classify) => { if (categoryIconMap.value[classify]) { return categoryIconMap.value[classify] } - + // 降级:使用本地映射(向后兼容) const iconMap = { 餐饮: 'food-o', @@ -648,7 +648,7 @@ const loadCategories = async () => { const response = await getCategoryList() if (response && response.success) { categories.value = response.data || [] - + // 构建分类名称 -> 图标的映射 const iconMap = {} categories.value.forEach(category => { @@ -668,7 +668,7 @@ const loadCategories = async () => { onMounted(() => { // 加载分类列表(用于图标映射) loadCategories() - + if (props.dataSource === 'api') { fetchTransactions() } diff --git a/Web/src/components/Charts/BaseChart.vue b/Web/src/components/Charts/BaseChart.vue index 7cd11e4..aa5cff1 100644 --- a/Web/src/components/Charts/BaseChart.vue +++ b/Web/src/components/Charts/BaseChart.vue @@ -105,7 +105,28 @@ const isEmpty = computed(() => { // 合并配置项 const mergedOptions = computed(() => { - return getChartOptions(props.options) + const isCircularChart = props.type === 'pie' || props.type === 'doughnut' + + // 先调用主题合并 + const merged = getChartOptions(props.options) + + // pie/doughnut 不需要 x/y 坐标轴;强制隐藏 scales 避免网格线 + if (isCircularChart) { + // 如果用户没有显式传 scales,或者传入的 scales 没有明确 display 设置 + // 则强制禁用坐标轴(圆形图表不应该显示笛卡尔坐标系) + if (!props.options?.scales) { + // 用户完全没传 scales,直接删除 + delete merged.scales + } else { + // 用户传了 scales,确保 display 设置为 false + if (merged.scales) { + if (merged.scales.x) {merged.scales.x.display = false} + if (merged.scales.y) {merged.scales.y.display = false} + } + } + } + + return merged }) // 图表插件(包含用户传入的插件) diff --git a/Web/src/composables/useChartTheme.ts b/Web/src/composables/useChartTheme.ts index 4a2b8d0..16f5e88 100644 --- a/Web/src/composables/useChartTheme.ts +++ b/Web/src/composables/useChartTheme.ts @@ -72,7 +72,7 @@ export function useChartTheme() { label += ': ' } if (context.parsed.y !== null) { - label += '¥' + context.parsed.y.toFixed(2) + label += '¥' + context.parsed.y.toFixed(0) } return label } diff --git a/Web/src/utils/format.js b/Web/src/utils/format.js index 20d12c9..bc6be89 100644 --- a/Web/src/utils/format.js +++ b/Web/src/utils/format.js @@ -1,14 +1,15 @@ /** * 格式化金额 * @param {number} value 金额数值 + * @param {number} decimals 小数位数 * @returns {string} 格式化后的金额字符串 */ -export const formatMoney = (value) => { +export const formatMoney = (value, decimals = 1) => { if (!value && value !== 0) { - return '0' + return Number(0).toFixed(decimals) } return Number(value) - .toFixed(0) + .toFixed(decimals) .replace(/\B(?=(\d{3})+(?!\d))/g, ',') } diff --git a/Web/src/views/statisticsV2/modules/DailyTrendChart.vue b/Web/src/views/statisticsV2/modules/DailyTrendChart.vue index 09df7a7..c0579e8 100644 --- a/Web/src/views/statisticsV2/modules/DailyTrendChart.vue +++ b/Web/src/views/statisticsV2/modules/DailyTrendChart.vue @@ -134,6 +134,8 @@ const chartData = computed(() => { label: '支出', data: expenseData, borderColor: '#ff6b6b', + yAxisID: 'yExpense', + order: 2, backgroundColor: (context) => { const chart = context.chart const { ctx, chartArea } = chart @@ -150,13 +152,15 @@ const chartData = computed(() => { label: '收入', data: incomeData, borderColor: '#4ade80', + yAxisID: 'yIncome', + order: 1, backgroundColor: (context) => { const chart = context.chart const { ctx, chartArea } = chart if (!chartArea) {return 'rgba(74, 222, 128, 0.1)'} return createGradient(ctx, chartArea, '#4ade80') }, - fill: true, + fill: false, tension: 0.4, pointRadius: 0, pointHoverRadius: 4, @@ -168,12 +172,40 @@ const chartData = computed(() => { // Chart.js 配置 const chartOptions = computed(() => { - const { chartData: rawData } = prepareChartData() + const { chartData: rawData, expenseData, incomeData } = prepareChartData() + const maxExpense = Math.max(...expenseData, 0) + const maxIncome = Math.max(...incomeData, 0) return getChartOptions({ scales: { - x: { display: false }, - y: { display: false } + x: { + display: false, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + }, + y: { + display: false, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + }, + yIncome: { + display: false, + beginAtZero: true, + suggestedMax: maxIncome ? maxIncome * 1.1 : undefined, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + }, + yExpense: { + display: false, + beginAtZero: true, + suggestedMax: maxExpense ? maxExpense * 1.1 : undefined, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + } }, plugins: { legend: { display: false }, @@ -202,7 +234,7 @@ const chartOptions = computed(() => { }, label: (context) => { if (context.parsed.y === 0) {return null} - return `${context.dataset.label}: ¥${context.parsed.y.toFixed(2)}` + return `${context.dataset.label}: ¥${context.parsed.y.toFixed(1)}` } } } diff --git a/Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue b/Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue index 065b2f2..5034588 100644 --- a/Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue +++ b/Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue @@ -210,13 +210,14 @@ const pieLabelLinePlugin = { } // 格式化金额 -const formatMoney = (value) => { +const formatMoney = (value, decimals = 1) => { if (!value && value !== 0) { - return '0' + return Number(0).toFixed(decimals) } return Number(value) - .toFixed(0) + .toFixed(decimals) .replace(/\B(?=(\d{3})+(?!\d))/g, ',') + .replace(/\.0$/, '') } // 计算属性 @@ -317,6 +318,11 @@ const chartOptions = computed(() => { right: 2 } }, + // 显式禁用笛卡尔坐标系(Doughnut 图表不需要) + scales: { + x: { display: false }, + y: { display: false } + }, plugins: { legend: { display: false @@ -335,12 +341,12 @@ const chartOptions = computed(() => { const value = context.parsed || 0 const total = context.dataset.data.reduce((a, b) => a + b, 0) const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0 - return `${label}: ¥${formatMoney(value)} (${percentage}%)` + return `${label}: ¥${formatMoney(value, 0)} (${percentage}%)` } } }, pieCenterText: { - text: `¥${formatMoney(totalAmount.value)}`, + text: `¥${formatMoney(totalAmount.value, 0)}`, subtext: '总支出', textColor: isDarkMode ? '#ffffff' : '#323233', subtextColor: isDarkMode ? '#969799' : '#969799', @@ -403,7 +409,7 @@ const onChartRender = (chart) => { .ring-chart { position: relative; width: 100%; - height: 170px; + height: 190px; margin: 0px auto 0; overflow: visible; } diff --git a/Web/src/views/statisticsV2/modules/IncomeNoneCategoryCard.vue b/Web/src/views/statisticsV2/modules/IncomeNoneCategoryCard.vue index 19cf79e..10ddf77 100644 --- a/Web/src/views/statisticsV2/modules/IncomeNoneCategoryCard.vue +++ b/Web/src/views/statisticsV2/modules/IncomeNoneCategoryCard.vue @@ -10,7 +10,7 @@ class="income-text" style="font-size: 13px; margin-left: 4px" > - ¥{{ formatMoney(totalIncome) }} + ¥{{ formatMoney(totalIncome, 0) }} {{ category.classify || '未分类' }}
- ¥{{ formatMoney(category.amount) }} + ¥{{ formatMoney(category.amount, 0) }}
@@ -80,7 +80,7 @@ {{ category.classify || '未分类' }}
- ¥{{ formatMoney(category.amount) }} + ¥{{ formatMoney(category.amount, 0) }}
diff --git a/Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue b/Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue index 176df26..d7f484a 100644 --- a/Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue +++ b/Web/src/views/statisticsV2/modules/MonthlyExpenseCard.vue @@ -7,7 +7,7 @@ 支出
- ¥{{ formatMoney(amount) }} + ¥{{ formatMoney(amount, 0) }}
@@ -15,7 +15,7 @@ 收入
- ¥{{ formatMoney(income) }} + ¥{{ formatMoney(income, 0) }}
@@ -26,7 +26,7 @@ class="stat-amount" :class="balanceClass" > - ¥{{ formatMoney(balance) }} + ¥{{ formatMoney(balance, 0) }}
@@ -203,10 +203,12 @@ const prepareChartData = () => { let expense = 0 let income = 0 - if (item.expense !== undefined || item.income !== undefined) { + // 优先使用 expense 和 income 字段 + if ('expense' in item && 'income' in item) { expense = item.expense || 0 income = item.income || 0 - } else { + } else if ('amount' in item) { + // 如果只有 amount 字段,根据正负值判断 const amount = item.amount || 0 if (amount < 0) { expense = Math.abs(amount) @@ -239,6 +241,8 @@ const chartData = computed(() => { label: '支出', data: expenseData, borderColor: expenseColor.value, + yAxisID: 'yExpense', + order: 2, backgroundColor: (context) => { const chart = context.chart const { ctx, chartArea } = chart @@ -258,6 +262,8 @@ const chartData = computed(() => { label: '收入', data: incomeData, borderColor: incomeColor.value, + yAxisID: 'yIncome', + order: 1, backgroundColor: (context) => { const chart = context.chart const { ctx, chartArea } = chart @@ -266,7 +272,7 @@ const chartData = computed(() => { } return createGradient(ctx, chartArea, incomeColor.value) }, - fill: true, + fill: false, tension: 0.4, pointRadius: 0, pointHoverRadius: 6, @@ -279,12 +285,40 @@ const chartData = computed(() => { // Chart.js 配置 const chartOptions = computed(() => { - const { chartData: rawData } = prepareChartData() + const { chartData: rawData, expenseData, incomeData } = prepareChartData() + const maxExpense = Math.max(...expenseData, 0) + const maxIncome = Math.max(...incomeData, 0) return getChartOptions({ scales: { - x: { display: false }, - y: { display: false } + x: { + display: false, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + }, + y: { + display: false, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + }, + yIncome: { + display: false, + beginAtZero: true, + suggestedMax: maxIncome ? maxIncome * 1.1 : undefined, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + }, + yExpense: { + display: false, + beginAtZero: true, + suggestedMax: maxExpense ? maxExpense * 1.1 : undefined, + grid: { display: false, drawBorder: false }, + ticks: { display: false }, + border: { display: false } + } }, plugins: { legend: { display: false }, @@ -326,10 +360,12 @@ const chartOptions = computed(() => { let dailyExpense = 0 let dailyIncome = 0 - if (item.expense !== undefined || item.income !== undefined) { + // 优先使用 expense 和 income 字段 + if ('expense' in item && 'income' in item) { dailyExpense = item.expense || 0 dailyIncome = item.income || 0 - } else { + } else if ('amount' in item) { + // 如果只有 amount 字段,根据正负值判断 const amount = item.amount || 0 if (amount < 0) { dailyExpense = Math.abs(amount) @@ -343,7 +379,7 @@ const chartOptions = computed(() => { return null } - return `${context.dataset.label}: ¥${value.toFixed(2)}` + return `${context.dataset.label}: ¥${value.toFixed(1)}` } } }