Browse Source
- 顶栏各小部件按钮支持选择位置(header/user-dropdown/none),偏好设置按钮额外保留 auto/fixed 智能兜底,移动端/全屏内容模式下自动切 fixed 浮动保命,避免偏好入口死锁 - draggable-list.vue 的 listRef 未在模板绑定,Sortable 永不初始化导致小部件无法拖拽;加 ref 绑定与 data-key 让 onEnd 从 DOM 读真实新顺序 - preferences.ts 的 updatePreferences 用 defu(merge) 合并数组,对 widget.order 是 concat 语义,每次拖拽触发更新都会把当前 order 追加一次,localStorage 里膨胀到上百条;改用 mergeWithArrayOverride 整体替换,加载缓存时 sanitizeCachedArray 去重已存在的脏数据 - preferences 按钮之前在 widget.order 里因 labelMap 未登记显示成裸字符串,且 widgetChecks 缺 refresh 导致刷新按钮不按 order 渲染;统一 preferences 与其它 widget 的拖拽排序与位置 select,refresh 补进 rightSlots.widgetChecks 并补模板渲染 - 补 sortablejs 依赖、WidgetPreferences.order 类型字段、preferences-drawer.vue 的 widgetOrder 绑定pull/8166/head
21 changed files with 847 additions and 160 deletions
@ -0,0 +1,140 @@ |
|||
<script setup lang="ts"> |
|||
import type { SelectOption } from '@vben/types'; |
|||
|
|||
import { computed, nextTick, onMounted, ref } from 'vue'; |
|||
|
|||
import { GripVertical } from '@vben/icons'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import Sortable from 'sortablejs'; |
|||
|
|||
interface Item { |
|||
key: string; |
|||
label: string; |
|||
position: 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown'; |
|||
/** 每项可选的 position 选项;不传则用 props.positionItems */ |
|||
positionItems?: SelectOption[]; |
|||
} |
|||
|
|||
const props = defineProps<{ |
|||
items: Item[]; |
|||
positionItems: SelectOption[]; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
updateOrder: [keys: string[]]; |
|||
updatePosition: [ |
|||
key: string, |
|||
position: 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown', |
|||
]; |
|||
}>(); |
|||
|
|||
const listRef = ref<HTMLElement>(); |
|||
let sortableInstance: null | Sortable = null; |
|||
|
|||
// 划分规则: |
|||
// - hidden:position === 'none'(不显示) |
|||
// - sortable:其它(含 auto/fixed,偏好按钮的智能模式视觉归入顶栏组参与排序, |
|||
// 避免 preferences 从 widget.order 丢失) |
|||
const sortableList = computed(() => |
|||
props.items.filter((item) => item.position !== 'none'), |
|||
); |
|||
|
|||
const hiddenList = computed(() => |
|||
props.items.filter((item) => item.position === 'none'), |
|||
); |
|||
function initSortable() { |
|||
if (!listRef.value) return; |
|||
sortableInstance?.destroy(); |
|||
sortableInstance = Sortable.create(listRef.value, { |
|||
animation: 200, |
|||
handle: '.drag-handle', |
|||
onEnd() { |
|||
// Sortable 已经改了 DOM,但 sortableList computed 还是旧顺序。 |
|||
// 直接从 DOM 读 children 的 data-key 拿新顺序,再追加 hidden 部分。 |
|||
const newOrder = [...listRef.value!.children] |
|||
.map((el) => (el as HTMLElement).dataset.key) |
|||
.filter(Boolean) as string[]; |
|||
emit('updateOrder', [...newOrder, ...hiddenList.value.map((i) => i.key)]); |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
onMounted(initSortable); |
|||
|
|||
function setPosition(key: string, event: Event) { |
|||
const value = (event.target as HTMLSelectElement).value as |
|||
| 'auto' |
|||
| 'fixed' |
|||
| 'header' |
|||
| 'none' |
|||
| 'user-dropdown'; |
|||
emit('updatePosition', key, value); |
|||
nextTick(() => { |
|||
emit( |
|||
'updateOrder', |
|||
[...sortableList.value, ...hiddenList.value].map((i) => i.key), |
|||
); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="space-y-1"> |
|||
<div ref="listRef" class="space-y-1"> |
|||
<div |
|||
v-for="item in sortableList" |
|||
:key="item.key" |
|||
:data-key="item.key" |
|||
class="bg-accent flex items-center gap-2 rounded-md px-2 py-1.5" |
|||
> |
|||
<GripVertical |
|||
class="drag-handle size-4 shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing" |
|||
/> |
|||
<span class="min-w-0 flex-1 truncate text-sm">{{ item.label }}</span> |
|||
<select |
|||
:value="item.position" |
|||
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs" |
|||
@change="(e) => setPosition(item.key, e)" |
|||
> |
|||
<option |
|||
v-for="opt in item.positionItems ?? positionItems" |
|||
:key="opt.value" |
|||
:value="opt.value" |
|||
> |
|||
{{ opt.label }} |
|||
</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
<div v-if="hiddenList.length > 0" class="pt-2"> |
|||
<div class="text-muted-foreground mb-1 text-xs font-medium"> |
|||
{{ $t('preferences.widget.hidden') }} |
|||
</div> |
|||
<div |
|||
v-for="item in hiddenList" |
|||
:key="item.key" |
|||
class="flex items-center gap-2 rounded-md px-2 py-1" |
|||
> |
|||
<span |
|||
class="text-muted-foreground min-w-0 flex-1 truncate text-sm line-through decoration-dotted" |
|||
> |
|||
{{ item.label }} |
|||
</span> |
|||
<select |
|||
:value="item.position" |
|||
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs" |
|||
@change="(e) => setPosition(item.key, e)" |
|||
> |
|||
<option |
|||
v-for="opt in item.positionItems ?? positionItems" |
|||
:key="opt.value" |
|||
:value="opt.value" |
|||
> |
|||
{{ opt.label }} |
|||
</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
Loading…
Reference in new issue