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:
144
client/src/components/DragSortList/DragSortList.vue
Normal file
144
client/src/components/DragSortList/DragSortList.vue
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user