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:
File diff suppressed because it is too large
Load Diff
132
Web/src/components/Charts/BaseChart.vue
Normal file
132
Web/src/components/Charts/BaseChart.vue
Normal 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>
|
||||
54
Web/src/components/Icon.vue
Normal file
54
Web/src/components/Icon.vue
Normal 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>
|
||||
202
Web/src/components/IconSelector.vue
Normal file
202
Web/src/components/IconSelector.vue
Normal 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>
|
||||
Reference in New Issue
Block a user