From e8ac89dcd2b02cedb295543e9518f54584b0c0eb Mon Sep 17 00:00:00 2001 From: guoyuqi Date: Fri, 6 Nov 2026 17:39:19 +0800 Subject: [PATCH] fix : page caching settings --- frontend/src/components/cached-tabs/index.vue | 57 ++++++++ .../src/components/router-button/index.vue | 6 +- frontend/src/components/table/TableSearch.vue | 43 +++++- .../components/table/complex/ComplexTable.vue | 22 +++- .../table/complex/usePaginationCache.ts | 124 ++++++++++++++++++ frontend/src/composables/useCachedTab.ts | 89 +++++++++++++ .../src/layout/components/Sidebar/index.vue | 2 +- frontend/src/layout/index.vue | 8 +- frontend/src/routers/index.ts | 37 +----- frontend/src/utils/router.ts | 42 +++++- frontend/src/utils/search-reset.ts | 6 +- frontend/src/views/ai/agents/agent/index.vue | 1 + frontend/src/views/ai/mcp/server/index.vue | 7 +- frontend/src/views/ai/model/index.vue | 1 + frontend/src/views/ai/ollama/models/index.vue | 1 + .../src/views/container/container/index.vue | 1 + frontend/src/views/container/image/index.vue | 1 + .../src/views/container/network/index.vue | 1 + frontend/src/views/container/repo/index.vue | 1 + .../src/views/container/template/index.vue | 1 + frontend/src/views/container/volume/index.vue | 1 + frontend/src/views/cronjob/cronjob/index.vue | 1 + frontend/src/views/cronjob/library/index.vue | 1 + frontend/src/views/database/mongodb/index.vue | 1 + .../views/database/mongodb/remote/index.vue | 8 +- frontend/src/views/database/mysql/index.vue | 1 + .../src/views/database/mysql/remote/index.vue | 8 +- .../src/views/database/postgresql/index.vue | 1 + .../database/postgresql/remote/index.vue | 8 +- .../src/views/database/redis/remote/index.vue | 8 +- .../src/views/host/file-management/index.vue | 1 + .../src/views/host/firewall/docker/index.vue | 2 + .../src/views/host/firewall/forward/index.vue | 1 + .../src/views/host/firewall/rule/index.vue | 1 + frontend/src/views/host/ssh/log/log.vue | 8 +- frontend/src/views/log/host-system/index.vue | 8 +- frontend/src/views/log/login/index.vue | 8 +- frontend/src/views/log/operation/index.vue | 8 +- frontend/src/views/log/task/index.vue | 8 +- .../src/views/setting/alert/dash/index.vue | 1 + .../src/views/setting/alert/log/index.vue | 7 +- .../src/views/setting/alert/setting/index.vue | 1 + .../views/setting/backup-account/index.vue | 1 + frontend/src/views/setting/license/index.vue | 8 +- frontend/src/views/setting/snapshot/index.vue | 1 + frontend/src/views/terminal/command/index.vue | 11 +- frontend/src/views/terminal/host/index.vue | 11 +- frontend/src/views/terminal/index.vue | 86 ++++-------- frontend/src/views/toolbox/clam/index.vue | 1 + frontend/src/views/toolbox/ftp/index.vue | 1 + .../views/website/runtime/dotnet/index.vue | 8 +- .../src/views/website/runtime/go/index.vue | 8 +- .../src/views/website/runtime/java/index.vue | 8 +- .../src/views/website/runtime/node/index.vue | 8 +- .../src/views/website/runtime/php/index.vue | 8 +- .../views/website/runtime/python/index.vue | 8 +- .../views/website/ssl/acme-account/index.vue | 8 +- frontend/src/views/website/ssl/ca/index.vue | 8 +- .../views/website/ssl/dns-account/index.vue | 7 +- frontend/src/views/website/ssl/index.vue | 8 +- frontend/src/views/website/template/index.vue | 8 +- frontend/src/views/website/website/index.vue | 1 + 62 files changed, 603 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/cached-tabs/index.vue create mode 100644 frontend/src/components/table/complex/usePaginationCache.ts create mode 100644 frontend/src/composables/useCachedTab.ts diff --git a/frontend/src/components/cached-tabs/index.vue b/frontend/src/components/cached-tabs/index.vue new file mode 100644 index 000000000..1d256edfa --- /dev/null +++ b/frontend/src/components/cached-tabs/index.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/src/components/router-button/index.vue b/frontend/src/components/router-button/index.vue index e44d0e15d..6262b8f98 100644 --- a/frontend/src/components/router-button/index.vue +++ b/frontend/src/components/router-button/index.vue @@ -50,7 +50,7 @@ + + diff --git a/frontend/src/components/table/complex/ComplexTable.vue b/frontend/src/components/table/complex/ComplexTable.vue index 71e7af278..fe86c1314 100644 --- a/frontend/src/components/table/complex/ComplexTable.vue +++ b/frontend/src/components/table/complex/ComplexTable.vue @@ -175,6 +175,8 @@ import { useCardColumns } from './useCardColumns'; import { useContextMenu } from './useContextMenu'; import { useResponsivePagination } from './useResponsivePagination'; import { useTableSelection } from './useTableSelection'; +import { useRoute } from 'vue-router'; +import { usePaginationCache } from './usePaginationCache'; const slots = useSlots(); const attrs = useAttrs(); const { isMobile, openMenuTabs, currentNode } = useGlobalStore(); @@ -187,11 +189,16 @@ type ViewMode = 'table' | 'card'; const props = defineProps({ header: String, data: { type: Array as PropType, default: () => [] }, + loading: { type: Boolean, default: false }, selectionContext: { type: [String, Number, Array, Object, Function], default: undefined }, paginationConfig: { type: Object, required: false, }, + cachePagination: { type: Boolean, default: undefined }, + refreshOnRestore: { type: Boolean, default: true }, + cacheId: String, + cacheScope: [String, Number], pageSizes: { type: Array as PropType, default: () => [5, 10, 20, 50, 100, 200, 500], @@ -217,6 +224,15 @@ const props = defineProps({ }, }); const emit = defineEmits(['search', 'update:selects', 'update:paginationConfig', 'update:viewMode']); +usePaginationCache({ + pagePath: useRoute().path, + pagination: () => props.paginationConfig as { currentPage: number; pageSize: number; cacheSizeKey?: string }, + enabled: () => props.cachePagination, + id: () => props.cacheId, + scope: () => props.cacheScope, + search: () => emit('search'), + refreshOnRestore: () => props.refreshOnRestore, +}); const tableRef = ref(); const complexTableRef = ref(); const tableHeight = ref(''); @@ -343,10 +359,10 @@ const handleRightClick = (row, column, event) => { }; watch( - () => props.data, + () => [props.data, props.loading], () => { const pagination = props.paginationConfig; - if (!pagination || !pagination.pageSize) return; + if (props.loading || !pagination || !pagination.pageSize) return; const lastPage = Math.max(1, Math.ceil(pagination.total / pagination.pageSize)); if (pagination.currentPage > lastPage) { pagination.currentPage = lastPage; @@ -361,7 +377,7 @@ function currentChange() { } function sizeChange() { - if (props.paginationConfig.cacheSizeKey) { + if (props.cachePagination === false && props.paginationConfig.cacheSizeKey) { writePanelCache( 'pagination', panelCacheKey(`size:${props.paginationConfig.cacheSizeKey}`), diff --git a/frontend/src/components/table/complex/usePaginationCache.ts b/frontend/src/components/table/complex/usePaginationCache.ts new file mode 100644 index 000000000..1d74269a9 --- /dev/null +++ b/frontend/src/components/table/complex/usePaginationCache.ts @@ -0,0 +1,124 @@ +import { nextTick, onActivated, onDeactivated, onScopeDispose, watch } from 'vue'; +import GlobalStore from '@/store/modules/global'; +import { panelCacheKey, readPanelCache, writePanelCache } from '@/utils/panel-cache'; + +interface Pagination { + currentPage: number; + pageSize: number; + cacheSizeKey?: string; +} +interface Options { + pagePath: string; + pagination: () => Pagination | undefined; + enabled: () => boolean | undefined; + id: () => string | undefined; + scope: () => string | number | undefined; + search: () => void; + refreshOnRestore?: () => boolean; +} + +/** Restore during child setup, before the owning page's onMounted query. */ +export const usePaginationCache = (options: Options) => { + const store = GlobalStore(); + const defaults = new WeakMap(); + let active = true; + let restoring = false; + let activeKey: string | undefined; + let queued = false; + let disposed = false; + let pending: { key: string; currentPage: number; pageSize: number } | undefined; + let writeQueued = false; + const flush = () => { + if (!pending) return; + const { key: target, currentPage, pageSize } = pending; + pending = undefined; + writePanelCache('pagination', target, { currentPage, pageSize }); + }; + const requestSearch = () => { + if (queued || !key()) return; + queued = true; + void nextTick(() => { + queued = false; + if (active && !disposed && key() && options.refreshOnRestore?.() !== false) options.search(); + }); + }; + const key = () => { + const pagination = options.pagination(); + if (options.enabled() === false || !pagination || !store.cacheUserID) return undefined; + return panelCacheKey( + JSON.stringify([ + 'table-pagination', + options.pagePath, + options.id() ?? pagination.cacheSizeKey ?? 'default', + options.scope() ?? '', + ]), + ); + }; + const positiveInteger = (value: unknown): value is number => Number.isInteger(value) && Number(value) > 0; + const restore = () => { + flush(); + activeKey = key(); + const pagination = options.pagination(); + if (!activeKey || !pagination) return false; + if (!defaults.has(pagination)) { + defaults.set(pagination, { currentPage: pagination.currentPage, pageSize: pagination.pageSize }); + } + const initial = defaults.get(pagination)!; + const saved = readPanelCache>('pagination', activeKey); + const page = positiveInteger(saved?.currentPage) ? saved.currentPage : initial.currentPage; + const size = positiveInteger(saved?.pageSize) ? saved.pageSize : initial.pageSize; + const changed = pagination.currentPage !== page || pagination.pageSize !== size; + restoring = true; + try { + pagination.pageSize = size; + pagination.currentPage = page; + } finally { + restoring = false; + } + return changed; + }; + // The initial restore deliberately does not emit: the parent owns its first query. + restore(); + watch( + () => [key(), options.pagination(), store.panelCache.pagination, store.panelCache.duration], + () => { + if (!active) return; + const previousKey = activeKey; + const changed = restore(); + if (activeKey && (changed || previousKey !== activeKey)) requestSearch(); + }, + { flush: 'sync' }, + ); + watch( + () => [options.pagination()?.currentPage, options.pagination()?.pageSize], + ([currentPage, pageSize]) => { + // Never write old-page state under a newly selected node/account. + if (!active || restoring || !activeKey || activeKey !== key()) return; + if (!positiveInteger(currentPage) || !positiveInteger(pageSize)) return; + pending = { key: activeKey, currentPage, pageSize }; + if (!writeQueued) { + writeQueued = true; + queueMicrotask(() => { + writeQueued = false; + flush(); + }); + } + }, + { flush: 'sync' }, + ); + onDeactivated(() => { + flush(); + active = false; + }); + onActivated(() => { + if (active) return; + active = true; + restore(); + requestSearch(); + }); + onScopeDispose(() => { + flush(); + disposed = true; + active = false; + }); +}; diff --git a/frontend/src/composables/useCachedTab.ts b/frontend/src/composables/useCachedTab.ts new file mode 100644 index 000000000..1d9e86d6e --- /dev/null +++ b/frontend/src/composables/useCachedTab.ts @@ -0,0 +1,89 @@ +import { nextTick, onActivated, onDeactivated, onMounted, onScopeDispose, readonly, ref, watch } from 'vue'; +import GlobalStore from '@/store/modules/global'; +import { panelCacheKey, readPanelCache, writePanelCache } from '@/utils/panel-cache'; + +interface CachedTabOptions { + key: () => string; + values: () => string[]; + defaultValue: () => string; + requestedValue?: () => string | undefined; + onActivate: (value: string) => unknown; +} + +export const useCachedTab = (options: CachedTabOptions) => { + const store = GlobalStore(); + const activeTab = ref(''); + let active = true; + let mounted = false; + let version = 0; + let restoredKey: string | undefined; + const key = () => panelCacheKey(`inline-tabs:${options.key()}`); + const valid = (value: unknown): value is string => typeof value === 'string' && options.values().includes(value); + const restore = (useRequested = true) => { + const requested = useRequested ? options.requestedValue?.() : undefined; + const saved = readPanelCache('tabs', key()); + restoredKey = key(); + activeTab.value = valid(requested) + ? requested + : valid(saved) + ? saved + : valid(options.defaultValue()) + ? options.defaultValue() + : options.values()[0] || ''; + }; + const activate = async () => { + const current = ++version; + await nextTick(); + if (current !== version || !active || !mounted || !valid(activeTab.value)) return; + options.onActivate(activeTab.value); + }; + const select = (value: string) => { + if (!active || !valid(value) || value === activeTab.value) return; + activeTab.value = value; + writePanelCache('tabs', key(), value); + void activate(); + }; + watch( + [ + key, + () => store.panelCache.tabs, + () => store.panelCache.duration, + () => JSON.stringify(options.values()), + options.defaultValue, + () => options.requestedValue?.(), + ], + (values, previous) => { + if (!active) return; + // Reflecting the selected tab into the URL is not another selection. + if ( + previous.length && + values.slice(0, 5).every((value, index) => value === previous[index]) && + values[5] === activeTab.value + ) + return; + // A URL left over from the previous node/account must not override its cache. + restore(!previous.length || values[0] === previous[0]); + if (mounted) void activate(); + }, + { immediate: true, flush: 'sync' }, + ); + onMounted(() => { + mounted = true; + void activate(); + }); + onDeactivated(() => { + active = false; + version++; + }); + onActivated(() => { + if (active) return; + active = true; + restore(restoredKey === key()); + void activate(); + }); + onScopeDispose(() => { + active = false; + version++; + }); + return { activeTab: readonly(activeTab), select }; +}; diff --git a/frontend/src/layout/components/Sidebar/index.vue b/frontend/src/layout/components/Sidebar/index.vue index 65b7eea64..1f3fece7b 100644 --- a/frontend/src/layout/components/Sidebar/index.vue +++ b/frontend/src/layout/components/Sidebar/index.vue @@ -15,7 +15,7 @@ { menuStore.setCollapse(); } }; -const router = useRouter(); -const route = useRoute(); const menuStore = MenuStore(); const tabsStore = TabsStore(); @@ -151,9 +149,7 @@ watch( }, ); const handleMenuClick = async (path) => { - await router.push({ path: path }); - tabsStore.addTab(route); - tabsStore.activeTabPath = route.path; + await openMenu(path); }; const toLogin = () => { diff --git a/frontend/src/routers/index.ts b/frontend/src/routers/index.ts index 80ea49835..b9abfb108 100644 --- a/frontend/src/routers/index.ts +++ b/frontend/src/routers/index.ts @@ -1,4 +1,4 @@ -import { panelCacheKey, readPanelCache, writePanelCache, removePanelCache, prunePanelCache } from '@/utils/panel-cache'; +import { panelCacheKey, writePanelCache, prunePanelCache } from '@/utils/panel-cache'; import router from '@/routers/router'; import NProgress from '@/config/nprogress'; import { useGlobalStore } from '@/composables/useGlobalStore'; @@ -11,7 +11,6 @@ import { TerminalDockSessionStore, TerminalSessionStore } from '@/store'; const axiosCanceler = new AxiosCanceler(); -let isRedirecting = false; const enterpriseLicenseCheckWhiteList = ['EnterpriseLicenseRequired', 'entrance', 'login', 'Expired']; const noLoginWhiteList = ['entrance', 'login', 'file-share', '404', 'Expired']; @@ -30,7 +29,7 @@ const clearLoginStatus = () => { TerminalDockSessionStore().closeAll(); }; -router.beforeEach(async (to, from) => { +router.beforeEach(async (to) => { const { entrance, isEnterprise, isEnterpriseLicenseLoaded, isEnterpriseLicensed, isLogin, isOnRestart } = useGlobalStore(); NProgress.start(); @@ -103,35 +102,11 @@ router.beforeEach(async (to, from) => { if (to.name === 'Expired') { return true; } - const activeMenuKey = panelCacheKey(String(to.meta.activeMenu || '')); + // Compatibility for old links: explicit navigation no longer needs to clear a cache. if (to.query.uncached != undefined) { const query = { ...to.query }; delete query.uncached; - removePanelCache('tabs', activeMenuKey); - return { path: to.path, query }; - } - - const cachedRoute = readPanelCache('tabs', activeMenuKey); - if ( - to.meta.activeMenu && - to.name !== 'ModelFiles' && - to.meta.activeMenu != from.meta.activeMenu && - typeof cachedRoute === 'string' && - cachedRoute && - cachedRoute !== to.path && - !isRedirecting - ) { - const cachedRouteInfo = router.resolve(cachedRoute); - if ( - cachedRouteInfo.matched.length > 0 && - cachedRouteInfo.meta.activeMenu === to.meta.activeMenu && - hasRouteAccess(cachedRouteInfo) - ) { - isRedirecting = true; - NProgress.done(); - return cachedRoute; - } - removePanelCache('tabs', activeMenuKey); + return { path: to.path, query, hash: to.hash }; } if (!hasRouteAccess(to)) { @@ -145,11 +120,10 @@ router.beforeEach(async (to, from) => { router.afterEach((to, _from, failure) => { prunePanelCache(); if (failure) { - isRedirecting = false; NProgress.done(); return; } - if (to.meta.activeMenu && !to.meta.ignoreTab && !isRedirecting) { + if (to.meta.activeMenu && !to.meta.ignoreTab) { let notMathParam = true; if (to.matched.some((record) => record.path.includes(':'))) { notMathParam = false; @@ -167,7 +141,6 @@ router.afterEach((to, _from, failure) => { } } - isRedirecting = false; NProgress.done(); }); diff --git a/frontend/src/utils/router.ts b/frontend/src/utils/router.ts index de082f4f3..f833c6eda 100644 --- a/frontend/src/utils/router.ts +++ b/frontend/src/utils/router.ts @@ -4,6 +4,7 @@ import { hasRouteAccess } from '@/utils/rbac'; import i18n from '@/lang'; import { MsgError } from '@/utils/message'; import type { RouteLocationRaw } from 'vue-router'; +import { panelCacheKey, readPanelCache, removePanelCache } from '@/utils/panel-cache'; const pushWithAccessCheck = async (to: RouteLocationRaw) => { const resolvedRoute = router.resolve(to); @@ -15,24 +16,53 @@ const pushWithAccessCheck = async (to: RouteLocationRaw) => { tabStoreMiddleWare(); }; +/** Explicit navigation always opens the requested page, independent of tab caching. */ +export const openPage = pushWithAccessCheck; + +/** Only module-menu entry points restore the last visited route within that module. */ +export const openMenu = async (to: RouteLocationRaw) => { + let entry = router.resolve(to); + const visited = new Set(); + // Menu roots often redirect and only their destination declares activeMenu. + while (!visited.has(entry.fullPath)) { + visited.add(entry.fullPath); + const redirect = entry.matched[entry.matched.length - 1]?.redirect; + if (!redirect) break; + entry = router.resolve(typeof redirect === 'function' ? redirect(entry, router.currentRoute.value) : redirect); + } + const menu = entry.meta.activeMenu; + if (menu) { + const key = panelCacheKey(String(menu)); + const cached = readPanelCache('tabs', key); + if (typeof cached === 'string') { + const destination = router.resolve(cached); + if (destination.matched.length && destination.meta.activeMenu === menu && hasRouteAccess(destination)) { + return openPage(cached); + } + } + if (cached !== undefined) removePanelCache('tabs', key); + } + return openPage(to); +}; + export const routerToName = async (name: string) => { - await pushWithAccessCheck({ name: name }); + await openPage({ name: name }); }; export const routerToPath = async (path: string) => { - await pushWithAccessCheck({ path: path }); + await openPage({ path: path }); }; export const routerToFileWithPath = async (pathItem: string) => { - await pushWithAccessCheck({ name: 'File', query: { path: pathItem, uncached: 'true' } }); + await openPage({ name: 'File', query: { path: pathItem, uncached: 'true' } }); }; export const routerToNameWithQuery = async (name: string, query: any) => { - await pushWithAccessCheck({ name: name, query: query }); + await openPage({ name: name, query: query }); }; export const routerToPathWithQuery = async (path: string, query: any) => { - await pushWithAccessCheck({ path: path, query: query }); + await openPage({ path: path, query: query }); }; export const jumpToPath = (router: any, path: string) => { @@ -40,7 +70,7 @@ export const jumpToPath = (router: any, path: string) => { }; export const routerToNameWithParams = async (name: string, params: any) => { - await pushWithAccessCheck({ name: name, params: params }); + await openPage({ name: name, params: params }); }; const tabStoreMiddleWare = () => { diff --git a/frontend/src/utils/search-reset.ts b/frontend/src/utils/search-reset.ts index e22e0d44b..83bef953a 100644 --- a/frontend/src/utils/search-reset.ts +++ b/frontend/src/utils/search-reset.ts @@ -139,11 +139,11 @@ export const bindSearchState = (key: () => string | undefined, fields: SearchFie interface SearchStateOptions { scope?: () => string | number | undefined; - onRevisit: () => unknown; + onRevisit?: () => unknown; } /** Keep queries across page visits and reload data when a cached page becomes active. */ -export const useSearchState = (page: string, fields: SearchFields, options: SearchStateOptions) => { +export const useSearchState = (page: string, fields: SearchFields, options: SearchStateOptions = {}) => { const { cacheUserID } = storeToRefs(GlobalStore()); const binding = bindSearchState(() => { const scope = options.scope?.(); @@ -160,6 +160,6 @@ export const useSearchState = (page: string, fields: SearchFields, options: Sear onActivated(async () => { if (!deactivated) return; deactivated = false; - if (await binding.resume()) return options.onRevisit(); + if (await binding.resume()) return options.onRevisit?.(); }); }; diff --git a/frontend/src/views/ai/agents/agent/index.vue b/frontend/src/views/ai/agents/agent/index.vue index 237ea5347..41dc37aa1 100644 --- a/frontend/src/views/ai/agents/agent/index.vue +++ b/frontend/src/views/ai/agents/agent/index.vue @@ -20,6 +20,7 @@ v-if="!noApp" v-model:view-mode="viewMode" :data="items" + :cache-pagination="false" :pagination-config="paginationConfig" @search="search" > diff --git a/frontend/src/views/ai/mcp/server/index.vue b/frontend/src/views/ai/mcp/server/index.vue index f6009dd87..1cda9a7db 100644 --- a/frontend/src/views/ai/mcp/server/index.vue +++ b/frontend/src/views/ai/mcp/server/index.vue @@ -16,7 +16,12 @@