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:
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
65
client/src/components/Snackbar/Snackbar.vue
Normal file
65
client/src/components/Snackbar/Snackbar.vue
Normal 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>
|
||||
@@ -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">
|
||||
<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;
|
||||
|
||||
39
client/src/composables/usePageReady.ts
Normal file
39
client/src/composables/usePageReady.ts
Normal 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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
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>
|
||||
|
||||
@@ -69,7 +69,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
try {
|
||||
await loadConfig()
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -125,7 +125,7 @@ function getTodayStr(): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
await Promise.all([loadStats(), loadLogs()])
|
||||
})
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ const defaultForm = {
|
||||
const form = ref({ ...defaultForm })
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
await loadNotifications(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
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,13 +292,8 @@ 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)
|
||||
// 乐观删除:先从列表移除,显示 Snackbar 供撤销
|
||||
txStore.startDelete(item.id)
|
||||
txList.value = txList.value.filter(t => t.id !== item.id)
|
||||
total.value--
|
||||
// 更新筛选金额
|
||||
@@ -286,13 +302,20 @@ async function onDelete(item: any) {
|
||||
} else {
|
||||
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
|
||||
}
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// 显示 Snackbar
|
||||
snackbarMsg.value = '已删除 1 条记录'
|
||||
snackbarVisible.value = true
|
||||
setTimeout(() => {
|
||||
snackbarVisible.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 撤销删除 */
|
||||
function onUndoDelete() {
|
||||
txStore.cancelDelete()
|
||||
snackbarVisible.value = false
|
||||
// 重新加载以恢复完整列表
|
||||
loadTx(true)
|
||||
}
|
||||
|
||||
// 触底加载更多
|
||||
|
||||
@@ -127,7 +127,7 @@ async function fetchMyExpense() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
loading.value = true
|
||||
try {
|
||||
await budgetStore.fetchBudget(currentMonth)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -274,7 +274,7 @@ async function updateExportCount() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
|
||||
@@ -213,7 +213,7 @@ function getTodayStr(): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
await Promise.all([loadList(), syncAndLoad()])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
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 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)
|
||||
await fetchCategories()
|
||||
// 用真实 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 }) {
|
||||
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)
|
||||
await fetchCategories()
|
||||
} 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[]) {
|
||||
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)
|
||||
await fetchCategories()
|
||||
} 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 }
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
])
|
||||
}
|
||||
|
||||
/** 获取群组列表 */
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 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)
|
||||
await fetchTags()
|
||||
// 用真实 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 }) {
|
||||
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)
|
||||
await fetchTags()
|
||||
} 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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user