Files
xiaocai/client/src/pages/index/index.vue

618 lines
15 KiB
Vue
Raw 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="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header" :style="{ paddingRight: capsuleRight + 'px' }">
<view class="avatar-wrap">
<image v-if="userStore.avatarUrl" :src="userStore.avatarUrl" class="avatar-img" mode="aspectFill" />
<Icon v-else name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}{{ userStore.nickname }}</text>
<view class="bell" @tap="goNotifications">
<Icon name="bell" :size="40" color="#8B7E7E" />
<view v-if="notifStore.unreadCount > 0" class="bell-badge">
<text class="bell-badge-text">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</text>
</view>
</view>
</view>
</view>
<view class="overview-card">
<text class="card-label">本月支出</text>
<view class="amount-row" v-if="!loading">
<text class="currency">¥</text>
<text class="amount">{{ formatAmountRaw(overview.expense) }}</text>
</view>
<view class="amount-skeleton" v-else>
<Skeleton width="280rpx" height="72rpx" variant="rect" />
</view>
<BudgetBar v-if="!loading" :total="budget?.amount || DEFAULT_BUDGET" :used="overview.expense" />
<Skeleton v-else width="100%" height="16rpx" variant="text" />
<view class="mini-stats">
<view class="mini-stat">
<text class="mini-label">日均</text>
<text class="mini-value" v-if="!loading">{{ formatAmount(overview.daily) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="mini-stat">
<text class="mini-label">收入</text>
<text class="mini-value income" v-if="!loading">+{{ formatAmount(overview.income) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="mini-stat">
<text class="mini-label">笔数</text>
<text class="mini-value" v-if="!loading">{{ overview.count }}</text>
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
</view>
</view>
</view>
<view class="today-card" v-if="!loading">
<view class="today-item">
<text class="today-label">今日支出</text>
<text class="today-amount expense">{{ formatAmount(todayExpense) }}</text>
</view>
<view class="today-divider"></view>
<view class="today-item">
<text class="today-label">今日收入</text>
<text class="today-amount income">{{ formatAmount(todayIncome) }}</text>
</view>
<view class="today-count">{{ todayCount }}</view>
</view>
<view class="today-card" v-else>
<view class="today-item">
<Skeleton width="96rpx" height="24rpx" variant="text" />
<Skeleton width="140rpx" height="36rpx" variant="rect" />
</view>
<view class="today-divider"></view>
<view class="today-item">
<Skeleton width="96rpx" height="24rpx" variant="text" />
<Skeleton width="140rpx" height="36rpx" variant="rect" />
</view>
<Skeleton width="48rpx" height="22rpx" variant="text" />
</view>
<view class="quick-actions">
<view class="quick-btn" @tap="goAdd('expense')">
<Icon name="trend-down" :size="48" color="#FF6B6B" />
<text class="quick-text">记支出</text>
</view>
<view class="quick-btn" @tap="goAdd('income')">
<Icon name="trend-up" :size="48" color="#7BC67E" />
<text class="quick-text">记收入</text>
</view>
</view>
<view class="section-header">
<text class="section-title">近期账单</text>
<text class="section-more" @tap="goBills">查看全部</text>
</view>
<view class="tx-list" v-if="!loading && !loadError && recentTx.length > 0">
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" :disableSwipe="true" :showCreator="groupStore.isGroupMode" :currentUserId="userStore.userInfo?.id" @item-tap="onTxTap(item)" />
</view>
<view class="tx-list" v-if="loading">
<view v-for="i in 3" :key="i" class="skeleton-item">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="160rpx" height="28rpx" variant="text" />
<Skeleton width="240rpx" height="24rpx" variant="text" />
</view>
<Skeleton width="120rpx" height="32rpx" variant="rect" />
</view>
</view>
<view class="state-box" v-if="loadError">
<Icon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</view>
<!-- 强提醒公告弹窗 -->
<view class="modal-mask" v-if="urgentNotice" @tap="dismissUrgent">
<view class="urgent-modal" @tap.stop>
<view class="urgent-header">
<Icon name="bell" :size="32" color="#FF8C69" />
<text class="urgent-title">{{ urgentNotice.title }}</text>
</view>
<scroll-view class="urgent-body" scroll-y>
<image v-if="urgentNotice.image_url" class="urgent-image" :src="getNotificationImageUrl(urgentNotice.image_url)" mode="widthFix" />
<text class="urgent-content">{{ urgentNotice.content }}</text>
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
<text class="urgent-link-text">查看详情</text>
</view>
</scroll-view>
<view class="urgent-footer" @tap="dismissUrgent">
<text class="urgent-btn-text">我知道了</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification'
import { waitForReady } from '@/utils/app-ready'
import { getOverview } from '@/api/stats'
import { syncRecurring } from '@/api/recurring'
import type { Transaction } from '@/api/transaction'
import { getNotificationImageUrl } from '@/api/notification'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const notifStore = useNotificationStore()
const DEFAULT_BUDGET = 500000 // 5000元
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
const recentTx = ref<Transaction[]>([])
const loading = ref(true)
const loadError = ref(false)
const todayExpense = ref(0)
const todayIncome = ref(0)
const todayCount = ref(0)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
// 强提醒公告:取第一条未读的
const urgentNotice = computed(() => notifStore.urgentNotifications[0] || null)
function dismissUrgent() {
if (urgentNotice.value) {
notifStore.markRead(urgentNotice.value.id)
}
}
function openLink(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
})
// #endif
}
async function loadData(silent = false) {
await waitForReady()
try {
if (!silent) loading.value = true
loadError.value = false
// 第一级首屏关键数据2个请求
await Promise.all([
statsStore.fetchOverview(),
txStore.fetchTransactions({ page: 1 })
])
recentTx.value = txStore.transactions.slice(0, 5)
// 第二级:次要数据(异步,不阻塞首屏)
Promise.all([
budgetStore.fetchBudget(),
fetchTodayData()
]).catch(() => {})
// 第三级:非关键数据
Promise.allSettled([
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications(),
syncRecurring().catch(() => {})
]).catch(() => {})
} catch (e) {
console.error('Load error:', e)
if (!silent) loadError.value = true
} finally {
loading.value = false
}
}
async function fetchTodayData() {
try {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
todayExpense.value = data.expense || 0
todayIncome.value = data.income || 0
todayCount.value = data.count || 0
} catch {
todayExpense.value = 0
todayIncome.value = 0
todayCount.value = 0
}
}
const initialLoaded = ref(false)
onMounted(() => {
loadData().finally(() => { initialLoaded.value = true })
})
// Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => {
if (initialLoaded.value && !loading.value) loadData(true)
})
onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
function goAdd(type: string) {
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
}
function goNotifications() {
uni.navigateTo({ url: '/pages/notifications/index' })
}
function onTxTap(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
function goBills() {
uni.switchTab({ url: '/pages/bills/index' })
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.header {
display: flex;
align-items: center;
padding: $space-sm $space-xl $space-lg;
}
.avatar-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid $border;
background: $surface-warm;
@include flex-center;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
}
.greeting {
flex: 1;
font-size: $font-2xl;
font-weight: 600;
color: $text;
margin-left: $space-sm;
}
.bell {
width: 88rpx;
height: 88rpx;
@include flex-center;
position: relative;
&:active { opacity: 0.6; }
}
.bell-badge {
position: absolute;
top: $space-xs;
right: $space-xs;
min-width: 32rpx;
height: 32rpx;
padding: 0 $space-xs;
background: $danger;
border-radius: $radius-full;
@include flex-center;
}
.bell-badge-text {
font-size: $font-xs;
font-weight: 600;
color: $surface;
}
.overview-card {
margin: 0 $space-xl;
padding: $space-2xl;
@include card($radius-2xl);
box-shadow: $shadow-clay;
}
.card-label {
font-size: $font-lg;
color: $text-sec;
display: block;
margin-bottom: $space-sm;
}
.amount-row {
display: flex;
align-items: baseline;
margin-bottom: $space-lg;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: $font-3xl;
font-weight: 500;
color: $text-sec;
margin-right: 4rpx;
}
.amount {
font-family: 'Fredoka', sans-serif;
font-size: 72rpx;
font-weight: 600;
color: $text;
font-variant-numeric: tabular-nums;
}
.mini-stats {
display: flex;
gap: $space-md;
margin-top: $space-xl;
}
.mini-stat {
flex: 1;
min-width: 0;
padding: $space-md $space-sm;
border-radius: $radius-xl;
background: $bg;
text-align: center;
}
.mini-label {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.mini-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-xl;
font-weight: 600;
@include text-ellipsis;
&.income { color: $success; }
&.expense { color: $danger; }
}
.today-card {
margin: $space-md $space-xl 0;
padding: $space-lg $space-lg;
@include card($radius-xl);
display: flex;
align-items: center;
gap: $space-md;
}
.today-item {
flex: 1;
display: flex;
flex-direction: column;
gap: $space-xs;
}
.today-label {
font-size: $font-md;
color: $text-sec;
}
.today-amount {
font-family: 'Fredoka', sans-serif;
font-size: $font-xl;
font-weight: 600;
&.expense { color: $danger; }
&.income { color: $success; }
}
.today-divider {
width: 2rpx;
height: 56rpx;
background: $border;
flex-shrink: 0;
}
.today-count {
font-size: $font-sm;
color: $text-muted;
flex-shrink: 0;
}
.quick-actions {
display: flex;
gap: $space-md;
padding: 0 $space-xl;
margin-top: $space-xl;
}
.quick-btn {
flex: 1;
height: 144rpx;
border-radius: $radius-2xl;
@include card;
flex-direction: column;
@include flex-center;
gap: $space-xs;
&:active {
transform: scale(0.97);
box-shadow: $shadow-clay-pressed;
}
}
.quick-text { font-size: $font-md; font-weight: 500; color: $text; }
.section-header {
@include flex-between;
padding: $space-2xl $space-xl $space-md;
}
.section-title {
font-size: $font-lg;
font-weight: 500;
color: $text-sec;
}
.section-more {
font-size: $font-md;
color: $primary;
padding: $space-sm $space-md;
margin: -$space-sm -#{$space-md};
&:active { opacity: 0.6; }
}
.tx-list {
padding: 0 $space-xl;
}
.amount-skeleton {
margin-bottom: $space-lg;
}
.skeleton-item {
display: flex;
align-items: center;
padding: $space-lg 0;
gap: $space-md;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: $space-xs;
}
.state-box {
@include state-box;
}
.state-text {
@include state-text;
}
/* 强提醒公告弹窗 */
.modal-mask {
@include modal-mask-high;
}
.urgent-modal {
width: 80%;
max-height: 70vh;
background: $surface;
border-radius: $radius-2xl;
overflow: hidden;
display: flex;
flex-direction: column;
}
.urgent-header {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-xl $space-lg $space-md;
}
.urgent-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
flex: 1;
}
.urgent-body {
flex: 1;
padding: 0 $space-lg $space-lg;
max-height: 50vh;
}
.urgent-image {
width: 100%;
border-radius: $radius-md;
margin-bottom: $space-sm;
}
.urgent-content {
font-size: $font-lg;
color: $text;
line-height: 1.6;
}
.urgent-link {
margin-top: $space-sm;
padding: $space-xs 0;
}
.urgent-link-text {
font-size: $font-lg;
color: $primary;
text-decoration: underline;
}
.urgent-footer {
padding: $space-lg;
border-top: 2rpx solid $border;
text-align: center;
&:active { background: $surface-warm; }
}
.urgent-btn-text {
font-size: 30rpx;
font-weight: 600;
color: #FF8C69;
}
</style>