263 lines
9.6 KiB
Vue
263 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import '@/styles/adminDensity.css'
|
|
import '@/styles/adminWorkbench.css'
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { MoreHorizontal, X } from '@lucide/vue'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
|
import { workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
|
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
|
|
|
type CloseCommand = 'current' | 'others' | 'right' | 'all'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const access = useAccessPreviewStore()
|
|
const unsaved = useUnsavedWorkStore()
|
|
const workspace = useWorkspaceTabsStore()
|
|
const tabListRef = ref<HTMLElement>()
|
|
|
|
const activeIndex = computed(() => workspace.tabs.findIndex((tab) => tab.id === workspace.activeId))
|
|
const hasRightTabs = computed(() => activeIndex.value >= 0 && activeIndex.value < workspace.tabs.length - 1)
|
|
|
|
function blockingEntries(tabIds: readonly string[]) {
|
|
return tabIds.flatMap((tabId) => unsaved.findForWorkspace(tabId))
|
|
}
|
|
|
|
async function confirmDiscard(tabIds: readonly string[]) {
|
|
const blocking = blockingEntries(tabIds)
|
|
if (blocking.length === 0) return true
|
|
const labels = [...new Set(blocking.map((entry) => entry.label))]
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
|
|
'放弃未保存的修改?',
|
|
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
|
)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function focusActiveTab() {
|
|
await nextTick()
|
|
const active = tabListRef.value?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
|
active?.focus({ preventScroll: true })
|
|
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
|
}
|
|
|
|
async function navigateToFallback(path: string | null) {
|
|
const destination = path || access.firstAccessiblePath
|
|
if (!destination) {
|
|
await router.push('/403')
|
|
return
|
|
}
|
|
if (destination === route.fullPath) {
|
|
const candidate = workspaceTabCandidate(route)
|
|
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
|
|
await focusActiveTab()
|
|
return
|
|
}
|
|
await router.push(destination)
|
|
}
|
|
|
|
async function closeTabs(tabIds: readonly string[]) {
|
|
const uniqueIds = [...new Set(tabIds)].filter((id) => workspace.tabs.some((tab) => tab.id === id))
|
|
if (uniqueIds.length === 0 || !await confirmDiscard(uniqueIds)) return
|
|
const activeWillClose = workspace.activeId !== null && uniqueIds.includes(workspace.activeId)
|
|
unsaved.removeWorkspaces(uniqueIds)
|
|
const fallback = workspace.removeByIds(uniqueIds)
|
|
if (activeWillClose) await navigateToFallback(fallback?.path ?? null)
|
|
else await focusActiveTab()
|
|
}
|
|
|
|
async function activateTab(path: string) {
|
|
if (path === route.fullPath) return
|
|
try {
|
|
await router.push(path)
|
|
} catch {
|
|
ElMessage.error('页签切换失败,请重试')
|
|
}
|
|
}
|
|
|
|
function idsForCommand(command: CloseCommand) {
|
|
if (command === 'all') return workspace.tabs.map((tab) => tab.id)
|
|
if (command === 'others') return workspace.tabs.filter((tab) => tab.id !== workspace.activeId).map((tab) => tab.id)
|
|
if (command === 'right') return workspace.tabs.slice(activeIndex.value + 1).map((tab) => tab.id)
|
|
return workspace.activeId ? [workspace.activeId] : []
|
|
}
|
|
|
|
function handleCommand(command: CloseCommand) {
|
|
void closeTabs(idsForCommand(command))
|
|
}
|
|
|
|
function handleTabKeydown(event: KeyboardEvent, index: number) {
|
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
event.preventDefault()
|
|
const buttons = [...(tabListRef.value?.querySelectorAll<HTMLElement>('[role="tab"]') ?? [])]
|
|
if (buttons.length === 0) return
|
|
const direction = event.key === 'ArrowRight' ? 1 : -1
|
|
buttons[(index + direction + buttons.length) % buttons.length]?.focus()
|
|
}
|
|
|
|
function handleTabWheel(event: WheelEvent) {
|
|
const tabList = tabListRef.value
|
|
if (!tabList || tabList.scrollWidth <= tabList.clientWidth) return
|
|
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
|
if (delta === 0) return
|
|
const maxScrollLeft = tabList.scrollWidth - tabList.clientWidth
|
|
const canScroll = delta < 0 ? tabList.scrollLeft > 0 : tabList.scrollLeft < maxScrollLeft
|
|
if (!canScroll) return
|
|
event.preventDefault()
|
|
tabList.scrollLeft = Math.max(0, Math.min(maxScrollLeft, tabList.scrollLeft + delta))
|
|
}
|
|
|
|
watch(
|
|
() => workspace.activeId,
|
|
async () => {
|
|
await nextTick()
|
|
const active = tabListRef.value?.querySelector<HTMLElement>('[aria-selected="true"]')
|
|
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
|
},
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<section class="workspace-tabs admin-workbench" aria-label="页面工作区">
|
|
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
|
|
<div
|
|
v-for="(tab, index) in workspace.tabs"
|
|
:key="tab.id"
|
|
class="workspace-tab"
|
|
:class="{ 'is-active': tab.id === workspace.activeId, 'is-dirty': unsaved.dirtyWorkspaceIds.has(tab.id) }"
|
|
>
|
|
<button
|
|
class="workspace-tab-activate"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="tab.id === workspace.activeId"
|
|
:tabindex="tab.id === workspace.activeId ? 0 : -1"
|
|
:title="tab.title"
|
|
@click="activateTab(tab.path)"
|
|
@keydown="handleTabKeydown($event, index)"
|
|
>
|
|
<span class="workspace-tab-label">{{ tab.title }}</span>
|
|
<span v-if="unsaved.dirtyWorkspaceIds.has(tab.id)" class="workspace-tab-dirty" aria-label="有未保存修改" />
|
|
</button>
|
|
<button
|
|
class="workspace-tab-close"
|
|
type="button"
|
|
:aria-label="`关闭页签:${tab.title}`"
|
|
:title="`关闭 ${tab.title}`"
|
|
@click="closeTabs([tab.id])"
|
|
><X :size="14" aria-hidden="true" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<el-dropdown trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper" @command="handleCommand">
|
|
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
|
|
<MoreHorizontal :size="18" aria-hidden="true" />
|
|
</el-button>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item command="current" :disabled="!workspace.activeId">关闭当前</el-dropdown-item>
|
|
<el-dropdown-item command="others" :disabled="workspace.tabs.length <= 1">关闭其他</el-dropdown-item>
|
|
<el-dropdown-item command="right" :disabled="!hasRightTabs">关闭右侧</el-dropdown-item>
|
|
<el-dropdown-item command="all" :disabled="workspace.tabs.length === 0" divided>关闭全部</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.workspace-tabs {
|
|
position: sticky;
|
|
top: 64px;
|
|
z-index: 14;
|
|
display: flex;
|
|
min-width: 0;
|
|
height: 44px;
|
|
align-items: stretch;
|
|
border-bottom: 1px solid var(--workbench-border);
|
|
background: var(--workbench-surface);
|
|
}
|
|
.workspace-tab-list {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex: 1;
|
|
align-items: flex-end;
|
|
gap: 4px;
|
|
overflow-x: auto;
|
|
padding: 6px 8px 0;
|
|
overscroll-behavior-inline: contain;
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
}
|
|
.workspace-tab-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
|
.workspace-tab {
|
|
display: flex;
|
|
min-width: 126px;
|
|
max-width: 220px;
|
|
height: 38px;
|
|
flex: 0 0 auto;
|
|
align-items: center;
|
|
border: 1px solid transparent;
|
|
border-bottom: 0;
|
|
border-radius: 8px 8px 0 0;
|
|
color: var(--workbench-muted);
|
|
}
|
|
.workspace-tab:hover { background: var(--workbench-surface-soft); color: var(--workbench-text); }
|
|
.workspace-tab.is-active { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
|
|
.workspace-tab-activate {
|
|
display: flex;
|
|
min-width: 0;
|
|
height: 100%;
|
|
flex: 1;
|
|
align-items: center;
|
|
gap: 7px;
|
|
padding: 0 4px 0 12px;
|
|
border: 0;
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
text-align: left;
|
|
}
|
|
.workspace-tab-label { overflow: hidden; font-size: 12px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
|
|
.workspace-tab-dirty { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #d97706; }
|
|
.workspace-tab-close {
|
|
display: grid;
|
|
width: 30px;
|
|
height: 30px;
|
|
flex: 0 0 30px;
|
|
place-items: center;
|
|
margin-right: 3px;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--workbench-muted);
|
|
cursor: pointer;
|
|
}
|
|
.workspace-tab-close:hover { background: var(--workbench-accent-border); color: var(--workbench-primary); }
|
|
.workspace-tab-activate:focus-visible,
|
|
.workspace-tab-close:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: -2px; }
|
|
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--workbench-border); color: var(--workbench-muted); }
|
|
|
|
@media (max-width: 720px) {
|
|
.workspace-tabs { top: 64px; height: 48px; }
|
|
.workspace-tab-list { align-items: center; padding: 4px 6px; }
|
|
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
|
|
.workspace-tab.is-active { border-color: var(--workbench-accent-border); }
|
|
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
|
|
.workspace-tab-more { height: 48px; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.workspace-tab-list { scroll-behavior: auto; }
|
|
}
|
|
</style>
|