feat: 前后端功能对齐 - 实现8个对齐差距

P0-1: 分类拖拽排序 - category-manage 添加拖拽 UI
P1-1: 反馈回复展示 - 新增 GET /feedback/mine + 前端我的反馈Tab
P1-2: 备份管理页面 - 新增下载端点 + backup-manage 页面
P2-1: 统计年度/周视图 - stats 支持 period 参数 + 前端维度切换器
P2-2: 数据导入 - 新增 POST /import 端点 + data-import 页面
P2-3: 数据导出服务端化 - 新增 GET /export 流式端点
P3-1: 健康检查展示 - health 移到 auth 前 + admin 状态卡片
P3-2~4: 交易标签系统 - tags CRUD + 交易关联 + 按标签筛选统计

后端: 新增 tag.ts/export.ts 路由, 改造 feedback/backup/transaction/stats
前端: 新增 DragSortList 组件, 3个新页面, 改造 7 个现有页面
QA 修复: 5个严重Bug + 4个潜在问题
This commit is contained in:
2026-06-10 17:30:36 +08:00
parent 5df910c9f1
commit 31f6487d61
36 changed files with 4162 additions and 64 deletions

View File

@@ -17,6 +17,25 @@
</view>
</view>
<!-- 标签筛选条 -->
<scroll-view class="tag-filter-scroll" scroll-x v-if="tagStore.tags.length > 0">
<view class="tag-filter-row">
<view class="tag-filter-chip" :class="{ active: filterTagId === null }" @tap="filterTagId = null; loadTx(true)">
<text class="tag-filter-text">全部标签</text>
</view>
<view
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-filter-chip"
:class="{ active: filterTagId === tag.id }"
@tap="filterTagId = tag.id; loadTx(true)"
>
<view class="tag-filter-dot" :style="{ background: tag.color }"></view>
<text class="tag-filter-text">{{ tag.name }}</text>
</view>
</view>
</scroll-view>
<!-- 筛选结果统计 -->
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
<text class="filter-stats-text"> {{ total }} </text>
@@ -43,6 +62,8 @@
:hideDate="true"
:showCreator="groupStore.isGroupMode"
:currentUserId="userStore.userInfo?.id"
:disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id"
:swipeHint="!swipeHintDismissed && isFirstDeletable(item)"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
@@ -91,6 +112,7 @@ import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTagStore } from '@/stores/tag'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
@@ -104,8 +126,10 @@ import type { FilterParams } from '@/api/filter'
const userStore = useUserStore()
const groupStore = useGroupStore()
const tagStore = useTagStore()
const loading = ref(false)
const filterType = ref('all')
const filterTagId = ref<number | null>(null)
const page = ref(1)
const pageSize = 20
const txList = ref<Transaction[]>([])
@@ -113,6 +137,35 @@ const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown'
function isFirstDeletable(item: Transaction): boolean {
if (swipeHintDismissed.value) return false
// 群组模式下只有自己的记录可删除
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) return false
// 找到第一个可删除的项
for (const group of groupedTx.value) {
for (const tx of group.items) {
if (groupStore.isGroupMode && tx.user_id !== userStore.userInfo?.id) continue
return tx.id === item.id
}
}
return false
}
// 动画播完后标记已显示
try {
if (uni.getStorageSync(HINT_KEY)) {
swipeHintDismissed.value = true
}
} catch {}
setTimeout(() => {
swipeHintDismissed.value = true
try { uni.setStorageSync(HINT_KEY, '1') } catch {}
}, 3000)
// 高级筛选
const showFilter = ref(false)
const advancedFilters = ref<FilterParams>({})
@@ -145,6 +198,7 @@ const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
tagStore.fetchTags().catch(() => {})
loadTx(true).finally(() => { initialLoaded.value = true })
})
@@ -174,6 +228,7 @@ async function loadTx(reset = false, silent = false) {
if (af.minAmount) params.minAmount = af.minAmount
if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword
if (filterTagId.value) params.tagId = filterTagId.value
try {
loading.value = true
loadError.value = false
@@ -379,4 +434,51 @@ onPullDownRefresh(() => {
.state-text {
@include state-text;
}
/* 标签筛选条 */
.tag-filter-scroll {
white-space: nowrap;
padding: 0 $space-xl $space-sm;
}
.tag-filter-row {
display: inline-flex;
gap: $space-xs;
}
.tag-filter-chip {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: $space-xs $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
flex-shrink: 0;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.tag-filter-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-filter-text {
font-size: $font-sm;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
</style>