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:
@@ -1,2 +1,5 @@
|
||||
# 开发环境配置
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:5071/api
|
||||
|
||||
# 图表库选择:true 使用 Chart.js,false 使用 ECharts
|
||||
VITE_USE_CHARTJS=true
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"echarts": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vant": "^4.9.22",
|
||||
"vue": "^3.5.25",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
64
Web/pnpm-lock.yaml
generated
64
Web/pnpm-lock.yaml
generated
@@ -8,15 +8,18 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@iconify/iconify':
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
chart.js:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(vue@3.5.26)
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.25
|
||||
version: 3.5.26
|
||||
vue-chartjs:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3(chart.js@4.5.1)(vue@3.5.26)
|
||||
vue-router:
|
||||
specifier: ^4.6.3
|
||||
version: 4.6.4(vue@3.5.26)
|
||||
@@ -416,6 +422,13 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@iconify/iconify@3.1.1':
|
||||
resolution: {integrity: sha512-1nemfyD/OJzh9ALepH7YfuuP8BdEB24Skhd8DXWh0hzcOxImbb1ZizSZkpCzAwSZSGcJFmscIBaBQu+yLyWaxQ==}
|
||||
deprecated: no longer maintained, switch to modern iconify-icon web component
|
||||
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -432,6 +445,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@kurkle/color@0.3.4':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -799,6 +815,10 @@ packages:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chart.js@4.5.1:
|
||||
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
|
||||
engines: {pnpm: '>=8'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
@@ -878,9 +898,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
@@ -1631,6 +1648,12 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vue-chartjs@5.3.3:
|
||||
resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==}
|
||||
peerDependencies:
|
||||
chart.js: ^4.1.1
|
||||
vue: ^3.0.0-0 || ^2.7.0
|
||||
|
||||
vue-eslint-parser@10.2.0:
|
||||
resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1674,9 +1697,6 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
@@ -2007,6 +2027,12 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.3': {}
|
||||
|
||||
'@iconify/iconify@3.1.1':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -2026,6 +2052,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
@@ -2383,6 +2411,10 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chart.js@4.5.1:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
@@ -2446,11 +2478,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
entities@7.0.0: {}
|
||||
@@ -3148,6 +3175,11 @@ snapshots:
|
||||
sass: 1.97.3
|
||||
sass-embedded: 1.97.3
|
||||
|
||||
vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.26):
|
||||
dependencies:
|
||||
chart.js: 4.5.1
|
||||
vue: 3.5.26
|
||||
|
||||
vue-eslint-parser@10.2.0(eslint@9.39.2):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -3188,7 +3220,3 @@ snapshots:
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- esbuild
|
||||
|
||||
41
Web/src/api/icons.js
Normal file
41
Web/src/api/icons.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 生成搜索关键字
|
||||
* @param {string} categoryName - 分类名称
|
||||
* @returns {Promise<{success: boolean, data: Array<string>>}
|
||||
*/
|
||||
export const generateSearchKeywords = (categoryName) => {
|
||||
return request({
|
||||
url: '/icons/search-keywords',
|
||||
method: 'post',
|
||||
data: { categoryName }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索图标
|
||||
* @param {Array<string>} keywords - 搜索关键字数组
|
||||
* @returns {Promise<{success: boolean, data: Array<object>>}
|
||||
*/
|
||||
export const searchIcons = (keywords) => {
|
||||
return request({
|
||||
url: '/icons/search',
|
||||
method: 'post',
|
||||
data: { keywords }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分类图标
|
||||
* @param {number} categoryId - 分类ID
|
||||
* @param {string} iconIdentifier - 图标标识符
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
export const updateCategoryIcon = (categoryId, iconIdentifier) => {
|
||||
return request({
|
||||
url: `/icons/categories/${categoryId}/icon`,
|
||||
method: 'put',
|
||||
data: { iconIdentifier }
|
||||
})
|
||||
}
|
||||
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>
|
||||
161
Web/src/composables/useChartTheme.ts
Normal file
161
Web/src/composables/useChartTheme.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { computed } from 'vue'
|
||||
import { ConfigProvider } from 'vant'
|
||||
|
||||
/**
|
||||
* Chart.js 主题配置 Composable
|
||||
* 根据 Vant UI 主题自动适配颜色方案,支持暗色模式
|
||||
*/
|
||||
export function useChartTheme() {
|
||||
// 获取 CSS 变量值
|
||||
const getCSSVar = (varName: string) => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
|
||||
}
|
||||
|
||||
// 基础颜色配置
|
||||
const colors = computed(() => ({
|
||||
primary: getCSSVar('--van-primary-color') || '#1989fa',
|
||||
success: getCSSVar('--van-success-color') || '#07c160',
|
||||
danger: getCSSVar('--van-danger-color') || '#ee0a24',
|
||||
warning: getCSSVar('--van-warning-color') || '#ff976a',
|
||||
text: getCSSVar('--van-text-color') || '#323233',
|
||||
textSecondary: getCSSVar('--van-text-color-2') || '#969799',
|
||||
border: getCSSVar('--van-border-color') || '#ebedf0',
|
||||
background: getCSSVar('--van-background') || '#f7f8fa',
|
||||
cardBackground: getCSSVar('--van-background-2') || '#ffffff'
|
||||
}))
|
||||
|
||||
// 图表色板(用于多系列图表)
|
||||
const chartPalette = computed(() => [
|
||||
colors.value.primary,
|
||||
colors.value.success,
|
||||
colors.value.warning,
|
||||
colors.value.danger,
|
||||
'#6f42c1', // purple
|
||||
'#20c997', // teal
|
||||
'#fd7e14', // orange
|
||||
'#e83e8c' // pink
|
||||
])
|
||||
|
||||
// 基础配置项
|
||||
const baseChartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 750,
|
||||
easing: 'easeInOutQuart'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: colors.value.text,
|
||||
font: {
|
||||
size: 12,
|
||||
family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
|
||||
},
|
||||
padding: 12,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: colors.value.cardBackground,
|
||||
titleColor: colors.value.text,
|
||||
bodyColor: colors.value.text,
|
||||
borderColor: colors.value.border,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += '¥' + context.parsed.y.toFixed(2)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: colors.value.border,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: colors.value.textSecondary,
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: colors.value.border,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: colors.value.textSecondary,
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
callback: (value: any) => '¥' + value
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// 检测是否启用了动画减弱
|
||||
const prefersReducedMotion = computed(() => {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
})
|
||||
|
||||
// 获取带动画控制的配置
|
||||
const getChartOptions = (customOptions: any = {}) => {
|
||||
const options = { ...baseChartOptions.value }
|
||||
|
||||
// 如果用户偏好减少动画,禁用动画
|
||||
if (prefersReducedMotion.value) {
|
||||
options.animation = { duration: 0 }
|
||||
}
|
||||
|
||||
// 深度合并自定义配置
|
||||
return mergeDeep(options, customOptions)
|
||||
}
|
||||
|
||||
return {
|
||||
colors,
|
||||
chartPalette,
|
||||
baseChartOptions,
|
||||
getChartOptions,
|
||||
prefersReducedMotion
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度合并对象
|
||||
*/
|
||||
function mergeDeep(target: any, source: any): any {
|
||||
const output = { ...target }
|
||||
if (isObject(target) && isObject(source)) {
|
||||
Object.keys(source).forEach((key) => {
|
||||
if (isObject(source[key])) {
|
||||
if (!(key in target)) {
|
||||
Object.assign(output, { [key]: source[key] })
|
||||
} else {
|
||||
output[key] = mergeDeep(target[key], source[key])
|
||||
}
|
||||
} else {
|
||||
Object.assign(output, { [key]: source[key] })
|
||||
}
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function isObject(item: any): boolean {
|
||||
return item && typeof item === 'object' && !Array.isArray(item)
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import Vant from 'vant'
|
||||
import { ConfigProvider } from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
|
||||
// 导入 Iconify (使用本地包而不是 CDN)
|
||||
import '@iconify/iconify'
|
||||
|
||||
// 注册 Service Worker
|
||||
import { register } from './registerServiceWorker'
|
||||
|
||||
|
||||
113
Web/src/plugins/chartjs-gauge-plugin.ts
Normal file
113
Web/src/plugins/chartjs-gauge-plugin.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Plugin } from 'chart.js'
|
||||
|
||||
/**
|
||||
* Chart.js Gauge 插件
|
||||
* 在 Doughnut 图表中心显示文本(用于实现仪表盘效果)
|
||||
*/
|
||||
|
||||
export interface GaugePluginOptions {
|
||||
centerText?: {
|
||||
label?: string
|
||||
value?: string
|
||||
labelColor?: string
|
||||
valueColor?: string
|
||||
labelFontSize?: number
|
||||
valueFontSize?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const chartjsGaugePlugin: Plugin = {
|
||||
id: 'gaugePlugin',
|
||||
afterDraw: (chart: any) => {
|
||||
const { ctx, chartArea } = chart
|
||||
|
||||
if (!chartArea) return
|
||||
|
||||
const centerX = (chartArea.left + chartArea.right) / 2
|
||||
const centerY = (chartArea.top + chartArea.bottom) / 2
|
||||
|
||||
// 从图表配置中获取插件选项
|
||||
const pluginOptions = chart.options.plugins?.gaugePlugin as GaugePluginOptions | undefined
|
||||
|
||||
if (!pluginOptions?.centerText) return
|
||||
|
||||
const { label, value, labelColor, valueColor, labelFontSize, valueFontSize } = pluginOptions.centerText
|
||||
|
||||
ctx.save()
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// 绘制标签
|
||||
if (label) {
|
||||
ctx.font = `${labelFontSize || 14}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
|
||||
ctx.fillStyle = labelColor || '#969799'
|
||||
ctx.fillText(label, centerX, centerY - 20)
|
||||
}
|
||||
|
||||
// 绘制值
|
||||
if (value) {
|
||||
ctx.font = `bold ${valueFontSize || 28}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`
|
||||
ctx.fillStyle = valueColor || '#323233'
|
||||
ctx.fillText(value, centerX, centerY + 10)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仪表盘图表配置
|
||||
* @param value 当前值
|
||||
* @param limit 限额
|
||||
* @param label 标签文字(如 "余额"、"差额")
|
||||
* @param colors 颜色配置
|
||||
*/
|
||||
export function createGaugeConfig(
|
||||
value: number,
|
||||
limit: number,
|
||||
label: string,
|
||||
colors: { primary: string; danger: string; success: string; background: string }
|
||||
) {
|
||||
const percentage = limit > 0 ? Math.min((value / limit) * 100, 200) : 0
|
||||
const remaining = Math.abs(limit - value)
|
||||
const isOver = value > limit
|
||||
|
||||
// 确定颜色:超支使用 danger,否则使用 primary
|
||||
const activeColor = isOver ? colors.danger : colors.primary
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
data: [percentage, 200 - percentage], // 半圆形,总共 200(100% * 2)
|
||||
backgroundColor: [activeColor, colors.background],
|
||||
borderWidth: 0,
|
||||
circumference: 180, // 半圆
|
||||
rotation: 270 // 从底部开始
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
cutout: '75%', // 内圈大小
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false
|
||||
},
|
||||
gaugePlugin: {
|
||||
centerText: {
|
||||
label: label,
|
||||
value: `¥${remaining.toFixed(0)}`,
|
||||
labelColor: '#969799',
|
||||
valueColor: isOver ? colors.danger : '#323233',
|
||||
labelFontSize: 14,
|
||||
valueFontSize: 24
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [chartjsGaugePlugin]
|
||||
}
|
||||
}
|
||||
140
Web/src/utils/chartHelpers.ts
Normal file
140
Web/src/utils/chartHelpers.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user