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

474 lines
12 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">
<view class="surface-card hero-card">
<text class="hero-date">{{ todayLabel }}</text>
<text class="hero-title">账单小管家</text>
<!-- <text class="hero-subtitle">轻量记账预算管理消费统计所有账单仅保存在当前设备</text> -->
<view class="hero-metrics">
<view class="metric-block">
<text class="tiny-text">今日支出</text>
<text class="metric-value negative">{{ formatCurrency(todayExpense) }}</text>
</view>
<view class="metric-block">
<text class="tiny-text">本月支出</text>
<text class="metric-value negative">{{ formatCurrency(monthExpense) }}</text>
</view>
<view class="metric-block">
<text class="tiny-text">本月收入</text>
<text class="metric-value positive">{{ formatCurrency(monthIncome) }}</text>
</view>
<view class="metric-block">
<text class="tiny-text">本月结余</text>
<text class="metric-value">{{ formatCurrency(balance) }}</text>
</view>
</view>
</view>
<ad-custom unit-id="adunit-74730c6c27c95a37"></ad-custom>
<section-card title="预算概览" subtitle="实时查看预算执行情况与剩余额度">
<view class="budget-head">
<view>
<text class="budget-value">{{ formatCurrency(remainingBudget) }}</text>
<text style="margin-left: 16rpx;"></text>
<text class="tiny-text">剩余预算</text>
</view>
<view class="budget-side">
<text class="tiny-text">预算使用</text>
<text style="margin-left: 16rpx;"></text>
<text class="budget-percent">{{ budgetProgressLabel }}</text>
</view>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: budgetProgressWidth }"></view>
</view>
<view class="budget-note-row">
<text class="tiny-text">{{ totalBudget ? `总预算 ${formatCurrency(totalBudget)}` : '当前尚未设置月预算' }}</text>
<text class="tiny-text">{{ dailyBudgetText }}</text>
</view>
<view v-if="!totalBudget" class="budget-action-row">
<view class="ghost-button" @click="goBudget">去设置预算</view>
</view>
</section-card>
<section-card title="快捷记账" subtitle="常用场景一步录入,提高日常记录效率">
<view class="quick-action-row">
<view class="primary-button quick-main" @click="openQuickAdd('', 'expense')">记录支出</view>
<view class="ghost-button quick-main" @click="openQuickAdd('', 'income')">记录收入</view>
</view>
<view class="quick-grid">
<view
v-for="item in quickCategories"
:key="item.id"
class="quick-chip"
@click="openQuickAdd(item.id, 'expense')"
>
<view class="quick-dot" :style="{ background: item.color }"></view>
<text>{{ item.name }}</text>
</view>
</view>
</section-card>
<section-card title="最近记录" subtitle="保留最近 5 笔账单,长按可编辑或删除">
<template #action>
<text class="section-link" @click="goBills">查看全部</text>
</template>
<view v-if="recentBills.length" class="bill-list">
<view
v-for="bill in recentBills"
:key="bill.id"
class="bill-item"
@longpress="handleBillLongPress(bill)"
>
<view class="bill-leading">
<view class="bill-dot" :style="{ background: getCategory(bill).color || '#7b8794' }"></view>
<view>
<text class="bill-title">{{ getCategory(bill).name || '未分类' }}</text>
<text class="bill-meta">{{ getAccount(bill).name || '账户' }} · {{ formatDateLabel(bill.date) }}</text>
</view>
</view>
<view class="bill-right">
<text class="bill-amount" :class="bill.type === 'income' ? 'positive' : 'negative'">
{{ bill.type === 'income' ? '+' : '-' }}{{ formatCurrency(bill.amount).replace('¥', '') }}
</text>
<text class="tiny-text">{{ bill.note || '无备注' }}</text>
</view>
</view>
</view>
<view v-else class="empty-card">
<text class="section-subtitle">还没有账单记录先记下第一笔收支</text>
<view class="ghost-button empty-action" @click="openQuickAdd('', 'expense')">立即记账</view>
</view>
</section-card>
<view class="fab-button" @click="openQuickAdd('', 'expense')">+ 记一笔</view>
<app-tab-bar current="home" />
<bill-editor-popup
:visible="editorVisible"
:entry="editingBill"
:categories="store.state.categories"
:accounts="store.state.accounts"
:default-type="quickType"
:initial-category-id="quickCategoryId"
@close="closeEditor"
@save="saveBill"
/>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onPullDownRefresh,onShareAppMessage,onShow,onLoad } from '@dcloudio/uni-app'
import SectionCard from '../../components/SectionCard.vue'
import AppTabBar from '../../components/AppTabBar.vue'
import BillEditorPopup from '../../components/BillEditorPopup.vue'
import { useAppStore } from '../../utils/store'
import { formatDateLabel, getDaysLeftInMonth, isSameMonth, toDateKey, toMonthKey } from '../../utils/date'
import { clampPercent, formatCurrency, formatPercent } from '../../utils/money'
const store = useAppStore()
const editorVisible = ref(false)
const editingBill = ref(null)
const quickCategoryId = ref('')
const quickType = ref('expense')
const todayKey = computed(() => toDateKey())
const currentMonth = computed(() => toMonthKey())
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
const todayLabel = computed(() => formatDateLabel(todayKey.value))
const sortedBills = computed(() => [...store.state.bills].sort((left, right) => right.createdAt - left.createdAt))
const recentBills = computed(() => sortedBills.value.slice(0, 5))
const todayBills = computed(() => sortedBills.value.filter((item) => item.date === todayKey.value))
const monthBills = computed(() => sortedBills.value.filter((item) => isSameMonth(item.date, currentMonth.value)))
const todayExpense = computed(() => todayBills.value.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Number(item.amount), 0))
const monthExpense = computed(() => monthBills.value.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Number(item.amount), 0))
const monthIncome = computed(() => monthBills.value.filter((item) => item.type === 'income').reduce((sum, item) => sum + Number(item.amount), 0))
const balance = computed(() => monthIncome.value - monthExpense.value)
const totalBudget = computed(() => Number(store.state.budgets.total) || 0)
const remainingBudget = computed(() => totalBudget.value - monthExpense.value)
const dailyAllowance = computed(() => Math.max(remainingBudget.value, 0) / Math.max(1, getDaysLeftInMonth(currentMonth.value)))
const budgetProgressWidth = computed(() => clampPercent(monthExpense.value / Math.max(totalBudget.value || 1, 1)))
const budgetProgressLabel = computed(() => (totalBudget.value ? formatPercent(monthExpense.value / Math.max(totalBudget.value, 1)) : '未设置'))
const dailyBudgetText = computed(() => {
if (!totalBudget.value) {
return '设置预算后可查看日均可用额度'
}
if (remainingBudget.value < 0) {
return `已超支 ${formatCurrency(Math.abs(remainingBudget.value))}`
}
return `日均可用 ${formatCurrency(dailyAllowance.value)}`
})
const quickCategories = computed(() => store.state.categories.expense.slice(0, 6))
function getCategory(bill) {
return (store.state.categories[bill.type] || []).find((item) => item.id === bill.categoryId) || {}
}
function getAccount(bill) {
return store.state.accounts.find((item) => item.id === bill.accountId) || {}
}
function openQuickAdd(categoryId = '', type = 'expense') {
quickCategoryId.value = categoryId
quickType.value = type
editingBill.value = null
editorVisible.value = true
}
function closeEditor() {
editorVisible.value = false
editingBill.value = null
quickCategoryId.value = ''
quickType.value = 'expense'
}
function saveBill(payload) {
store.saveBill(payload)
uni.showToast({
title: payload.id ? '账单已更新' : '账单已保存',
icon: 'none'
})
}
function confirmDelete(bill) {
uni.showModal({
title: '删除账单',
content: `确认删除 ${getCategory(bill).name || '这笔账单'} 吗?`,
success: ({ confirm }) => {
if (confirm) {
store.deleteBill(bill.id)
}
}
})
}
function handleBillLongPress(bill) {
uni.showActionSheet({
itemList: ['编辑账单', '删除账单'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
editingBill.value = { ...bill }
editorVisible.value = true
}
if (tapIndex === 1) {
confirmDelete(bill)
}
}
})
}
function goBills() {
uni.redirectTo({
url: '/pages/bills/index'
})
}
function goBudget() {
uni.redirectTo({
url: '/pages/budget/index'
})
}
onPullDownRefresh(() => {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
})
const showFlag = ref(false);
onLoad(()=>{
showIt()
})
onShow(()=>{
showIt()
})
function showIt(){
if (showFlag.value){
return
}
showFlag.value = true
let interstitialAd = null;
if (wx.createInterstitialAd) {
interstitialAd = wx.createInterstitialAd({
adUnitId: 'adunit-0abc32053b19a4e9'
})
interstitialAd.onLoad(() => {})
interstitialAd.onError((err) => {
console.error('插屏广告加载失败', err)
})
interstitialAd.onClose(() => {})
}
setTimeout(()=>{
if (interstitialAd) {
interstitialAd.show().catch((err) => {
console.error('插屏广告显示失败', err)
})
showFlag.value = false
}
}, 5480)
}
onShareAppMessage((res) => {
// res.from === 'button' 代表来自页面内按钮
// res.from === 'menu' 代表来自右上角菜单
return {
title: '账单助手', // 分享卡片标题
desc:'本地单机极简记账,支持收支记录、预算管控、消费报表,数据安全私密,轻便好用的个人账单管家。',
path: '/pages/home/index', // 分享后点击跳转的页面(必须是绝对路径)
}
})
</script>
<style lang="scss" scoped>
.hero-card {
display: flex;
flex-direction: column;
gap: 18rpx;
padding: 32rpx;
background: var(--bg-accent);
color: #ffffff;
}
.hero-date,
.hero-subtitle,
.hero-card .tiny-text {
color: rgba(255, 255, 255, 0.76);
}
.hero-title {
font-size: 46rpx;
font-weight: 700;
}
.hero-subtitle {
font-size: 24rpx;
line-height: 1.7;
}
.hero-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 10rpx;
}
.metric-block {
padding: 20rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.12);
}
.metric-value {
display: block;
margin-top: 10rpx;
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
}
.budget-head,
.budget-note-row,
.quick-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.budget-side {
text-align: right;
}
.budget-value,
.budget-percent {
font-size: 36rpx;
font-weight: 700;
color: var(--text-primary);
}
.progress-track {
overflow: hidden;
height: 18rpx;
margin: 24rpx 0 16rpx;
border-radius: 999rpx;
background: var(--surface-muted);
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #d36c43 0%, #1f6f5f 100%);
}
.budget-action-row {
margin-top: 18rpx;
}
.quick-action-row .quick-main {
flex: 1;
}
.quick-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 18rpx;
}
.quick-chip {
display: flex;
align-items: center;
gap: 12rpx;
padding: 16rpx 18rpx;
border-radius: 22rpx;
background: var(--surface-muted);
font-size: 24rpx;
color: var(--text-secondary);
}
.quick-dot,
.bill-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.section-link {
font-size: 24rpx;
color: var(--brand);
}
.bill-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.bill-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx;
border-radius: 24rpx;
background: var(--surface-muted);
}
.bill-leading {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.bill-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.bill-title,
.bill-amount {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.bill-meta {
margin-top: 8rpx;
font-size: 22rpx;
color: var(--text-secondary);
}
.empty-card {
padding: 28rpx 0 8rpx;
text-align: center;
}
.empty-action {
margin-top: 18rpx;
}
.fab-button {
position: fixed;
right: 32rpx;
bottom: 348rpx;
padding: 24rpx 30rpx;
border-radius: 999rpx;
background: var(--bg-accent);
color: #ffffff;
font-size: 28rpx;
font-weight: 700;
box-shadow: 0 20rpx 42rpx rgba(16, 42, 67, 0.24);
z-index: 18;
}
</style>