diff --git a/CLAUDE.md b/CLAUDE.md index c3721e7..9a09e15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,7 +162,8 @@ server/src/ ### 一起记 (群组) - 群组仅做数据聚合,记录始终属于记录者 (user_id) - 个人视图: `WHERE user_id = 我` (所有记录) -- 群组视图: `WHERE group_id = X AND user_id IN (当前成员)` +- 群组视图: `WHERE user_id IN (群组成员)` — 统计所有群组成员的个人账单 +- 群组账单 = 所有群组成员的个人账单之和 - 退出群组: 该用户的 group_id 设为 NULL,记录回到个人视图 - 身份切换: 「我的」页底部弹窗选择,全局生效 diff --git a/client/src/App.vue b/client/src/App.vue index d81a597..209f30e 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -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, retries = 3): Promise { + 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() diff --git a/client/src/components/SaveSuccess/SaveSuccess.vue b/client/src/components/SaveSuccess/SaveSuccess.vue index b087db2..4903c5f 100644 --- a/client/src/components/SaveSuccess/SaveSuccess.vue +++ b/client/src/components/SaveSuccess/SaveSuccess.vue @@ -1,9 +1,19 @@ @@ -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) } diff --git a/client/src/pages/add/index.vue b/client/src/pages/add/index.vue index 0fc12bb..dbffb14 100644 --- a/client/src/pages/add/index.vue +++ b/client/src/pages/add/index.vue @@ -49,7 +49,13 @@ - + @@ -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 diff --git a/client/src/pages/admin/index.vue b/client/src/pages/admin/index.vue index 2fa7e9d..f652ae2 100644 --- a/client/src/pages/admin/index.vue +++ b/client/src/pages/admin/index.vue @@ -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) { diff --git a/client/src/pages/admin/notifications/index.vue b/client/src/pages/admin/notifications.vue similarity index 98% rename from client/src/pages/admin/notifications/index.vue rename to client/src/pages/admin/notifications.vue index 2964df7..510dd3a 100644 --- a/client/src/pages/admin/notifications/index.vue +++ b/client/src/pages/admin/notifications.vue @@ -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) }) diff --git a/client/src/pages/bills/index.vue b/client/src/pages/bills/index.vue index db2207b..7419892 100644 --- a/client/src/pages/bills/index.vue +++ b/client/src/pages/bills/index.vue @@ -27,7 +27,7 @@ - + {{ group.label }} @@ -49,7 +49,7 @@ - + @@ -66,7 +66,7 @@ - + 加载中... @@ -79,7 +79,7 @@ 加载失败,下拉刷新重试 - + 暂无账单记录 @@ -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) }) diff --git a/client/src/pages/index/index.vue b/client/src/pages/index/index.vue index 128f9ae..5f46da1 100644 --- a/client/src/pages/index/index.vue +++ b/client/src/pages/index/index.vue @@ -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) { diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 39e8e3f..4c8b32c 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS transactions ( category_id INT, note VARCHAR(200), 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, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_date (user_id, date), diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts index 026524d..f1f7bf6 100644 --- a/server/src/routes/stats.ts +++ b/server/src/routes/stats.ts @@ -22,9 +22,9 @@ async function buildWhereClause( ) if ((memberCheck as any[]).length === 0) return null - // 群组视图:显示该群组所有记录(包括已退出成员的历史记录) + // 群组视图:统计所有群组成员的个人账单 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 ') return { where, params } } diff --git a/server/src/routes/transaction.ts b/server/src/routes/transaction.ts index 667da46..c12abda 100644 --- a/server/src/routes/transaction.ts +++ b/server/src/routes/transaction.ts @@ -15,7 +15,7 @@ function safeInt(val: any, fallback: number, min: number, max: number): number { router.get('/:id', async (req: AuthRequest, res: Response) => { try { - // 用户可以查看:自己的记录 + 自己所在群组的所有记录(包括已退出成员的历史记录) + // 用户可以查看:自己的记录 + 自己所在群组成员的所有记录 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, 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 WHERE t.id = ? AND ( 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] ) @@ -51,7 +51,7 @@ router.get('/', async (req: AuthRequest, res: Response) => { const params: any[] = [] if (group_id && group_id !== 'null') { - // 群组视图:验证用户是群组成员,然后显示该群组所有记录(包括已退出成员的历史记录) + // 群组视图:验证用户是群组成员,然后显示所有群组成员的记录 const [memberCheck] = await pool.query( 'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?', [group_id, req.userId] @@ -59,7 +59,8 @@ router.get('/', async (req: AuthRequest, res: Response) => { if ((memberCheck as any[]).length === 0) { 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) } else { // 个人视图:我的所有记录(不管 group_id)