FrontendRefactoring:标签支持拖动
This commit is contained in:
parent
3288829a01
commit
7fd5da85d1
@ -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默认存储');
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「更多」入口:超出上限的标签收在这里,按钮紧跟在最后一个可见标签右侧
|
// 「更多」入口:超出上限的标签收在这里,按钮紧跟在最后一个可见标签右侧
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user