style: unify card styles across calendar, statistics, and budget pages
- 调整 theme.css 中的 --radius-lg 为 12px 以符合设计标准 - 统一日历页面卡片样式(padding, border-radius, shadow) - 统一统计页面所有卡片组件的样式 - 统一预算页面图表卡片样式,替换硬编码值为 CSS 变量 - 为关键样式添加 fallback 值以防止变量未定义 - 所有卡片现在使用统一的样式变量: - padding: var(--spacing-xl, 16px) - border-radius: var(--radius-lg, 12px) - box-shadow: var(--shadow-sm) - background: var(--bg-secondary)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<!-- 支出分类统计 -->
|
||||
<div
|
||||
class="common-card"
|
||||
style="padding-bottom: 10px"
|
||||
class="common-card expense-category-card"
|
||||
style="padding: 12px;"
|
||||
>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
@@ -25,6 +25,7 @@
|
||||
type="doughnut"
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:plugins="[pieCenterTextPlugin, pieLabelLinePlugin]"
|
||||
:loading="false"
|
||||
@chart:render="onChartRender"
|
||||
/>
|
||||
@@ -87,6 +88,7 @@ import { getCssVar } from '@/utils/theme'
|
||||
import ModernEmpty from '@/components/ModernEmpty.vue'
|
||||
import BaseChart from '@/components/Charts/BaseChart.vue'
|
||||
import { useChartTheme } from '@/composables/useChartTheme'
|
||||
import { pieCenterTextPlugin } from '@/plugins/chartjs-pie-center-plugin'
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
@@ -108,9 +110,105 @@ defineEmits(['category-click'])
|
||||
const showAllExpense = ref(false)
|
||||
|
||||
// Chart.js 相关
|
||||
const { getChartOptions } = useChartTheme()
|
||||
const { getChartOptionsByType } = useChartTheme()
|
||||
let _chartJSInstance = null
|
||||
|
||||
// 饼图标签引导线
|
||||
const pieLabelLinePlugin = {
|
||||
id: 'pieLabelLine',
|
||||
afterDraw: (chart) => {
|
||||
const ctx = chart.ctx
|
||||
const meta = chart.getDatasetMeta(0)
|
||||
if (!meta?.data?.length) {return}
|
||||
|
||||
const labels = chart.data.labels || []
|
||||
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2
|
||||
const lineColor = getCssVar('--van-text-color-2') || '#8a8a8a'
|
||||
const textColor = getCssVar('--van-text-color') || '#323233'
|
||||
const strokeColor = getCssVar('--van-background-2') || '#ffffff'
|
||||
const minSpacing = 12
|
||||
const labelOffset = 18
|
||||
const lineOffset = 8
|
||||
const yPadding = 6
|
||||
|
||||
const items = meta.data
|
||||
.map((arc, index) => {
|
||||
const label = labels[index]
|
||||
if (!label) {return null}
|
||||
const props = arc.getProps(['startAngle', 'endAngle', 'outerRadius', 'x', 'y'], true)
|
||||
const angle = (props.startAngle + props.endAngle) / 2
|
||||
const rawX = props.x + Math.cos(angle) * props.outerRadius
|
||||
const rawY = props.y + Math.sin(angle) * props.outerRadius
|
||||
const isRight = rawX >= centerX
|
||||
return {
|
||||
arc: props,
|
||||
label,
|
||||
angle,
|
||||
isRight,
|
||||
y: rawY
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const left = items.filter((item) => !item.isRight).sort((a, b) => a.y - b.y)
|
||||
const right = items.filter((item) => item.isRight).sort((a, b) => a.y - b.y)
|
||||
|
||||
const spread = (list) => {
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
if (list[i].y - list[i - 1].y < minSpacing) {
|
||||
list[i].y = list[i - 1].y + minSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topLimit = chart.chartArea.top + yPadding
|
||||
const bottomLimit = chart.chartArea.bottom - yPadding
|
||||
const clampY = (value) => Math.min(bottomLimit, Math.max(topLimit, value))
|
||||
|
||||
spread(left)
|
||||
spread(right)
|
||||
|
||||
left.forEach((item) => { item.y = clampY(item.y) })
|
||||
right.forEach((item) => { item.y = clampY(item.y) })
|
||||
|
||||
ctx.save()
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = textColor
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = 'bold 10px "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'
|
||||
|
||||
const drawItem = (item) => {
|
||||
const cos = Math.cos(item.angle)
|
||||
const sin = Math.sin(item.angle)
|
||||
const startX = item.arc.x + cos * (item.arc.outerRadius + 2)
|
||||
const startY = item.arc.y + sin * (item.arc.outerRadius + 2)
|
||||
const midX = item.arc.x + cos * (item.arc.outerRadius + lineOffset)
|
||||
const midY = item.arc.y + sin * (item.arc.outerRadius + lineOffset)
|
||||
const endX = item.arc.x + (item.isRight ? 1 : -1) * (item.arc.outerRadius + labelOffset)
|
||||
const endY = item.y
|
||||
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX, startY)
|
||||
ctx.lineTo(midX, midY)
|
||||
ctx.lineTo(endX, endY)
|
||||
ctx.stroke()
|
||||
|
||||
const textX = endX + (item.isRight ? 6 : -6)
|
||||
ctx.textAlign = item.isRight ? 'left' : 'right'
|
||||
ctx.fillStyle = textColor
|
||||
ctx.fillText(item.label, textX, endY)
|
||||
}
|
||||
|
||||
left.forEach(drawItem)
|
||||
right.forEach(drawItem)
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (value) => {
|
||||
if (!value && value !== 0) {
|
||||
@@ -192,21 +290,45 @@ const chartData = computed(() => {
|
||||
backgroundColor: data.map((item) => item.color),
|
||||
borderWidth: 2,
|
||||
borderColor: getCssVar('--van-background-2') || '#fff',
|
||||
hoverOffset: 4
|
||||
hoverOffset: 8,
|
||||
borderRadius: 4,
|
||||
radius: '88%' // 拉大半径,减少上下留白
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 计算总金额
|
||||
const totalAmount = computed(() => {
|
||||
return props.totalExpense || 0
|
||||
})
|
||||
|
||||
// Chart.js 配置
|
||||
const chartOptions = computed(() => {
|
||||
return getChartOptions({
|
||||
cutout: '50%',
|
||||
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
|
||||
return getChartOptionsByType('doughnut', {
|
||||
cutout: '65%',
|
||||
layout: {
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 2,
|
||||
right: 2
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: getCssVar('--van-background-2'),
|
||||
titleColor: getCssVar('--van-text-color'),
|
||||
bodyColor: getCssVar('--van-text-color'),
|
||||
borderColor: getCssVar('--van-border-color'),
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.label || ''
|
||||
@@ -216,12 +338,22 @@ const chartOptions = computed(() => {
|
||||
return `${label}: ¥${formatMoney(value)} (${percentage}%)`
|
||||
}
|
||||
}
|
||||
},
|
||||
pieCenterText: {
|
||||
text: `¥${formatMoney(totalAmount.value)}`,
|
||||
subtext: '总支出',
|
||||
textColor: isDarkMode ? '#ffffff' : '#323233',
|
||||
subtextColor: isDarkMode ? '#969799' : '#969799',
|
||||
fontSize: 24,
|
||||
subFontSize: 12
|
||||
},
|
||||
// 扇区外侧显示分类名称
|
||||
datalabels: {
|
||||
display: true
|
||||
}
|
||||
},
|
||||
onClick: (_event, _elements) => {
|
||||
// 点击饼图扇区时,触发跳转到分类详情
|
||||
// 注意:这个功能在 BaseChart 中不会自动触发,需要后续完善
|
||||
}
|
||||
// 悬停效果增强
|
||||
hoverOffset: 8
|
||||
})
|
||||
})
|
||||
|
||||
@@ -236,12 +368,20 @@ const onChartRender = (chart) => {
|
||||
|
||||
.common-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--spacing-xl, 16px);
|
||||
margin-bottom: var(--spacing-xl, 16px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.expense-category-card .card-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.expense-category-card .chart-container {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -263,8 +403,27 @@ const onChartRender = (chart) => {
|
||||
.ring-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
height: 170px;
|
||||
margin: 0px auto 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ring-chart :deep(.chartjs-size-monitor),
|
||||
.ring-chart :deep(.chartjs-size-monitor-expand),
|
||||
.ring-chart :deep(.chartjs-size-monitor-shrink) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ring-chart :deep(.base-chart) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ring-chart :deep(canvas) {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
@@ -340,7 +499,8 @@ const onChartRender = (chart) => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 0;
|
||||
color: var(--van-text-color-3);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -156,9 +156,9 @@ const noneCategories = computed(() => {
|
||||
// 通用卡片样式
|
||||
.common-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--spacing-xl, 16px);
|
||||
margin-bottom: var(--spacing-xl, 16px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -179,15 +179,15 @@ const noneCategories = computed(() => {
|
||||
/* 并列显示卡片 */
|
||||
.side-by-side-cards {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 0 12px 16px;
|
||||
gap: var(--spacing-lg, 12px);
|
||||
margin: 0 var(--spacing-lg, 12px) var(--spacing-xl, 16px);
|
||||
}
|
||||
|
||||
.side-by-side-cards .common-card {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0; /* 允许内部元素缩小 */
|
||||
padding: 12px;
|
||||
padding: var(--spacing-lg, 12px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
|
||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// Chart.js 相关
|
||||
const { getChartOptions } = useChartTheme()
|
||||
const { getChartOptionsByType, colors } = useChartTheme()
|
||||
|
||||
// 计算结余样式类
|
||||
const balanceClass = computed(() => ({
|
||||
@@ -99,7 +99,34 @@ const prepareChartData = () => {
|
||||
let xAxisLabels = []
|
||||
|
||||
if (props.period === 'week') {
|
||||
chartData = [...props.trendData].sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
// 获取当前周的日期范围
|
||||
const current = props.currentDate
|
||||
const weekStart = new Date(current)
|
||||
const day = weekStart.getDay()
|
||||
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1) // 调整为周一开始
|
||||
weekStart.setDate(diff)
|
||||
weekStart.setHours(0, 0, 0, 0)
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
// 判断是否在当前周
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekStart.getDate() + 6)
|
||||
const isCurrentWeek = today >= weekStart && today <= weekEnd
|
||||
|
||||
// 过滤到当前日期(如果是当前周)
|
||||
chartData = [...props.trendData]
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
.filter((item) => {
|
||||
const itemDate = new Date(item.date)
|
||||
itemDate.setHours(0, 0, 0, 0)
|
||||
if (isCurrentWeek) {
|
||||
return itemDate <= today
|
||||
}
|
||||
return true // 历史周显示完整数据
|
||||
})
|
||||
|
||||
xAxisLabels = chartData.map((item) => {
|
||||
const date = new Date(item.date)
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
@@ -111,6 +138,11 @@ const prepareChartData = () => {
|
||||
const month = currentDate.getMonth() + 1
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
|
||||
// 获取今天的日期
|
||||
const today = new Date()
|
||||
const isCurrentMonth = today.getFullYear() === year && today.getMonth() + 1 === month
|
||||
const currentDay = isCurrentMonth ? today.getDate() : daysInMonth
|
||||
|
||||
const allDays = Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const day = i + 1
|
||||
const paddedDay = day.toString().padStart(2, '0')
|
||||
@@ -124,7 +156,11 @@ const prepareChartData = () => {
|
||||
}
|
||||
})
|
||||
|
||||
chartData = allDays.map((date) => {
|
||||
// 只获取到当前日期的数据(历史月份则展示完整月份)
|
||||
const daysToShow = isCurrentMonth ? currentDay : daysInMonth
|
||||
const daysToDisplay = allDays.slice(0, daysToShow)
|
||||
|
||||
chartData = daysToDisplay.map((date) => {
|
||||
const dayData = dataMap.get(date)
|
||||
return {
|
||||
date,
|
||||
@@ -136,9 +172,21 @@ const prepareChartData = () => {
|
||||
|
||||
xAxisLabels = chartData.map((_, index) => (index + 1).toString())
|
||||
} else if (props.period === 'year') {
|
||||
const currentYear = props.currentDate.getFullYear()
|
||||
const today = new Date()
|
||||
const isCurrentYear = today.getFullYear() === currentYear
|
||||
const currentMonth = isCurrentYear ? today.getMonth() + 1 : 12
|
||||
|
||||
// 过滤到当前月份(如果是当前年)
|
||||
chartData = [...props.trendData]
|
||||
.filter((item) => item && item.date)
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
.filter((item) => {
|
||||
const itemDate = new Date(item.date)
|
||||
const itemMonth = itemDate.getMonth() + 1
|
||||
return itemMonth <= currentMonth
|
||||
})
|
||||
|
||||
xAxisLabels = chartData.map((item) => {
|
||||
const date = new Date(item.date)
|
||||
return `${date.getMonth() + 1}月`
|
||||
@@ -176,6 +224,10 @@ const prepareChartData = () => {
|
||||
return { chartData, xAxisLabels, expenseData, incomeData }
|
||||
}
|
||||
|
||||
// 使用主题颜色
|
||||
const expenseColor = computed(() => colors.value.danger)
|
||||
const incomeColor = computed(() => colors.value.success)
|
||||
|
||||
// Chart.js 数据
|
||||
const chartData = computed(() => {
|
||||
const { xAxisLabels, expenseData, incomeData } = prepareChartData()
|
||||
@@ -186,33 +238,39 @@ const chartData = computed(() => {
|
||||
{
|
||||
label: '支出',
|
||||
data: expenseData,
|
||||
borderColor: '#ff6b6b',
|
||||
borderColor: expenseColor.value,
|
||||
backgroundColor: (context) => {
|
||||
const chart = context.chart
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea) {return 'rgba(255, 107, 107, 0.1)'}
|
||||
return createGradient(ctx, chartArea, '#ff6b6b')
|
||||
if (!chartArea) {
|
||||
return 'rgba(255, 107, 107, 0.1)'
|
||||
}
|
||||
return createGradient(ctx, chartArea, expenseColor.value)
|
||||
},
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
hitRadius: 20,
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: '收入',
|
||||
data: incomeData,
|
||||
borderColor: '#4ade80',
|
||||
borderColor: incomeColor.value,
|
||||
backgroundColor: (context) => {
|
||||
const chart = context.chart
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea) {return 'rgba(74, 222, 128, 0.1)'}
|
||||
return createGradient(ctx, chartArea, '#4ade80')
|
||||
if (!chartArea) {
|
||||
return 'rgba(74, 222, 128, 0.1)'
|
||||
}
|
||||
return createGradient(ctx, chartArea, incomeColor.value)
|
||||
},
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
hitRadius: 20,
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
@@ -223,24 +281,23 @@ const chartData = computed(() => {
|
||||
const chartOptions = computed(() => {
|
||||
const { chartData: rawData } = prepareChartData()
|
||||
|
||||
return getChartOptions({
|
||||
return getChartOptionsByType('line', {
|
||||
scales: {
|
||||
x: {
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
display: false
|
||||
}
|
||||
x: { display: false },
|
||||
y: { display: false }
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const index = context[0].dataIndex
|
||||
if (!rawData[index]) {return ''}
|
||||
if (!rawData[index]) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = rawData[index].date
|
||||
if (props.period === 'week') {
|
||||
@@ -262,7 +319,9 @@ const chartOptions = computed(() => {
|
||||
label: (context) => {
|
||||
const index = context.dataIndex
|
||||
const item = rawData[index]
|
||||
if (!item) {return ''}
|
||||
if (!item) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let dailyExpense = 0
|
||||
let dailyIncome = 0
|
||||
@@ -280,16 +339,14 @@ const chartOptions = computed(() => {
|
||||
}
|
||||
|
||||
const value = context.dataset.label === '支出' ? dailyExpense : dailyIncome
|
||||
if (value === 0) {return null}
|
||||
if (value === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${context.dataset.label}: ¥${value.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -300,9 +357,9 @@ const chartOptions = computed(() => {
|
||||
|
||||
.common-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--spacing-xl, 16px);
|
||||
margin-bottom: var(--spacing-xl, 16px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -369,6 +426,8 @@ const chartOptions = computed(() => {
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user