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) {
|
export function setBudget(amount: number, month: string, group_id?: number | null) {
|
||||||
return request<void>({ url: '/budget', method: 'POST', data: { amount, month } })
|
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 } })
|
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 {
|
export interface CategorySuggestion {
|
||||||
category: { id: number; name: string; color: string } | null
|
category: { id: number; name: string; color: string } | null
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
|
<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>
|
<text class="currency">¥</text>
|
||||||
<view class="amount-value">
|
<view class="amount-value">
|
||||||
<template v-if="displayChars.length > 0">
|
<template v-if="displayChars.length > 0">
|
||||||
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
|
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
|
||||||
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
|
<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>
|
</template>
|
||||||
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
|
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
<text v-if="innerFocused" class="cursor"></text>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
v-model:cursorIndex="cursorIndex"
|
v-model:cursorIndex="cursorIndex"
|
||||||
:max="max"
|
:max="max"
|
||||||
:fixed="fixed"
|
:fixed="fixed"
|
||||||
:visible="innerFocused && visible"
|
:visible="innerFocused && visible && !disabled"
|
||||||
@confirm="emit('confirm')"
|
@confirm="emit('confirm')"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{
|
|||||||
size?: 'small' | 'medium' | 'large'
|
size?: 'small' | 'medium' | 'large'
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
|
disabled?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
max: 9999999.99,
|
max: 9999999.99,
|
||||||
@@ -51,7 +52,8 @@ const props = withDefaults(defineProps<{
|
|||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
size: 'large',
|
size: 'large',
|
||||||
placeholder: '0',
|
placeholder: '0',
|
||||||
unit: ''
|
unit: '',
|
||||||
|
disabled: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -91,6 +93,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function focus() {
|
function focus() {
|
||||||
|
if (props.disabled || !props.visible) return
|
||||||
cursorIndex.value = innerValue.value.length
|
cursorIndex.value = innerValue.value.length
|
||||||
if (!innerFocused.value) {
|
if (!innerFocused.value) {
|
||||||
innerFocused.value = true
|
innerFocused.value = true
|
||||||
@@ -106,7 +109,7 @@ function blur() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusAt(index: number) {
|
function focusAt(index: number) {
|
||||||
if (!props.visible) return
|
if (props.disabled || !props.visible) return
|
||||||
cursorIndex.value = clampCursorIndex(innerValue.value, index)
|
cursorIndex.value = clampCursorIndex(innerValue.value, index)
|
||||||
if (!innerFocused.value) {
|
if (!innerFocused.value) {
|
||||||
innerFocused.value = true
|
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>
|
<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
|
<view
|
||||||
class="tx-item"
|
class="tx-item"
|
||||||
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<view class="info">
|
<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 class="meta">
|
||||||
<view v-if="isOtherCreator" class="creator-tag">
|
<view v-if="isOtherCreator" class="creator-tag">
|
||||||
<image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" />
|
<image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" />
|
||||||
@@ -67,8 +71,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
|
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
|
||||||
const isOtherCreator = computed(() => {
|
const isOtherCreator = computed(() => {
|
||||||
if (!props.showCreator || !props.item.creator_nickname) return false
|
return !!props.showCreator && !!props.currentUserId && props.item.user_id !== props.currentUserId
|
||||||
return !props.currentUserId || props.item.user_id !== props.currentUserId
|
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['item-tap', 'delete'])
|
defineEmits(['item-tap', 'delete'])
|
||||||
@@ -193,14 +196,29 @@ function onTouchEnd() {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $text;
|
color: $text;
|
||||||
display: block;
|
|
||||||
@include text-ellipsis;
|
@include text-ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 只读状态:降低整体透明度 */
|
||||||
|
.read-only .tx-item {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: $text-sec;
|
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",
|
"path": "pages/add/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "记一笔"
|
"navigationBarTitleText": "记一笔",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,7 +67,8 @@
|
|||||||
"path": "pages/profile-edit/index",
|
"path": "pages/profile-edit/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "编辑资料"
|
"navigationBarTitleText": "编辑资料",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,7 +122,8 @@
|
|||||||
"path": "pages/feedback/index",
|
"path": "pages/feedback/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "意见反馈"
|
"navigationBarTitleText": "意见反馈",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,7 +187,8 @@
|
|||||||
"navigationBarTitleText": "小菜记账",
|
"navigationBarTitleText": "小菜记账",
|
||||||
"navigationBarBackgroundColor": "#FFF8F0",
|
"navigationBarBackgroundColor": "#FFF8F0",
|
||||||
"backgroundColor": "#FFF8F0",
|
"backgroundColor": "#FFF8F0",
|
||||||
"backgroundTextStyle": "dark"
|
"backgroundTextStyle": "dark",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
},
|
},
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
"color": "#BFB3B3",
|
"color": "#BFB3B3",
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
<view class="nav-back" @tap="goBack">
|
<view class="nav-back" @tap="goBack">
|
||||||
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
</view>
|
</view>
|
||||||
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
|
<text class="title">{{ viewId ? '查看记录' : editId ? '编辑记录' : '记一笔' }}</text>
|
||||||
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
|
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
|
||||||
</view>
|
</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="type-toggle" :class="{ income: type === 'income' }">
|
||||||
<view class="slider"></view>
|
<view class="slider"></view>
|
||||||
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
|
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
|
||||||
@@ -19,18 +19,26 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 只读模式:显示收支类型标签 -->
|
||||||
|
<view class="type-toggle-wrap" v-if="viewId">
|
||||||
|
<view class="readonly-type-badge" :class="type">
|
||||||
|
<text>{{ type === 'expense' ? '支出' : '收入' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<AmountEditor
|
<AmountEditor
|
||||||
ref="amountEditorRef"
|
ref="amountEditorRef"
|
||||||
v-model="amountStr"
|
v-model="amountStr"
|
||||||
:fixed="true"
|
:fixed="true"
|
||||||
size="large"
|
size="large"
|
||||||
:auto-focus="true"
|
:auto-focus="!viewId"
|
||||||
|
:disabled="!!viewId"
|
||||||
@confirm="save"
|
@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 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">
|
<view class="cat-icon-wrap">
|
||||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
<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">
|
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
|
||||||
@@ -43,17 +51,21 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<view class="extra-fields">
|
<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">
|
<view class="extra-row" @tap="blurAmountEditor">
|
||||||
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||||
<text class="extra-text">{{ dateLabel }}</text>
|
<text class="extra-text">{{ dateLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</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">
|
<view class="extra-row">
|
||||||
<Icon name="edit" :size="28" color="#8B7E7E" />
|
<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>
|
||||||
<view class="extra-row" @tap="openTagPicker">
|
<view class="extra-row" @tap="!viewId && openTagPicker">
|
||||||
<Icon name="tag" :size="28" color="#8B7E7E" />
|
<Icon name="tag" :size="28" color="#8B7E7E" />
|
||||||
<view class="tag-display">
|
<view class="tag-display">
|
||||||
<text class="extra-text" v-if="selectedTagIds.length === 0">选择标签(选填)</text>
|
<text class="extra-text" v-if="selectedTagIds.length === 0">选择标签(选填)</text>
|
||||||
@@ -70,6 +82,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<SaveSuccess
|
<SaveSuccess
|
||||||
|
v-if="!viewId"
|
||||||
v-model:visible="showSuccess"
|
v-model:visible="showSuccess"
|
||||||
:text="editId ? '修改成功' : '记账成功'"
|
:text="editId ? '修改成功' : '记账成功'"
|
||||||
:showContinue="!editId"
|
:showContinue="!editId"
|
||||||
@@ -138,6 +151,7 @@ const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2
|
|||||||
const selectedDate = ref(localToday)
|
const selectedDate = ref(localToday)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editId = ref<number | null>(null)
|
const editId = ref<number | null>(null)
|
||||||
|
const viewId = ref<number | null>(null)
|
||||||
const showSuccess = ref(false)
|
const showSuccess = ref(false)
|
||||||
const scrollToCat = ref('')
|
const scrollToCat = ref('')
|
||||||
const todayStr = localToday
|
const todayStr = localToday
|
||||||
@@ -178,7 +192,7 @@ watch([amountStr, note], () => {
|
|||||||
if (suggestTimer) clearTimeout(suggestTimer)
|
if (suggestTimer) clearTimeout(suggestTimer)
|
||||||
suggestTimer = setTimeout(async () => {
|
suggestTimer = setTimeout(async () => {
|
||||||
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
|
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||||
if (amt <= 0 || editId.value) {
|
if (amt <= 0 || editId.value || viewId.value) {
|
||||||
suggestedCatId.value = null
|
suggestedCatId.value = null
|
||||||
suggestConfidence.value = 0
|
suggestConfidence.value = 0
|
||||||
return
|
return
|
||||||
@@ -189,7 +203,7 @@ watch([amountStr, note], () => {
|
|||||||
suggestedCatId.value = result.category.id
|
suggestedCatId.value = result.category.id
|
||||||
suggestConfidence.value = result.confidence
|
suggestConfidence.value = result.confidence
|
||||||
// 置信度 >= 60 时自动选中
|
// 置信度 >= 60 时自动选中
|
||||||
if (result.confidence >= 60 && !editId.value) {
|
if (result.confidence >= 60 && !editId.value && !viewId.value) {
|
||||||
selectedCat.value = result.category.id
|
selectedCat.value = result.category.id
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -203,7 +217,13 @@ watch([amountStr, note], () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try {
|
||||||
|
await waitForReady()
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '网络异常,请重试', icon: 'none' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 加载分类列表,失败时重试一次
|
// 加载分类列表,失败时重试一次
|
||||||
try {
|
try {
|
||||||
@@ -253,6 +273,29 @@ onMounted(async () => {
|
|||||||
setTimeout(() => uni.navigateBack(), 1500)
|
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) {
|
if (currentCategories.value.length > 0 && !selectedCat.value) {
|
||||||
selectedCat.value = currentCategories.value[0].id
|
selectedCat.value = currentCategories.value[0].id
|
||||||
}
|
}
|
||||||
@@ -277,7 +320,7 @@ function blurAmountEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (saving.value) return
|
if (saving.value || viewId.value) return
|
||||||
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||||
if (isNaN(amount) || amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
uni.showToast({ title: '请输入金额', icon: 'none' })
|
uni.showToast({ title: '请输入金额', icon: 'none' })
|
||||||
@@ -355,6 +398,11 @@ function getTagById(id: number) {
|
|||||||
function goBack() {
|
function goBack() {
|
||||||
// 保存成功后直接返回,不弹确认框
|
// 保存成功后直接返回,不弹确认框
|
||||||
if (showSuccess.value) return
|
if (showSuccess.value) return
|
||||||
|
// 只读查看模式不应提示放弃编辑
|
||||||
|
if (viewId.value) {
|
||||||
|
uni.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
const hasInput = amountStr.value !== '0' || note.value
|
const hasInput = amountStr.value !== '0' || note.value
|
||||||
if (hasInput) {
|
if (hasInput) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
@@ -633,5 +681,28 @@ function goBack() {
|
|||||||
width: 100%;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
try {
|
try {
|
||||||
await loadConfig()
|
await loadConfig()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ const replyText = ref('')
|
|||||||
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
loadList(true).finally(() => { initialLoaded.value = true })
|
loadList(true).finally(() => { initialLoaded.value = true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const dashboard = ref<Dashboard | null>(null)
|
|||||||
const health = ref<HealthStatus | null>(null)
|
const health = ref<HealthStatus | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dashboard.value = await getDashboard()
|
dashboard.value = await getDashboard()
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function getTodayStr(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await Promise.all([loadStats(), loadLogs()])
|
await Promise.all([loadStats(), loadLogs()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ const defaultForm = {
|
|||||||
const form = ref({ ...defaultForm })
|
const form = ref({ ...defaultForm })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await loadNotifications(true)
|
await loadNotifications(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const total = ref(0)
|
|||||||
const noMore = computed(() => eventList.value.length >= total.value && total.value > 0)
|
const noMore = computed(() => eventList.value.length >= total.value && total.value > 0)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await Promise.all([loadStats(), loadEvents()])
|
await Promise.all([loadStats(), loadEvents()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const loading = ref(false)
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await loadUsers(true)
|
await loadUsers(true)
|
||||||
initialLoaded.value = true
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const creating = ref(false)
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await loadBackups()
|
await loadBackups()
|
||||||
initialLoaded.value = true
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -104,6 +104,12 @@
|
|||||||
<Icon name="edit" :size="48" color="#BFB3B3" />
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
<text class="state-text">暂无账单记录</text>
|
<text class="state-text">暂无账单记录</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
:visible="snackbarVisible"
|
||||||
|
:message="snackbarMsg"
|
||||||
|
@undo="onUndoDelete"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -113,11 +119,13 @@ import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
|||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { useTagStore } from '@/stores/tag'
|
import { useTagStore } from '@/stores/tag'
|
||||||
|
import { useTransactionStore } from '@/stores/transaction'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
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 TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||||
|
import Snackbar from '@/components/Snackbar/Snackbar.vue'
|
||||||
import FilterPanel from './filter-panel.vue'
|
import FilterPanel from './filter-panel.vue'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { formatAmount, formatDate } from '@/utils/format'
|
import { formatAmount, formatDate } from '@/utils/format'
|
||||||
@@ -127,6 +135,7 @@ import type { FilterParams } from '@/api/filter'
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const tagStore = useTagStore()
|
const tagStore = useTagStore()
|
||||||
|
const txStore = useTransactionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const filterType = ref('all')
|
const filterType = ref('all')
|
||||||
const filterTagId = ref<number | null>(null)
|
const filterTagId = ref<number | null>(null)
|
||||||
@@ -137,6 +146,10 @@ const total = ref(0)
|
|||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
let loadSeq = 0 // 请求序号,防止并发覆盖
|
let loadSeq = 0 // 请求序号,防止并发覆盖
|
||||||
|
|
||||||
|
// Snackbar 删除撤销相关
|
||||||
|
const snackbarVisible = ref(false)
|
||||||
|
const snackbarMsg = ref('')
|
||||||
|
|
||||||
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
|
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
|
||||||
const swipeHintDismissed = ref(false)
|
const swipeHintDismissed = ref(false)
|
||||||
const HINT_KEY = 'xiaocai_swipe_hint_shown'
|
const HINT_KEY = 'xiaocai_swipe_hint_shown'
|
||||||
@@ -197,7 +210,13 @@ const groupedTx = computed(() => {
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try {
|
||||||
|
await waitForReady()
|
||||||
|
} catch {
|
||||||
|
// waitForReady 超时:显示网络异常提示
|
||||||
|
loadError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
tagStore.fetchTags().catch(() => {})
|
tagStore.fetchTags().catch(() => {})
|
||||||
loadTx(true).finally(() => { initialLoaded.value = true })
|
loadTx(true).finally(() => { initialLoaded.value = true })
|
||||||
})
|
})
|
||||||
@@ -208,6 +227,7 @@ onShow(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function switchFilter(type: string) {
|
function switchFilter(type: string) {
|
||||||
|
if (loading.value) return // 防止重复提交
|
||||||
filterType.value = type
|
filterType.value = type
|
||||||
loadTx(true)
|
loadTx(true)
|
||||||
}
|
}
|
||||||
@@ -260,7 +280,8 @@ function onFilterApply(filters: FilterParams) {
|
|||||||
|
|
||||||
function onEdit(item: any) {
|
function onEdit(item: any) {
|
||||||
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||||
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
|
// 非本人记录:进入查看模式(只读)
|
||||||
|
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
||||||
@@ -271,28 +292,30 @@ async function onDelete(item: any) {
|
|||||||
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
|
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uni.showModal({
|
// 乐观删除:先从列表移除,显示 Snackbar 供撤销
|
||||||
title: '删除记录',
|
txStore.startDelete(item.id)
|
||||||
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}?`,
|
txList.value = txList.value.filter(t => t.id !== item.id)
|
||||||
success: async (res) => {
|
total.value--
|
||||||
if (res.confirm) {
|
// 更新筛选金额
|
||||||
try {
|
if (item.type === 'expense') {
|
||||||
await deleteTransaction(item.id)
|
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
|
||||||
txList.value = txList.value.filter(t => t.id !== item.id)
|
} else {
|
||||||
total.value--
|
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
|
||||||
// 更新筛选金额
|
}
|
||||||
if (item.type === 'expense') {
|
// 显示 Snackbar
|
||||||
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
|
snackbarMsg.value = '已删除 1 条记录'
|
||||||
} else {
|
snackbarVisible.value = true
|
||||||
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
|
setTimeout(() => {
|
||||||
}
|
snackbarVisible.value = false
|
||||||
uni.showToast({ title: '已删除', icon: 'success' })
|
}, 3000)
|
||||||
} catch {
|
}
|
||||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
|
||||||
}
|
/** 撤销删除 */
|
||||||
}
|
function onUndoDelete() {
|
||||||
}
|
txStore.cancelDelete()
|
||||||
})
|
snackbarVisible.value = false
|
||||||
|
// 重新加载以恢复完整列表
|
||||||
|
loadTx(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触底加载更多
|
// 触底加载更多
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async function fetchMyExpense() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await budgetStore.fetchBudget(currentMonth)
|
await budgetStore.fetchBudget(currentMonth)
|
||||||
|
|||||||
@@ -138,18 +138,18 @@ const migrateTargets = computed(() => {
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await catStore.fetchCategories()
|
await catStore.fetchCategories()
|
||||||
initialLoaded.value = true
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 返回页面时静默刷新
|
// 返回页面时静默刷新(使用缓存)
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (initialLoaded.value) catStore.fetchCategories()
|
if (initialLoaded.value) catStore.fetchCategories() // 不强制刷新(使用缓存)
|
||||||
})
|
})
|
||||||
|
|
||||||
onPullDownRefresh(async () => {
|
onPullDownRefresh(async () => {
|
||||||
await catStore.fetchCategories()
|
await catStore.fetchCategories(true) // 强制刷新
|
||||||
uni.stopPullDownRefresh()
|
uni.stopPullDownRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const members = ref<GroupMember[]>([])
|
|||||||
const currentGroup = ref<Group | null>(null)
|
const currentGroup = ref<Group | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await groupStore.fetchGroups()
|
await groupStore.fetchGroups()
|
||||||
initialLoaded.value = true
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,7 +108,17 @@
|
|||||||
<text class="state-text">加载失败,下拉刷新重试</text>
|
<text class="state-text">加载失败,下拉刷新重试</text>
|
||||||
</view>
|
</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" />
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
<text class="state-text">还没有记录,快去记一笔吧</text>
|
<text class="state-text">还没有记录,快去记一笔吧</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -170,6 +180,7 @@ const budget = computed(() => budgetStore.budget)
|
|||||||
const recentTx = ref<Transaction[]>([])
|
const recentTx = ref<Transaction[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
|
const networkError = ref(false)
|
||||||
const todayExpense = ref(0)
|
const todayExpense = ref(0)
|
||||||
const todayIncome = ref(0)
|
const todayIncome = ref(0)
|
||||||
const todayCount = ref(0)
|
const todayCount = ref(0)
|
||||||
@@ -205,7 +216,16 @@ function openLink(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadData(silent = false) {
|
async function loadData(silent = false) {
|
||||||
await waitForReady()
|
try {
|
||||||
|
await waitForReady()
|
||||||
|
} catch {
|
||||||
|
// waitForReady 超时:显示网络异常提示
|
||||||
|
if (!silent) {
|
||||||
|
networkError.value = true
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
@@ -278,7 +298,8 @@ function goNotifications() {
|
|||||||
|
|
||||||
function onTxTap(item: any) {
|
function onTxTap(item: any) {
|
||||||
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||||
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
|
// 非本人记录:进入查看模式(只读)
|
||||||
|
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
||||||
@@ -287,6 +308,12 @@ function onTxTap(item: any) {
|
|||||||
function goBills() {
|
function goBills() {
|
||||||
uni.switchTab({ url: '/pages/bills/index' })
|
uni.switchTab({ url: '/pages/bills/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 网络异常重试 */
|
||||||
|
function retryNetwork() {
|
||||||
|
networkError.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -619,4 +646,41 @@ function goBills() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FF8C69;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ let loadSeq = 0
|
|||||||
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await loadNotifications(true)
|
await loadNotifications(true)
|
||||||
initialLoaded.value = true
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const uploading = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
formNickname.value = userStore.nickname
|
formNickname.value = userStore.nickname
|
||||||
formSlogan.value = userStore.slogan
|
formSlogan.value = userStore.slogan
|
||||||
previewUrl.value = userStore.avatarUrl
|
previewUrl.value = userStore.avatarUrl
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ async function updateExportCount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ function getTodayStr(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await Promise.all([loadList(), syncAndLoad()])
|
await Promise.all([loadList(), syncAndLoad()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ const monthCompareText = computed(() => {
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
loadData().finally(() => { initialLoaded.value = true })
|
loadData().finally(() => { initialLoaded.value = true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,10 +258,9 @@ async function loadData(silent = false) {
|
|||||||
try {
|
try {
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
|
// 使用聚合接口一次获取 overview + category + trend,再并行请求额外数据
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
statsStore.fetchOverview(currentMonth.value),
|
statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value),
|
||||||
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
|
|
||||||
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
|
|
||||||
fetchMaxSingle(),
|
fetchMaxSingle(),
|
||||||
fetchPrevMonthData()
|
fetchPrevMonthData()
|
||||||
])
|
])
|
||||||
@@ -279,11 +278,8 @@ async function loadData(silent = false) {
|
|||||||
async function loadTabData() {
|
async function loadTabData() {
|
||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value)
|
||||||
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
|
await fetchMaxSingle()
|
||||||
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
|
|
||||||
fetchMaxSingle()
|
|
||||||
])
|
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const editModel = ref({ name: '', color: '#FF8C69' })
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||||
await tagStore.fetchTags()
|
await tagStore.fetchTags()
|
||||||
initialLoaded.value = true
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const useBudgetStore = defineStore('budget', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setBudget(amount: number, month?: string) {
|
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)
|
await fetchBudget(month)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,20 @@ export type Category = api.Category
|
|||||||
|
|
||||||
export const useCategoryStore = defineStore('category', () => {
|
export const useCategoryStore = defineStore('category', () => {
|
||||||
const categories = ref<api.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) {
|
||||||
categories.value = await api.getCategories()
|
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') {
|
function getByType(type: 'expense' | 'income') {
|
||||||
@@ -20,31 +31,79 @@ export const useCategoryStore = defineStore('category', () => {
|
|||||||
return categories.value.find(c => c.id === id)
|
return categories.value.find(c => c.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 乐观新增 */
|
||||||
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
|
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
|
||||||
const result = await api.createCategory(data)
|
const tempId = -Date.now()
|
||||||
await fetchCategories()
|
const optimistic: api.Category = { id: tempId, ...data, is_custom: 1, sort_order: 0 }
|
||||||
return result.id
|
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 }) {
|
async function updateCategory(id: number, data: { name: string; color: string }) {
|
||||||
await api.updateCategory(id, data)
|
const idx = categories.value.findIndex(c => c.id === id)
|
||||||
await fetchCategories()
|
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) {
|
async function deleteCategory(id: number) {
|
||||||
|
// 删除操作等 API 确认(防误删)
|
||||||
await api.deleteCategory(id)
|
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) {
|
async function migrateCategory(fromId: number, toId: number) {
|
||||||
await api.migrateCategory(fromId, toId)
|
await api.migrateCategory(fromId, toId)
|
||||||
await fetchCategories()
|
categories.value = categories.value.filter(c => c.id !== fromId)
|
||||||
|
lastFetchTime.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 乐观排序 */
|
||||||
async function sortCategories(ids: number[]) {
|
async function sortCategories(ids: number[]) {
|
||||||
await api.sortCategories(ids)
|
const backup = [...categories.value]
|
||||||
await fetchCategories()
|
// 按新顺序重排
|
||||||
|
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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,14 +35,17 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
await refreshAll()
|
await refreshAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱) */
|
/** 切换身份后全局刷新所有数据(无依赖关系的可并行) */
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
const budgetStore = useBudgetStore()
|
const budgetStore = useBudgetStore()
|
||||||
const txStore = useTransactionStore()
|
const txStore = useTransactionStore()
|
||||||
await statsStore.fetchOverview()
|
// stats、budget、transactions 互相独立,可以并行
|
||||||
await budgetStore.fetchBudget()
|
await Promise.all([
|
||||||
await txStore.fetchTransactions({ page: 1 })
|
statsStore.fetchOverview(),
|
||||||
|
budgetStore.fetchBudget(),
|
||||||
|
txStore.fetchTransactions({ page: 1 })
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取群组列表 */
|
/** 获取群组列表 */
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ import type { Notification } from '@/api/notification'
|
|||||||
export const useNotificationStore = defineStore('notification', () => {
|
export const useNotificationStore = defineStore('notification', () => {
|
||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
const urgentNotifications = ref<Notification[]>([])
|
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 {
|
try {
|
||||||
const data = await api.getUnreadCount()
|
const data = await api.getUnreadCount()
|
||||||
unreadCount.value = data.count
|
unreadCount.value = data.count
|
||||||
|
lastCountFetchTime.value = Date.now()
|
||||||
} catch {
|
} 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', () => {
|
export const useTagStore = defineStore('tag', () => {
|
||||||
const tags = ref<api.Tag[]>([])
|
const tags = ref<api.Tag[]>([])
|
||||||
const loading = ref(false)
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.getTags()
|
const data = await api.getTags()
|
||||||
tags.value = Array.isArray(data) ? data : []
|
tags.value = Array.isArray(data) ? data : []
|
||||||
|
lastFetchTime.value = Date.now()
|
||||||
} catch {
|
} catch {
|
||||||
tags.value = []
|
tags.value = []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -19,23 +26,48 @@ export const useTagStore = defineStore('tag', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建标签 */
|
/** 乐观创建标签 */
|
||||||
async function addTag(data: { name: string; color: string }) {
|
async function addTag(data: { name: string; color: string }) {
|
||||||
const result = await api.createTag(data)
|
const tempId = -Date.now()
|
||||||
await fetchTags()
|
const optimistic: api.Tag = { id: tempId, name: data.name, color: data.color, created_at: new Date().toISOString() }
|
||||||
return result.id
|
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 }) {
|
async function updateTag(id: number, data: { name?: string; color?: string }) {
|
||||||
await api.updateTag(id, data)
|
const idx = tags.value.findIndex(t => t.id === id)
|
||||||
await fetchTags()
|
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) {
|
async function deleteTag(id: number) {
|
||||||
await api.deleteTag(id)
|
await api.deleteTag(id)
|
||||||
await fetchTags()
|
tags.value = tags.value.filter(t => t.id !== id)
|
||||||
|
lastFetchTime.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据 ID 获取标签 */
|
/** 根据 ID 获取标签 */
|
||||||
@@ -48,5 +80,5 @@ export const useTagStore = defineStore('tag', () => {
|
|||||||
return tags.value.filter(t => ids.includes(t.id))
|
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 total = ref(0)
|
||||||
const loading = ref(false)
|
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) {
|
async function fetchTransactions(params?: api.TransactionParams) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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
|
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 启动完成(登录成功) */
|
/** 标记 App 启动完成(登录成功) */
|
||||||
export function markReady() { _resolve() }
|
export function markReady() { _resolve() }
|
||||||
|
|
||||||
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */
|
/** 等待 App 就绪,最多等 8 秒超时 */
|
||||||
export function waitForReady(): Promise<void> {
|
export function waitForReady(): Promise<void> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
_ready,
|
_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 eventQueue: TrackEvent[] = []
|
||||||
let flushTimer: number | null = null
|
let flushTimer: number | null = null
|
||||||
const FLUSH_INTERVAL = 5000 // 5秒上报一次
|
const FLUSH_INTERVAL = 500 // 500ms 攒批
|
||||||
const MAX_QUEUE_SIZE = 20
|
const MAX_QUEUE_SIZE = 10 // 10 条立即发送
|
||||||
|
|
||||||
/** 获取当前页面路径 */
|
/** 获取当前页面路径 */
|
||||||
function getCurrentPage(): string {
|
function getCurrentPage(): string {
|
||||||
|
|||||||
@@ -83,10 +83,13 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
|
`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,
|
COUNT(DISTINCT t.id) as tx_count,
|
||||||
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count
|
COUNT(DISTINCT gm.group_id) as group_count
|
||||||
FROM users u
|
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}
|
${where}
|
||||||
|
GROUP BY u.id, u.nickname, u.avatar_url, u.role, u.created_at
|
||||||
ORDER BY u.created_at DESC
|
ORDER BY u.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[...params, pSize, offset]
|
[...params, pSize, offset]
|
||||||
|
|||||||
@@ -63,12 +63,23 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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) {
|
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之间的正整数(单位:分)' })
|
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||||
}
|
}
|
||||||
const m = validateMonth(month || getCurrentMonth())
|
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(
|
await pool.query(
|
||||||
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
|
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
|
||||||
[req.userId, amount, m, 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
|
export default router
|
||||||
|
|||||||
@@ -13,15 +13,21 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
|
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量插入埋点事件(限制每个事件的字段长度)
|
const validEvents = events.slice(0, 50).map(event => ({
|
||||||
for (const event of events.slice(0, 50)) { // 最多 50 个事件
|
event: typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown',
|
||||||
const eventType = typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown'
|
page: typeof event.page === 'string' ? event.page.slice(0, 200) : '',
|
||||||
const page = typeof event.page === 'string' ? event.page.slice(0, 200) : ''
|
data: event.data && typeof event.data === 'object' ? JSON.stringify(event.data) : '{}',
|
||||||
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
userId
|
||||||
|
}))
|
||||||
|
|
||||||
await pool.execute(
|
if (validEvents.length > 0) {
|
||||||
'INSERT INTO track_events (event, page, data, user_id) VALUES (?, ?, ?, ?)',
|
// 批量 INSERT
|
||||||
[eventType, page, JSON.stringify(data), userId]
|
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