feat: 用户体验优化 — 首页加载/继续记账/登录恢复

## 首页加载优化
- 分级加载:首屏只请求 overview + recentTx(2个请求)
- 数据新鲜度:30秒内不重复请求
- 次要数据异步加载:budget + todayData
- 非关键数据后台加载:userInfo + notifications

## 继续记账功能
- SaveSuccess 组件新增「继续记一笔」按钮
- 新增模式显示继续按钮,编辑模式自动返回
- 继续记账时保留分类和日期,清空金额和备注

## 登录失败恢复
- 登录重试机制:最多3次,间隔1秒
- 失败后显示提示而非静默等待

## 群组账单逻辑修正
- 群组账单 = 所有群组成员的个人账单之和
- 修改查询:WHERE user_id IN (群组成员)
This commit is contained in:
wangxiaogang
2026-06-07 17:49:46 +08:00
parent bd7af8e512
commit 1abc55323d
11 changed files with 183 additions and 87 deletions

View File

@@ -162,7 +162,8 @@ server/src/
### 一起记 (群组) ### 一起记 (群组)
- 群组仅做数据聚合,记录始终属于记录者 (user_id) - 群组仅做数据聚合,记录始终属于记录者 (user_id)
- 个人视图: `WHERE user_id = 我` (所有记录) - 个人视图: `WHERE user_id = 我` (所有记录)
- 群组视图: `WHERE group_id = X AND user_id IN (当前成员)` - 群组视图: `WHERE user_id IN (群组成员)` — 统计所有群组成员的个人账单
- 群组账单 = 所有群组成员的个人账单之和
- 退出群组: 该用户的 group_id 设为 NULL记录回到个人视图 - 退出群组: 该用户的 group_id 设为 NULL记录回到个人视图
- 身份切换: 「我的」页底部弹窗选择,全局生效 - 身份切换: 「我的」页底部弹窗选择,全局生效

View File

@@ -5,6 +5,24 @@ import { useGroupStore } from '@/stores/group'
import { saveLoginResult } from '@/utils/request' import { saveLoginResult } from '@/utils/request'
import { markReady } from '@/utils/app-ready' 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(() => { onLaunch(() => {
const groupStore = useGroupStore() const groupStore = useGroupStore()
groupStore.init() groupStore.init()
@@ -14,12 +32,12 @@ onLaunch(() => {
provider: 'weixin', provider: 'weixin',
success: async (loginRes) => { success: async (loginRes) => {
if (loginRes.code) { if (loginRes.code) {
try { const success = await loginWithRetry(() => wxLogin(loginRes.code))
saveLoginResult(await wxLogin(loginRes.code)) if (success) {
groupStore.fetchGroups() groupStore.fetchGroups()
console.log('[Auth] Login success') console.log('[Auth] Login success')
} catch (e) { } else {
console.error('[Auth] Login failed:', e) uni.showToast({ title: '登录失败,请重试', icon: 'none', duration: 3000 })
} }
} }
markReady() markReady()
@@ -30,13 +48,15 @@ onLaunch(() => {
// #ifdef H5 // #ifdef H5
const token = uni.getStorageSync('xc:token') const token = uni.getStorageSync('xc:token')
if (!token) { if (!token) {
demoLogin().then((data) => { loginWithRetry(demoLogin).then((success) => {
saveLoginResult(data) if (success) {
groupStore.fetchGroups() groupStore.fetchGroups()
console.log('[Auth] H5 demo login success') console.log('[Auth] H5 demo login success')
}).catch((e) => { } else {
console.error('[Auth] H5 demo login failed:', e) uni.showToast({ title: '登录失败,请刷新页面', icon: 'none', duration: 3000 })
}).finally(() => { markReady() }) }
markReady()
})
} else { } else {
groupStore.fetchGroups() groupStore.fetchGroups()
markReady() markReady()

View File

@@ -1,9 +1,19 @@
<template> <template>
<view class="save-success" v-if="visible" @tap="hide"> <view class="save-success" v-if="visible">
<view class="success-icon" :class="{ show: animating }"> <view class="success-content" :class="{ show: animating }">
<Icon name="check" :size="80" color="#FFFFFF" /> <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> </view>
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
</view> </view>
</template> </template>
@@ -14,51 +24,61 @@ import Icon from '@/components/Icon/Icon.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
visible: boolean visible: boolean
text?: string text?: string
}>(), { text: '保存成功' }) showContinue?: boolean
}>(), { text: '保存成功', showContinue: false })
const emit = defineEmits(['update:visible']) const emit = defineEmits(['update:visible', 'continue', 'back'])
const animating = ref(false) const animating = ref(false)
watch(() => props.visible, (val) => { watch(() => props.visible, (val) => {
if (val) { if (val) {
setTimeout(() => { animating.value = true }, 50) setTimeout(() => { animating.value = true }, 50)
setTimeout(() => { hide() }, 2000) // 如果不显示继续按钮,自动关闭
if (!props.showContinue) {
setTimeout(() => { onBack() }, 2000)
}
} else { } else {
animating.value = false animating.value = false
} }
}) })
function hide() { function onContinue() {
animating.value = false 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/styles/mixins.scss';
.save-success { .save-success {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
display: flex; @include flex-center;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 9999; z-index: 9999;
} }
.success-icon { .success-content {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7BC67E, #5BA85E);
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; transform: scale(0.8);
transform: scale(0);
opacity: 0; opacity: 0;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -68,18 +88,47 @@ function hide() {
} }
} }
.success-text { .success-icon {
font-size: 32rpx; width: 160rpx;
font-weight: 600; height: 160rpx;
color: #FFFFFF; border-radius: 50%;
margin-top: 24rpx; background: linear-gradient(135deg, $success, #5BA85E);
transform: translateY(20rpx); @include flex-center;
opacity: 0; }
transition: all 0.3s ease 0.2s;
&.show { .success-text {
transform: translateY(0); font-size: $font-xl;
opacity: 1; 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> </style>

View File

@@ -49,7 +49,13 @@
<Numpad v-model="amountStr" @confirm="save" /> <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> </view>
</template> </template>
@@ -200,7 +206,10 @@ async function save() {
await txStore.addTransaction(data) await txStore.addTransaction(data)
} }
showSuccess.value = true showSuccess.value = true
setTimeout(() => uni.navigateBack(), 2500) // 编辑模式自动返回,新增模式显示继续按钮
if (editId.value) {
setTimeout(() => uni.navigateBack(), 2000)
}
} catch (e) { } catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' }) uni.showToast({ title: '保存失败', icon: 'none' })
} finally { } finally {
@@ -208,6 +217,19 @@ async function save() {
} }
} }
/** 继续记一笔 */
function onContinue() {
// 重置表单,保留分类和日期
amountStr.value = ''
note.value = ''
showSuccess.value = false
}
/** 返回上一页 */
function onBack() {
uni.navigateBack()
}
function goBack() { function goBack() {
// 保存成功后直接返回,不弹确认框 // 保存成功后直接返回,不弹确认框
if (showSuccess.value) return if (showSuccess.value) return

View File

@@ -96,14 +96,6 @@ const CACHE_TTL = 5 * 60 * 1000
onMounted(async () => { onMounted(async () => {
await waitForReady() await waitForReady()
// 客户端角色检查
const role = uni.getStorageSync('xc:role')
if (role !== 'admin') {
uni.showToast({ title: '需要管理员权限', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
try { try {
// 检查缓存是否有效 // 检查缓存是否有效
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) { if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {

View File

@@ -198,15 +198,6 @@ const form = ref({ ...defaultForm })
onMounted(async () => { onMounted(async () => {
await waitForReady() await waitForReady()
//
const role = uni.getStorageSync('xc:role')
if (role !== 'admin') {
uni.showToast({ title: '需要管理员权限', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
await loadNotifications(true) await loadNotifications(true)
}) })

View File

@@ -27,7 +27,7 @@
<!-- 筛选面板 --> <!-- 筛选面板 -->
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" /> <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 v-for="group in groupedTx" :key="group.date" class="date-group">
<view class="group-header"> <view class="group-header">
<text class="group-date">{{ group.label }}</text> <text class="group-date">{{ group.label }}</text>
@@ -49,7 +49,7 @@
</view> </view>
</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 v-for="g in 2" :key="g" class="date-group">
<view class="group-header"> <view class="group-header">
<Skeleton width="120rpx" height="26rpx" variant="text" /> <Skeleton width="120rpx" height="26rpx" variant="text" />
@@ -66,7 +66,7 @@
</view> </view>
</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> <text class="loading-text">加载中...</text>
</view> </view>
@@ -79,7 +79,7 @@
<text class="state-text">加载失败下拉刷新重试</text> <text class="state-text">加载失败下拉刷新重试</text>
</view> </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> <text class="empty-text">暂无账单记录</text>
</view> </view>
</view> </view>
@@ -91,7 +91,7 @@ import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready' 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 TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue' import Skeleton from '@/components/Skeleton/Skeleton.vue'
@@ -102,6 +102,7 @@ import type { FilterParams } from '@/api/filter'
const userStore = useUserStore() const userStore = useUserStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const loading = ref(false)
const filterType = ref('all') const filterType = ref('all')
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 20
@@ -172,6 +173,7 @@ async function loadTx(reset = false, silent = false) {
if (af.maxAmount) params.maxAmount = af.maxAmount if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword if (af.keyword) params.keyword = af.keyword
try { try {
loading.value = true
loadError.value = false loadError.value = false
const data = await getTransactions(params) const data = await getTransactions(params)
// 丢弃过期请求的结果 // 丢弃过期请求的结果
@@ -188,6 +190,8 @@ async function loadTx(reset = false, silent = false) {
if (seq !== loadSeq) return if (seq !== loadSeq) return
console.error('Load bills error:', e) console.error('Load bills error:', e)
loadError.value = true loadError.value = true
} finally {
if (seq === loadSeq) loading.value = false
} }
} }
@@ -216,7 +220,7 @@ async function onDelete(item: any) {
success: async (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
try { try {
await txStore.deleteTransaction(item.id) await deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id) txList.value = txList.value.filter(t => t.id !== item.id)
total.value-- total.value--
// 更新筛选金额 // 更新筛选金额
@@ -236,7 +240,7 @@ async function onDelete(item: any) {
// 触底加载更多 // 触底加载更多
onReachBottom(() => { onReachBottom(() => {
if (noMore.value || txStore.loading) return if (noMore.value || loading) return
page.value++ page.value++
loadTx(false) loadTx(false)
}) })

View File

@@ -202,23 +202,37 @@ function openLink(url: string) {
// #endif // #endif
} }
async function loadData(silent = false) { // 数据新鲜度控制30秒内不重复请求
let lastLoadTime = 0
const CACHE_TTL = 30000
async function loadData(silent = false, force = false) {
await waitForReady() await waitForReady()
// 数据新鲜度判断30秒内不重复请求
if (!force && silent && Date.now() - lastLoadTime < CACHE_TTL) {
return
}
try { try {
if (!silent) loading.value = true if (!silent) loading.value = true
loadError.value = false 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([ await Promise.all([
statsStore.fetchOverview(), statsStore.fetchOverview(),
budgetStore.fetchBudget(), txStore.fetchTransactions({ page: 1 })
txStore.fetchTransactions({ page: 1 }),
fetchTodayData(today)
]) ])
recentTx.value = txStore.transactions.slice(0, 5) recentTx.value = txStore.transactions.slice(0, 5)
lastLoadTime = Date.now()
// 非关键数据:失败不影响页面显示 // 第二级:次要数据(异步,不阻塞首屏)
Promise.all([
budgetStore.fetchBudget(),
fetchTodayData()
]).catch(() => {})
// 第三级:非关键数据
Promise.allSettled([ Promise.allSettled([
userStore.fetchUserInfo(), userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(), notifStore.fetchUnreadCount(),
@@ -232,8 +246,10 @@ async function loadData(silent = false) {
} }
} }
async function fetchTodayData(today: string) { async function fetchTodayData() {
try { 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 }) const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
todayExpense.value = data.expense || 0 todayExpense.value = data.expense || 0
todayIncome.value = data.income || 0 todayIncome.value = data.income || 0
@@ -252,11 +268,11 @@ onMounted(() => {
// Refresh data when returning from other pages (e.g., after adding a transaction) // Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => { onShow(() => {
if (initialLoaded.value && !loading.value) loadData(true) if (initialLoaded.value && !loading.value) loadData(true, false)
}) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh()) loadData(false, true).finally(() => uni.stopPullDownRefresh())
}) })
function goAdd(type: string) { function goAdd(type: string) {

View File

@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS transactions (
category_id INT, category_id INT,
note VARCHAR(200), note VARCHAR(200),
date DATE NOT NULL, date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签NULL=纯个人记录', group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date), INDEX idx_user_date (user_id, date),

View File

@@ -22,9 +22,9 @@ async function buildWhereClause(
) )
if ((memberCheck as any[]).length === 0) return null if ((memberCheck as any[]).length === 0) return null
// 群组视图:显示该群组所有记录(包括已退出成员的历史记录) // 群组视图:统计所有群组成员的个人账单
params.push(groupId) params.push(groupId)
let where = `WHERE t.group_id = ?` let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ') if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params } return { where, params }
} }

View File

@@ -15,7 +15,7 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
router.get('/:id', async (req: AuthRequest, res: Response) => { router.get('/:id', async (req: AuthRequest, res: Response) => {
try { try {
// 用户可以查看:自己的记录 + 自己所在群组的所有记录(包括已退出成员的历史记录 // 用户可以查看:自己的记录 + 自己所在群组成员的所有记录
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id, `SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color, c.name as category_name, c.icon as category_icon, c.color as category_color,
@@ -25,7 +25,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = ? AND ( WHERE t.id = ? AND (
t.user_id = ? t.user_id = ?
OR t.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) OR t.user_id IN (SELECT user_id FROM group_members WHERE group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))
)`, )`,
[req.params.id, req.userId, req.userId] [req.params.id, req.userId, req.userId]
) )
@@ -51,7 +51,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const params: any[] = [] const params: any[] = []
if (group_id && group_id !== 'null') { if (group_id && group_id !== 'null') {
// 群组视图:验证用户是群组成员,然后显示该群组所有记录(包括已退出成员的历史记录 // 群组视图:验证用户是群组成员,然后显示所有群组成员的记录
const [memberCheck] = await pool.query( const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?', 'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId] [group_id, req.userId]
@@ -59,7 +59,8 @@ router.get('/', async (req: AuthRequest, res: Response) => {
if ((memberCheck as any[]).length === 0) { if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' }) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
} }
where = 'WHERE t.group_id = ?' // 群组账单 = 所有群组成员的个人账单
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id) params.push(group_id)
} else { } else {
// 个人视图:我的所有记录(不管 group_id // 个人视图:我的所有记录(不管 group_id