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
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCard class="system-health-dialog-card">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-cog" class="me-2" />
@@ -442,7 +442,7 @@ onMounted(() => {
<VDialogCloseBtn @click="systemTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<VCardText class="system-health-dialog-body pa-0">
<ModuleTestView />
</VCardText>
</VCard>
@@ -492,3 +492,24 @@ onMounted(() => {
</VCard>
</VDialog>
</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 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()
@@ -34,71 +54,23 @@ onMounted(() => {
if (!activeTab.value && settingTabs.value.length > 0) {
activeTab.value = settingTabs.value[0].tab
}
markTabVisited(activeTab.value)
})
watch(activeTab, markTabVisited, { immediate: true })
</script>
<template>
<div>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<!-- 系统 -->
<VWindowItem value="system">
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
<transition name="fade-slide" appear>
<div>
<AccountSettingSystem />
</div>
</transition>
</VWindowItem>
<!-- 目录 -->
<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 />
<component
:is="item.component"
v-if="visitedTabs.has(item.value)"
:active="activeTab === item.value"
/>
</div>
</transition>
</VWindowItem>

View File

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

View File

@@ -8,10 +8,18 @@ import StorageCard from '@/components/cards/StorageCard.vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { storageAttributes } from '@/api/constants'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 拖拽排序和分类编辑弹窗按需加载,避免设置框架预加载目录页时带上这些交互依赖。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
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(() => {
loadDirectories()
loadStorages()
loadMediaCategories()
loadSystemSettings()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
})
</script>

View File

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

View File

@@ -7,10 +7,18 @@ import { CustomRule, FilterRuleGroup } from '@/api/types'
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化
const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 拖拽库和导入弹窗只在规则编辑交互中需要,拆出设置页入口 chunk。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
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(() => {
loadMediaCategories()
queryCustomRules()
queryFilterRuleGroups()
queryTorrentPriority()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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