20260817225227
This commit is contained in:
1 parent
d3acc7d28f
commit
b90ae89b0e
39 files changed
+3861
-353
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
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'
|
||||
@@ -7,13 +7,54 @@ 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()
|
||||
|
||||
// 路由实例 key:fullPath + 刷新计数,双击页签刷新时强制重渲染
|
||||
const routeInstanceKey = computed(
|
||||
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
|
||||
/* 页签缓存核心(统一处理所有页签,含详情页):
|
||||
* - 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 接管) ---------- */
|
||||
@@ -70,8 +111,19 @@ function onSplitterResize(sizes) {
|
||||
<div class="fms-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<keep-alive :max="8">
|
||||
<component :is="Component" :key="routeInstanceKey" />
|
||||
<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>
|
||||
</transition>
|
||||
</router-view>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { AudioWaveform, Check, ChevronsUpDown, Command, Component, GalleryVerticalEnd, LayoutGrid } from '@lucide/vue'
|
||||
import {
|
||||
AudioWaveform,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Command,
|
||||
Component,
|
||||
GalleryVerticalEnd,
|
||||
LayoutGrid,
|
||||
} from '@lucide/vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { getIcon } from '@/utils/lucideIcons'
|
||||
@@ -41,7 +49,6 @@ const navMain = computed(() => {
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
const menuModules = permissionStore.modules.filter(
|
||||
(m) => m.b_canmenu === 1 && m.b_canuse === 1 && ['directory', 'page'].includes(m.b_module_type),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import { cacheKeyOf } from '@/utils/cacheKey'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -23,10 +24,14 @@ watch(
|
||||
async () => {
|
||||
const title = route.meta?.title
|
||||
if (title) {
|
||||
// cacheByKey 路由(如 /detail/:id):每个 fullPath 一个独立组件名,
|
||||
// 使 keep-alive 的 include 能按参数精确销毁其实例,且不同参数天然多个独立缓存。
|
||||
const name = route.meta?.cacheByKey ? cacheKeyOf(route.fullPath) : route.meta?.componentName
|
||||
appStore.addVisitedTab({
|
||||
key: route.fullPath,
|
||||
label: title,
|
||||
closable: route.fullPath !== '/dashboard',
|
||||
name,
|
||||
})
|
||||
}
|
||||
await nextTick()
|
||||
@@ -325,21 +330,21 @@ function refreshTab(key) {
|
||||
&.is-active {
|
||||
background: var(--fms-primary);
|
||||
border-color: var(--fms-primary);
|
||||
color: #fff;
|
||||
color: var(--fms-primary-text);
|
||||
}
|
||||
|
||||
&.is-active:hover {
|
||||
background: var(--fms-primary);
|
||||
border-color: var(--fms-primary);
|
||||
color: #fff;
|
||||
color: var(--fms-primary-text);
|
||||
}
|
||||
|
||||
&.is-active .fms-tab-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: color-mix(in srgb, var(--fms-primary-text) 85%, transparent);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--fms-primary-text) 22%, transparent);
|
||||
color: var(--fms-primary-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,43 @@ import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
Check,
|
||||
CreditCard,
|
||||
Languages,
|
||||
LogOut,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Moon,
|
||||
Palette,
|
||||
PanelLeft,
|
||||
Search,
|
||||
Sun,
|
||||
} from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Message } from '@/components/ui/message/message-manager'
|
||||
import { PRESETS } from '@/theme/presets'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
import ColorThemePicker from '@/components/ui/ColorThemePicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/* 深浅色切换:原 ThemeToggle 组件逻辑内联,按钮样式复用 .fms-icon-btn */
|
||||
const isDark = computed(() => appStore.darkMode)
|
||||
function toggleDark() {
|
||||
appStore.toggleDark()
|
||||
}
|
||||
|
||||
/* 主色选择:原 ColorThemePicker 组件逻辑内联,直接复用 .fms-icon-btn 风格 */
|
||||
const activeColor = computed(() => appStore.primaryColor)
|
||||
function pickColor(color) {
|
||||
appStore.setPrimary(color)
|
||||
}
|
||||
|
||||
const user = computed(() => ({
|
||||
name: authStore.user?.name ?? '用户',
|
||||
account: authStore.user?.account ?? '',
|
||||
@@ -128,13 +142,35 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</template>
|
||||
<template #menu>
|
||||
<div class="fms-topbar-scheme">
|
||||
<ColorThemePicker />
|
||||
<div class="color-picker" role="group" aria-label="主题色">
|
||||
<button
|
||||
v-for="preset in PRESETS"
|
||||
:key="preset.color"
|
||||
class="swatch"
|
||||
type="button"
|
||||
:style="{ background: preset.color }"
|
||||
:aria-label="preset.name"
|
||||
:title="preset.name"
|
||||
:aria-pressed="activeColor === preset.color"
|
||||
@click="pickColor(preset.color)"
|
||||
>
|
||||
<Check v-if="activeColor === preset.color" class="check" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
<!-- 深浅色切换 -->
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fms-icon-btn"
|
||||
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
|
||||
:title="isDark ? '浅色模式' : '深色模式'"
|
||||
@click="toggleDark"
|
||||
>
|
||||
<component :is="isDark ? Moon : Sun" />
|
||||
</Button>
|
||||
|
||||
<!-- 全屏切换 -->
|
||||
<Button
|
||||
@@ -153,12 +189,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<span class="fms-separator" aria-hidden="true" />
|
||||
|
||||
<!-- 用户下拉:菜单项走组件库标准 items,不手写;富头部渲染头像+名字+组织 -->
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
:offset="8"
|
||||
:items="userMenuItems"
|
||||
@click="onUserMenuClick"
|
||||
>
|
||||
<Dropdown placement="bottom-end" :offset="8" :items="userMenuItems" @click="onUserMenuClick">
|
||||
<template #trigger>
|
||||
<button class="fms-user-trigger" type="button" :title="user.name">
|
||||
<span class="fms-avatar">{{ user.name.slice(0, 1) }}</span>
|
||||
@@ -303,6 +334,46 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
padding: 4px 8px 8px;
|
||||
}
|
||||
|
||||
/* 色板(原 ColorThemePicker 组件内联) */
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 1px;
|
||||
transition:
|
||||
outline-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--fms-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--fms-primary-text);
|
||||
}
|
||||
|
||||
/* 用户触发按钮 */
|
||||
.fms-user-trigger {
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user