feat: 用户体验优化 — 首页加载/继续记账/登录恢复
## 首页加载优化 - 分级加载:首屏只请求 overview + recentTx(2个请求) - 数据新鲜度:30秒内不重复请求 - 次要数据异步加载:budget + todayData - 非关键数据后台加载:userInfo + notifications ## 继续记账功能 - SaveSuccess 组件新增「继续记一笔」按钮 - 新增模式显示继续按钮,编辑模式自动返回 - 继续记账时保留分类和日期,清空金额和备注 ## 登录失败恢复 - 登录重试机制:最多3次,间隔1秒 - 失败后显示提示而非静默等待 ## 群组账单逻辑修正 - 群组账单 = 所有群组成员的个人账单之和 - 修改查询:WHERE user_id IN (群组成员)
This commit is contained in:
@@ -5,6 +5,24 @@ import { useGroupStore } from '@/stores/group'
|
||||
import { saveLoginResult } from '@/utils/request'
|
||||
import { markReady } from '@/utils/app-ready'
|
||||
|
||||
/** 登录重试(最多3次) */
|
||||
async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise<boolean> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const data = await loginFn()
|
||||
saveLoginResult(data)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error(`[Auth] Login attempt ${i + 1} failed:`, e)
|
||||
if (i < retries - 1) {
|
||||
// 等待1秒后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onLaunch(() => {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.init()
|
||||
@@ -14,12 +32,12 @@ onLaunch(() => {
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
saveLoginResult(await wxLogin(loginRes.code))
|
||||
const success = await loginWithRetry(() => wxLogin(loginRes.code))
|
||||
if (success) {
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] Login success')
|
||||
} catch (e) {
|
||||
console.error('[Auth] Login failed:', e)
|
||||
} else {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
markReady()
|
||||
@@ -30,13 +48,15 @@ onLaunch(() => {
|
||||
// #ifdef H5
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
if (!token) {
|
||||
demoLogin().then((data) => {
|
||||
saveLoginResult(data)
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] H5 demo login success')
|
||||
}).catch((e) => {
|
||||
console.error('[Auth] H5 demo login failed:', e)
|
||||
}).finally(() => { markReady() })
|
||||
loginWithRetry(demoLogin).then((success) => {
|
||||
if (success) {
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] H5 demo login success')
|
||||
} else {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none', duration: 3000 })
|
||||
}
|
||||
markReady()
|
||||
})
|
||||
} else {
|
||||
groupStore.fetchGroups()
|
||||
markReady()
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<template>
|
||||
<view class="save-success" v-if="visible" @tap="hide">
|
||||
<view class="success-icon" :class="{ show: animating }">
|
||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||
<view class="save-success" v-if="visible">
|
||||
<view class="success-content" :class="{ show: animating }">
|
||||
<view class="success-icon">
|
||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||
</view>
|
||||
<text class="success-text">{{ text }}</text>
|
||||
<view class="success-actions" v-if="showContinue">
|
||||
<view class="action-btn continue" @tap="onContinue">
|
||||
<text class="action-text">继续记一笔</text>
|
||||
</view>
|
||||
<view class="action-btn back" @tap="onBack">
|
||||
<text class="action-text">返回</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -14,51 +24,61 @@ import Icon from '@/components/Icon/Icon.vue'
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
text?: string
|
||||
}>(), { text: '保存成功' })
|
||||
showContinue?: boolean
|
||||
}>(), { text: '保存成功', showContinue: false })
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
const emit = defineEmits(['update:visible', 'continue', 'back'])
|
||||
|
||||
const animating = ref(false)
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => { animating.value = true }, 50)
|
||||
setTimeout(() => { hide() }, 2000)
|
||||
// 如果不显示继续按钮,自动关闭
|
||||
if (!props.showContinue) {
|
||||
setTimeout(() => { onBack() }, 2000)
|
||||
}
|
||||
} else {
|
||||
animating.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function hide() {
|
||||
function onContinue() {
|
||||
animating.value = false
|
||||
setTimeout(() => { emit('update:visible', false) }, 300)
|
||||
setTimeout(() => {
|
||||
emit('update:visible', false)
|
||||
emit('continue')
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
animating.value = false
|
||||
setTimeout(() => {
|
||||
emit('update:visible', false)
|
||||
emit('back')
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.save-success {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include flex-center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7BC67E, #5BA85E);
|
||||
.success-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(0);
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
@@ -68,18 +88,47 @@ function hide() {
|
||||
}
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-top: 24rpx;
|
||||
transform: translateY(20rpx);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease 0.2s;
|
||||
.success-icon {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $success, #5BA85E);
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
&.show {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
.success-text {
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: $surface;
|
||||
margin-top: $space-lg;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
display: flex;
|
||||
gap: $space-md;
|
||||
margin-top: $space-2xl;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: $space-sm $space-xl;
|
||||
border-radius: $radius-xl;
|
||||
min-width: 200rpx;
|
||||
@include flex-center;
|
||||
|
||||
&.continue {
|
||||
background: $primary;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
&.back {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
&:active { background: rgba(255, 255, 255, 0.3); }
|
||||
}
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: $surface;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,7 +49,13 @@
|
||||
|
||||
<Numpad v-model="amountStr" @confirm="save" />
|
||||
|
||||
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
|
||||
<SaveSuccess
|
||||
v-model:visible="showSuccess"
|
||||
:text="editId ? '修改成功' : '记账成功'"
|
||||
:showContinue="!editId"
|
||||
@continue="onContinue"
|
||||
@back="onBack"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -200,7 +206,10 @@ async function save() {
|
||||
await txStore.addTransaction(data)
|
||||
}
|
||||
showSuccess.value = true
|
||||
setTimeout(() => uni.navigateBack(), 2500)
|
||||
// 编辑模式自动返回,新增模式显示继续按钮
|
||||
if (editId.value) {
|
||||
setTimeout(() => uni.navigateBack(), 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
@@ -208,6 +217,19 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 继续记一笔 */
|
||||
function onContinue() {
|
||||
// 重置表单,保留分类和日期
|
||||
amountStr.value = ''
|
||||
note.value = ''
|
||||
showSuccess.value = false
|
||||
}
|
||||
|
||||
/** 返回上一页 */
|
||||
function onBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
// 保存成功后直接返回,不弹确认框
|
||||
if (showSuccess.value) return
|
||||
|
||||
@@ -96,14 +96,6 @@ const CACHE_TTL = 5 * 60 * 1000
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
|
||||
// 客户端角色检查
|
||||
const role = uni.getStorageSync('xc:role')
|
||||
if (role !== 'admin') {
|
||||
uni.showToast({ title: '需要管理员权限', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查缓存是否有效
|
||||
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {
|
||||
|
||||
@@ -198,15 +198,6 @@ const form = ref({ ...defaultForm })
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
|
||||
// 客户端角色检查
|
||||
const role = uni.getStorageSync('xc:role')
|
||||
if (role !== 'admin') {
|
||||
uni.showToast({ title: '需要管理员权限', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
|
||||
await loadNotifications(true)
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- 筛选面板 -->
|
||||
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
|
||||
|
||||
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
|
||||
<view class="tx-list" v-if="!loading || txList.length > 0">
|
||||
<view v-for="group in groupedTx" :key="group.date" class="date-group">
|
||||
<view class="group-header">
|
||||
<text class="group-date">{{ group.label }}</text>
|
||||
@@ -49,7 +49,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tx-list" v-if="txStore.loading && txList.length === 0">
|
||||
<view class="tx-list" v-if="loading && txList.length === 0">
|
||||
<view v-for="g in 2" :key="g" class="date-group">
|
||||
<view class="group-header">
|
||||
<Skeleton width="120rpx" height="26rpx" variant="text" />
|
||||
@@ -66,7 +66,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="loading-more" v-if="txStore.loading && txList.length > 0">
|
||||
<view class="loading-more" v-if="loading && txList.length > 0">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<text class="state-text">加载失败,下拉刷新重试</text>
|
||||
</view>
|
||||
|
||||
<view class="empty" v-if="txList.length === 0 && !txStore.loading && !loadError">
|
||||
<view class="empty" v-if="txList.length === 0 && !loading && !loadError">
|
||||
<text class="empty-text">暂无账单记录</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -91,7 +91,7 @@ import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getTransactions } from '@/api/transaction'
|
||||
import { getTransactions, deleteTransaction } from '@/api/transaction'
|
||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||
@@ -102,6 +102,7 @@ import type { FilterParams } from '@/api/filter'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const loading = ref(false)
|
||||
const filterType = ref('all')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
@@ -172,6 +173,7 @@ async function loadTx(reset = false, silent = false) {
|
||||
if (af.maxAmount) params.maxAmount = af.maxAmount
|
||||
if (af.keyword) params.keyword = af.keyword
|
||||
try {
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
const data = await getTransactions(params)
|
||||
// 丢弃过期请求的结果
|
||||
@@ -188,6 +190,8 @@ async function loadTx(reset = false, silent = false) {
|
||||
if (seq !== loadSeq) return
|
||||
console.error('Load bills error:', e)
|
||||
loadError.value = true
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +220,7 @@ async function onDelete(item: any) {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await txStore.deleteTransaction(item.id)
|
||||
await deleteTransaction(item.id)
|
||||
txList.value = txList.value.filter(t => t.id !== item.id)
|
||||
total.value--
|
||||
// 更新筛选金额
|
||||
@@ -236,7 +240,7 @@ async function onDelete(item: any) {
|
||||
|
||||
// 触底加载更多
|
||||
onReachBottom(() => {
|
||||
if (noMore.value || txStore.loading) return
|
||||
if (noMore.value || loading) return
|
||||
page.value++
|
||||
loadTx(false)
|
||||
})
|
||||
|
||||
@@ -202,23 +202,37 @@ function openLink(url: string) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function loadData(silent = false) {
|
||||
// 数据新鲜度控制(30秒内不重复请求)
|
||||
let lastLoadTime = 0
|
||||
const CACHE_TTL = 30000
|
||||
|
||||
async function loadData(silent = false, force = false) {
|
||||
await waitForReady()
|
||||
|
||||
// 数据新鲜度判断:30秒内不重复请求
|
||||
if (!force && silent && Date.now() - lastLoadTime < CACHE_TTL) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!silent) loading.value = true
|
||||
loadError.value = false
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
// 关键数据:任一失败则显示错误
|
||||
|
||||
// 第一级:首屏关键数据(2个请求)
|
||||
await Promise.all([
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1 }),
|
||||
fetchTodayData(today)
|
||||
txStore.fetchTransactions({ page: 1 })
|
||||
])
|
||||
recentTx.value = txStore.transactions.slice(0, 5)
|
||||
lastLoadTime = Date.now()
|
||||
|
||||
// 非关键数据:失败不影响页面显示
|
||||
// 第二级:次要数据(异步,不阻塞首屏)
|
||||
Promise.all([
|
||||
budgetStore.fetchBudget(),
|
||||
fetchTodayData()
|
||||
]).catch(() => {})
|
||||
|
||||
// 第三级:非关键数据
|
||||
Promise.allSettled([
|
||||
userStore.fetchUserInfo(),
|
||||
notifStore.fetchUnreadCount(),
|
||||
@@ -232,8 +246,10 @@ async function loadData(silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTodayData(today: string) {
|
||||
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
|
||||
@@ -252,11 +268,11 @@ onMounted(() => {
|
||||
|
||||
// Refresh data when returning from other pages (e.g., after adding a transaction)
|
||||
onShow(() => {
|
||||
if (initialLoaded.value && !loading.value) loadData(true)
|
||||
if (initialLoaded.value && !loading.value) loadData(true, false)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadData().finally(() => uni.stopPullDownRefresh())
|
||||
loadData(false, true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
function goAdd(type: string) {
|
||||
|
||||
Reference in New Issue
Block a user