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

@@ -0,0 +1,144 @@
<template>
<view class="drag-sort-list">
<movable-area class="drag-area">
<movable-view
v-for="(item, index) in list"
:key="itemKey ? item[itemKey] : index"
class="drag-item"
:class="{ dragging: dragIndex === index }"
direction="vertical"
:y="positions[index] || 0"
:damping="40"
:disabled="disabled"
@touchstart="onDragStart(index)"
@change="onChange(index, $event)"
@touchend="onDragEnd(index)"
>
<view class="drag-handle" v-if="!disabled">
<Icon name="dragHandle" :size="28" color="#BFB3B3" />
</view>
<slot name="item" :item="item" :index="index">
<text class="drag-default-text">{{ item[itemKey] || item }}</text>
</slot>
</movable-view>
</movable-area>
</view>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import Icon from '@/components/Icon/Icon.vue'
const props = withDefaults(defineProps<{
list: any[]
itemKey?: string
disabled?: boolean
itemHeight?: number
}>(), {
disabled: false,
itemHeight: 56
})
const emit = defineEmits<{
(e: 'sort', newOrder: any[]): void
}>()
/** 每个 item 的 y 坐标 */
const positions = ref<number[]>([])
/** 当前正在拖拽的 index */
const dragIndex = ref<number | null>(null)
/** 拖拽起始 y */
let startY = 0
/** 当前排序列表 */
const sortedList = ref<any[]>([])
watch(() => props.list, (newList) => {
sortedList.value = [...newList]
recalcPositions()
}, { immediate: true, deep: true })
function recalcPositions() {
const pos: number[] = []
for (let i = 0; i < sortedList.value.length; i++) {
pos.push(i * props.itemHeight)
}
positions.value = pos
}
function onDragStart(index: number) {
dragIndex.value = index
startY = positions.value[index] || 0
}
function onChange(index: number, e: any) {
if (e.detail.source !== 'touch') return
const newY = e.detail.y
const currentPos = index * props.itemHeight
const delta = newY - currentPos
const swapThreshold = props.itemHeight * 0.5
if (Math.abs(delta) > swapThreshold) {
const direction = delta > 0 ? 1 : -1
const swapIndex = index + direction
if (swapIndex < 0 || swapIndex >= sortedList.value.length) return
// 交换元素
const newList = [...sortedList.value]
const temp = newList[index]
newList[index] = newList[swapIndex]
newList[swapIndex] = temp
sortedList.value = newList
recalcPositions()
emit('sort', newList)
}
}
function onDragEnd(index: number) {
dragIndex.value = null
}
</script>
<style lang="scss" scoped>
.drag-sort-list {
width: 100%;
}
.drag-area {
width: 100%;
height: auto;
min-height: 200rpx;
}
.drag-item {
width: 100%;
display: flex;
align-items: center;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
margin-bottom: $space-xs;
transition: box-shadow $transition-normal;
&.dragging {
box-shadow: $shadow-lg;
opacity: 0.9;
z-index: 10;
}
}
.drag-handle {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:active { opacity: 0.6; }
}
.drag-default-text {
font-size: $font-lg;
color: $text;
flex: 1;
}
</style>

View File

@@ -50,6 +50,7 @@ const iconMap: Record<string, Record<string, string>> = {
},
check: {
'#FFFFFF': '/static/icons/check-white.png',
'#FF8C69': '/static/icons/check-orange.png',
},
trash: {
'#FF8C69': '/static/icons/trash-orange.png',
@@ -71,6 +72,25 @@ const iconMap: Record<string, Record<string, string>> = {
'#8B7E7E': '/static/icons/settings-gray.png',
'#FF8C69': '/static/icons/settings-orange.png',
},
tag: {
'#8B7E7E': '/static/icons/tag-gray.png',
'#FF8C69': '/static/icons/tag-orange.png',
'#BFB3B3': '/static/icons/tag-light.png',
},
archive: {
'#7BC67E': '/static/icons/archive-green.png',
'#BFB3B3': '/static/icons/archive-light.png',
},
download: {
'#5B9BD5': '/static/icons/download-blue.png',
},
upload: {
'#FF8C69': '/static/icons/upload-orange.png',
},
dragHandle: {
'#BFB3B3': '/static/icons/drag-handle-light.png',
'#8B7E7E': '/static/icons/drag-handle-gray.png',
},
}
const iconSrc = computed(() => {