Files
2026-06-11 09:53:11 +08:00

446 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="app-page" :class="themeClass">
<section-card title="报表总览" subtitle="按月份查看收支分布、消费趋势和月度对比">
<template #action>
<picker mode="date" fields="month" :value="monthPickerValue" @change="onMonthChange">
<text class="section-link">{{ selectedMonthLabel }}</text>
</picker>
</template>
<view class="summary-row">
<view class="summary-item surface-strong">
<text class="tiny-text">支出</text>
<text class="summary-value negative">{{ formatCurrency(monthExpenseTotal) }}</text>
</view>
<view class="summary-item surface-strong">
<text class="tiny-text">收入</text>
<text class="summary-value positive">{{ formatCurrency(monthIncomeTotal) }}</text>
</view>
<view class="summary-item surface-strong">
<text class="tiny-text">结余</text>
<text class="summary-value">{{ formatCurrency(monthIncomeTotal - monthExpenseTotal) }}</text>
</view>
</view>
</section-card>
<ad-custom unit-id="adunit-74730c6c27c95a37"></ad-custom>
<section-card title="支出分类" subtitle="查看本月主要消费去向与占比结构">
<view v-if="categoryStats.length" class="chart-list">
<view v-for="item in categoryStats" :key="item.id" class="chart-row">
<view class="chart-head">
<view class="chart-title-row">
<view class="chart-dot" :style="{ background: item.color }"></view>
<text class="chart-title">{{ item.name }}</text>
</view>
<text class="tiny-text">{{ formatCurrency(item.total) }} · {{ item.percentLabel }}</text>
</view>
<view class="bar-track"><view class="bar-fill" :style="{ width: item.percentWidth, background: item.color }"></view></view>
</view>
</view>
<view v-else class="empty-card"><text class="section-subtitle">当前月份暂无支出数据记一笔后会自动生成图表</text></view>
</section-card>
<section-card title="近 7 日趋势" subtitle="观察近一周消费变化,便于发现异常高峰">
<view class="column-chart">
<view v-for="item in dailySeries" :key="item.date" class="column-item">
<view class="column-track"><view class="column-fill" :style="{ height: item.height }"></view></view>
<text class="tiny-text">{{ item.label }}</text>
<text class="tiny-text">{{ item.value === 0 ? '-' : item.value }}</text>
</view>
</view>
</section-card>
<section-card title="月度对比" subtitle="最近 6 个月收入与支出走势一目了然">
<view class="compare-list">
<view v-for="item in monthCompare" :key="item.month" class="compare-row">
<text class="compare-label">{{ item.label }}</text>
<view class="compare-bars">
<view class="mini-track"><view class="mini-fill expense-fill" :style="{ width: item.expenseWidth }"></view></view>
<view class="mini-track"><view class="mini-fill income-fill" :style="{ width: item.incomeWidth }"></view></view>
</view>
<text class="tiny-text">{{ formatCurrency(item.expense) }} / {{ formatCurrency(item.income) }}</text>
</view>
</view>
</section-card>
<section-card title="导出与分享" subtitle="支持导出当月 CSV 账单与生成分享文案">
<view class="action-grid">
<view class="primary-button" @click="exportCsv">导出 CSV</view>
<view class="ghost-button" @click="posterVisible = true">分享摘要</view>
</view>
</section-card>
<view v-if="posterVisible" class="poster-shell" @touchmove.stop.prevent="">
<view class="poster-mask" @click="posterVisible = false"></view>
<view class="surface-card poster-panel">
<view class="poster-card">
<text class="poster-month">{{ selectedMonthLabel }}</text>
<text class="poster-title">收支月报</text>
<text class="poster-line">支出 {{ formatCurrency(monthExpenseTotal) }}</text>
<text class="poster-line">收入 {{ formatCurrency(monthIncomeTotal) }}</text>
<text class="poster-line">结余 {{ formatCurrency(monthIncomeTotal - monthExpenseTotal) }}</text>
<text class="poster-tip">内容本地生成可复制摘要或直接截图分享</text>
</view>
<view class="action-grid">
<view class="ghost-button" @click="copyPosterText">复制摘要</view>
<view class="primary-button" @click="posterVisible = false">关闭</view>
</view>
</view>
</view>
<app-tab-bar current="stats" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import SectionCard from '../../components/SectionCard.vue'
import AppTabBar from '../../components/AppTabBar.vue'
import { useAppStore } from '../../utils/store'
import { formatMonthLabel, getMonthSeries, getRecentDateKeys, isSameMonth, toDateKey, toMonthKey } from '../../utils/date'
import { clampPercent, formatCurrency, formatPercent } from '../../utils/money'
const store = useAppStore()
const monthValue = ref(toMonthKey())
const posterVisible = ref(false)
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
const selectedMonthKey = computed(() => monthValue.value.slice(0, 7))
const selectedMonthLabel = computed(() => formatMonthLabel(selectedMonthKey.value))
const monthPickerValue = computed(() => monthValue.value)
const monthBills = computed(() => store.state.bills.filter((item) => isSameMonth(item.date, selectedMonthKey.value)))
const monthExpenseBills = computed(() => monthBills.value.filter((item) => item.type === 'expense'))
const monthExpenseTotal = computed(() => monthExpenseBills.value.reduce((sum, item) => sum + Number(item.amount), 0))
const monthIncomeTotal = computed(() => monthBills.value.filter((item) => item.type === 'income').reduce((sum, item) => sum + Number(item.amount), 0))
const categoryStats = computed(() => {
const total = Math.max(monthExpenseTotal.value, 1)
return store.state.categories.expense
.map((category) => {
const categoryTotal = monthExpenseBills.value
.filter((item) => item.categoryId === category.id)
.reduce((sum, item) => sum + Number(item.amount), 0)
return {
...category,
total: categoryTotal,
percentLabel: formatPercent(categoryTotal / total),
percentWidth: clampPercent(categoryTotal / total)
}
})
.filter((item) => item.total > 0)
.sort((left, right) => right.total - left.total)
})
const dailySeries = computed(() => {
const [year, month] = selectedMonthKey.value.split('-').map(Number)
const today = toDateKey()
const monthEnd = new Date(year, month, 0)
const endDate = toMonthKey(today) === selectedMonthKey.value ? new Date() : monthEnd
const dateKeys = getRecentDateKeys(7, endDate)
const maxValue = Math.max(1, ...dateKeys.map((dateKey) => monthExpenseBills.value
.filter((item) => item.date === dateKey)
.reduce((sum, item) => sum + Number(item.amount), 0)))
return dateKeys.map((dateKey) => {
const value = monthExpenseBills.value
.filter((item) => item.date === dateKey)
.reduce((sum, item) => sum + Number(item.amount), 0)
return {
date: dateKey,
label: dateKey.slice(5),
value: Number(value.toFixed(2)),
height: `${Math.max(8, (value / maxValue) * 100)}%`
}
})
})
const monthCompare = computed(() => {
const series = getMonthSeries(6, selectedMonthKey.value)
const maxExpense = Math.max(1, ...series.map((month) => store.state.bills
.filter((item) => item.type === 'expense' && isSameMonth(item.date, month))
.reduce((sum, item) => sum + Number(item.amount), 0)))
const maxIncome = Math.max(1, ...series.map((month) => store.state.bills
.filter((item) => item.type === 'income' && isSameMonth(item.date, month))
.reduce((sum, item) => sum + Number(item.amount), 0)))
return series.map((month) => {
const expense = store.state.bills
.filter((item) => item.type === 'expense' && isSameMonth(item.date, month))
.reduce((sum, item) => sum + Number(item.amount), 0)
const income = store.state.bills
.filter((item) => item.type === 'income' && isSameMonth(item.date, month))
.reduce((sum, item) => sum + Number(item.amount), 0)
return {
month,
label: month.slice(5),
expense,
income,
expenseWidth: `${Math.max(8, (expense / maxExpense) * 100)}%`,
incomeWidth: `${Math.max(8, (income / maxIncome) * 100)}%`
}
})
})
function onMonthChange(event) {
monthValue.value = String(event.detail.value).slice(0, 7)
}
function categoryNameOf(bill) {
return (store.state.categories[bill.type] || []).find((item) => item.id === bill.categoryId)?.name || '未分类'
}
function accountNameOf(bill) {
return store.state.accounts.find((item) => item.id === bill.accountId)?.name || '未知账户'
}
function escapeCsvCell(value) {
const text = String(value ?? '')
if (!/[",\n]/.test(text)) {
return text
}
return `"${text.replace(/"/g, '""')}"`
}
function writeTextToFile(fileName, content, successText) {
if (typeof wx !== 'undefined' && wx.getFileSystemManager) {
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
wx.getFileSystemManager().writeFile({
filePath,
data: content,
encoding: 'utf8',
success: () => {
uni.showModal({
title: successText,
content: `文件已生成:${filePath}`,
showCancel: false
})
},
fail: () => {
uni.setClipboardData({ data: content })
}
})
return
}
uni.setClipboardData({ data: content })
}
function exportCsv() {
const header = '\uFEFF日期,类型,分类,账户,金额,备注'
const rows = monthBills.value.map((bill) => [
escapeCsvCell(bill.date),
escapeCsvCell(bill.type === 'income' ? '收入' : '支出'),
escapeCsvCell(categoryNameOf(bill)),
escapeCsvCell(accountNameOf(bill)),
escapeCsvCell(Number(bill.amount).toFixed(2)),
escapeCsvCell(bill.note || '')
].join(','))
writeTextToFile(`账单-${selectedMonthKey.value}.csv`, [header, ...rows].join('\n'), 'CSV 导出成功')
}
function copyPosterText() {
const message = `${selectedMonthLabel.value},支出 ${formatCurrency(monthExpenseTotal.value)},收入 ${formatCurrency(monthIncomeTotal.value)},结余 ${formatCurrency(monthIncomeTotal.value - monthExpenseTotal.value)}`
uni.setClipboardData({ data: message })
}
onShareAppMessage(() => ({
title: `${selectedMonthLabel.value}收支摘要`,
path: '/pages/stats/index'
}))
</script>
<style lang="scss" scoped>
.summary-row,
.action-grid {
display: flex;
gap: 16rpx;
}
.summary-item {
flex: 1;
padding: 22rpx;
border-radius: 24rpx;
}
.summary-value {
display: block;
margin-top: 10rpx;
font-size: 30rpx;
font-weight: 700;
color: var(--text-primary);
}
.section-link {
font-size: 24rpx;
color: var(--brand);
}
.chart-list,
.compare-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.chart-head,
.chart-title-row,
.compare-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.chart-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.chart-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.bar-track,
.mini-track,
.column-track {
overflow: hidden;
border-radius: 999rpx;
background: var(--surface-muted);
}
.bar-track {
height: 16rpx;
margin-top: 12rpx;
}
.bar-fill,
.mini-fill {
height: 100%;
border-radius: inherit;
}
.column-chart {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12rpx;
align-items: end;
height: 260rpx;
}
.column-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
.column-track {
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
height: 180rpx;
padding: 0 6rpx;
}
.column-fill {
width: 100%;
border-radius: 999rpx 999rpx 16rpx 16rpx;
background: linear-gradient(180deg, #5f8df5 0%, #1f6f5f 100%);
}
.compare-label {
width: 64rpx;
font-size: 24rpx;
color: var(--text-secondary);
}
.compare-bars {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.mini-track {
height: 12rpx;
}
.expense-fill {
background: #d36c43;
}
.income-fill {
background: #1f6f5f;
}
.action-grid {
margin-top: 8rpx;
}
.action-grid .primary-button,
.action-grid .ghost-button {
flex: 1;
}
.empty-card {
padding: 32rpx 0 10rpx;
text-align: center;
}
.poster-shell {
position: fixed;
inset: 0;
z-index: 50;
}
.poster-mask {
position: absolute;
inset: 0;
background: rgba(4, 12, 18, 0.42);
}
.poster-panel {
position: absolute;
left: 24rpx;
right: 24rpx;
top: 16vh;
padding: 28rpx;
}
.poster-card {
padding: 32rpx;
border-radius: 28rpx;
background: linear-gradient(145deg, #102a43 0%, #1f6f5f 100%);
color: #ffffff;
margin-bottom: 20rpx;
}
.poster-month,
.poster-tip {
color: rgba(255, 255, 255, 0.76);
}
.poster-title {
display: block;
margin: 12rpx 0 20rpx;
font-size: 42rpx;
font-weight: 700;
}
.poster-line {
display: block;
margin-bottom: 12rpx;
font-size: 28rpx;
}
.poster-tip {
display: block;
margin-top: 24rpx;
font-size: 22rpx;
line-height: 1.6;
}
</style>