446 lines
13 KiB
Vue
446 lines
13 KiB
Vue
<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>
|