feat(t03): improve performance and UX

- add category/tag caching and optimistic updates

- add stats dashboard aggregation and batched tracking

- add undo delete snackbar and group read-only view mode

- improve app-ready timeout handling and refresh guards
This commit is contained in:
2026-06-11 16:26:46 +08:00
parent 5a0ae0b28d
commit b33ebf0879
40 changed files with 761 additions and 132 deletions

View File

@@ -14,6 +14,6 @@ export function getBudget(month?: string, group_id?: number | null) {
}
/** 设置预算 */
export function setBudget(amount: number, month: string) {
return request<void>({ url: '/budget', method: 'POST', data: { amount, month } })
export function setBudget(amount: number, month: string, group_id?: number | null) {
return request<void>({ url: '/budget', method: 'POST', data: { amount, month, group_id } })
}

View File

@@ -43,6 +43,19 @@ export function getTrend(month?: string, type: string = 'expense', group_id?: nu
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id, period } })
}
/** 聚合统计(一次请求返回 overview + category + trend */
export interface DashboardData {
overview: Overview
category: CategoryStat[]
trend: TrendPoint[]
month: string
}
/** 获取聚合统计数据 */
export function getDashboard(params?: { month?: string; type?: string; period?: 'week' | 'month' | 'year'; group_id?: number | null }) {
return request<DashboardData>({ url: '/stats/dashboard', data: params })
}
/** 智能分类建议结果 */
export interface CategorySuggestion {
category: { id: number; name: string; color: string } | null

View File

@@ -1,17 +1,17 @@
<template>
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
<view class="amount-display" @tap="focusAtEnd">
<view class="amount-display" @tap="!disabled && focusAtEnd()">
<text class="currency">¥</text>
<view class="amount-value">
<template v-if="displayChars.length > 0">
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
<text class="amount-char" @tap.stop="focusAt(index + 1)">{{ char }}</text>
<text class="amount-char" @tap.stop="!disabled && focusAt(index + 1)">{{ char }}</text>
</template>
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
</template>
<template v-else>
<text class="placeholder" @tap.stop="focusAtEnd">{{ placeholder }}</text>
<text class="placeholder" @tap.stop="!disabled && focusAtEnd()">{{ placeholder }}</text>
<text v-if="innerFocused" class="cursor"></text>
</template>
</view>
@@ -23,7 +23,7 @@
v-model:cursorIndex="cursorIndex"
:max="max"
:fixed="fixed"
:visible="innerFocused && visible"
:visible="innerFocused && visible && !disabled"
@confirm="emit('confirm')"
/>
</view>
@@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{
size?: 'small' | 'medium' | 'large'
placeholder?: string
unit?: string
disabled?: boolean
}>(), {
modelValue: '',
max: 9999999.99,
@@ -51,7 +52,8 @@ const props = withDefaults(defineProps<{
autoFocus: true,
size: 'large',
placeholder: '0',
unit: ''
unit: '',
disabled: false
})
const emit = defineEmits<{
@@ -91,6 +93,7 @@ watch(
)
function focus() {
if (props.disabled || !props.visible) return
cursorIndex.value = innerValue.value.length
if (!innerFocused.value) {
innerFocused.value = true
@@ -106,7 +109,7 @@ function blur() {
}
function focusAt(index: number) {
if (!props.visible) return
if (props.disabled || !props.visible) return
cursorIndex.value = clampCursorIndex(innerValue.value, index)
if (!innerFocused.value) {
innerFocused.value = true

View File

@@ -0,0 +1,65 @@
<template>
<view class="snackbar" :class="{ show: visible, hide: !visible }">
<text class="snackbar-text">{{ message }}</text>
<view class="snackbar-action" @tap="$emit('undo')" v-if="showUndo">
<text class="snackbar-action-text">撤销</text>
</view>
</view>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
visible: boolean
message: string
showUndo?: boolean
}>(), { showUndo: true })
defineEmits<{ undo: [] }>()
</script>
<style lang="scss" scoped>
.snackbar {
position: fixed;
bottom: calc(120rpx + env(safe-area-inset-bottom));
left: $space-xl;
right: $space-xl;
background: #333;
color: #fff;
border-radius: $radius-md;
padding: $space-md $space-xl;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 999;
transform: translateY(200rpx);
opacity: 0;
transition: all $transition-normal;
&.show {
transform: translateY(0);
opacity: 1;
}
&.hide {
transform: translateY(200rpx);
opacity: 0;
}
}
.snackbar-text {
font-size: $font-lg;
flex: 1;
}
.snackbar-action {
padding: $space-xs $space-md;
border-radius: $radius-xs;
&:active { opacity: 0.7; }
}
.snackbar-action-text {
font-size: $font-lg;
font-weight: 600;
color: $primary;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe }">
<view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe, 'read-only': isOtherCreator }">
<view
class="tx-item"
:style="{ transform: `translateX(${offsetX}rpx)` }"
@@ -16,7 +16,11 @@
size="sm"
/>
<view class="info">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<view class="name-row">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<!-- 群组模式下非本人记录显示小锁图标 -->
<text v-if="isOtherCreator" class="lock-icon">🔒</text>
</view>
<view class="meta">
<view v-if="isOtherCreator" class="creator-tag">
<image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" />
@@ -67,8 +71,7 @@ const props = withDefaults(defineProps<{
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
const isOtherCreator = computed(() => {
if (!props.showCreator || !props.item.creator_nickname) return false
return !props.currentUserId || props.item.user_id !== props.currentUserId
return !!props.showCreator && !!props.currentUserId && props.item.user_id !== props.currentUserId
})
defineEmits(['item-tap', 'delete'])
@@ -193,14 +196,29 @@ function onTouchEnd() {
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.lock-icon {
font-size: 24rpx;
flex-shrink: 0;
}
.name {
font-size: 28rpx;
font-weight: 500;
color: $text;
display: block;
@include text-ellipsis;
}
/* 只读状态:降低整体透明度 */
.read-only .tx-item {
opacity: 0.85;
}
.meta {
font-size: 24rpx;
color: $text-sec;

View File

@@ -0,0 +1,39 @@
/**
* 页面就绪 composable
* 统一处理 waitForReady 超时 + 数据加载错误状态
*/
import { ref, onMounted } from 'vue'
import { waitForReady } from '@/utils/app-ready'
export function usePageReady(onReady: () => Promise<void>) {
const readyError = ref(false)
const loading = ref(true)
async function init() {
try {
await waitForReady()
} catch {
readyError.value = true
loading.value = false
return
}
try {
await onReady()
} catch (e) {
console.error('[PageReady] loadData error:', e)
} finally {
loading.value = false
}
}
/** 重试(用户点击"重新加载"按钮时调用) */
async function retry() {
readyError.value = false
loading.value = true
await init()
}
onMounted(init)
return { readyError, loading, retry }
}

View File

@@ -12,7 +12,8 @@
"path": "pages/add/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "记一笔"
"navigationBarTitleText": "记一笔",
"enablePullDownRefresh": false
}
},
{
@@ -66,7 +67,8 @@
"path": "pages/profile-edit/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "编辑资料"
"navigationBarTitleText": "编辑资料",
"enablePullDownRefresh": false
}
},
{
@@ -120,7 +122,8 @@
"path": "pages/feedback/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "意见反馈"
"navigationBarTitleText": "意见反馈",
"enablePullDownRefresh": false
}
},
{
@@ -184,7 +187,8 @@
"navigationBarTitleText": "小菜记账",
"navigationBarBackgroundColor": "#FFF8F0",
"backgroundColor": "#FFF8F0",
"backgroundTextStyle": "dark"
"backgroundTextStyle": "dark",
"enablePullDownRefresh": true
},
"tabBar": {
"color": "#BFB3B3",

View File

@@ -6,12 +6,12 @@
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
<text class="title">{{ viewId ? '查看记录' : editId ? '编辑记录' : '记一笔' }}</text>
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
</view>
</view>
<view class="type-toggle-wrap">
<view class="type-toggle-wrap" v-if="!viewId">
<view class="type-toggle" :class="{ income: type === 'income' }">
<view class="slider"></view>
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
@@ -19,18 +19,26 @@
</view>
</view>
<!-- 只读模式显示收支类型标签 -->
<view class="type-toggle-wrap" v-if="viewId">
<view class="readonly-type-badge" :class="type">
<text>{{ type === 'expense' ? '支出' : '收入' }}</text>
</view>
</view>
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
:auto-focus="true"
:auto-focus="!viewId"
:disabled="!!viewId"
@confirm="save"
/>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="!viewId && blurAmountEditor()">
<view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id, 'cat-readonly': !!viewId }" @tap="!viewId && (selectedCat = cat.id)">
<view class="cat-icon-wrap">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
@@ -43,17 +51,21 @@
</scroll-view>
<view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<picker v-if="!viewId" mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row" @tap="blurAmountEditor">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
</picker>
<view v-else class="extra-row">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
<view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
<input class="extra-input" v-model="note" :disabled="!!viewId" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="!viewId && blurAmountEditor()" />
</view>
<view class="extra-row" @tap="openTagPicker">
<view class="extra-row" @tap="!viewId && openTagPicker">
<Icon name="tag" :size="28" color="#8B7E7E" />
<view class="tag-display">
<text class="extra-text" v-if="selectedTagIds.length === 0">选择标签选填</text>
@@ -70,6 +82,7 @@
<SaveSuccess
v-if="!viewId"
v-model:visible="showSuccess"
:text="editId ? '修改成功' : '记账成功'"
:showContinue="!editId"
@@ -138,6 +151,7 @@ const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2
const selectedDate = ref(localToday)
const saving = ref(false)
const editId = ref<number | null>(null)
const viewId = ref<number | null>(null)
const showSuccess = ref(false)
const scrollToCat = ref('')
const todayStr = localToday
@@ -178,7 +192,7 @@ watch([amountStr, note], () => {
if (suggestTimer) clearTimeout(suggestTimer)
suggestTimer = setTimeout(async () => {
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
if (amt <= 0 || editId.value) {
if (amt <= 0 || editId.value || viewId.value) {
suggestedCatId.value = null
suggestConfidence.value = 0
return
@@ -189,7 +203,7 @@ watch([amountStr, note], () => {
suggestedCatId.value = result.category.id
suggestConfidence.value = result.confidence
// 置信度 >= 60 时自动选中
if (result.confidence >= 60 && !editId.value) {
if (result.confidence >= 60 && !editId.value && !viewId.value) {
selectedCat.value = result.category.id
}
} else {
@@ -203,7 +217,13 @@ watch([amountStr, note], () => {
})
onMounted(async () => {
await waitForReady()
try {
await waitForReady()
} catch {
uni.showToast({ title: '网络异常,请重试', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1200)
return
}
// 加载分类列表,失败时重试一次
try {
@@ -253,6 +273,29 @@ onMounted(async () => {
setTimeout(() => uni.navigateBack(), 1500)
}
}
// 只读查看模式:群组中非本人记录
if (page?.options?.viewId) {
viewId.value = Number(page.options.viewId)
let existing = txStore.transactions.find(t => t.id === viewId.value)
if (!existing) {
existing = await txStore.fetchTransactionById(viewId.value!)
}
if (existing) {
type.value = existing.type
amountStr.value = (existing.amount / 100).toFixed(2)
selectedCat.value = existing.category_id
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
selectedTagIds.value = existing.tags?.map(t => t.id) || []
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
}
if (currentCategories.value.length > 0 && !selectedCat.value) {
selectedCat.value = currentCategories.value[0].id
}
@@ -277,7 +320,7 @@ function blurAmountEditor() {
}
async function save() {
if (saving.value) return
if (saving.value || viewId.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' })
@@ -355,6 +398,11 @@ function getTagById(id: number) {
function goBack() {
// 保存成功后直接返回,不弹确认框
if (showSuccess.value) return
// 只读查看模式不应提示放弃编辑
if (viewId.value) {
uni.navigateBack()
return
}
const hasInput = amountStr.value !== '0' || note.value
if (hasInput) {
uni.showModal({
@@ -633,5 +681,28 @@ function goBack() {
width: 100%;
}
/* 只读模式样式 */
.readonly-type-badge {
display: inline-flex;
padding: $space-xs $space-lg;
border-radius: $radius-xl;
font-size: $font-lg;
font-weight: 500;
&.expense {
background: rgba(255, 107, 107, 0.1);
color: #FF6B6B;
}
&.income {
background: rgba(123, 198, 126, 0.1);
color: #7BC67E;
}
}
.cat-readonly {
pointer-events: none;
opacity: 0.7;
}
</style>

View File

@@ -69,7 +69,7 @@ const form = reactive({
})
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
try {
await loadConfig()
} catch (e: any) {

View File

@@ -153,7 +153,7 @@ const replyText = ref('')
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadList(true).finally(() => { initialLoaded.value = true })
})

View File

@@ -137,7 +137,7 @@ const dashboard = ref<Dashboard | null>(null)
const health = ref<HealthStatus | null>(null)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
try {
dashboard.value = await getDashboard()

View File

@@ -125,7 +125,7 @@ function getTodayStr(): string {
}
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadStats(), loadLogs()])
})

View File

@@ -197,7 +197,7 @@ const defaultForm = {
const form = ref({ ...defaultForm })
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadNotifications(true)
})

View File

@@ -142,7 +142,7 @@ const total = ref(0)
const noMore = computed(() => eventList.value.length >= total.value && total.value > 0)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadStats(), loadEvents()])
})

View File

@@ -74,7 +74,7 @@ const loading = ref(false)
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadUsers(true)
initialLoaded.value = true
})

View File

@@ -68,7 +68,7 @@ const creating = ref(false)
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadBackups()
initialLoaded.value = true
})

View File

@@ -104,6 +104,12 @@
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">暂无账单记录</text>
</view>
<Snackbar
:visible="snackbarVisible"
:message="snackbarMsg"
@undo="onUndoDelete"
/>
</view>
</template>
@@ -113,11 +119,13 @@ import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTagStore } from '@/stores/tag'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction'
import { getTransactions } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import Snackbar from '@/components/Snackbar/Snackbar.vue'
import FilterPanel from './filter-panel.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
@@ -127,6 +135,7 @@ import type { FilterParams } from '@/api/filter'
const userStore = useUserStore()
const groupStore = useGroupStore()
const tagStore = useTagStore()
const txStore = useTransactionStore()
const loading = ref(false)
const filterType = ref('all')
const filterTagId = ref<number | null>(null)
@@ -137,6 +146,10 @@ const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
// Snackbar 删除撤销相关
const snackbarVisible = ref(false)
const snackbarMsg = ref('')
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown'
@@ -197,7 +210,13 @@ const groupedTx = computed(() => {
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try {
await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
loadError.value = true
return
}
tagStore.fetchTags().catch(() => {})
loadTx(true).finally(() => { initialLoaded.value = true })
})
@@ -208,6 +227,7 @@ onShow(() => {
})
function switchFilter(type: string) {
if (loading.value) return // 防止重复提交
filterType.value = type
loadTx(true)
}
@@ -260,7 +280,8 @@ function onFilterApply(filters: FilterParams) {
function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
// 非本人记录:进入查看模式(只读)
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -271,28 +292,30 @@ async function onDelete(item: any) {
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
return
}
uni.showModal({
title: '删除记录',
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
try {
await deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
// 更新筛选金额
if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
}
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
// 乐观删除:先从列表移除,显示 Snackbar 供撤销
txStore.startDelete(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
// 更新筛选金额
if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
}
// 显示 Snackbar
snackbarMsg.value = '已删除 1 条记录'
snackbarVisible.value = true
setTimeout(() => {
snackbarVisible.value = false
}, 3000)
}
/** 撤销删除 */
function onUndoDelete() {
txStore.cancelDelete()
snackbarVisible.value = false
// 重新加载以恢复完整列表
loadTx(true)
}
// 触底加载更多

View File

@@ -127,7 +127,7 @@ async function fetchMyExpense() {
}
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true
try {
await budgetStore.fetchBudget(currentMonth)

View File

@@ -138,18 +138,18 @@ const migrateTargets = computed(() => {
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await catStore.fetchCategories()
initialLoaded.value = true
})
// 返回页面时静默刷新
// 返回页面时静默刷新(使用缓存)
onShow(() => {
if (initialLoaded.value) catStore.fetchCategories()
if (initialLoaded.value) catStore.fetchCategories() // 不强制刷新(使用缓存)
})
onPullDownRefresh(async () => {
await catStore.fetchCategories()
await catStore.fetchCategories(true) // 强制刷新
uni.stopPullDownRefresh()
})

View File

@@ -176,7 +176,7 @@ const members = ref<GroupMember[]>([])
const currentGroup = ref<Group | null>(null)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await groupStore.fetchGroups()
initialLoaded.value = true
})

View File

@@ -108,7 +108,17 @@
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0">
<!-- 网络异常全屏提示 -->
<view class="network-error" v-if="networkError">
<Icon name="alert-circle" :size="64" color="#BFB3B3" />
<text class="network-error-title">网络连接异常</text>
<text class="network-error-desc">请检查网络后点击重试</text>
<view class="network-error-btn" @tap="retryNetwork">
<text class="network-error-btn-text">重新加载</text>
</view>
</view>
<view class="state-box" v-if="!loading && !loadError && !networkError && recentTx.length === 0">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</view>
@@ -170,6 +180,7 @@ const budget = computed(() => budgetStore.budget)
const recentTx = ref<Transaction[]>([])
const loading = ref(true)
const loadError = ref(false)
const networkError = ref(false)
const todayExpense = ref(0)
const todayIncome = ref(0)
const todayCount = ref(0)
@@ -205,7 +216,16 @@ function openLink(url: string) {
}
async function loadData(silent = false) {
await waitForReady()
try {
await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
if (!silent) {
networkError.value = true
loading.value = false
}
return
}
try {
if (!silent) loading.value = true
@@ -278,7 +298,8 @@ function goNotifications() {
function onTxTap(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
// 非本人记录:进入查看模式(只读)
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -287,6 +308,12 @@ function onTxTap(item: any) {
function goBills() {
uni.switchTab({ url: '/pages/bills/index' })
}
/** 网络异常重试 */
function retryNetwork() {
networkError.value = false
loadData()
}
</script>
<style lang="scss" scoped>
@@ -619,4 +646,41 @@ function goBills() {
font-weight: 600;
color: #FF8C69;
}
/* 网络异常全屏提示 */
.network-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx $space-xl;
gap: $space-md;
}
.network-error-title {
font-size: $font-2xl;
font-weight: 600;
color: $text;
margin-top: $space-lg;
}
.network-error-desc {
font-size: $font-lg;
color: $text-sec;
}
.network-error-btn {
margin-top: $space-lg;
padding: $space-md $space-2xl;
background: $primary;
border-radius: $radius-xl;
&:active { opacity: 0.8; }
}
.network-error-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
</style>

View File

@@ -121,7 +121,7 @@ let loadSeq = 0
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadNotifications(true)
initialLoaded.value = true
})

View File

@@ -60,7 +60,7 @@ const uploading = ref(false)
const saving = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
formNickname.value = userStore.nickname
formSlogan.value = userStore.slogan
previewUrl.value = userStore.avatarUrl

View File

@@ -274,7 +274,7 @@ async function updateExportCount() {
}
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true
try {
await Promise.all([

View File

@@ -213,7 +213,7 @@ function getTodayStr(): string {
}
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadList(), syncAndLoad()])
})

View File

@@ -238,7 +238,7 @@ const monthCompareText = computed(() => {
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadData().finally(() => { initialLoaded.value = true })
})
@@ -258,10 +258,9 @@ async function loadData(silent = false) {
try {
if (!silent) loading.value = true
loadError.value = false
// 使用聚合接口一次获取 overview + category + trend再并行请求额外数据
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value),
fetchMaxSingle(),
fetchPrevMonthData()
])
@@ -279,11 +278,8 @@ async function loadData(silent = false) {
async function loadTabData() {
const seq = ++loadSeq
try {
await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
fetchMaxSingle()
])
await statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value)
await fetchMaxSingle()
if (seq !== loadSeq) return
} catch (e) {
if (seq !== loadSeq) return

View File

@@ -88,7 +88,7 @@ const editModel = ref({ name: '', color: '#FF8C69' })
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await tagStore.fetchTags()
initialLoaded.value = true
})

View File

@@ -13,7 +13,8 @@ export const useBudgetStore = defineStore('budget', () => {
}
async function setBudget(amount: number, month?: string) {
await api.setBudget(amount, month || getCurrentMonth())
const groupStore = useGroupStore()
await api.setBudget(amount, month || getCurrentMonth(), groupStore.currentGroupId)
await fetchBudget(month)
}

View File

@@ -7,9 +7,20 @@ export type Category = api.Category
export const useCategoryStore = defineStore('category', () => {
const categories = ref<api.Category[]>([])
/** 上次成功获取的时间戳ms用于 5 分钟缓存判断 */
const lastFetchTime = ref(0)
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
async function fetchCategories() {
categories.value = await api.getCategories()
async function fetchCategories(force = false) {
if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
return // 缓存有效,跳过
}
try {
categories.value = await api.getCategories()
lastFetchTime.value = Date.now()
} catch {
// 保留上次数据
}
}
function getByType(type: 'expense' | 'income') {
@@ -20,31 +31,79 @@ export const useCategoryStore = defineStore('category', () => {
return categories.value.find(c => c.id === id)
}
/** 乐观新增 */
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
const result = await api.createCategory(data)
await fetchCategories()
return result.id
const tempId = -Date.now()
const optimistic: api.Category = { id: tempId, ...data, is_custom: 1, sort_order: 0 }
categories.value.push(optimistic)
lastFetchTime.value = 0 // 使缓存失效(新增后下次 onShow 会刷新)
try {
const result = await api.createCategory(data)
// 用真实 ID 替换临时 ID
const idx = categories.value.findIndex(c => c.id === tempId)
if (idx !== -1) categories.value[idx].id = result.id
return result.id
} catch (err) {
// 回滚
categories.value = categories.value.filter(c => c.id !== tempId)
uni.showToast({ title: '添加失败', icon: 'none' })
throw err
}
}
/** 乐观编辑 */
async function updateCategory(id: number, data: { name: string; color: string }) {
await api.updateCategory(id, data)
await fetchCategories()
const idx = categories.value.findIndex(c => c.id === id)
if (idx === -1) return
const backup = { ...categories.value[idx] }
Object.assign(categories.value[idx], data)
lastFetchTime.value = 0
try {
await api.updateCategory(id, data)
} catch (err) {
// 回滚
categories.value[idx] = backup
uni.showToast({ title: '修改失败', icon: 'none' })
throw err
}
}
async function deleteCategory(id: number) {
// 删除操作等 API 确认(防误删)
await api.deleteCategory(id)
await fetchCategories()
categories.value = categories.value.filter(c => c.id !== id)
lastFetchTime.value = 0
}
async function migrateCategory(fromId: number, toId: number) {
await api.migrateCategory(fromId, toId)
await fetchCategories()
categories.value = categories.value.filter(c => c.id !== fromId)
lastFetchTime.value = 0
}
/** 乐观排序 */
async function sortCategories(ids: number[]) {
await api.sortCategories(ids)
await fetchCategories()
const backup = [...categories.value]
// 按新顺序重排
const sorted: api.Category[] = []
ids.forEach(id => {
const cat = categories.value.find(c => c.id === id)
if (cat) sorted.push(cat)
})
// 保留未参与排序的分类(不同 type 的)
categories.value.forEach(c => {
if (!ids.includes(c.id)) sorted.push(c)
})
categories.value = sorted
try {
await api.sortCategories(ids)
} catch (err) {
// 回滚
categories.value = backup
uni.showToast({ title: '排序失败', icon: 'none' })
throw err
}
}
return { categories, fetchCategories, getByType, getById, addCategory, updateCategory, deleteCategory, migrateCategory, sortCategories }
return { categories, lastFetchTime, fetchCategories, getByType, getById, addCategory, updateCategory, deleteCategory, migrateCategory, sortCategories }
})

View File

@@ -35,14 +35,17 @@ export const useGroupStore = defineStore('group', () => {
await refreshAll()
}
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱 */
/** 切换身份后全局刷新所有数据(无依赖关系的可并行 */
async function refreshAll() {
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const txStore = useTransactionStore()
await statsStore.fetchOverview()
await budgetStore.fetchBudget()
await txStore.fetchTransactions({ page: 1 })
// stats、budget、transactions 互相独立,可以并行
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 })
])
}
/** 获取群组列表 */

View File

@@ -6,11 +6,18 @@ import type { Notification } from '@/api/notification'
export const useNotificationStore = defineStore('notification', () => {
const unreadCount = ref(0)
const urgentNotifications = ref<Notification[]>([])
/** 上次获取未读计数的时间戳 */
const lastCountFetchTime = ref(0)
const COUNT_CACHE_TTL = 30 * 1000 // 30 秒
async function fetchUnreadCount() {
async function fetchUnreadCount(force = false) {
if (!force && lastCountFetchTime.value && Date.now() - lastCountFetchTime.value < COUNT_CACHE_TTL) {
return
}
try {
const data = await api.getUnreadCount()
unreadCount.value = data.count
lastCountFetchTime.value = Date.now()
} catch {
// 静默失败,不影响页面
}
@@ -46,5 +53,5 @@ export const useNotificationStore = defineStore('notification', () => {
}
}
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
return { unreadCount, urgentNotifications, lastCountFetchTime, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
})

View File

@@ -53,5 +53,36 @@ export const useStatsStore = defineStore('stats', () => {
})) : []
}
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend }
/** 聚合接口:一次请求返回 overview + category + trend减少 HTTP 往返 */
async function fetchDashboard(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getDashboard({
month: month || getCurrentMonth(),
type,
period,
group_id: getGroupId()
})
if (data) {
const ov = data.overview || EMPTY_OVERVIEW
overview.value = {
expense: Number(ov.expense) || 0,
income: Number(ov.income) || 0,
expenseCount: Number(ov.expenseCount) || 0,
incomeCount: Number(ov.incomeCount) || 0,
count: Number(ov.count) || 0,
daily: Number(ov.daily) || 0,
dailyIncome: Number(ov.dailyIncome) || 0
}
categoryStats.value = Array.isArray(data.category) ? data.category.map(item => ({
...item,
amount: Number(item.amount) || 0,
count: Number(item.count) || 0
})) : []
trendData.value = Array.isArray(data.trend) ? data.trend.map(item => ({
...item,
amount: Number(item.amount) || 0
})) : []
}
}
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend, fetchDashboard }
})

View File

@@ -5,13 +5,20 @@ import * as api from '@/api/tag'
export const useTagStore = defineStore('tag', () => {
const tags = ref<api.Tag[]>([])
const loading = ref(false)
/** 上次成功获取的时间戳ms用于 5 分钟缓存判断 */
const lastFetchTime = ref(0)
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
/** 获取标签列表 */
async function fetchTags() {
async function fetchTags(force = false) {
if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
return // 缓存有效,跳过
}
loading.value = true
try {
const data = await api.getTags()
tags.value = Array.isArray(data) ? data : []
lastFetchTime.value = Date.now()
} catch {
tags.value = []
} finally {
@@ -19,23 +26,48 @@ export const useTagStore = defineStore('tag', () => {
}
}
/** 创建标签 */
/** 乐观创建标签 */
async function addTag(data: { name: string; color: string }) {
const result = await api.createTag(data)
await fetchTags()
return result.id
const tempId = -Date.now()
const optimistic: api.Tag = { id: tempId, name: data.name, color: data.color, created_at: new Date().toISOString() }
tags.value.push(optimistic)
lastFetchTime.value = 0 // 使缓存失效
try {
const result = await api.createTag(data)
// 用真实 ID 替换临时 ID
const idx = tags.value.findIndex(t => t.id === tempId)
if (idx !== -1) tags.value[idx].id = result.id
return result.id
} catch (err) {
// 回滚
tags.value = tags.value.filter(t => t.id !== tempId)
uni.showToast({ title: '添加失败', icon: 'none' })
throw err
}
}
/** 更新标签 */
/** 乐观更新标签 */
async function updateTag(id: number, data: { name?: string; color?: string }) {
await api.updateTag(id, data)
await fetchTags()
const idx = tags.value.findIndex(t => t.id === id)
if (idx === -1) return
const backup = { ...tags.value[idx] }
Object.assign(tags.value[idx], data)
lastFetchTime.value = 0
try {
await api.updateTag(id, data)
} catch (err) {
// 回滚
tags.value[idx] = backup
uni.showToast({ title: '修改失败', icon: 'none' })
throw err
}
}
/** 删除标签 */
async function deleteTag(id: number) {
await api.deleteTag(id)
await fetchTags()
tags.value = tags.value.filter(t => t.id !== id)
lastFetchTime.value = 0
}
/** 根据 ID 获取标签 */
@@ -48,5 +80,5 @@ export const useTagStore = defineStore('tag', () => {
return tags.value.filter(t => ids.includes(t.id))
}
return { tags, loading, fetchTags, addTag, updateTag, deleteTag, getById, getByIds }
return { tags, loading, lastFetchTime, fetchTags, addTag, updateTag, deleteTag, getById, getByIds }
})

View File

@@ -8,6 +8,9 @@ export const useTransactionStore = defineStore('transaction', () => {
const total = ref(0)
const loading = ref(false)
// 待确认删除:{ id: 被删除的交易ID, item: 被删除的交易对象, index: 在列表中的位置, timer: 定时器句柄 }
const pendingDelete = ref<{ id: number; item: api.Transaction; index: number; timer: number } | null>(null)
async function fetchTransactions(params?: api.TransactionParams) {
loading.value = true
try {
@@ -65,5 +68,52 @@ export const useTransactionStore = defineStore('transaction', () => {
}
}
return { transactions, total, loading, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById }
/** 开始删除乐观先从列表移除3秒内可撤销 */
function startDelete(id: number) {
// 如果有上一条待确认删除,立即确认(不能同时有两条)
if (pendingDelete.value) {
confirmDelete()
}
const idx = transactions.value.findIndex(t => t.id === id)
if (idx === -1) return
const item = transactions.value[idx]
transactions.value.splice(idx, 1)
const timer = setTimeout(() => {
confirmDelete()
}, 3000) as unknown as number
pendingDelete.value = { id, item, index: idx, timer }
}
/** 撤销删除(恢复到原位置) */
function cancelDelete() {
if (!pendingDelete.value) return
const { item, index, timer } = pendingDelete.value
clearTimeout(timer)
// 插回原位置
transactions.value.splice(index, 0, item)
pendingDelete.value = null
}
/** 确认删除(实际调接口) */
async function confirmDelete() {
if (!pendingDelete.value) return
const { id, index, timer } = pendingDelete.value
const item = pendingDelete.value.item
clearTimeout(timer)
pendingDelete.value = null
try {
await api.deleteTransaction(id)
} catch {
// 接口失败,恢复
transactions.value.splice(index, 0, item)
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
return { transactions, total, loading, pendingDelete, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById, startDelete, cancelDelete, confirmDelete }
})

View File

@@ -6,15 +6,19 @@
*/
let _resolve: () => void
const _ready = new Promise<void>(resolve => { _resolve = resolve })
let _reject: (err: Error) => void
const _ready = new Promise<void>((resolve, reject) => {
_resolve = resolve
_reject = reject
})
/** 标记 App 启动完成(登录成功) */
export function markReady() { _resolve() }
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */
/** 等待 App 就绪,最多等 8 秒超时 */
export function waitForReady(): Promise<void> {
return Promise.race([
_ready,
new Promise<void>(resolve => setTimeout(resolve, 8000))
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('App ready timeout')), 8000))
])
}

View File

@@ -14,8 +14,8 @@ interface TrackEvent {
/** 上报队列(批量上报,减少请求) */
let eventQueue: TrackEvent[] = []
let flushTimer: number | null = null
const FLUSH_INTERVAL = 5000 // 5秒上报一次
const MAX_QUEUE_SIZE = 20
const FLUSH_INTERVAL = 500 // 500ms 攒批
const MAX_QUEUE_SIZE = 10 // 10 条立即发送
/** 获取当前页面路径 */
function getCurrentPage(): string {

View File

@@ -83,10 +83,13 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
const [rows] = await pool.query(
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count,
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count
COUNT(DISTINCT t.id) as tx_count,
COUNT(DISTINCT gm.group_id) as group_count
FROM users u
LEFT JOIN transactions t ON t.user_id = u.id
LEFT JOIN group_members gm ON gm.user_id = u.id
${where}
GROUP BY u.id, u.nickname, u.avatar_url, u.role, u.created_at
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]

View File

@@ -63,12 +63,23 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, month } = req.body
const { amount, month, group_id } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
}
const m = validateMonth(month || getCurrentMonth())
// 群组模式下验证成员身份
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]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权操作此群组' })
}
}
await pool.query(
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
[req.userId, amount, m, amount]

View File

@@ -335,4 +335,130 @@ router.get('/suggest-category', async (req: AuthRequest, res: Response) => {
}
})
/** 聚合统计接口:一次请求返回 overview + category + trend */
router.get('/dashboard', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense', period = 'month', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40001, message: '类型无效' })
}
if (!VALID_PERIODS.includes(period as string)) {
return res.status(400).json({ code: 40001, message: 'period参数无效' })
}
const now = new Date()
const m = (month as string) || getCurrentMonth()
let startDate: string
let endDate: string
let groupBy: string
let labelExpr: string
let elapsedDays = 1
if (period === 'week') {
const dayOfWeek = now.getDay() || 7
const monday = new Date(now)
monday.setDate(now.getDate() - dayOfWeek + 1)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
startDate = monday.toISOString().slice(0, 10)
endDate = sunday.toISOString().slice(0, 10)
elapsedDays = 7
groupBy = 't.date'
labelExpr = `CASE DAYOFWEEK(t.date)
WHEN 1 THEN '周日'
WHEN 2 THEN '周一'
WHEN 3 THEN '周二'
WHEN 4 THEN '周三'
WHEN 5 THEN '周四'
WHEN 6 THEN '周五'
WHEN 7 THEN '周六'
END`
} else if (period === 'year') {
const year = m.split('-')[0] || String(now.getFullYear())
startDate = `${year}-01-01`
endDate = `${year}-12-31`
elapsedDays = year === String(now.getFullYear()) ? Math.max(1, Math.ceil((now.getTime() - new Date(startDate).getTime()) / 86400000) + 1) : 365
groupBy = "DATE_FORMAT(t.date, '%Y-%m')"
labelExpr = `CONCAT(MONTH(t.date), '月')`
} else {
// month默认
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
startDate = range.startDate
endDate = range.endDate
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
groupBy = 't.date'
labelExpr = `CONCAT(DAY(t.date), '日')`
}
// 验证 group_id 一次即可
const whereBase = await buildWhereClause(req.userId, group_id as string | undefined, ['t.date >= ?', 't.date <= ?'])
if (!whereBase) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const whereTyped = await buildWhereClause(req.userId, group_id as string | undefined, ['t.type = ?', 't.date >= ?', 't.date <= ?'])
if (!whereTyped) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
// 三个查询并行执行
const [overviewRes, categoryRes, trendRes] = await Promise.all([
pool.query(
`SELECT
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
COUNT(*) as count,
SUM(CASE WHEN t.type = 'expense' THEN 1 ELSE 0 END) as expenseCount,
SUM(CASE WHEN t.type = 'income' THEN 1 ELSE 0 END) as incomeCount
FROM transactions t ${whereBase.where}`,
[...whereBase.params, startDate, endDate]
),
pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${whereTyped.where}
GROUP BY c.id, c.name, c.icon, c.color
ORDER BY amount DESC
LIMIT 10`,
[...whereTyped.params, type, startDate, endDate]
),
pool.query(
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, ${labelExpr} as label, SUM(t.amount) as amount
FROM transactions t
${whereTyped.where}
GROUP BY ${groupBy}
ORDER BY t.date`,
[...whereTyped.params, type, startDate, endDate]
)
])
const overviewRow = (overviewRes[0] as any[])[0]
const daily = elapsedDays > 0 ? Math.round(overviewRow.expense / elapsedDays) : 0
const dailyIncome = elapsedDays > 0 ? Math.round(overviewRow.income / elapsedDays) : 0
res.json({
code: 0,
data: {
overview: {
expense: overviewRow.expense,
income: overviewRow.income,
count: overviewRow.count,
daily,
dailyIncome,
expenseCount: overviewRow.expenseCount,
incomeCount: overviewRow.incomeCount
},
category: categoryRes[0],
trend: trendRes[0],
month: m
}
})
} catch (err) {
console.error('[Stats] dashboard error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -13,15 +13,21 @@ router.post('/', async (req, res) => {
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
}
// 批量插入埋点事件(限制每个事件的字段长度)
for (const event of events.slice(0, 50)) { // 最多 50 个事件
const eventType = typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown'
const page = typeof event.page === 'string' ? event.page.slice(0, 200) : ''
const data = event.data && typeof event.data === 'object' ? event.data : {}
const validEvents = events.slice(0, 50).map(event => ({
event: typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown',
page: typeof event.page === 'string' ? event.page.slice(0, 200) : '',
data: event.data && typeof event.data === 'object' ? JSON.stringify(event.data) : '{}',
userId
}))
await pool.execute(
'INSERT INTO track_events (event, page, data, user_id) VALUES (?, ?, ?, ?)',
[eventType, page, JSON.stringify(data), userId]
if (validEvents.length > 0) {
// 批量 INSERT
const placeholders = validEvents.map(() => '(?, ?, ?, ?)').join(', ')
const values: any[] = []
validEvents.forEach(e => values.push(e.event, e.page, e.data, e.userId))
await pool.query(
`INSERT INTO track_events (event, page, data, user_id) VALUES ${placeholders}`,
values
)
}