refactor: implement lazy-loaded tab components with silent background data refresh for settings pages

This commit is contained in:
jxxghp
2026-05-17 14:17:50 +08:00
parent 0e005c3c7e
commit c5e2b1349f
12 changed files with 222 additions and 87 deletions

View File

@@ -0,0 +1,33 @@
import { type MaybeRefOrGetter, toValue } from 'vue'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
type RefreshHandler = (context?: KeepAliveRefreshContext) => void | Promise<void>
interface SilentSettingRefreshOptions {
active?: MaybeRefOrGetter<boolean>
}
function isEditingFormField() {
if (typeof document === 'undefined') return false
const element = document.activeElement
if (!(element instanceof HTMLElement)) return false
// 设置页大多是可编辑表单,正在输入时跳过静默刷新,避免覆盖用户未保存内容。
return Boolean(element.closest('input, textarea, select, [contenteditable="true"], .ace_text-input'))
}
/**
* 设置面板重新可见时静默刷新数据;如果用户正在编辑表单,则本轮刷新让路给输入体验。
*/
export function useSilentSettingRefresh(refresh: RefreshHandler, options: SilentSettingRefreshOptions = {}) {
return useKeepAliveRefresh(
async context => {
if (context?.silent && isEditingFormField()) return
await refresh(context)
},
{
active: options.active === undefined ? undefined : () => Boolean(toValue(options.active)),
},
)
}

View File

@@ -433,7 +433,7 @@ onMounted(() => {
scrollable scrollable
:fullscreen="!display.mdAndUp.value" :fullscreen="!display.mdAndUp.value"
> >
<VCard> <VCard class="system-health-dialog-card">
<VCardItem> <VCardItem>
<VCardTitle> <VCardTitle>
<VIcon icon="mdi-cog" class="me-2" /> <VIcon icon="mdi-cog" class="me-2" />
@@ -442,7 +442,7 @@ onMounted(() => {
<VDialogCloseBtn @click="systemTestDialog = false" /> <VDialogCloseBtn @click="systemTestDialog = false" />
</VCardItem> </VCardItem>
<VDivider /> <VDivider />
<VCardText class="pa-0"> <VCardText class="system-health-dialog-body pa-0">
<ModuleTestView /> <ModuleTestView />
</VCardText> </VCardText>
</VCard> </VCard>
@@ -492,3 +492,24 @@ onMounted(() => {
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style scoped>
.system-health-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
.system-health-dialog-body {
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
display: flex;
flex: 1 1 auto;
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
min-block-size: 0;
overflow: hidden !important;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {
block-size: auto;
}
</style>

View File

@@ -19,6 +19,26 @@ const AccountSettingSearch = defineAsyncComponent(() => import('@/views/setting/
const AccountSettingSubscribe = defineAsyncComponent(() => import('@/views/setting/AccountSettingSubscribe.vue')) const AccountSettingSubscribe = defineAsyncComponent(() => import('@/views/setting/AccountSettingSubscribe.vue'))
const AccountSettingNotification = defineAsyncComponent(() => import('@/views/setting/AccountSettingNotification.vue')) const AccountSettingNotification = defineAsyncComponent(() => import('@/views/setting/AccountSettingNotification.vue'))
const visitedTabs = ref(new Set<string>())
const settingTabComponents = [
{ value: 'system', component: AccountSettingSystem },
{ value: 'directory', component: AccountSettingDirectory },
{ value: 'site', component: AccountSettingSite },
{ value: 'rule', component: AccountSettingRule },
{ value: 'search', component: AccountSettingSearch },
{ value: 'subscribe', component: AccountSettingSubscribe },
{ value: 'notification', component: AccountSettingNotification },
]
function markTabVisited(tab: string) {
if (!tab) return
const nextTabs = new Set(visitedTabs.value)
nextTabs.add(tab)
visitedTabs.value = nextTabs
}
// 使用动态标签页 // 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab() const { registerHeaderTab } = useDynamicHeaderTab()
@@ -34,71 +54,23 @@ onMounted(() => {
if (!activeTab.value && settingTabs.value.length > 0) { if (!activeTab.value && settingTabs.value.length > 0) {
activeTab.value = settingTabs.value[0].tab activeTab.value = settingTabs.value[0].tab
} }
markTabVisited(activeTab.value)
}) })
watch(activeTab, markTabVisited, { immediate: true })
</script> </script>
<template> <template>
<div> <div>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false"> <VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<!-- 系统 --> <VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
<VWindowItem value="system">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<AccountSettingSystem /> <component
</div> :is="item.component"
</transition> v-if="visitedTabs.has(item.value)"
</VWindowItem> :active="activeTab === item.value"
/>
<!-- 目录 -->
<VWindowItem value="directory">
<transition name="fade-slide" appear>
<div>
<AccountSettingDirectory />
</div>
</transition>
</VWindowItem>
<!-- 站点 -->
<VWindowItem value="site">
<transition name="fade-slide" appear>
<div>
<AccountSettingSite />
</div>
</transition>
</VWindowItem>
<!-- 规则 -->
<VWindowItem value="rule">
<transition name="fade-slide" appear>
<div>
<AccountSettingRule />
</div>
</transition>
</VWindowItem>
<!-- 搜索 -->
<VWindowItem value="search">
<transition name="fade-slide" appear>
<div>
<AccountSettingSearch />
</div>
</transition>
</VWindowItem>
<!-- 订阅 -->
<VWindowItem value="subscribe">
<transition name="fade-slide" appear>
<div>
<AccountSettingSubscribe />
</div>
</transition>
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="notification">
<transition name="fade-slide" appear>
<div>
<AccountSettingNotification />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>

View File

@@ -156,6 +156,7 @@ const router = createRouter({
path: '/setting', path: '/setting',
component: () => import('../pages/setting.vue'), component: () => import('../pages/setting.vue'),
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },

View File

@@ -8,10 +8,18 @@ import StorageCard from '@/components/cards/StorageCard.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { storageAttributes } from '@/api/constants' import { storageAttributes } from '@/api/constants'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
const { t } = useI18n() const { t } = useI18n()
const { global: globalTheme } = useTheme() const { global: globalTheme } = useTheme()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 拖拽排序和分类编辑弹窗按需加载,避免设置框架预加载目录页时带上这些交互依赖。 // 拖拽排序和分类编辑弹窗按需加载,避免设置框架预加载目录页时带上这些交互依赖。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default)) const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
@@ -247,12 +255,17 @@ async function saveSystemSettings(value: any) {
} }
} }
async function loadPageData() {
await Promise.all([loadDirectories(), loadStorages(), loadMediaCategories(), loadSystemSettings()])
}
// 加载数据 // 加载数据
onMounted(() => { onMounted(() => {
loadDirectories() loadPageData()
loadStorages() })
loadMediaCategories()
loadSystemSettings() useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
}) })
</script> </script>

View File

@@ -6,6 +6,7 @@ import NotificationChannelCard from '@/components/cards/NotificationChannelCard.
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { notificationSwitchDict } from '@/api/constants' import { notificationSwitchDict } from '@/api/constants'
import { useTheme, useDisplay } from 'vuetify' import { useTheme, useDisplay } from 'vuetify'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
@@ -13,6 +14,13 @@ const display = useDisplay()
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 通知渠道排序和进度弹窗按需加载,避免通知设置 chunk 直接包含拖拽库。 // 通知渠道排序和进度弹窗按需加载,避免通知设置 chunk 直接包含拖拽库。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default)) const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
@@ -308,12 +316,22 @@ function getNotificationSwitchText(type: string | undefined) {
return notificationSwitchDict[type] return notificationSwitchDict[type]
} }
async function loadPageData() {
await Promise.all([
loadNotificationSetting(),
loadNotificationSwitchs(),
loadNotificationTime(),
loadTemplateConfigs(),
])
}
// 加载数据 // 加载数据
onMounted(() => { onMounted(() => {
loadNotificationSetting() loadPageData()
loadNotificationSwitchs() })
loadNotificationTime()
loadTemplateConfigs() useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active && !editorVisible.value),
}) })
</script> </script>

View File

@@ -7,10 +7,18 @@ import { CustomRule, FilterRuleGroup } from '@/api/types'
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue' import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue' import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 拖拽库和导入弹窗只在规则编辑交互中需要,拆出设置页入口 chunk。 // 拖拽库和导入弹窗只在规则编辑交互中需要,拆出设置页入口 chunk。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default)) const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue')) const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
@@ -365,12 +373,17 @@ async function saveTorrentPriority() {
} }
} }
async function loadPageData() {
await Promise.all([loadMediaCategories(), queryCustomRules(), queryFilterRuleGroups(), queryTorrentPriority()])
}
// 加载数据 // 加载数据
onMounted(() => { onMounted(() => {
loadMediaCategories() loadPageData()
queryCustomRules() })
queryFilterRuleGroups()
queryTorrentPriority() useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
}) })
</script> </script>

View File

@@ -3,10 +3,18 @@ import { useToast } from 'vue-toastification'
import api from '@/api' import api from '@/api'
import type { FilterRuleGroup, Site } from '@/api/types' import type { FilterRuleGroup, Site } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -176,12 +184,16 @@ async function loadSystemSettings() {
} }
} }
async function loadPageData() {
await Promise.all([querySites(), queryFilterRuleGroups(), querySelectedSites(), loadSearchSetting(), loadSystemSettings()])
}
onMounted(() => { onMounted(() => {
querySites() loadPageData()
queryFilterRuleGroups() })
querySelectedSites()
loadSearchSetting() useSilentSettingRefresh(loadPageData, {
loadSystemSettings() active: computed(() => props.active),
}) })
</script> </script>

View File

@@ -3,10 +3,18 @@ import { useToast } from 'vue-toastification'
import api from '@/api' import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue' import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -122,6 +130,10 @@ async function saveSiteSetting(value: { [key: string]: any }) {
onMounted(() => { onMounted(() => {
loadSiteSettings() loadSiteSettings()
}) })
useSilentSettingRefresh(loadSiteSettings, {
active: computed(() => props.active),
})
</script> </script>
<template> <template>

View File

@@ -4,10 +4,18 @@ import api from '@/api'
import type { FilterRuleGroup, Site } from '@/api/types' import type { FilterRuleGroup, Site } from '@/api/types'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue' import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -184,12 +192,22 @@ async function saveSubscribeSetting() {
} }
} }
async function loadPageData() {
await Promise.all([
querySites(),
queryFilterRuleGroups(),
querySelectedRssSites(),
querySubscribeRules(),
loadSystemSettings(),
])
}
onMounted(() => { onMounted(() => {
querySites() loadPageData()
queryFilterRuleGroups() })
querySelectedRssSites()
querySubscribeRules() useSilentSettingRefresh(loadPageData, {
loadSystemSettings() active: computed(() => props.active),
}) })
</script> </script>

View File

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { downloaderOptions, mediaServerOptions } from '@/api/constants' import { downloaderOptions, mediaServerOptions } from '@/api/constants'
import { useDisplay, useTheme } from 'vuetify' import { useDisplay, useTheme } from 'vuetify'
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory' import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
const display = useDisplay() const display = useDisplay()
const theme = useTheme() const theme = useTheme()
@@ -19,6 +20,13 @@ const isTransparentTheme = computed(() => theme.name.value === 'transparent')
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 下载器/媒体服务器排序和进度弹窗按需加载,降低系统设置页入口解析量。 // 下载器/媒体服务器排序和进度弹窗按需加载,降低系统设置页入口解析量。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default)) const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
@@ -847,12 +855,11 @@ async function saveScrapingSwitchs() {
} }
// 加载数据 // 加载数据
onMounted(() => { async function loadPageData() {
loadDownloaderSetting() await Promise.all([loadDownloaderSetting(), loadMediaServerSetting(), loadSystemSettings(), loadScrapingSwitchs()])
loadMediaServerSetting() }
loadSystemSettings()
loadScrapingSwitchs() onMounted(loadPageData)
})
onActivated(async () => { onActivated(async () => {
isRequest.value = true isRequest.value = true
@@ -866,6 +873,16 @@ onBeforeUnmount(() => {
invalidateLlmTestState() invalidateLlmTestState()
}) })
useSilentSettingRefresh(
async () => {
if (progressDialog.value || advancedDialog.value || testingLlm.value || savingBasic.value) return
await loadPageData()
},
{
active: computed(() => props.active),
},
)
watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => { watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
if (snapshotKey !== previousSnapshotKey) invalidateLlmTestState() if (snapshotKey !== previousSnapshotKey) invalidateLlmTestState()
}) })

View File

@@ -231,6 +231,10 @@ onMounted(getModules)
.system-health-check { .system-health-check {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto;
block-size: 100%;
min-block-size: 0;
overflow: hidden;
} }
.progress-container { .progress-container {
@@ -316,6 +320,7 @@ onMounted(getModules)
flex: 1; flex: 1;
min-block-size: 0; min-block-size: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0 16px; padding-block: 0 16px;
padding-inline: 16px; padding-inline: 16px;
} }