618 lines
15 KiB
Vue
618 lines
15 KiB
Vue
<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>
|