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:
SunCheng
2026-02-18 20:44:58 +08:00
parent c1e2adacea
commit 61aa19b3d2
6 changed files with 491 additions and 186 deletions

View File

@@ -46,9 +46,9 @@
--spacing-3xl: 24px; --spacing-3xl: 24px;
/* 圆角 */ /* 圆角 */
--radius-sm: 12px; --radius-sm: 8px;
--radius-md: 16px; --radius-md: 12px;
--radius-lg: 20px; --radius-lg: 12px;
--radius-full: 22px; --radius-full: 22px;
/* 字体大小 */ /* 字体大小 */

View File

@@ -26,44 +26,29 @@
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }" :style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/> />
<div class="gauge-text-overlay"> <div class="gauge-text-overlay">
<div <div class="balance-label">
class="remaining-label" 余额
>
{{
activeTab === BudgetCategory.Expense
? (
overallStats.month.current > overallStats.month.limit
? '超支'
: '余额'
)
: overallStats.month.current > overallStats.month.limit
? '超额'
: '差额'
}}
</div> </div>
<div <div
class="remaining-value" class="balance-value"
:style="{ color: :style="{
overallStats.month.current > overallStats.month.limit color:
? activeTab === BudgetCategory.Expense ? 'var(--van-danger-color)' : 'var(--van-success-color)' overallStats.month.current > overallStats.month.limit
: '' ? 'var(--van-danger-color)'
: ''
}" }"
> >
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }} ¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
</div> </div>
</div> </div>
</div> </div>
<div class="gauge-footer compact"> <div class="gauge-footer">
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">已用</span>
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }} <span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
</span>
<span class="value expense">¥{{ formatMoney(overallStats.month.current) }}</span>
</div> </div>
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">预算</span>
{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}
</span>
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span> <span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
</div> </div>
</div> </div>
@@ -92,30 +77,30 @@
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }" :style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/> />
<div class="gauge-text-overlay"> <div class="gauge-text-overlay">
<div <div class="balance-label">
class="remaining-label" 余额
>
{{ activeTab === BudgetCategory.Expense ? (overallStats.year.current > overallStats.year.limit ? '超支' : '余额') : '差额' }}
</div> </div>
<div <div
class="remaining-value" class="balance-value"
:style="{ color: activeTab === BudgetCategory.Expense && overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '' }" :style="{
color:
activeTab === BudgetCategory.Expense &&
overallStats.year.current > overallStats.year.limit
? 'var(--van-danger-color)'
: ''
}"
> >
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }} ¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
</div> </div>
</div> </div>
</div> </div>
<div class="gauge-footer compact"> <div class="gauge-footer">
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">已用</span>
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }} <span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
</span>
<span class="value expense">¥{{ formatMoney(overallStats.year.current) }}</span>
</div> </div>
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">预算</span>
{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}
</span>
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span> <span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
</div> </div>
</div> </div>
@@ -146,8 +131,7 @@
<!-- 年度预算进度 --> <!-- 年度预算进度 -->
<div <div
v-if="budgets.length > 0" v-if="budgets.length > 0"
class="chart-card" class="chart-card chart-card-spacing"
style="margin-top: 12px"
> >
<div class="chart-header"> <div class="chart-header">
<div class="chart-title"> <div class="chart-title">
@@ -168,8 +152,7 @@
<!-- 偏差分析 --> <!-- 偏差分析 -->
<div <div
v-if="budgets.length > 0" v-if="budgets.length > 0"
class="chart-card" class="chart-card chart-card-spacing"
style="margin-top: 12px"
> >
<div class="chart-header"> <div class="chart-header">
<div class="chart-title"> <div class="chart-title">
@@ -207,9 +190,14 @@
height="70%" height="70%"
> >
<div <div
class="rich-html-content" class="rich-html-content popup-content-padding"
style="padding: 16px" v-html="
v-html="activeDescTab === 'month' ? (overallStats.month?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>') : (overallStats.year?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>')" activeDescTab === 'month'
? overallStats.month?.description ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>'
: overallStats.year?.description ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>'
"
/> />
</PopupContainer> </PopupContainer>
</template> </template>
@@ -222,6 +210,10 @@ import PopupContainer from '@/components/PopupContainer.vue'
import BaseChart from '@/components/Charts/BaseChart.vue' import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme' import { useChartTheme } from '@/composables/useChartTheme'
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin' import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
import { Chart as ChartJS } from 'chart.js'
// 注册仪表盘插件
ChartJS.register(chartjsGaugePlugin)
const props = defineProps({ const props = defineProps({
overallStats: { overallStats: {
@@ -247,7 +239,7 @@ const showDescriptionPopup = ref(false)
const activeDescTab = ref('month') const activeDescTab = ref('month')
// Chart.js 相关 // Chart.js 相关
const { getChartOptions } = useChartTheme() const { getChartOptions, getChartOptionsByType } = useChartTheme()
const formatMoney = (val) => { const formatMoney = (val) => {
if (Math.abs(val) >= 10000) { if (Math.abs(val) >= 10000) {
@@ -264,13 +256,15 @@ const monthGaugeData = computed(() => {
// 防御性检查:如果数据未加载,返回默认结构 // 防御性检查:如果数据未加载,返回默认结构
if (!props.overallStats || !props.overallStats.month) { if (!props.overallStats || !props.overallStats.month) {
return { return {
datasets: [{ datasets: [
data: [0, 100], {
backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')], data: [0, 100],
borderWidth: 0, backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')],
circumference: 180, borderWidth: 0,
rotation: 270 circumference: 180,
}] rotation: 270
}
]
} }
} }
@@ -317,8 +311,8 @@ const monthGaugeData = computed(() => {
data: [displayRate, remaining], data: [displayRate, remaining],
backgroundColor: [color, getCssVar('--chart-axis')], backgroundColor: [color, getCssVar('--chart-axis')],
borderWidth: 0, borderWidth: 0,
circumference: 180, circumference: 220,
rotation: 270 rotation: 250
} }
] ]
} }
@@ -328,14 +322,24 @@ const monthGaugeOptions = computed(() => {
return { return {
cutout: '75%', cutout: '75%',
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
display: false display: false
}, },
tooltip: { tooltip: {
enabled: false enabled: false
},
datalabels: {
display: false
},
gaugePlugin: {
centerText: false
} }
},
scales: {
x: { display: false },
y: { display: false }
} }
} }
}) })
@@ -345,13 +349,15 @@ const yearGaugeData = computed(() => {
// 防御性检查:如果数据未加载,返回默认结构 // 防御性检查:如果数据未加载,返回默认结构
if (!props.overallStats || !props.overallStats.year) { if (!props.overallStats || !props.overallStats.year) {
return { return {
datasets: [{ datasets: [
data: [0, 100], {
backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')], data: [0, 100],
borderWidth: 0, backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')],
circumference: 180, borderWidth: 0,
rotation: 270 circumference: 180,
}] rotation: 270
}
]
} }
} }
@@ -397,8 +403,8 @@ const yearGaugeData = computed(() => {
data: [displayRate, remaining], data: [displayRate, remaining],
backgroundColor: [color, getCssVar('--chart-axis')], backgroundColor: [color, getCssVar('--chart-axis')],
borderWidth: 0, borderWidth: 0,
circumference: 180, circumference: 220,
rotation: 270 rotation: 250
} }
] ]
} }
@@ -408,20 +414,32 @@ const yearGaugeOptions = computed(() => {
return { return {
cutout: '75%', cutout: '75%',
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
display: false display: false
}, },
tooltip: { tooltip: {
enabled: false enabled: false
},
datalabels: {
display: false
},
gaugePlugin: {
centerText: false
} }
},
scales: {
x: { display: false },
y: { display: false }
} }
} }
}) })
const calculateChartHeight = (budgets) => { const calculateChartHeight = (budgets) => {
if (!budgets) { return 100 } if (!budgets) {
return 100
}
const dataCount = budgets.length const dataCount = budgets.length
const minHeight = 100 const minHeight = 100
const heightPerItem = 30 const heightPerItem = 30
@@ -435,7 +453,7 @@ const varianceChartData = computed(() => {
return { labels: [], datasets: [] } return { labels: [], datasets: [] }
} }
const data = props.budgets.map(b => { const data = props.budgets.map((b) => {
const limit = b.limit || 0 const limit = b.limit || 0
const current = b.current || 0 const current = b.current || 0
const diff = current - limit const diff = current - limit
@@ -448,8 +466,8 @@ const varianceChartData = computed(() => {
} }
}) })
const monthlyData = data.filter(item => item.type === BudgetPeriodType.Month) const monthlyData = data.filter((item) => item.type === BudgetPeriodType.Month)
const annualData = data.filter(item => item.type === BudgetPeriodType.Year) const annualData = data.filter((item) => item.type === BudgetPeriodType.Year)
monthlyData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value)) monthlyData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
annualData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value)) annualData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
@@ -457,16 +475,20 @@ const varianceChartData = computed(() => {
const sortedData = [...annualData, ...monthlyData] const sortedData = [...annualData, ...monthlyData]
return { return {
labels: sortedData.map(item => item.name), labels: sortedData.map((item) => item.name),
datasets: [ datasets: [
{ {
label: '偏差', label: '偏差',
data: sortedData.map(item => item.value), data: sortedData.map((item) => item.value),
backgroundColor: sortedData.map(item => { backgroundColor: sortedData.map((item) => {
if (props.activeTab === BudgetCategory.Expense) { if (props.activeTab === BudgetCategory.Expense) {
return item.value > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color') return item.value > 0
? getCssVar('--van-danger-color')
: getCssVar('--van-success-color')
} else { } else {
return item.value > 0 ? getCssVar('--van-success-color') : getCssVar('--van-danger-color') return item.value > 0
? getCssVar('--van-success-color')
: getCssVar('--van-danger-color')
} }
}), }),
borderRadius: 4, borderRadius: 4,
@@ -478,19 +500,23 @@ const varianceChartData = computed(() => {
}) })
const varianceChartOptions = computed(() => { const varianceChartOptions = computed(() => {
return getChartOptions({ return getChartOptionsByType('bar', {
indexAxis: 'y', indexAxis: 'y',
plugins: { plugins: {
legend: { legend: {
display: false display: false
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const item = context.dataset._meta[context.dataIndex] const item = context.dataset._meta[context.dataIndex]
const diffText = item.value > 0 const diffText =
? `超支: ¥${formatMoney(item.value)}` item.value > 0
: `结余: ¥${formatMoney(Math.abs(item.value))}` ? `超支: ¥${formatMoney(item.value)}`
: `结余: ¥${formatMoney(Math.abs(item.value))}`
return [ return [
`预算: ¥${formatMoney(item.limit)}`, `预算: ¥${formatMoney(item.limit)}`,
`实际: ¥${formatMoney(item.current)}`, `实际: ¥${formatMoney(item.current)}`,
@@ -505,8 +531,15 @@ const varianceChartOptions = computed(() => {
display: false display: false
}, },
y: { y: {
grid: {
display: false
},
ticks: { ticks: {
autoSkip: false, autoSkip: false,
font: {
family: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
size: 11
},
callback: function (value, index) { callback: function (value, index) {
const label = this.getLabelForValue(index) const label = this.getLabelForValue(index)
return label.length > 10 ? label.substring(0, 10) + '...' : label return label.length > 10 ? label.substring(0, 10) + '...' : label
@@ -564,7 +597,7 @@ const burndownChartData = computed(() => {
} }
} else { } else {
if (i <= currentDay && totalBudget > 0) { if (i <= currentDay && totalBudget > 0) {
const actualRemaining = totalBudget - (currentExpense * i / currentDay) const actualRemaining = totalBudget - (currentExpense * i) / currentDay
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
@@ -583,7 +616,7 @@ const burndownChartData = computed(() => {
} }
} else { } else {
if (i <= currentDay && totalBudget > 0) { if (i <= currentDay && totalBudget > 0) {
const actualAccumulated = Math.min(totalBudget, currentExpense * i / currentDay) const actualAccumulated = Math.min(totalBudget, (currentExpense * i) / currentDay)
actualBurndown.push(Math.round(actualAccumulated)) actualBurndown.push(Math.round(actualAccumulated))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
@@ -622,13 +655,21 @@ const burndownChartData = computed(() => {
}) })
const burndownChartOptions = computed(() => { const burndownChartOptions = computed(() => {
return getChartOptions({ return getChartOptionsByType('line', {
plugins: { plugins: {
legend: { legend: {
display: true, display: true,
position: 'top' position: 'top',
labels: {
usePointStyle: true,
pointStyle: 'line',
boxWidth: 20
}
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const value = context.parsed.y const value = context.parsed.y
@@ -641,6 +682,18 @@ const burndownChartOptions = computed(() => {
} }
}, },
scales: { scales: {
x: {
grid: {
display: false
},
ticks: {
maxTicksLimit: 10,
font: {
family: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
size: 10
}
}
},
y: { y: {
ticks: { ticks: {
callback: (value) => { callback: (value) => {
@@ -648,6 +701,9 @@ const burndownChartOptions = computed(() => {
return (value / 10000).toFixed(0) + 'w' return (value / 10000).toFixed(0) + 'w'
} }
return value return value
},
font: {
size: 10
} }
} }
} }
@@ -700,7 +756,7 @@ const yearBurndownChartData = computed(() => {
const isFuture = year > currentYear || (year === currentYear && i > currentMonth) const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) { if (!isFuture && totalBudget > 0) {
const progress = (i + 1) / 12 const progress = (i + 1) / 12
const actualRemaining = totalBudget - (currentExpense * progress) const actualRemaining = totalBudget - currentExpense * progress
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
@@ -760,13 +816,21 @@ const yearBurndownChartData = computed(() => {
}) })
const yearBurndownChartOptions = computed(() => { const yearBurndownChartOptions = computed(() => {
return getChartOptions({ return getChartOptionsByType('line', {
plugins: { plugins: {
legend: { legend: {
display: true, display: true,
position: 'top' position: 'top',
labels: {
usePointStyle: true,
pointStyle: 'line',
boxWidth: 20
}
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const value = context.parsed.y const value = context.parsed.y
@@ -779,6 +843,16 @@ const yearBurndownChartOptions = computed(() => {
} }
}, },
scales: { scales: {
x: {
grid: {
display: false
},
ticks: {
font: {
size: 10
}
}
},
y: { y: {
ticks: { ticks: {
callback: (value) => { callback: (value) => {
@@ -786,6 +860,9 @@ const yearBurndownChartOptions = computed(() => {
return (value / 10000).toFixed(0) + 'w' return (value / 10000).toFixed(0) + 'w'
} }
return value return value
},
font: {
size: 10
} }
} }
} }
@@ -796,65 +873,77 @@ const yearBurndownChartOptions = computed(() => {
<style scoped> <style scoped>
.chart-analysis-container { .chart-analysis-container {
padding: 12px; padding: var(--spacing-lg, 12px);
padding-bottom: 80px; padding-bottom: 80px;
} }
.gauges-row { .gauges-row {
display: flex; display: flex;
gap: 12px; gap: var(--spacing-xl, 16px);
margin-bottom: 12px; margin-bottom: var(--spacing-xl, 16px);
} }
.chart-card { .chart-card {
background: var(--van-background-2); background: var(--van-background-2);
border-radius: 12px; border-radius: var(--radius-lg, 12px);
padding: 16px; padding: var(--spacing-xl, 16px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.04));
} }
.gauge-card { .gauge-card {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
/* 防止 flex 子项溢出 */ /* 防止 flex 子项溢出 */
padding: 12px; padding: var(--spacing-lg, 12px);
/* 减小内边距 */ /* 减小内边距 */
} }
.chart-card-spacing {
margin-top: var(--spacing-lg, 12px);
}
.popup-content-padding {
padding: var(--spacing-xl, 16px);
}
.gauge-wrapper { .gauge-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 120px; height: 120px;
display: flex;
justify-content: center;
align-items: center;
} }
.gauge-text-overlay { .gauge-text-overlay {
position: absolute; position: absolute;
bottom: 20%; top: 50%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translate(-50%, -50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
margin-top: 8px;
} }
.remaining-value { .balance-label {
font-size: 24px; font-size: 11px;
font-weight: bold;
font-family: DIN Alternate, system-ui;
color: var(--van-text-color);
line-height: 1;
transform-origin: center;
}
.remaining-label {
font-size: 10px;
color: var(--van-text-color-2); color: var(--van-text-color-2);
margin-top: 4px; letter-spacing: 1px;
font-family: system-ui; }
transform-origin: center;
.balance-value {
margin-top: 6px;
font-size: 24px;
font-weight: 700;
font-family:
DIN Alternate,
system-ui;
color: var(--van-text-color);
line-height: 1.1;
} }
.chart-header { .chart-header {
@@ -896,37 +985,33 @@ const yearBurndownChartOptions = computed(() => {
.gauge-footer { .gauge-footer {
display: flex; display: flex;
justify-content: space-around; justify-content: space-between;
/* 分散对齐 */
align-items: center; align-items: center;
margin-top: -20px; padding: 0 8px 6px;
position: relative; margin-top: -6px;
z-index: 10;
} }
.gauge-item { .gauge-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
min-width: 0;
} }
.gauge-item .label { .gauge-item .label {
font-size: 10px; font-size: 10px;
color: var(--van-text-color-2); color: var(--van-text-color-2);
transform: scale(0.9); margin-bottom: 4px;
/* 视觉上更小 */
} }
.gauge-item .value { .gauge-item .value {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
font-family: DIN Alternate, system-ui; font-family:
DIN Alternate,
system-ui;
color: var(--van-text-color); color: var(--van-text-color);
} }
.gauge-item .value.expense {
color: var(--van-primary-color);
}
/* expand styles removed as they are no longer used */ /* expand styles removed as they are no longer used */
</style> </style>

View File

@@ -114,9 +114,9 @@ const selectedDateFormatted = computed(() => {
.daily-stats { .daily-stats {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xl); gap: var(--spacing-xl, 16px);
padding: var(--spacing-3xl); padding: var(--spacing-xl, 16px);
padding-top: 8px; padding-top: var(--spacing-md, 12px);
} }
.stats-header { .stats-header {
@@ -137,9 +137,10 @@ const selectedDateFormatted = computed(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-lg); gap: var(--spacing-lg);
padding: var(--spacing-2xl); padding: var(--spacing-xl, 16px);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg, 12px);
box-shadow: var(--shadow-sm);
} }
.stats-dual-row { .stats-dual-row {

View File

@@ -1,8 +1,8 @@
<template> <template>
<!-- 支出分类统计 --> <!-- 支出分类统计 -->
<div <div
class="common-card" class="common-card expense-category-card"
style="padding-bottom: 10px" style="padding: 12px;"
> >
<div class="card-header"> <div class="card-header">
<h3 class="card-title"> <h3 class="card-title">
@@ -25,6 +25,7 @@
type="doughnut" type="doughnut"
:data="chartData" :data="chartData"
:options="chartOptions" :options="chartOptions"
:plugins="[pieCenterTextPlugin, pieLabelLinePlugin]"
:loading="false" :loading="false"
@chart:render="onChartRender" @chart:render="onChartRender"
/> />
@@ -87,6 +88,7 @@ import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue' import ModernEmpty from '@/components/ModernEmpty.vue'
import BaseChart from '@/components/Charts/BaseChart.vue' import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme' import { useChartTheme } from '@/composables/useChartTheme'
import { pieCenterTextPlugin } from '@/plugins/chartjs-pie-center-plugin'
const props = defineProps({ const props = defineProps({
categories: { categories: {
@@ -108,9 +110,105 @@ defineEmits(['category-click'])
const showAllExpense = ref(false) const showAllExpense = ref(false)
// Chart.js 相关 // Chart.js 相关
const { getChartOptions } = useChartTheme() const { getChartOptionsByType } = useChartTheme()
let _chartJSInstance = null 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) => { const formatMoney = (value) => {
if (!value && value !== 0) { if (!value && value !== 0) {
@@ -192,21 +290,45 @@ const chartData = computed(() => {
backgroundColor: data.map((item) => item.color), backgroundColor: data.map((item) => item.color),
borderWidth: 2, borderWidth: 2,
borderColor: getCssVar('--van-background-2') || '#fff', borderColor: getCssVar('--van-background-2') || '#fff',
hoverOffset: 4 hoverOffset: 8,
borderRadius: 4,
radius: '88%' // 拉大半径,减少上下留白
} }
] ]
} }
}) })
// 计算总金额
const totalAmount = computed(() => {
return props.totalExpense || 0
})
// Chart.js 配置 // Chart.js 配置
const chartOptions = computed(() => { const chartOptions = computed(() => {
return getChartOptions({ const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'
cutout: '50%',
return getChartOptionsByType('doughnut', {
cutout: '65%',
layout: {
padding: {
top: 0,
bottom: 0,
left: 2,
right: 2
}
},
plugins: { plugins: {
legend: { legend: {
display: false display: false
}, },
tooltip: { 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: { callbacks: {
label: (context) => { label: (context) => {
const label = context.label || '' const label = context.label || ''
@@ -216,12 +338,22 @@ const chartOptions = computed(() => {
return `${label}: ¥${formatMoney(value)} (${percentage}%)` 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) => { // 悬停效果增强
// 点击饼图扇区时,触发跳转到分类详情 hoverOffset: 8
// 注意:这个功能在 BaseChart 中不会自动触发,需要后续完善
}
}) })
}) })
@@ -236,12 +368,20 @@ const onChartRender = (chart) => {
.common-card { .common-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg, 12px);
padding: var(--spacing-xl); padding: var(--spacing-xl, 16px);
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl, 16px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.expense-category-card .card-header {
margin-bottom: 0;
}
.expense-category-card .chart-container {
padding-bottom: 0;
}
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -263,8 +403,27 @@ const onChartRender = (chart) => {
.ring-chart { .ring-chart {
position: relative; position: relative;
width: 100%; width: 100%;
height: 200px; height: 170px;
margin: 0 auto; 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 { .category-list {
@@ -340,7 +499,8 @@ const onChartRender = (chart) => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding-top: 0; padding-top: 10px;
padding-bottom: 0;
color: var(--van-text-color-3); color: var(--van-text-color-3);
font-size: 20px; font-size: 20px;
cursor: pointer; cursor: pointer;

View File

@@ -156,9 +156,9 @@ const noneCategories = computed(() => {
// 通用卡片样式 // 通用卡片样式
.common-card { .common-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg, 12px);
padding: var(--spacing-xl); padding: var(--spacing-xl, 16px);
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl, 16px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -179,15 +179,15 @@ const noneCategories = computed(() => {
/* 并列显示卡片 */ /* 并列显示卡片 */
.side-by-side-cards { .side-by-side-cards {
display: flex; display: flex;
gap: 12px; gap: var(--spacing-lg, 12px);
margin: 0 12px 16px; margin: 0 var(--spacing-lg, 12px) var(--spacing-xl, 16px);
} }
.side-by-side-cards .common-card { .side-by-side-cards .common-card {
margin: 0; margin: 0;
flex: 1; flex: 1;
min-width: 0; /* 允许内部元素缩小 */ min-width: 0; /* 允许内部元素缩小 */
padding: 12px; padding: var(--spacing-lg, 12px);
} }
.card-header { .card-header {

View File

@@ -80,7 +80,7 @@ const props = defineProps({
}) })
// Chart.js 相关 // Chart.js 相关
const { getChartOptions } = useChartTheme() const { getChartOptionsByType, colors } = useChartTheme()
// 计算结余样式类 // 计算结余样式类
const balanceClass = computed(() => ({ const balanceClass = computed(() => ({
@@ -99,7 +99,34 @@ const prepareChartData = () => {
let xAxisLabels = [] let xAxisLabels = []
if (props.period === 'week') { 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) => { xAxisLabels = chartData.map((item) => {
const date = new Date(item.date) const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六'] const weekDays = ['日', '一', '二', '三', '四', '五', '六']
@@ -111,6 +138,11 @@ const prepareChartData = () => {
const month = currentDate.getMonth() + 1 const month = currentDate.getMonth() + 1
const daysInMonth = getDaysInMonth(year, month) 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 allDays = Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1 const day = i + 1
const paddedDay = day.toString().padStart(2, '0') 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) const dayData = dataMap.get(date)
return { return {
date, date,
@@ -136,9 +172,21 @@ const prepareChartData = () => {
xAxisLabels = chartData.map((_, index) => (index + 1).toString()) xAxisLabels = chartData.map((_, index) => (index + 1).toString())
} else if (props.period === 'year') { } 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] chartData = [...props.trendData]
.filter((item) => item && item.date) .filter((item) => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.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) => { xAxisLabels = chartData.map((item) => {
const date = new Date(item.date) const date = new Date(item.date)
return `${date.getMonth() + 1}` return `${date.getMonth() + 1}`
@@ -176,6 +224,10 @@ const prepareChartData = () => {
return { chartData, xAxisLabels, expenseData, incomeData } return { chartData, xAxisLabels, expenseData, incomeData }
} }
// 使用主题颜色
const expenseColor = computed(() => colors.value.danger)
const incomeColor = computed(() => colors.value.success)
// Chart.js 数据 // Chart.js 数据
const chartData = computed(() => { const chartData = computed(() => {
const { xAxisLabels, expenseData, incomeData } = prepareChartData() const { xAxisLabels, expenseData, incomeData } = prepareChartData()
@@ -186,33 +238,39 @@ const chartData = computed(() => {
{ {
label: '支出', label: '支出',
data: expenseData, data: expenseData,
borderColor: '#ff6b6b', borderColor: expenseColor.value,
backgroundColor: (context) => { backgroundColor: (context) => {
const chart = context.chart const chart = context.chart
const { ctx, chartArea } = chart const { ctx, chartArea } = chart
if (!chartArea) {return 'rgba(255, 107, 107, 0.1)'} if (!chartArea) {
return createGradient(ctx, chartArea, '#ff6b6b') return 'rgba(255, 107, 107, 0.1)'
}
return createGradient(ctx, chartArea, expenseColor.value)
}, },
fill: true, fill: true,
tension: 0.4, tension: 0.4,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 4, pointHoverRadius: 6,
hitRadius: 20,
borderWidth: 2 borderWidth: 2
}, },
{ {
label: '收入', label: '收入',
data: incomeData, data: incomeData,
borderColor: '#4ade80', borderColor: incomeColor.value,
backgroundColor: (context) => { backgroundColor: (context) => {
const chart = context.chart const chart = context.chart
const { ctx, chartArea } = chart const { ctx, chartArea } = chart
if (!chartArea) {return 'rgba(74, 222, 128, 0.1)'} if (!chartArea) {
return createGradient(ctx, chartArea, '#4ade80') return 'rgba(74, 222, 128, 0.1)'
}
return createGradient(ctx, chartArea, incomeColor.value)
}, },
fill: true, fill: true,
tension: 0.4, tension: 0.4,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 4, pointHoverRadius: 6,
hitRadius: 20,
borderWidth: 2 borderWidth: 2
} }
] ]
@@ -223,24 +281,23 @@ const chartData = computed(() => {
const chartOptions = computed(() => { const chartOptions = computed(() => {
const { chartData: rawData } = prepareChartData() const { chartData: rawData } = prepareChartData()
return getChartOptions({ return getChartOptionsByType('line', {
scales: { scales: {
x: { x: { display: false },
display: false y: { display: false }
},
y: {
display: false
}
}, },
plugins: { plugins: {
legend: { legend: { display: false },
display: false
},
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: { callbacks: {
title: (context) => { title: (context) => {
const index = context[0].dataIndex const index = context[0].dataIndex
if (!rawData[index]) {return ''} if (!rawData[index]) {
return ''
}
const date = rawData[index].date const date = rawData[index].date
if (props.period === 'week') { if (props.period === 'week') {
@@ -262,7 +319,9 @@ const chartOptions = computed(() => {
label: (context) => { label: (context) => {
const index = context.dataIndex const index = context.dataIndex
const item = rawData[index] const item = rawData[index]
if (!item) {return ''} if (!item) {
return ''
}
let dailyExpense = 0 let dailyExpense = 0
let dailyIncome = 0 let dailyIncome = 0
@@ -280,16 +339,14 @@ const chartOptions = computed(() => {
} }
const value = context.dataset.label === '支出' ? dailyExpense : dailyIncome const value = context.dataset.label === '支出' ? dailyExpense : dailyIncome
if (value === 0) {return null} if (value === 0) {
return null
}
return `${context.dataset.label}: ¥${value.toFixed(2)}` return `${context.dataset.label}: ¥${value.toFixed(2)}`
} }
} }
} }
},
interaction: {
mode: 'index',
intersect: false
} }
}) })
}) })
@@ -300,9 +357,9 @@ const chartOptions = computed(() => {
.common-card { .common-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg, 12px);
padding: var(--spacing-xl); padding: var(--spacing-xl, 16px);
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl, 16px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -369,6 +426,8 @@ const chartOptions = computed(() => {
.trend-chart { .trend-chart {
width: 100%; width: 100%;
height: 180px; height: 140px;
overflow: hidden;
position: relative;
} }
</style> </style>