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

@@ -58,10 +58,10 @@
>
<van-cell :title="category.name">
<template #icon>
<div
<Icon
v-if="category.icon"
class="category-icon"
v-html="parseIcon(category.icon)"
:icon-identifier="category.icon"
:size="20"
/>
</template>
<template #default>
@@ -76,7 +76,7 @@
</van-button>
<van-button
size="small"
@click="handleEditOld(category)"
@click="handleEdit(category)"
>
编辑
</van-button>
@@ -97,177 +97,110 @@
<!-- 底部安全距离 -->
<div style="height: calc(55px + env(safe-area-inset-bottom, 0px))" />
<div class="bottom-button">
<!-- 新增分类按钮 -->
<van-button
type="primary"
size="large"
icon="plus"
@click="handleAddCategory"
>
新增分类
</van-button>
</div>
<!-- 新增分类对话框 -->
<PopupContainer
v-model:show="showAddDialog"
title="新增分类"
show-cancel-button
show-confirm-button
confirm-text="确认"
cancel-text="取消"
@confirm="handleConfirmAdd"
@cancel="resetAddForm"
>
<van-form ref="addFormRef">
<van-field
v-model="addForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<!-- 编辑分类对话框 -->
<PopupContainer
v-model:show="showEditDialog"
title="编辑分类"
show-cancel-button
show-confirm-button
confirm-text="保存"
cancel-text="取消"
@confirm="handleConfirmEdit"
@cancel="showEditDialog = false"
>
<van-form ref="editFormRef">
<van-field
v-model="editForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<!-- 删除确认对话框 -->
<PopupContainer
v-model:show="showDeleteConfirm"
title="删除分类"
show-confirm-button
show-cancel-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirmDelete"
@cancel="showDeleteConfirm = false"
>
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
删除后无法恢复确定要删除吗
</p>
</PopupContainer>
<!-- 删除图标确认对话框 -->
<PopupContainer
v-model:show="showDeleteIconConfirm"
title="删除图标"
show-confirm-button
show-cancel-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirmDeleteIcon"
@cancel="showDeleteIconConfirm = false"
>
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
确定要删除图标吗
</p>
</PopupContainer>
<!-- 图标选择对话框 -->
<PopupContainer
v-model:show="showIconDialog"
title="选择图标"
:closeable="false"
>
<div class="icon-selector">
<div
v-if="currentCategory && currentCategory.icon"
class="icon-list"
>
<div
v-for="(icon, index) in parseIconArray(currentCategory.icon)"
:key="index"
class="icon-item"
:class="{ active: selectedIconIndex === index }"
@click="selectedIconIndex = index"
>
<div
class="icon-preview"
v-html="icon"
/>
</div>
</div>
<div
v-else
class="empty-icons"
>
<van-empty description="暂无图标" />
</div>
</div>
<template #footer>
<div class="icon-actions">
<van-button
type="primary"
size="small"
:loading="isGeneratingIcon"
:disabled="isGeneratingIcon"
@click="handleGenerateIcon"
>
{{ isGeneratingIcon ? 'AI生成中...' : '生成新图标' }}
</van-button>
<van-button
v-if="currentCategory && currentCategory.icon"
type="danger"
size="small"
plain
:disabled="isDeletingIcon"
style="margin-left: 20px;"
@click="handleDeleteIcon"
>
{{ isDeletingIcon ? '删除中...' : '删除图标' }}
</van-button>
<van-button
size="small"
plain
style="margin-left: 10px;"
@click="showIconDialog = false"
>
关闭
</van-button>
</div>
</template>
</PopupContainer>
</div>
<!-- 新增分类按钮 -->
<div class="bottom-button">
<van-button
type="primary"
size="large"
icon="plus"
@click="handleAddCategory"
>
新增分类
</van-button>
</div>
<!-- 新增分类对话框 -->
<PopupContainer
v-model:show="showAddDialog"
title="新增分类"
show-cancel-button
show-confirm-button
confirm-text="确认"
cancel-text="取消"
@confirm="handleConfirmAdd"
@cancel="resetAddForm"
>
<van-form ref="addFormRef">
<van-field
v-model="addForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<!-- 编辑分类对话框 -->
<PopupContainer
v-model:show="showEditDialog"
title="编辑分类"
show-cancel-button
show-confirm-button
confirm-text="保存"
cancel-text="取消"
@confirm="handleConfirmEdit"
@cancel="showEditDialog = false"
>
<van-form ref="editFormRef">
<van-field
v-model="editForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<!-- 删除确认对话框 -->
<PopupContainer
v-model:show="showDeleteConfirm"
title="删除分类"
show-cancel-button
show-confirm-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirmDelete"
@cancel="showDeleteConfirm = false"
>
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
删除后无法恢复确定要删除吗
</p>
</PopupContainer>
<!-- 图标选择对话框 -->
<IconSelector
v-model:show="showIconDialog"
:icons="iconCandidates"
:title="`为「${currentCategory?.name || ''}」选择图标`"
:default-icon-identifier="currentCategory?.icon || ''"
@confirm="handleConfirmIconSelect"
@cancel="handleCancelIconSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
import Icon from '@/components/Icon.vue'
import IconSelector from '@/components/IconSelector.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import {
getCategoryList,
createCategory,
deleteCategory,
updateCategory,
generateIcon,
updateSelectedIcon,
deleteCategoryIcon
updateCategory
} from '@/api/transactionCategory'
import PopupContainer from '@/components/PopupContainer.vue'
import {
generateSearchKeywords,
searchIcons,
updateCategoryIcon as updateCategoryIconApi
} from '@/api/icons'
const router = useRouter()
@@ -279,7 +212,7 @@ const typeOptions = [
]
// 层级状态
const currentLevel = ref(0) // 0=类型选择, 1=分类管理
const currentLevel = ref(0) // 0=类型选择 1=分类管理
const currentType = ref(null) // 当前选中的交易类型
const currentTypeName = computed(() => {
const type = typeOptions.find((t) => t.value === currentType.value)
@@ -288,7 +221,6 @@ const currentTypeName = computed(() => {
// 分类数据
const categories = ref([])
// 编辑对话框
const showAddDialog = ref(false)
const addFormRef = ref(null)
@@ -310,13 +242,9 @@ const editForm = ref({
// 图标选择对话框
const showIconDialog = ref(false)
const currentCategory = ref(null) // 当前正在编辑图标的分类
const selectedIconIndex = ref(0)
const isGeneratingIcon = ref(false)
// 删除图标确认对话框
const showDeleteIconConfirm = ref(false)
const isDeletingIcon = ref(false)
const currentCategory = ref(null)
const iconCandidates = ref([])
const isLoadingIcons = ref(false)
// 计算导航栏标题
const navTitle = computed(() => {
@@ -401,7 +329,6 @@ const handleAddCategory = () => {
*/
const handleConfirmAdd = async () => {
try {
// 表单验证
await addFormRef.value?.validate()
showLoadingToast({
@@ -432,68 +359,58 @@ const handleConfirmAdd = async () => {
}
/**
* 编辑分类
* 重置新增表单
*/
const handleEdit = (category) => {
editForm.value = {
id: category.id,
name: category.name
const resetAddForm = () => {
addForm.value = {
name: ''
}
showEditDialog.value = true
}
/**
* 打开图标选择器
*/
const handleIconSelect = (category) => {
const handleIconSelect = async (category) => {
currentCategory.value = category
selectedIconIndex.value = 0
showIconDialog.value = true
}
/**
* 生成新图标
*/
const handleGenerateIcon = async () => {
if (!currentCategory.value) {
return
}
try {
isGeneratingIcon.value = true
showLoadingToast({
message: 'AI正在生成图标...',
forbidClick: true,
duration: 0
})
isLoadingIcons.value = true
const { success, data, message } = await generateIcon(currentCategory.value.id)
const { success: keywordsSuccess, data: keywordsResponse } = await generateSearchKeywords(category.name)
if (success) {
showSuccessToast('图标生成成功')
// 重新加载分类列表以获取最新的图标
await loadCategories()
// 更新当前分类引用
const updated = categories.value.find((c) => c.id === currentCategory.value.id)
if (updated) {
currentCategory.value = updated
}
} else {
showToast(message || '生成图标失败')
if (!keywordsSuccess || !keywordsResponse || !keywordsResponse.keywords || keywordsResponse.keywords.length === 0) {
showToast('生成搜索关键字失败')
return
}
const { success: iconsSuccess, data: icons } = await searchIcons(keywordsResponse.keywords)
console.log('图标搜索响应:', { iconsSuccess, icons, iconsType: typeof icons, iconsIsArray: Array.isArray(icons) })
if (!iconsSuccess) {
showToast('搜索图标失败')
return
}
if (!icons || icons.length === 0) {
console.warn('图标数据为空')
showToast('未找到匹配的图标')
return
}
iconCandidates.value = icons
} catch (error) {
console.error('生成图标失败:', error)
showToast('生成图标失败: ' + (error.message || '未知错误'))
} finally {
isGeneratingIcon.value = false
closeToast()
console.error('搜索图标错误:', error)
showToast('搜索图标失败')
isLoadingIcons.value = false
}
}
/**
* 确认选择图标
*/
const handleConfirmIconSelect = async () => {
const handleConfirmIconSelect = async (iconIdentifier) => {
if (!currentCategory.value) {
return
}
@@ -505,75 +422,41 @@ const handleConfirmIconSelect = async () => {
duration: 0
})
const { success, message } = await updateSelectedIcon(
const { success, message } = await updateCategoryIconApi(
currentCategory.value.id,
selectedIconIndex.value
iconIdentifier
)
if (success) {
showSuccessToast('图标保存成功')
showIconDialog.value = false
currentCategory.value = null
iconCandidates.value = []
await loadCategories()
} else {
showToast(message || '保存失败')
}
} catch (error) {
console.error('保存图标失败:', error)
showToast('保存图标失败: ' + (error.message || '未知错误'))
showToast('保存图标失败')
} finally {
closeToast()
}
}
/**
* 删除图标
* 取消图标选择
*/
const handleDeleteIcon = () => {
if (!currentCategory.value || !currentCategory.value.icon) {
return
}
showDeleteIconConfirm.value = true
}
/**
* 确认删除图标
*/
const handleConfirmDeleteIcon = async () => {
if (!currentCategory.value) {
return
}
try {
isDeletingIcon.value = true
showLoadingToast({
message: '删除中...',
forbidClick: true,
duration: 0
})
const { success, message } = await deleteCategoryIcon(currentCategory.value.id)
if (success) {
showSuccessToast('图标删除成功')
showDeleteIconConfirm.value = false
showIconDialog.value = false
await loadCategories()
} else {
showToast(message || '删除失败')
}
} catch (error) {
console.error('删除图标失败:', error)
showToast('删除图标失败: ' + (error.message || '未知错误'))
} finally {
isDeletingIcon.value = false
closeToast()
}
const handleCancelIconSelect = () => {
showIconDialog.value = false
currentCategory.value = null
iconCandidates.value = []
}
/**
* 编辑分类
*/
const handleEditOld = (category) => {
const handleEdit = (category) => {
editForm.value = {
id: category.id,
name: category.name
@@ -654,53 +537,9 @@ const handleConfirmDelete = async () => {
closeToast()
}
}
/**
* 重置新增表单
*/
const resetAddForm = () => {
addForm.value = {
name: ''
}
}
/**
* 解析图标数组(第一个图标为当前选中的)
*/
const parseIcon = (iconJson) => {
if (!iconJson) {
return ''
}
try {
const icons = JSON.parse(iconJson)
return Array.isArray(icons) && icons.length > 0 ? icons[0] : ''
} catch {
return ''
}
}
/**
* 解析图标数组为完整数组
*/
const parseIconArray = (iconJson) => {
if (!iconJson) {
return []
}
try {
const icons = JSON.parse(iconJson)
return Array.isArray(icons) ? icons : []
} catch {
return []
}
}
onMounted(() => {
// 初始化时显示类型选择
currentLevel.value = 0
})
</script>
<style scoped>
<style scoped lang="scss">
.level-container {
min-height: calc(100vh - 50px);
margin-top: 16px;
@@ -714,96 +553,17 @@ onMounted(() => {
cursor: pointer;
}
.category-icon {
width: 24px;
height: 24px;
margin-right: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
.scroll-content {
flex: 1;
overflow-y: auto;
}
.category-icon :deep(svg) {
width: 100%;
height: 100%;
fill: currentColor;
.bottom-button {
padding: 16px;
}
.category-actions {
display: flex;
gap: 8px;
}
.icon-selector {
padding: 16px;
}
.icon-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.icon-item {
width: 60px;
height: 60px;
border: 2px solid var(--van-border-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.icon-item:hover {
border-color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
}
.icon-item.active {
border-color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
box-shadow: 0 2px 8px rgba(25, 137, 250, 0.3);
}
.icon-preview {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-preview :deep(svg) {
width: 100%;
height: 100%;
fill: currentColor;
}
.empty-icons {
padding: 20px 0;
}
.icon-actions {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0;
}
/* PopupContainer 的 footer 已有边框,所以这里不需要重复 */
/* 深色模式 */
/* @media (prefers-color-scheme: dark) {
.level-container {
background: var(--van-background);
}
} */
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>

View File

@@ -6,17 +6,22 @@
</h3>
</div>
<div
ref="chartRef"
class="trend-chart"
/>
<div class="trend-chart">
<BaseChart
type="line"
:data="chartData"
:options="chartOptions"
:loading="false"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { useMessageStore } from '@/stores/message'
import { computed } from 'vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { createGradient } from '@/utils/chartHelpers'
const props = defineProps({
data: {
@@ -33,10 +38,8 @@ const props = defineProps({
}
})
const messageStore = useMessageStore()
const chartRef = ref()
let chartInstance = null
// Chart.js 相关
const { getChartOptions } = useChartTheme()
// 计算图表标题
const chartTitle = computed(() => {
@@ -57,284 +60,158 @@ const getDaysInMonth = (year, month) => {
return new Date(year, month, 0).getDate()
}
// 初始化图表
const initChart = async () => {
await nextTick()
if (!chartRef.value) {
console.warn('图表容器未找到')
return
}
// 销毁已存在的图表实例
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
try {
chartInstance = echarts.init(chartRef.value)
updateChart()
} catch (error) {
console.error('初始化图表失败:', error)
}
}
// 更新图表
const updateChart = () => {
if (!chartInstance) {
console.warn('图表实例不存在')
return
}
// 验证数据
if (!Array.isArray(props.data)) {
console.warn('图表数据格式错误')
return
}
// 根据时间段类型和数据来生成图表
// 准备图表数据(通用)
const prepareChartData = () => {
let chartData = []
let xAxisLabels = []
try {
if (props.period === 'week') {
// 周统计:直接使用传入的数据,按日期排序
chartData = [...props.data].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
})
} else if (props.period === 'month') {
// 月统计:生成完整的月份数据
const currentDate = props.currentDate
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const daysInMonth = getDaysInMonth(year, month)
if (props.period === 'week') {
chartData = [...props.data].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
})
} else if (props.period === 'month') {
const currentDate = props.currentDate
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const daysInMonth = getDaysInMonth(year, month)
const allDays = Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const paddedDay = day.toString().padStart(2, '0')
return `${year}-${month.toString().padStart(2, '0')}-${paddedDay}`
})
const allDays = Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const paddedDay = day.toString().padStart(2, '0')
return `${year}-${month.toString().padStart(2, '0')}-${paddedDay}`
})
// 创建完整的数据映射
const dataMap = new Map()
props.data.forEach((item) => {
if (item && item.date) {
dataMap.set(item.date, item)
}
})
// 生成完整的数据序列
chartData = allDays.map((date) => {
const dayData = dataMap.get(date)
return {
date,
amount: dayData?.amount || 0,
count: dayData?.count || 0
}
})
xAxisLabels = chartData.map((_, index) => (index + 1).toString())
} else if (props.period === 'year') {
// 年统计:直接使用数据,显示月份标签
chartData = [...props.data]
.filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
}
// 如果没有有效数据,显示空图表
if (chartData.length === 0) {
const option = {
backgroundColor: 'transparent',
graphic: [
{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
}
}
]
const dataMap = new Map()
props.data.forEach((item) => {
if (item && item.date) {
dataMap.set(item.date, item)
}
chartInstance.setOption(option)
return
}
// 准备图表数据
const expenseData = chartData.map((item) => {
const amount = item.amount || 0
return amount < 0 ? Math.abs(amount) : 0
})
const incomeData = chartData.map((item) => {
const amount = item.amount || 0
return amount > 0 ? amount : 0
})
const isDark = messageStore.isDarkMode
chartData = allDays.map((date) => {
const dayData = dataMap.get(date)
return {
date,
amount: dayData?.amount || 0,
count: dayData?.count || 0
}
})
const option = {
backgroundColor: 'transparent',
grid: {
top: 20,
left: 10,
right: 10,
bottom: 20,
containLabel: false
},
xAxis: {
type: 'category',
data: xAxisLabels,
show: false
},
yAxis: {
type: 'value',
show: false
},
series: [
// 支出线
{
name: '支出',
type: 'line',
data: expenseData,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#ff6b6b',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 107, 107, 0.3)' },
{ offset: 1, color: 'rgba(255, 107, 107, 0.05)' }
]
}
}
xAxisLabels = chartData.map((_, index) => (index + 1).toString())
} else if (props.period === 'year') {
chartData = [...props.data]
.filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
}
const expenseData = chartData.map((item) => {
const amount = item.amount || 0
return amount < 0 ? Math.abs(amount) : 0
})
const incomeData = chartData.map((item) => {
const amount = item.amount || 0
return amount > 0 ? amount : 0
})
return { chartData, xAxisLabels, expenseData, incomeData }
}
// Chart.js 数据
const chartData = computed(() => {
const { xAxisLabels, expenseData, incomeData } = prepareChartData()
return {
labels: xAxisLabels,
datasets: [
{
label: '支出',
data: expenseData,
borderColor: '#ff6b6b',
backgroundColor: (context) => {
const chart = context.chart
const { ctx, chartArea } = chart
if (!chartArea) {return 'rgba(255, 107, 107, 0.1)'}
return createGradient(ctx, chartArea, '#ff6b6b')
},
// 收入线
{
name: '收入',
type: 'line',
data: incomeData,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#4ade80',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(74, 222, 128, 0.3)' },
{ offset: 1, color: 'rgba(74, 222, 128, 0.05)' }
]
}
}
}
],
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2
},
{
label: '收入',
data: incomeData,
borderColor: '#4ade80',
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,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2
}
]
}
})
// Chart.js 配置
const chartOptions = computed(() => {
const { chartData: rawData } = prepareChartData()
return getChartOptions({
scales: {
x: { display: false },
y: { display: false }
},
plugins: {
legend: { display: false },
tooltip: {
trigger: 'axis',
backgroundColor: isDark ? 'rgba(39, 39, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: isDark ? 'rgba(63, 63, 70, 0.8)' : 'rgba(229, 231, 235, 0.8)',
textStyle: {
color: isDark ? '#f4f4f5' : '#1a1a1a'
},
formatter: (params) => {
if (!params || params.length === 0 || !chartData[params[0].dataIndex]) {
return ''
}
callbacks: {
title: (context) => {
const index = context[0].dataIndex
if (!rawData[index]) {return ''}
const date = chartData[params[0].dataIndex].date
let content = ''
try {
const date = rawData[index].date
if (props.period === 'week') {
const dateObj = new Date(date)
const month = dateObj.getMonth() + 1
const day = dateObj.getDate()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const weekDay = weekDays[dateObj.getDay()]
content = `${month}${day}日 (周${weekDay})<br/>`
return `${month}${day}日 (周${weekDay})`
} else if (props.period === 'month') {
const day = new Date(date).getDate()
content = `${props.currentDate.getMonth() + 1}${day}<br/>`
return `${props.currentDate.getMonth() + 1}${day}`
} else if (props.period === 'year') {
const dateObj = new Date(date)
content = `${dateObj.getFullYear()}${dateObj.getMonth() + 1}<br/>`
return `${dateObj.getFullYear()}${dateObj.getMonth() + 1}`
}
params.forEach((param) => {
if (param.value > 0) {
const color = param.seriesName === '支出' ? '#ff6b6b' : '#4ade80'
content += `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${color}"></span>`
content += `${param.seriesName}: ¥${param.value.toFixed(2)}<br/>`
}
})
} catch (error) {
console.warn('格式化tooltip失败:', error)
content = '数据格式错误'
return ''
},
label: (context) => {
if (context.parsed.y === 0) {return null}
return `${context.dataset.label}: ¥${context.parsed.y.toFixed(2)}`
}
return content
}
}
},
interaction: {
mode: 'index',
intersect: false
}
chartInstance.setOption(option)
} catch (error) {
console.error('更新图表失败:', error)
}
}
// 监听数据变化
watch(
() => props.data,
() => {
if (chartInstance) {
updateChart()
}
},
{ deep: true }
)
// 监听主题变化
watch(
() => messageStore.isDarkMode,
() => {
if (chartInstance) {
updateChart()
}
}
)
onMounted(() => {
initChart()
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
})
</script>

View File

@@ -21,9 +21,12 @@
class="chart-container"
>
<div class="ring-chart">
<div
ref="pieChartRef"
style="width: 100%; height: 100%"
<BaseChart
type="doughnut"
:data="chartData"
:options="chartOptions"
:loading="false"
@chart:render="onChartRender"
/>
</div>
</div>
@@ -79,10 +82,11 @@
</template>
<script setup>
import { ref, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
import { ref, computed } from 'vue'
import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
const props = defineProps({
categories: {
@@ -101,10 +105,12 @@ const props = defineProps({
defineEmits(['category-click'])
const pieChartRef = ref(null)
let pieChartInstance = null
const showAllExpense = ref(false)
// Chart.js 相关
const { getChartOptions } = useChartTheme()
let _chartJSInstance = null
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
@@ -133,7 +139,6 @@ const expenseCategoriesSimpView = computed(() => {
return list
}
// 只展示未分类
const unclassified = list.filter((c) => c.classify === '未分类' || !c.classify)
if (unclassified.length > 0) {
return [...unclassified]
@@ -141,142 +146,94 @@ const expenseCategoriesSimpView = computed(() => {
return []
})
// 渲染饼图
const renderPieChart = () => {
if (!pieChartRef.value) {
return
}
if (expenseCategoriesView.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(pieChartRef.value)
if (pieChartInstance && pieChartInstance !== existingInstance) {
if (!pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
pieChartInstance = null
}
if (pieChartInstance && pieChartInstance.getDom() !== pieChartRef.value) {
pieChartInstance.dispose()
pieChartInstance = null
}
if (!pieChartInstance && existingInstance) {
pieChartInstance = existingInstance
}
if (!pieChartInstance) {
pieChartInstance = echarts.init(pieChartRef.value)
}
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
// 准备图表数据(通用)
const prepareChartData = () => {
const list = [...expenseCategoriesView.value]
let chartData = []
// 按照金额排序
list.sort((a, b) => b.amount - a.amount)
const MAX_SLICES = 8 // 最大显示扇区数,其余合并为"其他"
const MAX_SLICES = 8
if (list.length > MAX_SLICES) {
const topList = list.slice(0, MAX_SLICES - 1)
const otherList = list.slice(MAX_SLICES - 1)
const otherAmount = otherList.reduce((sum, item) => sum + item.amount, 0)
chartData = topList.map((item, index) => ({
const chartData = topList.map((item, index) => ({
label: item.classify || '未分类',
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
color: props.colors[index % props.colors.length]
}))
chartData.push({
label: '其他',
value: otherAmount,
name: '其他',
itemStyle: { color: getCssVar('--van-gray-6') } // 使用灰色表示其他
color: getCssVar('--van-gray-6')
})
return chartData
} else {
chartData = list.map((item, index) => ({
return list.map((item, index) => ({
label: item.classify || '未分类',
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
color: props.colors[index % props.colors.length]
}))
}
}
const option = {
title: {
text: '¥' + formatMoney(props.totalExpense),
subtext: '总支出',
left: 'center',
top: 'center',
textStyle: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
fontSize: 20,
fontWeight: 'bold'
},
subtextStyle: {
color: getCssVar('--chart-text-muted'),
fontSize: 13
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
}
},
series: [
// Chart.js 数据
const chartData = computed(() => {
const data = prepareChartData()
return {
labels: data.map((item) => item.label),
datasets: [
{
name: '支出分类',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: true,
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
itemStyle: {
borderRadius: 5,
borderColor: getCssVar('--van-background-2'),
borderWidth: 2
},
label: {
show: false
},
labelLine: {
show: false
},
data: chartData
data: data.map((item) => item.value),
backgroundColor: data.map((item) => item.color),
borderWidth: 2,
borderColor: getCssVar('--van-background-2') || '#fff',
hoverOffset: 4
}
]
}
pieChartInstance.setOption(option)
}
// 监听数据变化重新渲染图表
watch(
() => [props.categories, props.totalExpense, props.colors],
() => {
nextTick(() => {
renderPieChart()
})
},
{ deep: true, immediate: true }
)
// 组件销毁时清理图表实例
onBeforeUnmount(() => {
if (pieChartInstance && !pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
})
// Chart.js 配置
const chartOptions = computed(() => {
return getChartOptions({
cutout: '50%',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context) => {
const label = context.label || ''
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}%)`
}
}
}
},
onClick: (_event, _elements) => {
// 点击饼图扇区时,触发跳转到分类详情
// 注意:这个功能在 BaseChart 中不会自动触发,需要后续完善
}
})
})
// Chart.js 渲染完成回调
const onChartRender = (chart) => {
_chartJSInstance = chart
}
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
@@ -299,7 +256,6 @@ onBeforeUnmount(() => {
margin: 0;
}
/* 环形图 */
.chart-container {
padding: 0;
}
@@ -311,7 +267,6 @@ onBeforeUnmount(() => {
margin: 0 auto;
}
/* 分类列表 */
.category-list {
padding: 0;
}

View File

@@ -33,19 +33,24 @@
<!-- 趋势图 -->
<div class="trend-section">
<div
ref="chartRef"
class="trend-chart"
/>
<div class="trend-chart">
<BaseChart
type="line"
:data="chartData"
:options="chartOptions"
:loading="false"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { computed } from 'vue'
import { formatMoney } from '@/utils/format'
import { useMessageStore } from '@/stores/message'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { createGradient } from '@/utils/chartHelpers'
const props = defineProps({
amount: {
@@ -74,9 +79,8 @@ const props = defineProps({
}
})
const messageStore = useMessageStore()
const chartRef = ref()
let chartInstance = null
// Chart.js 相关
const { getChartOptions } = useChartTheme()
// 计算结余样式类
const balanceClass = computed(() => ({
@@ -84,282 +88,182 @@ const balanceClass = computed(() => ({
negative: props.balance < 0
}))
// 计算图表标题
const chartTitle = computed(() => {
switch (props.period) {
case 'week':
return '每日趋势'
case 'month':
return '每日趋势'
case 'year':
return '每月趋势'
default:
return '趋势'
}
})
// 获取月份天数
const getDaysInMonth = (year, month) => {
return new Date(year, month, 0).getDate()
}
// 初始化图表
const initChart = async () => {
await nextTick()
if (!chartRef.value) {
// 如果容器还未准备好,等待一小段时间后重试
setTimeout(() => {
if (chartRef.value && !chartInstance) {
initChart()
}
}, 100)
return
}
// 销毁已存在的图表实例
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
try {
chartInstance = echarts.init(chartRef.value)
updateChart()
} catch (error) {
console.error('初始化图表失败:', error)
}
}
// 更新图表
const updateChart = () => {
if (!chartInstance) {
console.warn('图表实例不存在')
return
}
// 验证数据
if (!Array.isArray(props.trendData)) {
console.warn('图表数据格式错误')
return
}
// 根据时间段类型和数据来生成图表
// 准备图表数据通用函数ECharts 和 Chart.js 都使用)
const prepareChartData = () => {
let chartData = []
let xAxisLabels = []
try {
if (props.period === 'week') {
// 周统计:直接使用传入的数据,按日期排序
chartData = [...props.trendData].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
})
} else if (props.period === 'month') {
// 月统计:生成完整的月份数据
const currentDate = props.currentDate
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const daysInMonth = getDaysInMonth(year, month)
if (props.period === 'week') {
chartData = [...props.trendData].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
})
} else if (props.period === 'month') {
const currentDate = props.currentDate
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const daysInMonth = getDaysInMonth(year, month)
const allDays = Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const paddedDay = day.toString().padStart(2, '0')
return `${year}-${month.toString().padStart(2, '0')}-${paddedDay}`
})
// 创建完整的数据映射
const dataMap = new Map()
props.trendData.forEach((item) => {
if (item && item.date) {
dataMap.set(item.date, item)
}
})
// 生成完整的数据序列
chartData = allDays.map((date) => {
const dayData = dataMap.get(date)
return {
date,
expense: dayData?.expense || 0,
income: dayData?.income || 0,
count: dayData?.count || 0
}
})
xAxisLabels = chartData.map((_, index) => (index + 1).toString())
} else if (props.period === 'year') {
// 年统计:直接使用数据,显示月份标签
chartData = [...props.trendData]
.filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
}
// 如果没有有效数据,显示空图表
if (chartData.length === 0) {
const option = {
backgroundColor: 'transparent',
graphic: [
{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
}
}
]
}
chartInstance.setOption(option)
return
}
// 准备图表数据 - 计算累计值
let cumulativeExpense = 0
let cumulativeIncome = 0
const expenseData = []
const incomeData = []
chartData.forEach((item) => {
// 支持两种数据格式1) expense/income字段 2) amount字段兼容旧数据
let expense = 0
let income = 0
if (item.expense !== undefined || item.income !== undefined) {
expense = item.expense || 0
income = item.income || 0
} else {
const amount = item.amount || 0
if (amount < 0) {
expense = Math.abs(amount)
} else {
income = amount
}
}
// 累加计算
cumulativeExpense += expense
cumulativeIncome += income
expenseData.push(cumulativeExpense)
incomeData.push(cumulativeIncome)
const allDays = Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const paddedDay = day.toString().padStart(2, '0')
return `${year}-${month.toString().padStart(2, '0')}-${paddedDay}`
})
const isDark = messageStore.isDarkMode
const dataMap = new Map()
props.trendData.forEach((item) => {
if (item && item.date) {
dataMap.set(item.date, item)
}
})
const option = {
backgroundColor: 'transparent',
grid: {
top: 20,
left: 10,
right: 10,
bottom: 20,
containLabel: false
},
xAxis: {
type: 'category',
data: xAxisLabels,
show: false
},
yAxis: {
type: 'value',
show: false
},
series: [
// 支出线
{
name: '支出',
type: 'line',
data: expenseData,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#ff6b6b',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 107, 107, 0.3)' },
{ offset: 1, color: 'rgba(255, 107, 107, 0.05)' }
]
}
}
chartData = allDays.map((date) => {
const dayData = dataMap.get(date)
return {
date,
expense: dayData?.expense || 0,
income: dayData?.income || 0,
count: dayData?.count || 0
}
})
xAxisLabels = chartData.map((_, index) => (index + 1).toString())
} else if (props.period === 'year') {
chartData = [...props.trendData]
.filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map((item) => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
}
// 计算累计值
let cumulativeExpense = 0
let cumulativeIncome = 0
const expenseData = []
const incomeData = []
chartData.forEach((item) => {
let expense = 0
let income = 0
if (item.expense !== undefined || item.income !== undefined) {
expense = item.expense || 0
income = item.income || 0
} else {
const amount = item.amount || 0
if (amount < 0) {
expense = Math.abs(amount)
} else {
income = amount
}
}
cumulativeExpense += expense
cumulativeIncome += income
expenseData.push(cumulativeExpense)
incomeData.push(cumulativeIncome)
})
return { chartData, xAxisLabels, expenseData, incomeData }
}
// Chart.js 数据
const chartData = computed(() => {
const { xAxisLabels, expenseData, incomeData } = prepareChartData()
return {
labels: xAxisLabels,
datasets: [
{
label: '支出',
data: expenseData,
borderColor: '#ff6b6b',
backgroundColor: (context) => {
const chart = context.chart
const { ctx, chartArea } = chart
if (!chartArea) {return 'rgba(255, 107, 107, 0.1)'}
return createGradient(ctx, chartArea, '#ff6b6b')
},
// 收入线
{
name: '收入',
type: 'line',
data: incomeData,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#4ade80',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(74, 222, 128, 0.3)' },
{ offset: 1, color: 'rgba(74, 222, 128, 0.05)' }
]
}
}
}
],
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2
},
{
label: '收入',
data: incomeData,
borderColor: '#4ade80',
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,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2
}
]
}
})
// Chart.js 配置
const chartOptions = computed(() => {
const { chartData: rawData } = prepareChartData()
return getChartOptions({
scales: {
x: {
display: false
},
y: {
display: false
}
},
plugins: {
legend: {
display: false
},
tooltip: {
trigger: 'axis',
backgroundColor: isDark ? 'rgba(39, 39, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: isDark ? 'rgba(63, 63, 70, 0.8)' : 'rgba(229, 231, 235, 0.8)',
textStyle: {
color: isDark ? '#f4f4f5' : '#1a1a1a'
},
formatter: (params) => {
if (!params || params.length === 0 || !chartData[params[0].dataIndex]) {
return ''
}
callbacks: {
title: (context) => {
const index = context[0].dataIndex
if (!rawData[index]) {return ''}
const dataIndex = params[0].dataIndex
const date = chartData[dataIndex].date
const item = chartData[dataIndex]
let content = ''
try {
const date = rawData[index].date
if (props.period === 'week') {
const dateObj = new Date(date)
const month = dateObj.getMonth() + 1
const day = dateObj.getDate()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const weekDay = weekDays[dateObj.getDay()]
content = `${month}${day}日 (周${weekDay})<br/>`
return `${month}${day}日 (周${weekDay})`
} else if (props.period === 'month') {
const day = new Date(date).getDate()
content = `${props.currentDate.getMonth() + 1}${day}<br/>`
return `${props.currentDate.getMonth() + 1}${day}`
} else if (props.period === 'year') {
const dateObj = new Date(date)
content = `${dateObj.getFullYear()}${dateObj.getMonth() + 1}<br/>`
return `${dateObj.getFullYear()}${dateObj.getMonth() + 1}`
}
return ''
},
label: (context) => {
const index = context.dataIndex
const item = rawData[index]
if (!item) {return ''}
// 计算当日值
let dailyExpense = 0
let dailyIncome = 0
@@ -375,69 +279,25 @@ const updateChart = () => {
}
}
// 只显示当日值
params.forEach((param) => {
const color = param.seriesName === '支出' ? '#ff6b6b' : '#4ade80'
const dailyValue = param.seriesName === '支出' ? dailyExpense : dailyIncome
const value = context.dataset.label === '支出' ? dailyExpense : dailyIncome
if (value === 0) {return null}
if (dailyValue > 0) {
content += `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${color}"></span>`
content += `${param.seriesName}: ¥${dailyValue.toFixed(2)}`
content += '<br/>'
}
})
} catch (error) {
console.warn('格式化tooltip失败:', error)
content = '数据格式错误'
return `${context.dataset.label}: ¥${value.toFixed(2)}`
}
return content
}
}
},
interaction: {
mode: 'index',
intersect: false
}
chartInstance.setOption(option)
} catch (error) {
console.error('更新图表失败:', error)
}
}
// 监听数据变化
watch(
() => props.trendData,
() => {
if (chartInstance) {
updateChart()
}
},
{ deep: true }
)
// 监听主题变化
watch(
() => messageStore.isDarkMode,
() => {
if (chartInstance) {
updateChart()
}
}
)
onMounted(() => {
initChart()
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
@@ -452,7 +312,6 @@ onBeforeUnmount(() => {
gap: var(--spacing-lg);
}
// 收支结余一行展示
.stats-row {
display: flex;
justify-content: space-between;
@@ -502,7 +361,6 @@ onBeforeUnmount(() => {
}
}
// 趋势图部分
.trend-section {
display: flex;
flex-direction: column;