Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | 1x 1x 1x 1x 1x 1x 1x | <script setup>
import { computed, defineComponent, h, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import Splitter from '@/components/ui/splitter/splitter.vue'
import SplitterPanel from '@/components/ui/splitter/panel.vue'
import AppSidebar from './components/AppSidebar.vue'
import AppTabs from './components/AppTabs.vue'
import AppTopbar from './components/AppTopbar.vue'
import DetailPage from '@/views/detail/DetailPage.vue'
import { cacheKeyOf } from '@/utils/cacheKey'
const route = useRoute()
const appStore = useAppStore()
/* 页签缓存核心(统一处理所有页签,含详情页):
* - keep-alive 的 cache key = 外层 <component> 的 :key = route.fullPath(稳定)。
* 因此切来切去、刷新都不会生成新的 keep-alive 缓存项 —— 消除「刷新泄漏」。
* - 刷新(双击页签)仅改变内层 <component> 的 :key(fullPath:refreshKey),
* 使页面组件自身重新挂载、状态归零,而外层缓存槽位不变。
* - 每个页签一个命名包装组件(include 名:普通页 = componentName,详情页 =
* cacheKeyOf(fullPath)),使 keep-alive 的 include 能按页签精确销毁实例;
* 不同参数(/detail/1、/detail/2)因外层 key = fullPath 不同而天然多个独立缓存。 */
const pageWrapperCache = {}
function getPageWrapper(name, fullPath, innerComponent) {
if (!pageWrapperCache[name]) {
pageWrapperCache[name] = {
fullPath,
comp: defineComponent({
name,
setup() {
const r = useRoute()
const app = useAppStore()
return () => {
const inner = r.meta?.cacheByKey ? h(DetailPage, { id: r.params.id }) : innerComponent
const innerKey = `${r.fullPath}:${app.getRouteRefreshKey(r.fullPath)}`
return h(inner, { key: innerKey })
}
},
}),
}
}
return pageWrapperCache[name].comp
}
/* 回收已关闭详情页(page_ 前缀)的包装组件定义,避免 pageWrapperCache 随访问过的
* fullPath 无限增长(普通页的 componentName 数量有限、实例本身由 keep-alive :max 限制)。 */
watch(
() => appStore.visitedTabs.map((t) => t.key),
(keys) => {
const keep = new Set(keys)
for (const name of Object.keys(pageWrapperCache)) {
if (name.startsWith('page_') && !keep.has(pageWrapperCache[name].fullPath)) {
delete pageWrapperCache[name]
}
}
},
)
/* ---------- 侧栏宽度(展开态可拖拽调宽,由 Splitter 接管) ---------- */
const SIDEBAR_MIN = 180
const SIDEBAR_MAX = 400
const SIDEBAR_DEFAULT = 220
// 折叠态:48px(对齐 shadcn sidebar 默认 3rem,仅容图标)
const SIDEBAR_COLLAPSED = 48
const sidebarWidth = ref(SIDEBAR_DEFAULT)
// 拖拽中:禁用 flex-basis 过渡,保证跟手
const dragging = ref(false)
const sidebarSize = computed(() =>
appStore.sidebarCollapsed ? SIDEBAR_COLLAPSED : sidebarWidth.value,
)
// Splitter resize 事件回调:sizes 为各面板宽度(px)数组
function onSplitterResize(sizes) {
const width = Math.round(Number(sizes[0]))
if (Number.isFinite(width)) sidebarWidth.value = width
}
</script>
<template>
<div class="fms-layout" :class="{ 'is-collapsed': appStore.sidebarCollapsed }">
<Splitter
direction="horizontal"
bar-on-hover
class="fms-layout-splitter"
:class="{ 'is-dragging': dragging }"
@resize="onSplitterResize"
@resize-start="dragging = true"
@resize-end="dragging = false"
>
<!-- 侧栏面板:折叠态固定 48px 且不可调整 -->
<SplitterPanel
:size="sidebarSize"
:min="appStore.sidebarCollapsed ? undefined : SIDEBAR_MIN"
:max="appStore.sidebarCollapsed ? undefined : SIDEBAR_MAX"
:resizable="!appStore.sidebarCollapsed"
>
<AppSidebar />
</SplitterPanel>
<!-- 主区面板:顶栏独立在灰底上,下方一张白卡放 tabs + 内容 -->
<SplitterPanel class="fms-main-panel">
<div class="fms-main-col">
<AppTopbar />
<div class="fms-main">
<AppTabs />
<div class="fms-content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<div :key="route.fullPath" class="fms-page-wrap">
<keep-alive :include="appStore.cachedViews" :max="8">
<component
:is="
getPageWrapper(
route.meta?.cacheByKey
? cacheKeyOf(route.fullPath)
: route.meta?.componentName,
route.fullPath,
Component,
)
"
:key="route.fullPath"
/>
</keep-alive>
</div>
</transition>
</router-view>
</div>
</div>
</div>
</SplitterPanel>
</Splitter>
</div>
</template>
<style lang="scss" scoped>
/* ============================================================
* 布局壳样式(scoped):布局骨架 + 页面过渡动画
* 基于 --fms-* token,无响应式断点
* 侧栏 / 主区由 Splitter 组件布局,拖拽条为 bar-on-hover
* 注:命中 Splitter 内部元素(.fms-splitter-bar / .fms-splitter-horizontal)
* 的规则需用 :deep(),因为这些 DOM 不在本组件模板内
* ============================================================ */
/* ---- 布局骨架 ---- */
.fms-layout {
display: flex;
height: 100vh;
min-width: 0;
overflow: hidden;
background: var(--fms-background);
}
/* Splitter 容器:撑满布局高度 */
.fms-layout-splitter {
flex: 1;
min-width: 0;
}
/* 折叠态:隐藏分隔条(此时侧栏固定 48px 且不可调整) */
.fms-layout.is-collapsed .fms-layout-splitter :deep(.fms-splitter-bar) {
display: none;
}
/* 非折叠态:分隔条命中区改为悬浮(width: 0 + 负 margin),不占 flex 宽度。
* 否则侧栏内容右 padding(8px) + 分隔条占位(6px) 叠加成 14px 空隙,
* 而右侧仅 8px,两侧不对称(折叠态因 display:none 掩盖了此问题) */
.fms-layout .fms-layout-splitter :deep(.fms-splitter-horizontal .fms-splitter-bar) {
width: 0;
margin: 0 calc(var(--fms-splitter-trigger-size) / -2);
position: relative;
z-index: 1;
}
/* 侧栏面板宽度过渡:折叠/展开切换平滑;拖拽时禁用保证跟手 */
.fms-layout-splitter .fms-splitter-panel {
transition: flex-basis 180ms ease;
}
.fms-layout-splitter.is-dragging .fms-splitter-panel {
transition: none;
}
/* 主区面板:四周留 8px 间距,左侧 0。
* 左侧空隙改由侧栏内容自身的右 padding(8px)承担,与右侧 8px 对称;
* 左 0 后卡片阴影会外扩到面板边界外,须将主区面板 overflow 改为 visible 才不被裁。
* 卡片自身 overflow: hidden + .fms-content 内部滚动,内容不会真正外溢,改为 visible 安全 */
.fms-main-panel {
padding: 8px 8px 8px 0;
}
/* 覆盖 Splitter 面板默认 overflow: auto(scoped 同名规则特异性更高,需带 .fms-layout 提权),
* 让主卡片左侧阴影完整绘制到侧栏背景上(对齐 old2 的 z-index 效果) */
.fms-layout .fms-splitter-panel.fms-main-panel {
overflow: visible;
}
/* 主区纵向列:顶栏在灰底上独立成条,下方白卡与其留 8px 间距 */
.fms-main-col {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
min-height: 0;
gap: 8px;
}
/* 主区白卡:tabs + 内容一体,圆角 + 阴影,撑满剩余高度 */
.fms-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
}
/* 内容区:白卡内滚动区,顶部由 .fms-tabs 的底边线分隔 */
.fms-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 16px 16px;
}
/* 页面过渡外层包裹:稳定的 DOM 根,供 transition 的 out-in 模式直接管理,
* 避免 keep-alive 直接作 Transition 子节点时在 patch 阶段访问 null.parentNode 报错 */
.fms-page-wrap {
min-height: 100%;
}
/* ---- 页面过渡 ---- */
.page-enter-active {
transition:
opacity 160ms ease-out,
transform 160ms ease-out;
}
.page-leave-active {
transition: opacity 80ms ease-in;
}
.page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.page-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.fms-layout-splitter .fms-splitter-panel {
transition: none;
}
.page-enter-active,
.page-leave-active {
transition: none;
}
}
</style>
|