1 Commits

Author SHA1 Message Date
SunCheng
a2871cb775 v2 2026-02-04 14:33:30 +08:00
8 changed files with 1483 additions and 6 deletions

View File

@@ -71,6 +71,12 @@ const router = createRouter({
component: () => import('../views/StatisticsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/statistics-v2',
name: 'statistics-v2',
component: () => import('../views/statisticsV2/Index.vue'),
meta: { requiresAuth: true }
},
{
path: '/bill-analysis',
name: 'bill-analysis',

View File

@@ -65,6 +65,14 @@
<div class="bottom-spacer" />
</van-pull-refresh>
</div>
<!-- 交易详情弹窗 -->
<TransactionDetailSheet
v-model:show="showTransactionDetail"
:transaction="currentTransaction"
@save="handleTransactionSave"
@delete="handleTransactionDelete"
/>
</div>
</template>
@@ -75,6 +83,8 @@ import { showToast } from 'vant'
import CalendarModule from './modules/Calendar.vue'
import StatsModule from './modules/Stats.vue'
import TransactionListModule from './modules/TransactionList.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import { getTransactionDetail } from '@/api/transactionRecord'
// 定义组件名称keep-alive 需要通过 name 识别)
defineOptions({
@@ -135,12 +145,35 @@ const onDayClick = async (day) => {
selectedDate.value = clickedDate
}
// 点击交易卡片 - 跳转到详情页
const onTransactionClick = (txn) => {
router.push({
path: '/transaction-detail',
query: { id: txn.id }
})
// 交易详情弹窗相关
const showTransactionDetail = ref(false)
const currentTransaction = ref(null)
// 点击交易卡片 - 打开详情弹窗
const onTransactionClick = async (txn) => {
try {
// 获取完整的交易详情
const response = await getTransactionDetail(txn.id)
if (response.success && response.data) {
currentTransaction.value = response.data
showTransactionDetail.value = true
} else {
showToast('获取交易详情失败')
}
} catch (error) {
console.error('获取交易详情失败:', error)
showToast('获取交易详情失败')
}
}
// 保存交易后刷新列表
const handleTransactionSave = () => {
handleTransactionsChanged()
}
// 删除交易后刷新列表
const handleTransactionDelete = () => {
handleTransactionsChanged()
}
// 点击通知按钮

View File

@@ -0,0 +1,172 @@
<template>
<div class="statistics-v2">
<!-- 顶部导航栏 -->
<van-nav-bar placeholder>
<template #title>
<div
class="nav-title"
@click="showYearPicker = true"
>
{{ currentYear }}
<van-icon name="arrow-down" />
</div>
</template>
<template #right>
<van-icon
name="bell-o"
size="20"
class="notification-icon"
@click="goToNotifications"
/>
</template>
</van-nav-bar>
<!-- 可滚动内容 -->
<div class="scroll-content">
<!-- 周期选择模块 -->
<PeriodSelector
:current-period="currentPeriod"
@update:period="handlePeriodChange"
/>
<!-- 核心指标模块 -->
<MetricsCards
:year="currentYear"
:period="currentPeriod"
/>
<!-- 分类支出模块 -->
<CategorySection
:year="currentYear"
:month="currentMonth"
/>
<!-- 支出趋势模块 -->
<TrendSection
:year="currentYear"
:period="currentPeriod"
/>
<!-- 预算使用模块 -->
<BudgetSection
:year="currentYear"
:month="currentMonth"
/>
<!-- 底部安全距离 -->
<div class="safe-area-bottom" />
</div>
<!-- 年份选择器 -->
<van-popup
v-model:show="showYearPicker"
position="bottom"
round
>
<van-picker
:columns="yearColumns"
:default-index="yearDefaultIndex"
@confirm="onYearConfirm"
@cancel="showYearPicker = false"
/>
</van-popup>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import PeriodSelector from './modules/PeriodSelector.vue'
import MetricsCards from './modules/MetricsCards.vue'
import CategorySection from './modules/CategorySection.vue'
import TrendSection from './modules/TrendSection.vue'
import BudgetSection from './modules/BudgetSection.vue'
const router = useRouter()
// 状态管理
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1)
const currentPeriod = ref('month') // 'week' | 'month' | 'year'
const showYearPicker = ref(false)
// 年份选择器配置
const yearColumns = computed(() => {
const startYear = 2020
const endYear = new Date().getFullYear()
const years = []
for (let y = startYear; y <= endYear; y++) {
years.push({ text: `${y}`, value: y })
}
return years.reverse()
})
const yearDefaultIndex = computed(() => {
const index = yearColumns.value.findIndex(item => item.value === currentYear.value)
return index >= 0 ? index : 0
})
// 事件处理
const handlePeriodChange = (period) => {
currentPeriod.value = period
}
const onYearConfirm = ({ selectedOptions }) => {
currentYear.value = selectedOptions[0].value
showYearPicker.value = false
}
const goToNotifications = () => {
router.push({ path: '/balance', query: { tab: 'message' } })
}
onMounted(() => {
// 初始化逻辑(如需要)
})
</script>
<style scoped>
.statistics-v2 {
min-height: 100vh;
background: var(--van-background-2);
display: flex;
flex-direction: column;
}
.nav-title {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-family: 'DM Sans', -apple-system, sans-serif;
font-size: 24px;
font-weight: 500;
color: var(--van-text-color);
@media (prefers-color-scheme: dark) {
color: #a1a1aa;
}
}
.notification-icon {
cursor: pointer;
color: var(--van-text-color);
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.safe-area-bottom {
height: calc(16px + env(safe-area-inset-bottom, 0px));
}
:deep(.van-nav-bar) {
background: var(--van-background-2);
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="budget-section">
<!-- 预算标题 -->
<div class="section-header">
<span class="label">预算使用</span>
<div
class="header-right"
@click="goToBudget"
>
<span class="link-text">管理预算</span>
<van-icon
name="arrow-right"
class="link-icon"
/>
</div>
</div>
<!-- 预算卡片 -->
<div class="budget-card">
<van-loading v-if="loading" />
<van-empty
v-else-if="budgets.length === 0"
description="暂无预算数据"
/>
<div
v-else
class="budget-list"
>
<template
v-for="(budget, index) in budgets"
:key="budget.id"
>
<div class="budget-item">
<div class="budget-header">
<span class="budget-name">{{ budget.name }}</span>
<div class="budget-stats">
<span class="used-amount">¥{{ formatMoney(budget.current) }}</span>
<span class="separator">/</span>
<span class="limit-amount">¥{{ formatMoney(budget.limit) }}</span>
</div>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: getProgressWidth(budget) + '%' }"
/>
</div>
</div>
<div
v-if="index < budgets.length - 1"
class="divider"
/>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getBudgetList } from '@/api/budget'
const props = defineProps({
year: Number,
month: Number
})
const router = useRouter()
// 状态
const loading = ref(false)
const budgets = ref([])
// 方法
const formatMoney = (value) => {
if (!value && value !== 0) {return '0'}
return Number(value).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
const getProgressWidth = (budget) => {
if (budget.limit === 0) {return 0}
const percent = (budget.current / budget.limit) * 100
return Math.min(Math.max(percent, 0), 100)
}
const goToBudget = () => {
router.push('/budget')
}
// 获取预算数据
const fetchBudgets = async () => {
loading.value = true
try {
const referenceDate = new Date(props.year, props.month - 1, 1).toISOString()
const response = await getBudgetList(referenceDate)
if (response.success) {
// 只显示支出预算且非不记额预算
budgets.value = response.data
.filter(b => b.category === 0 && !b.noLimit)
.slice(0, 4) // 最多显示4个
}
} catch (error) {
console.error('获取预算数据失败:', error)
} finally {
loading.value = false
}
}
// 监听变化
watch([() => props.year, () => props.month], fetchBudgets, { immediate: true })
</script>
<style scoped>
.budget-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
color: #888888;
text-transform: uppercase;
}
.header-right {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
&:active {
opacity: 0.7;
}
}
.link-text {
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 500;
color: #0D6E6E;
}
.link-icon {
color: #0D6E6E;
font-size: 16px;
}
.budget-card {
background: #FFFFFF;
border: 1px solid #E5E5E5;
border-radius: 12px;
padding: 24px;
min-height: 120px;
@media (prefers-color-scheme: dark) {
background: #1A1A1A;
border-color: #2A2A2A;
}
}
.budget-list {
display: flex;
flex-direction: column;
}
.budget-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
}
.budget-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.budget-name {
font-family: 'Newsreader', Georgia, serif;
font-size: 16px;
font-weight: 500;
color: #1A1A1A;
@media (prefers-color-scheme: dark) {
color: #f4f4f5;
}
}
.budget-stats {
display: flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.used-amount {
font-weight: 600;
color: #1A1A1A;
@media (prefers-color-scheme: dark) {
color: #f4f4f5;
}
}
.separator {
color: #888888;
}
.limit-amount {
color: #888888;
}
.progress-bar {
width: 100%;
height: 8px;
background: #F0F0F0;
border-radius: 4px;
overflow: hidden;
@media (prefers-color-scheme: dark) {
background: #2A2A2A;
}
}
.progress-fill {
height: 100%;
background: #0D6E6E;
border-radius: 4px;
transition: width 0.3s;
}
.divider {
height: 1px;
background: #F0F0F0;
@media (prefers-color-scheme: dark) {
background: #2A2A2A;
}
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="category-section">
<!-- 分类标题 -->
<div class="section-header">
<div class="header-left">
<span class="label">分类支出</span>
</div>
<div
class="header-right"
@click="toggleExpand"
>
<span class="link-text">{{ isExpanded ? '收起' : '查看全部' }}</span>
<van-icon
:name="isExpanded ? 'arrow-up' : 'arrow-right'"
class="link-icon"
/>
</div>
</div>
<!-- 分类卡片 -->
<div class="category-card">
<van-loading v-if="loading" />
<van-empty
v-else-if="categories.length === 0"
description="暂无分类数据"
/>
<div
v-else
class="category-list"
>
<template
v-for="(category, index) in displayCategories"
:key="category.classify"
>
<div class="category-item">
<div class="item-left">
<div
class="icon-wrapper"
:style="{ background: category.color + '15' }"
>
<van-icon
:name="getCategoryIcon(category.classify)"
:color="category.color"
size="20"
/>
</div>
<div class="item-info">
<div class="category-name">
{{ category.classify || '未分类' }}
</div>
<div class="category-count">
{{ category.count }}
</div>
</div>
</div>
<div class="item-right">
<div class="category-amount">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<div
v-if="index < displayCategories.length - 1"
class="divider"
/>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getCategoryStatistics } from '@/api/statistics'
const props = defineProps({
year: Number,
month: Number
})
// 状态
const loading = ref(false)
const categories = ref([])
const isExpanded = ref(false)
// 显示的分类列表
const displayCategories = computed(() => {
if (isExpanded.value) {
return categories.value
}
// 只显示前3个
return categories.value.slice(0, 3)
})
// 方法
const formatMoney = (value) => {
if (!value && value !== 0) {return '0'}
return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
const getCategoryIcon = (classify) => {
// 简单的图标映射
const iconMap = {
'餐饮': 'goods-collect-o',
'购物': 'cart-o',
'交通': 'guide-o',
'娱乐': 'smile-o',
'医疗': 'medic-o',
'教育': 'certificate-o',
'住房': 'home-o',
'通讯': 'phone-o'
}
return iconMap[classify] || 'records-o'
}
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
// 颜色配置
const colors = [
'#0D6E6E',
'#E07B54',
'#888888',
'#4c9cf1',
'#51cf66',
'#ff6b6b',
'#f59f00',
'#7950f2'
]
// 获取分类数据
const fetchCategories = async () => {
loading.value = true
try {
const response = await getCategoryStatistics({
year: props.year,
month: props.month,
type: 0 // 支出
})
if (response.success) {
categories.value = response.data.map((item, index) => ({
...item,
color: colors[index % colors.length]
}))
}
} catch (error) {
console.error('获取分类数据失败:', error)
} finally {
loading.value = false
}
}
// 监听变化
watch([() => props.year, () => props.month], fetchCategories, { immediate: true })
</script>
<style scoped>
.category-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
color: #888888;
text-transform: uppercase;
}
.header-right {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
&:active {
opacity: 0.7;
}
}
.link-text {
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 500;
color: #0D6E6E;
}
.link-icon {
color: #0D6E6E;
font-size: 16px;
}
.category-card {
background: #FFFFFF;
border: 1px solid #E5E5E5;
border-radius: 12px;
padding: 24px;
min-height: 120px;
@media (prefers-color-scheme: dark) {
background: #1A1A1A;
border-color: #2A2A2A;
}
}
.category-list {
display: flex;
flex-direction: column;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.item-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.icon-wrapper {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.category-name {
font-family: 'Newsreader', Georgia, serif;
font-size: 16px;
font-weight: 500;
color: #1A1A1A;
@media (prefers-color-scheme: dark) {
color: #f4f4f5;
}
}
.category-count {
font-family: 'DM Sans', sans-serif;
font-size: 12px;
color: #888888;
}
.item-right {
display: flex;
align-items: center;
}
.category-amount {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 600;
color: #1A1A1A;
@media (prefers-color-scheme: dark) {
color: #f4f4f5;
}
}
.divider {
height: 1px;
background: #F0F0F0;
@media (prefers-color-scheme: dark) {
background: #2A2A2A;
}
}
</style>

View File

@@ -0,0 +1,401 @@
<template>
<div class="metrics-cards">
<!-- 核心指标标题 -->
<div class="section-header">
核心指标
</div>
<!-- 第一行总支出 + 交易笔数 -->
<div class="metrics-row">
<!-- 总支出卡片 -->
<div class="metric-card">
<div class="card-header">
<span class="label">总支出</span>
<span
v-if="expenseChange !== 0"
class="badge expense"
>
{{ expenseChange > 0 ? '+' : '' }}{{ expenseChange }}%
</span>
</div>
<div class="value">
¥{{ formatMoney(stats.totalExpense) }}
</div>
<div class="description">
{{ getChangeDescription('expense') }}
</div>
</div>
<!-- 交易笔数卡片 -->
<div class="metric-card">
<div class="card-header">
<span class="label">交易笔数</span>
<span
v-if="countChange > 0"
class="badge success"
>
+{{ countChange }}
</span>
</div>
<div class="value">
{{ stats.totalCount }}
</div>
<!-- 7天趋势小图 -->
<div class="chart-bars">
<div
v-for="(bar, index) in weeklyBars"
:key="index"
class="bar"
:style="{ height: bar.height + 'px' }"
/>
</div>
</div>
</div>
<!-- 第二行总收入 + 结余 -->
<div class="metrics-row">
<!-- 总收入卡片 -->
<div class="metric-card">
<div class="card-header">
<span class="label">总收入</span>
<span
v-if="incomeChange !== 0"
class="badge success"
>
{{ incomeChange > 0 ? '+' : '' }}{{ incomeChange }}%
</span>
</div>
<div class="value income">
¥{{ formatMoney(stats.totalIncome) }}
</div>
<div class="description">
工资红包等
</div>
</div>
<!-- 结余卡片 -->
<div class="metric-card">
<div class="card-header">
<span class="label">结余</span>
</div>
<div
class="value"
:class="balanceClass"
>
{{ stats.balance >= 0 ? '+' : '' }}¥{{ formatMoney(Math.abs(stats.balance)) }}
</div>
<div class="balance-indicator">
<div
class="indicator-dot"
:class="balanceClass"
/>
<span class="description">{{ balanceDescription }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getMonthlyStatistics, getDailyStatistics } from '@/api/statistics'
const props = defineProps({
year: Number,
period: String
})
// 数据状态
const stats = ref({
totalExpense: 0,
totalIncome: 0,
balance: 0,
totalCount: 0
})
const prevStats = ref({
totalExpense: 0,
totalIncome: 0,
totalCount: 0
})
const weeklyBars = ref([])
// 计算环比变化
const expenseChange = computed(() => {
if (prevStats.value.totalExpense === 0) {return 0}
const change = ((stats.value.totalExpense - prevStats.value.totalExpense) / prevStats.value.totalExpense) * 100
return Math.round(change)
})
const incomeChange = computed(() => {
if (prevStats.value.totalIncome === 0) {return 0}
const change = ((stats.value.totalIncome - prevStats.value.totalIncome) / prevStats.value.totalIncome) * 100
return Math.round(change)
})
const countChange = computed(() => {
return stats.value.totalCount - prevStats.value.totalCount
})
const balanceClass = computed(() => {
return stats.value.balance >= 0 ? 'income' : 'expense'
})
const balanceDescription = computed(() => {
return stats.value.balance >= 0 ? '收入大于支出' : '支出大于收入'
})
// 方法
const formatMoney = (value) => {
if (!value && value !== 0) {return '0'}
return Math.abs(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
const getChangeDescription = (type) => {
const change = type === 'expense' ? expenseChange.value : incomeChange.value
if (change === 0) {return '与上期持平'}
if (change > 0) {return '较上期增加'}
return '较上期减少'
}
// 获取当前期数据
const fetchCurrentStats = async () => {
try {
const month = props.period === 'year' ? 0 : new Date().getMonth() + 1
const response = await getMonthlyStatistics({
year: props.year,
month
})
if (response.success) {
stats.value = response.data
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 获取上期数据(用于环比计算)
const fetchPrevStats = async () => {
try {
let prevYear = props.year
let prevMonth = new Date().getMonth()
if (props.period === 'year') {
prevYear = props.year - 1
prevMonth = 0
} else if (props.period === 'week') {
// 周期:上周数据(简化处理,使用上月数据)
prevMonth = new Date().getMonth()
if (prevMonth === 0) {
prevYear--
prevMonth = 12
}
}
const response = await getMonthlyStatistics({
year: prevYear,
month: prevMonth
})
if (response.success) {
prevStats.value = response.data
}
} catch (error) {
console.error('获取上期数据失败:', error)
}
}
// 获取7天趋势数据
const fetchWeeklyTrend = async () => {
try {
const response = await getDailyStatistics({
year: props.year,
month: new Date().getMonth() + 1
})
if (response.success && response.data) {
// 取最近7天数据
const recent7Days = response.data.slice(-7)
const maxExpense = Math.max(...recent7Days.map(d => d.expense))
weeklyBars.value = recent7Days.map(day => ({
height: maxExpense > 0 ? (day.expense / maxExpense) * 24 : 4
}))
}
} catch (error) {
console.error('获取趋势数据失败:', error)
}
}
// 监听变化
watch([() => props.year, () => props.period], async () => {
await Promise.all([
fetchCurrentStats(),
fetchPrevStats(),
fetchWeeklyTrend()
])
}, { immediate: true })
</script>
<style scoped>
.metrics-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
color: #888888;
text-transform: uppercase;
}
.metrics-row {
display: flex;
gap: 16px;
}
.metric-card {
flex: 1;
background: #FFFFFF;
border: 1px solid #E5E5E5;
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 146px;
justify-content: space-between;
@media (prefers-color-scheme: dark) {
background: #1A1A1A;
border-color: #2A2A2A;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.label {
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 500;
color: #888888;
}
.badge {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
&.expense {
background: #FFE5E5;
color: #E07B54;
@media (prefers-color-scheme: dark) {
background: #E07B5415;
}
}
&.success {
background: #E5F5E5;
color: #0D6E6E;
@media (prefers-color-scheme: dark) {
background: #0D6E6E15;
}
}
}
.value {
font-family: 'JetBrains Mono', monospace;
font-size: 32px;
font-weight: 700;
line-height: 0.85;
color: #1A1A1A;
&.income {
color: #0D6E6E;
}
&.expense {
color: #E07B54;
}
@media (prefers-color-scheme: dark) {
color: #f4f4f5;
&.income {
color: #0D6E6E;
}
&.expense {
color: #E07B54;
}
}
}
.description {
font-family: 'Newsreader', Georgia, serif;
font-size: 13px;
font-style: italic;
color: #666666;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 24px;
}
.bar {
flex: 1;
background: #E5E5E5;
border-radius: 2px;
min-height: 4px;
transition: all 0.3s;
&:last-child {
background: #0D6E6E;
}
@media (prefers-color-scheme: dark) {
background: #2A2A2A;
&:last-child {
background: #0D6E6E;
}
}
}
.balance-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.income {
background: #0D6E6E;
}
&.expense {
background: #E07B54;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="period-selector">
<div class="segment-control">
<div
v-for="period in periods"
:key="period.value"
class="segment-item"
:class="{ active: currentPeriod === period.value }"
@click="selectPeriod(period.value)"
>
{{ period.label }}
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
currentPeriod: {
type: String,
default: 'month'
}
})
const emit = defineEmits(['update:period'])
const periods = [
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const selectPeriod = (value) => {
emit('update:period', value)
}
</script>
<style scoped>
.period-selector {
width: 100%;
}
.segment-control {
display: flex;
background: #F0F0F0;
border-radius: 8px;
padding: 4px;
gap: 4px;
height: 44px;
@media (prefers-color-scheme: dark) {
background: #1A1A1A;
}
}
.segment-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-family: 'DM Sans', -apple-system, sans-serif;
font-size: 14px;
font-weight: 500;
color: #888888;
cursor: pointer;
transition: all 0.2s;
user-select: none;
&.active {
background: #FFFFFF;
color: #1A1A1A;
font-weight: 600;
@media (prefers-color-scheme: dark) {
background: #27272a;
color: #f4f4f5;
}
}
&:active {
opacity: 0.7;
}
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="trend-section">
<!-- 趋势标题 -->
<div class="section-header">
<span class="label">支出趋势</span>
<div class="percent-badge">
<span class="percent-value">{{ completionPercent }}</span>
<span class="percent-sign">%</span>
</div>
</div>
<!-- 分隔线 -->
<div class="hr-line" />
<!-- 趋势卡片 -->
<div class="trend-card">
<van-loading v-if="loading" />
<div
v-else
class="week-chart"
>
<div
v-for="(day, index) in weekDays"
:key="index"
class="day-col"
>
<div
class="day-bar"
:style="{ height: day.barHeight + 'px' }"
>
<div
class="bar-fill"
:style="{ height: '100%', background: day.color }"
/>
</div>
<div
class="day-label"
:class="{ highlight: day.isToday }"
>
{{ day.label }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getDailyStatistics } from '@/api/statistics'
const props = defineProps({
year: Number,
period: String
})
// 状态
const loading = ref(false)
const dailyData = ref([])
// 计算本周完成百分比
const completionPercent = computed(() => {
if (dailyData.value.length === 0) {return 0}
const totalDays = dailyData.value.length
const completedDays = dailyData.value.filter(d => d.expense > 0).length
return Math.round((completedDays / totalDays) * 100)
})
// 周数据
const weekDays = computed(() => {
if (dailyData.value.length === 0) {return []}
// 取最近7天
const recent7Days = dailyData.value.slice(-7)
const maxExpense = Math.max(...recent7Days.map(d => d.expense), 1)
const today = new Date().getDay()
const dayLabels = ['日', '一', '二', '三', '四', '五', '六']
return recent7Days.map((day, index) => {
const dayOfWeek = new Date(day.date).getDay()
const barHeight = (day.expense / maxExpense) * 60
const isToday = index === recent7Days.length - 1
return {
label: dayLabels[dayOfWeek],
barHeight: Math.max(barHeight, 8),
isToday,
color: isToday ? '#E07B54' : '#F0F0F0'
}
})
})
// 获取每日数据
const fetchDailyData = async () => {
loading.value = true
try {
const response = await getDailyStatistics({
year: props.year,
month: new Date().getMonth() + 1
})
if (response.success) {
dailyData.value = response.data
}
} catch (error) {
console.error('获取每日数据失败:', error)
} finally {
loading.value = false
}
}
// 监听变化
watch([() => props.year, () => props.period], fetchDailyData, { immediate: true })
</script>
<style scoped>
.trend-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
color: #888888;
text-transform: uppercase;
}
.percent-badge {
display: flex;
align-items: center;
gap: 4px;
}
.percent-value {
font-family: 'JetBrains Mono', monospace;
font-size: 28px;
font-weight: 700;
color: #1A1A1A;
@media (prefers-color-scheme: dark) {
color: #f4f4f5;
}
}
.percent-sign {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 500;
color: #888888;
}
.hr-line {
height: 1px;
background: #E5E5E5;
@media (prefers-color-scheme: dark) {
background: #2A2A2A;
}
}
.trend-card {
background: #FFFFFF;
border: 1px solid #E5E5E5;
border-radius: 12px;
padding: 24px;
min-height: 120px;
@media (prefers-color-scheme: dark) {
background: #1A1A1A;
border-color: #2A2A2A;
}
}
.week-chart {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 80px;
}
.day-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
}
.day-bar {
width: 32px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar-fill {
width: 100%;
border-radius: 4px;
transition: all 0.3s;
}
.day-label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
color: #888888;
&.highlight {
color: #E07B54;
font-weight: 700;
}
}
</style>