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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user