feat: 用户体验优化 — 首页加载/继续记账/登录恢复
## 首页加载优化 - 分级加载:首屏只请求 overview + recentTx(2个请求) - 数据新鲜度:30秒内不重复请求 - 次要数据异步加载:budget + todayData - 非关键数据后台加载:userInfo + notifications ## 继续记账功能 - SaveSuccess 组件新增「继续记一笔」按钮 - 新增模式显示继续按钮,编辑模式自动返回 - 继续记账时保留分类和日期,清空金额和备注 ## 登录失败恢复 - 登录重试机制:最多3次,间隔1秒 - 失败后显示提示而非静默等待 ## 群组账单逻辑修正 - 群组账单 = 所有群组成员的个人账单之和 - 修改查询:WHERE user_id IN (群组成员)
This commit is contained in:
@@ -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,记录回到个人视图
|
||||||
- 身份切换: 「我的」页底部弹窗选择,全局生效
|
- 身份切换: 「我的」页底部弹窗选择,全局生效
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user