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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
<template>
<div class="base-chart" ref="chartContainer">
<van-loading v-if="loading" size="24px" vertical>加载中...</van-loading>
<van-empty v-else-if="isEmpty" description="暂无数据" />
<component
v-else
:is="chartComponent"
:data="chartData"
:options="mergedOptions"
:plugins="chartPlugins"
@chart:render="onChartRender"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Line, Bar, Pie, Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { useChartTheme } from '@/composables/useChartTheme'
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
)
interface Props {
type: 'line' | 'bar' | 'pie' | 'doughnut'
data: any
options?: any
plugins?: any[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
options: () => ({}),
plugins: () => [],
loading: false
})
const emit = defineEmits<{
(e: 'chart:render', chart: any): void
}>()
const chartContainer = ref<HTMLDivElement>()
const { getChartOptions } = useChartTheme()
// 图表组件映射
const chartComponent = computed(() => {
const components = {
line: Line,
bar: Bar,
pie: Pie,
doughnut: Doughnut
}
return components[props.type]
})
// 检查是否为空数据
const isEmpty = computed(() => {
if (!props.data || !props.data.datasets) return true
return props.data.datasets.length === 0 || props.data.datasets.every((ds: any) => !ds.data || ds.data.length === 0)
})
// 合并配置项
const mergedOptions = computed(() => {
return getChartOptions(props.options)
})
// 图表插件(包含用户传入的插件)
const chartPlugins = computed(() => {
return [...props.plugins]
})
// 响应式处理:监听容器大小变化
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (!chartContainer.value) return
resizeObserver = new ResizeObserver(() => {
// Chart.js 会自动处理 resize这里只是确保容器正确
})
resizeObserver.observe(chartContainer.value)
})
onUnmounted(() => {
if (resizeObserver && chartContainer.value) {
resizeObserver.unobserve(chartContainer.value)
resizeObserver.disconnect()
}
})
// 图表渲染完成回调
const onChartRender = (chart: any) => {
emit('chart:render', chart)
}
</script>
<style scoped lang="scss">
.base-chart {
position: relative;
width: 100%;
height: 100%;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<span
class="iconify"
:data-icon="iconIdentifier"
:style="iconStyle"
></span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
iconIdentifier: string
width?: string | number
height?: string | number
color?: string
size?: string | number
}
const props = withDefaults(defineProps<Props>(), {
width: '1em',
height: '1em',
color: undefined,
size: undefined
})
const iconStyle = computed(() => {
const style: Record<string, string> = {}
if (props.width) {
style.width = typeof props.width === 'number' ? `${props.width}px` : props.width
}
if (props.height) {
style.height = typeof props.height === 'number' ? `${props.height}px` : props.height
}
if (props.color) {
style.color = props.color
}
if (props.size) {
const size = typeof props.size === 'number' ? `${props.size}px` : props.size
style.fontSize = size
}
return style
})
</script>
<style scoped lang="scss">
.iconify {
display: inline-block;
vertical-align: middle;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<PopupContainer
:show="show"
:title="title"
show-cancel-button
show-confirm-button
confirm-text="选择"
cancel-text="取消"
@update:show="emit('update:show', $event)"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<div class="icon-selector">
<!-- 搜索框 -->
<van-search
v-model="searchKeyword"
placeholder="搜索图标"
:clearable="true"
@input="handleSearch"
/>
<!-- 图标列表 -->
<div class="icon-list" v-if="filteredIcons.length > 0">
<div
v-for="icon in paginatedIcons"
:key="icon.iconIdentifier"
class="icon-item"
:class="{ active: selectedIconIdentifier === icon.iconIdentifier }"
@click="handleSelectIcon(icon)"
>
<Icon
:icon-identifier="icon.iconIdentifier"
:size="32"
:color="selectedIconIdentifier === icon.iconIdentifier ? '#1989fa' : '#969799'"
/>
<span class="icon-label">{{ icon.iconName }}</span>
</div>
</div>
<!-- 无结果提示 -->
<van-empty v-else description="未找到匹配的图标" />
<!-- 分页 -->
<van-pagination
v-if="totalPages > 1"
v-model:currentPage="currentPage"
:total-items="filteredIcons.length"
:items-per-page="pageSize"
@change="handlePageChange"
class="pagination"
/>
</div>
</PopupContainer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import Icon from './Icon.vue'
import PopupContainer from './PopupContainer.vue'
interface Icon {
iconIdentifier: string
iconName: string
collectionName: string
}
interface Props {
show: boolean
icons: Icon[]
title?: string
defaultIconIdentifier?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '选择图标',
defaultIconIdentifier: ''
})
const emit = defineEmits<{
'update:show': [value: boolean]
confirm: [iconIdentifier: string]
cancel: []
}>()
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedIconIdentifier = ref(props.defaultIconIdentifier)
// 搜索过滤
const filteredIcons = computed(() => {
if (!searchKeyword.value.trim()) {
return props.icons
}
const keyword = searchKeyword.value.toLowerCase().trim()
return props.icons.filter(icon =>
icon.iconName.toLowerCase().includes(keyword) ||
icon.collectionName.toLowerCase().includes(keyword) ||
icon.iconIdentifier.toLowerCase().includes(keyword)
)
})
// 分页
const totalPages = computed(() => Math.ceil(filteredIcons.value.length / pageSize.value))
const paginatedIcons = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredIcons.value.slice(start, end)
})
const handleSearch = () => {
currentPage.value = 1
}
const handleSelectIcon = (icon: Icon) => {
selectedIconIdentifier.value = icon.iconIdentifier
}
const handlePageChange = (page: number) => {
currentPage.value = page
}
const handleConfirm = () => {
if (!selectedIconIdentifier.value) {
showToast('请选择一个图标')
return
}
emit('confirm', selectedIconIdentifier.value)
handleClose()
}
const handleCancel = () => {
emit('cancel')
handleClose()
}
const handleClose = () => {
searchKeyword.value = ''
currentPage.value = 1
selectedIconIdentifier.value = props.defaultIconIdentifier
}
// 监听默认图标变化
watch(() => props.defaultIconIdentifier, (newVal) => {
selectedIconIdentifier.value = newVal
})
</script>
<style scoped lang="scss">
.icon-selector {
max-height: 70vh;
display: flex;
flex-direction: column;
.icon-list {
flex: 1;
overflow-y: auto;
max-height: 55vh;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #1989fa;
background-color: #f5f5f5;
}
&.active {
border-color: #1989fa;
background-color: #e6f7ff;
}
}
.icon-label {
font-size: 12px;
color: #646464;
margin-top: 8px;
text-align: center;
}
.pagination {
padding: 16px;
border-top: 1px solid #e5e7eb;
}
}
</style>