FrontendRefactoring:标签支持拖动

This commit is contained in:
zhugongping 2026-09-23 17:59:30 +08:00
parent 3288829a01
commit 7fd5da85d1
4 changed files with 237 additions and 6 deletions

View File

@ -158,7 +158,7 @@ INSERT INTO `oss_config`
(tenant_id, tenant_name, config_key, access_key, secret_key, bucket_name, prefix, (tenant_id, tenant_name, config_key, access_key, secret_key, bucket_name, prefix,
endpoint, domain, is_https, region, access_policy, status, del_flag, create_by, create_time, update_by, update_time, remark) endpoint, domain, is_https, region, access_policy, status, del_flag, create_by, create_time, update_by, update_time, remark)
VALUES VALUES
(1, 'admin', 'minio', 'yack-superme', 'yack-minio20260816', 'fastbee', 'report', (1, 'admin', 'minio', 'supermeminio', 'minio20260909', 'fastbee', 'report',
'http://47.121.180.229:9000', '', 'N', '', '0', '0', '0', 'admin', NOW(), 'admin', NOW(), 'MinIO默认存储'); 'http://47.121.180.229:9000', '', 'N', '', '0', '0', '0', 'admin', NOW(), 'admin', NOW(), 'MinIO默认存储');
-- ------------------------------------------------------------ -- ------------------------------------------------------------

View File

@ -38,7 +38,11 @@ export default {
const $container = this.$refs.scrollContainer.$el const $container = this.$refs.scrollContainer.$el
const $containerWidth = $container.offsetWidth const $containerWidth = $container.offsetWidth
const $scrollWrapper = this.scrollWrapper const $scrollWrapper = this.scrollWrapper
const tagList = this.$parent.$refs.tag // $refs.tag 的下标是「首次注册顺序」,ref 只在 create/destroy 时增删;
// 标签拖拽排序只移动 DOM,不会更新这个数组,所以直接按数组顺序取首/尾和
// 相邻标签会取错。改为按标签在栏上的实际横坐标重排后再取。
// [].concat 同时兼容 refs 为单个元素(v-for 只剩 1 个时不一定是数组)的情况
const tagList = [].concat(this.$parent.$refs.tag || []).sort((a, b) => a.$el.offsetLeft - b.$el.offsetLeft)
let firstTag = null let firstTag = null
let lastTag = null let lastTag = null

View File

@ -1,5 +1,10 @@
<template> <template>
<div id="tags-view-container" class="tags-view-container"> <div
id="tags-view-container"
class="tags-view-container"
:class="{ 'is-dragging': dragMoved }"
@click.capture="onClickCapture"
>
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll"> <scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
<router-link <router-link
v-for="(tag, index) in visibleTags" v-for="(tag, index) in visibleTags"
@ -10,6 +15,7 @@
tag="span" tag="span"
class="tags-view-item" class="tags-view-item"
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''" @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
@mousedown.native="onTagMouseDown(tag, index, $event)"
@contextmenu.prevent.native="openMenu(tag,$event)" @contextmenu.prevent.native="openMenu(tag,$event)"
> >
<svg v-if="isAffix(tag)" class="tab-pin" viewBox="0 0 12 12" aria-hidden="true"> <svg v-if="isAffix(tag)" class="tab-pin" viewBox="0 0 12 12" aria-hidden="true">
@ -79,6 +85,8 @@ import path from 'path'
const MAX_VISIBLE_TAGS = 14 const MAX_VISIBLE_TAGS = 14
// 「更多」下拉面板宽度,用于把面板夹在标签栏可视范围内(与样式里的 width 保持一致) // 「更多」下拉面板宽度,用于把面板夹在标签栏可视范围内(与样式里的 width 保持一致)
const MORE_PANEL_WIDTH = 200 const MORE_PANEL_WIDTH = 200
// 鼠标横向位移超过这个像素才算拖拽,否则仍按普通点击处理
const DRAG_THRESHOLD = 4
export default { export default {
components: { ScrollPane }, components: { ScrollPane },
@ -92,7 +100,20 @@ export default {
// 「更多」下拉是否展开 // 「更多」下拉是否展开
moreVisible: false, moreVisible: false,
// 「更多」下拉的横向位置(相对 #tags-view-container),展开时按按钮实际位置计算 // 「更多」下拉的横向位置(相对 #tags-view-container),展开时按按钮实际位置计算
moreLeft: 0 moreLeft: 0,
// 拖拽排序:正在拖动时,被拖标签在 visibleTags 中的下标(-1 = 未拖动)
dragFrom: -1,
// 用 path 而不是下标来标记被拖动的标签:拖动过程中它会不断换位置
dragPath: '',
dragStartX: 0,
dragMoved: false,
// 拖拽结束时浏览器会紧跟着补一个 click,用它把这个 click 拦掉,避免误跳转
suppressClick: false,
// 被拖标签的 DOM 与拖拽开始时的槽位信息。用 offsetLeft(不含 transform)
// 加鼠标位移就能推出标签的「视觉中心」应该在哪,重排后依然跟手
dragEl: null,
dragBaseLeft: 0,
dragWidth: 0
} }
}, },
computed: { computed: {
@ -167,6 +188,8 @@ export default {
beforeDestroy() { beforeDestroy() {
document.body.removeEventListener('click', this.closeMenu) document.body.removeEventListener('click', this.closeMenu)
document.body.removeEventListener('click', this.closeMore) document.body.removeEventListener('click', this.closeMore)
document.removeEventListener('mousemove', this.onTagMouseMove)
document.removeEventListener('mouseup', this.onTagMouseUp)
}, },
methods: { methods: {
isActive(route) { isActive(route) {
@ -176,7 +199,8 @@ export default {
return [ return [
this.isActive(tag) ? 'active' : '', this.isActive(tag) ? 'active' : '',
this.isAffix(tag) ? 'is-fixed' : '', this.isAffix(tag) ? 'is-fixed' : '',
this.isFixedBlockEnd(index) ? 'fixed-block-end' : '' this.isFixedBlockEnd(index) ? 'fixed-block-end' : '',
this.dragMoved && tag.path === this.dragPath ? 'is-drag-source' : ''
] ]
}, },
isAffix(tag) { isAffix(tag) {
@ -402,6 +426,151 @@ export default {
handleScroll() { handleScroll() {
this.closeMenu() this.closeMenu()
this.closeMore() this.closeMore()
},
// ---------- 标签拖拽排序 ----------
// 规则:普通标签可以按住左键拖动换位;固定标签锁在最左侧,既拖不动、
// 也不接受任何标签插到它前面(落点索引一律加到固定区之后)
onTagMouseDown(tag, index, e) {
if (e.button !== 0 || this.isAffix(tag)) {
return
}
// 点在关闭按钮上是要关闭标签,不启动拖拽
if (e.target.closest && e.target.closest('.el-icon-close')) {
return
}
// 只拦掉 mousedown 的默认行为(拖拽中选中文本),不影响随后正常派发的 click
e.preventDefault()
this.closeMore()
const el = e.currentTarget
this.dragFrom = index
this.dragPath = tag.path
this.dragEl = el
this.dragBaseLeft = el.offsetLeft
this.dragWidth = el.offsetWidth
this.dragStartX = e.clientX
this.dragMoved = false
document.addEventListener('mousemove', this.onTagMouseMove)
document.addEventListener('mouseup', this.onTagMouseUp)
},
onTagMouseMove(e) {
if (this.dragFrom === -1) {
return
}
// 位移没过阈值之前仍算一次普通点击,避免轻微抖动被当成拖拽
if (!this.dragMoved) {
if (Math.abs(e.clientX - this.dragStartX) < DRAG_THRESHOLD) {
return
}
this.dragMoved = true
}
const to = this.resolveDropIndex(e.clientX)
if (to >= 0 && to !== this.dragFrom) {
this.moveTagTo(this.dragFrom, to, e.clientX)
return
}
this.followPointer(e.clientX)
},
// 鼠标当前对应的落点槽位:从被拖标签所在的槽位出发,一路吃到鼠标为止。
// 用 offsetLeft/offsetWidth(都不含 transform)读数,这样不会被其它标签
// 正在进行的让位动画中间态干扰;一次算到目标位,跨多个槽位也只重排一次
resolveDropIndex(clientX) {
const items = this.$el.querySelectorAll('.tags-view-item')
if (this.dragFrom < 0 || !items.length) {
return -1
}
// 被拖标签的「视觉中心」= 抓住它的位置 + 鼠标位移
const center = this.dragBaseLeft + this.dragWidth / 2 + (clientX - this.dragStartX)
const midOf = i => items[i].offsetLeft + items[i].offsetWidth / 2
let from = this.dragFrom
while (from + 1 < items.length && center > midOf(from + 1)) {
from++
}
// 左边界卡在固定区之后 —— 这就是「不能移到固定标签页前面」的落实点
while (from - 1 >= this.affixCount && center < midOf(from - 1)) {
from--
}
return from
},
// 把可见区第 from 个标签挪到第 to 个位置
moveTagTo(from, to, clientX) {
const visible = this.visibleTags
// 可见下标要换算成 visitedViews 的绝对下标:被挤出到「更多」的标签仍排在
// 可见标签前面,直接拿可见下标去 splice 会插错位置
const fromAbs = this.visitedViews.indexOf(visible[from])
const toAbs = this.visitedViews.indexOf(visible[to])
if (fromAbs < 0 || toAbs < 0 || fromAbs === toAbs) {
this.dragFrom = to
this.followPointer(clientX)
return
}
// FLIP:先量下重排前每个标签的位置,重排后让它们从旧位置滑到新位置
const items = this.$el.querySelectorAll('.tags-view-item')
const before = []
for (let i = 0; i < items.length; i++) {
before.push(items[i].getBoundingClientRect().left)
}
this.$store.dispatch('tagsView/sortVisitedViews', { fromIndex: fromAbs, toIndex: toAbs })
this.dragFrom = to
this.$nextTick(() => {
for (let i = 0; i < items.length; i++) {
const el = items[i]
// 被拖动的这块由 followPointer 控制,不参与让位动画
if (el === this.dragEl) {
continue
}
const delta = before[i] - el.getBoundingClientRect().left
if (!delta) {
continue
}
// 先瞬移到旧位置,强制一次重排,再放开让它按 CSS 过渡滑回新位置
el.style.transition = 'none'
el.style.transform = 'translateX(' + delta + 'px)'
el.offsetWidth
el.style.transition = ''
el.style.transform = ''
}
this.followPointer(clientX)
})
},
// 让被拖动的标签贴着鼠标走:目标视觉位置减去当前槽位位置,就是该补偿的位移。
// 重排会改变槽位,每次重新算这个差值,"手感"才会始终黏着指针
followPointer(clientX) {
const el = this.dragEl
if (!el) {
return
}
const dx = clientX - this.dragStartX
el.style.transform = 'translateX(' + (this.dragBaseLeft + dx - el.offsetLeft) + 'px)'
},
onTagMouseUp() {
document.removeEventListener('mousemove', this.onTagMouseMove)
document.removeEventListener('mouseup', this.onTagMouseUp)
const el = this.dragEl
if (this.dragMoved) {
// mouseup 之后浏览器立刻补一个 click,不拦掉的话标签会立刻跳转
this.suppressClick = true
}
this.dragFrom = -1
this.dragPath = ''
this.dragMoved = false
this.dragEl = null
if (el) {
// 等 is-drag-source 摘掉(transition 恢复)再清位移,
// 这样松手时是平滑吸附回槽位,而不是硬跳
this.$nextTick(() => {
el.style.transition = ''
el.style.transform = ''
})
}
},
// 在捕获阶段掐掉拖拽结束后的那一次 click(要在 router-link 处理之前拦下)
onClickCapture(e) {
if (this.suppressClick) {
this.suppressClick = false
e.stopPropagation()
e.preventDefault()
}
} }
} }
} }
@ -436,7 +605,9 @@ export default {
line-height: 1.4; line-height: 1.4;
max-width: 180px; max-width: 180px;
user-select: none; user-select: none;
transition: background 0.2s, color 0.2s; // transform 那段是给拖拽让位动画用的:FLIP 先把元素瞬移到旧位置,
// 靠这里恢复过渡后滑回新槽位,其它标签就会"滑开"而不是瞬移
transition: transform 0.18s ease, background 0.2s, color 0.2s;
&:hover { &:hover {
background: var(--top-tabs-hover, rgba(0, 0, 0, 0.06)); background: var(--top-tabs-hover, rgba(0, 0, 0, 0.06));
@ -488,6 +659,45 @@ export default {
height: 26px; height: 26px;
background: var(--top-tabs-line, rgba(255, 255, 255, 0.12)); background: var(--top-tabs-line, rgba(255, 255, 255, 0.12));
} }
// 正在被拖动的标签:从标签栏里「拿起来」成一块浮起的方块,贴着鼠标走
&.is-drag-source {
z-index: 6;
// 跟手要即时,这里必须把过渡关掉,否则位移会拖泥带水
transition: none;
border-radius: 6px;
background: var(--top-tabs-active-bg, #fff);
color: var(--top-tabs-active-fg, #303133);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
cursor: grabbing;
// 方块是亮底色,关闭按钮要跟着换成深色,否则在深色主题下看不见
.el-icon-close {
color: #909399;
&:hover {
background-color: #dcdfe6;
}
}
}
}
// 拖拽进行中:光标变抓取态(鼠标可能已经移出标签栏,所以挂在容器上)
&.is-dragging {
cursor: grabbing;
.tags-view-item {
cursor: grabbing;
// 鼠标掠过其它标签时不要有 hover 反馈,否则满屏闪
&:hover {
background: transparent;
}
&.is-drag-source {
background: var(--top-tabs-active-bg, #fff);
}
}
} }
// 「更多」入口:超出上限的标签收在这里,按钮紧跟在最后一个可见标签右侧 // 「更多」入口:超出上限的标签收在这里,按钮紧跟在最后一个可见标签右侧

View File

@ -138,6 +138,19 @@ const mutations = {
state.visitedViews.splice(index, 1) state.visitedViews.splice(index, 1)
moveToAffixBlockEnd(state.visitedViews, target) moveToAffixBlockEnd(state.visitedViews, target)
}, },
// 拖拽排序:把 visitedViews 里的 fromIndex 挪到 toIndex(toIndex 是「最终落在」的位置)
SORT_VISITED_VIEWS: (state, { fromIndex, toIndex }) => {
const list = state.visitedViews
const moved = list[fromIndex]
if (!moved) return
// 固定区锁在最左侧:固定标签自己不能被拖动,也不接受任何标签插到它前面
if (moved.meta && moved.meta.affix) return
const affixCount = list.filter(v => v.meta && v.meta.affix).length
if (fromIndex === toIndex || toIndex < affixCount || toIndex >= list.length) return
// 先摘出再插入,「toIndex」直接就是移动后的下标(Vue 的数组响应式会照常触发)
list.splice(fromIndex, 1)
list.splice(toIndex, 0, moved)
},
DEL_RIGHT_VIEWS: (state, view) => { DEL_RIGHT_VIEWS: (state, view) => {
const index = state.visitedViews.findIndex(v => v.path === view.path) const index = state.visitedViews.findIndex(v => v.path === view.path)
if (index === -1) { if (index === -1) {
@ -277,6 +290,10 @@ const actions = {
unpinView({ commit }, view) { unpinView({ commit }, view) {
commit('UNPIN_VIEW', view) commit('UNPIN_VIEW', view)
}, },
// 拖拽排序
sortVisitedViews({ commit }, payload) {
commit('SORT_VISITED_VIEWS', payload)
},
delRightTags({ commit }, view) { delRightTags({ commit }, view) {
return new Promise(resolve => { return new Promise(resolve => {
commit('DEL_RIGHT_VIEWS', view) commit('DEL_RIGHT_VIEWS', view)