mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-26 22:31:21 +00:00
refactor(frontend): 统一组件名匹配KeepAlive缓存逻辑,重构路由缓存机制
1. 调整所有页面组件的name为路由/菜单对应的route_name,适配KeepAlive匹配规则 2. 重构路由嵌套缓存逻辑,修复多标签页下重复挂载页面导致接口并发请求的问题 3. 移除冗余的onMounted自动请求代码,统一数据加载逻辑 4. 优化缓存键生成和KeepAlive包含排除规则,确保缓存精准生效
This commit is contained in:
@@ -17,25 +17,19 @@
|
||||
<RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle">
|
||||
<Transition :name="actualTransition" mode="out-in">
|
||||
<div v-if="Component" class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
|
||||
<!-- 是否缓存以后端菜单 keep_alive → meta.keepAlive 为准;此处 !== false 即包 KeepAlive(与 MenuProcessor 一致) -->
|
||||
<KeepAlive
|
||||
v-if="wrapPageWithKeepAlive"
|
||||
:max="10"
|
||||
:include="keepAliveInclude"
|
||||
:exclude="keepAliveExclude"
|
||||
>
|
||||
<!--
|
||||
外层缓存「一级出口组件」:目录菜单为壳组件 NestedRouterParent,一级叶子为页面组件。
|
||||
此处 KeepAlive 必须常驻,不能按当前路由 meta.keepAlive 做 v-if 开关:
|
||||
卸载 KeepAlive 会连同已缓存实例一起销毁,导致每次切回都重新挂载(接口重复请求)。
|
||||
叶子的取舍由 include/exclude 表达(include 只在多标签模式下生效)。
|
||||
-->
|
||||
<KeepAlive :max="10" :include="keepAliveInclude" :exclude="keepAliveExclude">
|
||||
<component
|
||||
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
|
||||
:is="Component"
|
||||
:key="routeLeafCacheKey(router)"
|
||||
:key="routeShellCacheKey(router)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
v-else
|
||||
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
|
||||
:is="Component"
|
||||
:key="routeLeafCacheKey(router)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
@@ -67,25 +61,42 @@ import { useSettingsStore, useWorktabStore } from "@stores";
|
||||
defineOptions({ name: "FaPageContent" });
|
||||
|
||||
/**
|
||||
* KeepAlive / RouterView 子节点缓存键。
|
||||
* - `meta.remountOnFullPath === true`:整 URL 参与键(依赖 query 初值或须随 query 重建的页)。
|
||||
* - 有 `name`:`name` + `params`,同一路由仅 query 变化不换实例。
|
||||
* - 无 `name`:`path`(勿用 fullPath,否则仅 query 变化也会反复挂载)。
|
||||
* KeepAlive 缓存键(外层出口)。
|
||||
*
|
||||
* 本处 `RouterView` 处于 depth=1,其 `Component` 是 **一级路由组件**:
|
||||
* 目录菜单为壳组件 `NestedRouterParent`,一级叶子(如 /home)为页面组件。
|
||||
* 因此缓存单位必须是「壳组件」,不能用叶子路由身份(name/params)当键:
|
||||
* 否则同一壳会被缓存成 N 份实例,旧实例只被 move 到游离容器而不销毁,
|
||||
* 其内部 RouterView 仍随全局 route 重渲染 —— 切页时目标页面会在所有存活壳里
|
||||
* 被重复挂载,同一接口被并发触发 N 次(N = 存活壳数,随缓存淘汰动态变化)。
|
||||
* 用壳组件名作键后,同一壳全局只有一个实例,页面的重复挂载随之消失,
|
||||
* 叶子级缓存由壳内的 KeepAlive 负责。
|
||||
*/
|
||||
function routeLeafCacheKey(r: RouteLocationNormalizedLoaded): string {
|
||||
if (r.meta.remountOnFullPath === true) {
|
||||
return r.fullPath;
|
||||
}
|
||||
if (r.name != null) {
|
||||
return `${String(r.name)}:${JSON.stringify(r.params ?? {})}`;
|
||||
}
|
||||
return r.path;
|
||||
function routeShellCacheKey(r: RouteLocationNormalizedLoaded): string {
|
||||
const shell = r.matched[1]?.components?.default as { name?: string } | undefined;
|
||||
if (shell?.name) return shell.name;
|
||||
return r.matched[1]?.path ?? r.path;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
/** 动态菜单 meta.keepAlive(后端 keep_alive);仅显式 false 时不包 KeepAlive */
|
||||
const wrapPageWithKeepAlive = computed(() => route.meta.keepAlive !== false);
|
||||
|
||||
/**
|
||||
* 解析 path 在外层 RouterView 出口(depth=1)实际渲染的组件。
|
||||
* KeepAlive 的 include / exclude 按「组件 name」匹配,所以必须回解组件,不能用路由 name;
|
||||
* depth=1 命中目录时为壳组件 NestedRouterParent(isShell=true),命中一级叶子时为页面组件。
|
||||
*/
|
||||
function resolveDepth1(path: string): { name: string; isShell: boolean } {
|
||||
try {
|
||||
const matched = router.resolve({ path }).matched;
|
||||
const comp = matched[1]?.components?.default as
|
||||
| { name?: string; __name?: string }
|
||||
| undefined;
|
||||
return { name: comp?.name ?? comp?.__name ?? "", isShell: matched.length > 2 };
|
||||
} catch {
|
||||
return { name: "", isShell: false };
|
||||
}
|
||||
}
|
||||
|
||||
const isNarrowViewport = useMediaQuery("(max-width: 800px)");
|
||||
const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-content"));
|
||||
@@ -94,32 +105,23 @@ const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"
|
||||
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
|
||||
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
|
||||
|
||||
/** 嵌套路由壳组件名:内层叶子缓存依赖壳实例存活,include 需一并纳入(对应 routes.ts 的 NestedRouterParent) */
|
||||
const NESTED_PARENT_COMPONENT_NAME = "NestedRouterParent";
|
||||
|
||||
/** 判断路径是否命中嵌套路由(匹配链上存在 NestedRouterParent 壳) */
|
||||
function isNestedRoutePath(path: string): boolean {
|
||||
try {
|
||||
return router
|
||||
.resolve({ path })
|
||||
.matched.some((m) => m.components?.default?.name === NESTED_PARENT_COMPONENT_NAME);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。
|
||||
* 嵌套路由额外把壳组件 NestedRouterParent 纳入 include,否则内层叶子缓存会随壳销毁而失效。
|
||||
* 多标签开启时:只把工作栏已打开标签对应的一级组件名放进 include(组件 name,非路由 name)。
|
||||
* - 目录标签:一级组件是壳 NestedRouterParent,必须恒缓存 —— 壳实例一旦销毁,其内部叶子缓存也随之丢失。
|
||||
* - 一级叶子标签:按标签自身 keepAlive 取舍。
|
||||
* 关闭多标签时不传 include,避免白名单过窄误伤缓存。
|
||||
*/
|
||||
const keepAliveInclude = computed(() => {
|
||||
if (!showWorkTab.value) return undefined;
|
||||
const names = new Set<string>();
|
||||
for (const t of opened.value) {
|
||||
if (t.name && t.keepAlive !== false) names.add(String(t.name));
|
||||
if (t.keepAlive !== false && isNestedRoutePath(t.path)) {
|
||||
names.add(NESTED_PARENT_COMPONENT_NAME);
|
||||
}
|
||||
const { name, isShell } = resolveDepth1(t.path);
|
||||
if (name && (isShell || t.keepAlive !== false)) names.add(name);
|
||||
}
|
||||
// 兜底当前路由:避免 opened 尚未写入时当前页面命中不到白名单而不被缓存
|
||||
const current = resolveDepth1(route.path);
|
||||
if (current.name && (current.isShell || route.meta.keepAlive !== false)) {
|
||||
names.add(current.name);
|
||||
}
|
||||
return names.size ? Array.from(names) : undefined;
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import type { AppRouteRecordRaw } from "@utils";
|
||||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||||
import { defineComponent, h, KeepAlive, onMounted, ref, type VNode } from "vue";
|
||||
import { computed, defineComponent, h, KeepAlive, onMounted, ref, type VNode } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { $t } from "@/locales";
|
||||
import { useWorktabStore } from "@stores";
|
||||
@@ -146,20 +146,34 @@ export const NestedRouterParent = defineComponent({
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const worktabStore = useWorktabStore();
|
||||
|
||||
/** 当前叶子组件名:KeepAlive 的 include/exclude 按「组件 name」匹配,不能用路由 name */
|
||||
const leafComponentName = computed(() => {
|
||||
const comp = route.matched[route.matched.length - 1]?.components?.default as
|
||||
| { name?: string; __name?: string }
|
||||
| undefined;
|
||||
return comp?.name ?? comp?.__name ?? "";
|
||||
});
|
||||
|
||||
/**
|
||||
* 关闭标签由工作栏的 keepAliveExclude 负责清理;
|
||||
* meta.keepAlive === false 的叶子额外追加自身组件名使其不进缓存。
|
||||
* 这里始终渲染 KeepAlive:若按当前路由 keepAlive 做 v-if 开关,卸载 KeepAlive
|
||||
* 会把其余叶子的缓存一并销毁,导致切回时重新挂载(接口重复请求)。
|
||||
*/
|
||||
const innerExclude = computed(() => {
|
||||
const base = worktabStore.keepAliveExclude ?? [];
|
||||
if (route.meta.keepAlive === false && leafComponentName.value) {
|
||||
return [...base, leafComponentName.value];
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(RouterView, null, {
|
||||
default: ({ Component }: { Component?: VNode }) => {
|
||||
if (!Component) return null;
|
||||
// 内层 KeepAlive(v-slot 模式):按叶子组件名缓存,与工作栏 tab.name 一致,
|
||||
// 关闭标签时由 keepAliveExclude 精确清除;meta.keepAlive === false 时不缓存叶子
|
||||
if (route.meta.keepAlive !== false) {
|
||||
return h(
|
||||
KeepAlive,
|
||||
{ exclude: worktabStore.keepAliveExclude },
|
||||
{ default: () => h(Component) }
|
||||
);
|
||||
}
|
||||
return h(Component);
|
||||
return h(KeepAlive, { exclude: innerExclude.value }, { default: () => h(Component) });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
import VersionAPI from "@/api/module_system/version";
|
||||
|
||||
defineOptions({ name: "SystemChangeLog" });
|
||||
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "FastlinkChangeLog" });
|
||||
|
||||
interface UpgradeLog {
|
||||
version: string;
|
||||
|
||||
@@ -345,7 +345,8 @@ import { ElMessage } from "element-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { redirectToLogin, dataURLToFile } from "@utils";
|
||||
|
||||
defineOptions({ name: "UserProfile" });
|
||||
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "FastlinkProfile" });
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -125,7 +125,8 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Check } from "@element-plus/icons-vue";
|
||||
|
||||
defineOptions({ name: "DashboardPricing" });
|
||||
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "FastlinkPricing" });
|
||||
|
||||
// ─── Mock Types ───
|
||||
|
||||
|
||||
@@ -1065,7 +1065,8 @@ import { MANUAL_MODULES_AFTER_SYSTEM, MANUAL_SYSTEM_TAIL_PAGES } from "./manualS
|
||||
import { manualModuleMatchesQuery, manualPageMatchesQuery } from "./manualTocSearch";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
defineOptions({ name: "DashboardTutorial" });
|
||||
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "FastlinkTutorial" });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -141,7 +141,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "ChatSession",
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
name: "Memory",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -281,7 +281,8 @@ import CacheAPI, {
|
||||
import { echarts } from "@/plugins/echarts";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
|
||||
defineOptions({ name: "CacheMonitor" });
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "MonitorCache" });
|
||||
|
||||
const activeTab = ref("0");
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "OnlineUser",
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
name: "MonitorOnline",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
@@ -263,11 +264,6 @@ async function handleClearAll() {
|
||||
clearAllLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 列表数据在页面挂载时加载一次,不自动轮询
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -188,7 +188,8 @@
|
||||
<script lang="ts" setup>
|
||||
import ServerAPI, { type ServerInfo } from "@/api/module_monitor/server";
|
||||
|
||||
defineOptions({ name: "ServerMonitor" });
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "MonitorServer" });
|
||||
|
||||
const server = ref<ServerInfo>({
|
||||
cpu: { cpu_num: 0, used: 0, sys: 0, free: 0 },
|
||||
|
||||
@@ -561,10 +561,6 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (props.dictTypeId) getData();
|
||||
});
|
||||
|
||||
async function handleSearchBarSearch(params: DictDataSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams({
|
||||
|
||||
@@ -255,7 +255,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "SysMenu",
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
// eslint-disable-next-line vue/no-reserved-component-names -- 须与后端菜单 route_name 对齐;本组件仅由路由加载,不作为模板标签使用
|
||||
name: "Menu",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -237,7 +237,8 @@ import { useConfigStore } from "@stores";
|
||||
import ParamTabPane from "./components/ParamTabPane.vue";
|
||||
import ParamFieldCard from "./components/ParamFieldCard.vue";
|
||||
|
||||
defineOptions({ name: "ParamsSettings" });
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "Params" });
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const activeTab = ref("brand");
|
||||
|
||||
@@ -407,7 +407,8 @@ import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
|
||||
import FaCardGrid from "@/components/cards/fa-card-grid/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "TicketCard",
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
name: "ModuleTicket",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -150,7 +150,8 @@ import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "Version",
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
name: "ModuleVersion",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -368,6 +368,9 @@ import type { FormItemRule, FormRules } from "element-plus";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
defineOptions({ name: "WorkflowTransfer", inheritAttrs: false });
|
||||
|
||||
// ── 存储源 ────────────────────────────────────────────────────────────
|
||||
const sources = ref<SourceTable[]>([]);
|
||||
const sourceMap = computed<Record<number, SourceTable>>(() => {
|
||||
|
||||
@@ -184,7 +184,8 @@ import FaFileBrowserDialog from "./components/FaFileBrowserDialog.vue";
|
||||
import { protocolColor, protocolLabel } from "./components/protocol.ts";
|
||||
|
||||
defineOptions({
|
||||
name: "Workflow",
|
||||
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
|
||||
name: "WorkflowFlow",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user