20260817225227

This commit is contained in:
oneao committed 2026-08-17 22:52:27 +08:00
1 parent d3acc7d28f
commit b90ae89b0e
39 files changed
+3861 -353

No files matched your search

+58 -6
View File
@@ -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;