第一次上传

This commit is contained in:
xxk
2026-06-11 09:53:11 +08:00
commit e257f2009e
89 changed files with 4336 additions and 0 deletions
+445
View File
@@ -0,0 +1,445 @@
<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>