474 lines
12 KiB
Vue
474 lines
12 KiB
Vue
<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>
|