Compare commits

..

23 Commits

Author SHA1 Message Date
jxxghp
30a4c55050 refactor: enable scroll-to-top button globally in browse page by removing path restriction 2026-05-17 19:27:04 +08:00
jxxghp
dee5d9d213 feat: replace Playwright with CloakBrowser for site emulation and update related translations 2026-05-17 15:50:47 +08:00
jxxghp
c5e2b1349f refactor: implement lazy-loaded tab components with silent background data refresh for settings pages 2026-05-17 14:17:50 +08:00
jxxghp
0e005c3c7e refactor: optimize Keep-Alive component rendering and data synchronization by introducing silent refresh states and fallback layout calculations. 2026-05-17 14:06:05 +08:00
jxxghp
348ae6b313 refactor: enhance scroll locking and touch event handling in QuickAccess component to prevent unwanted background scrolling 2026-05-17 12:48:31 +08:00
jxxghp
122ecc82fd 搜索中隐藏资源结果抬头 2026-05-17 12:48:06 +08:00
jxxghp
88fad5b764 优化资源搜索结果页抬头布局 2026-05-17 11:14:05 +08:00
jxxghp
f01971ee3a refactor: migrate page-specific action buttons to dynamic FABs for PWA mode compatibility 2026-05-17 11:01:47 +08:00
jxxghp
5e8489c620 refactor: standardize keep-alive data refreshing using useKeepAliveRefresh composable across views and dashboards 2026-05-17 10:04:30 +08:00
jxxghp
6900042cf7 chore: bump project version to 2.12.0 2026-05-17 08:36:00 +08:00
jxxghp
75862c026a refactor: replace useBackgroundOptimization with unified useBackground composable and update Nginx SSE route configuration 2026-05-17 08:32:17 +08:00
jxxghp
bbe3368c69 feat: introduce useKeepAliveRefresh composable to manage tab data synchronization and lifecycle refresh logic 2026-05-17 07:43:42 +08:00
jxxghp
587f06eb9f perf: safely optimize list loading 2026-05-15 23:15:14 +08:00
jxxghp
7114c63e8f Revert "perf: optimize infinite list loading"
This reverts commit 2a6f9e3cc0.
2026-05-15 23:08:56 +08:00
jxxghp
2a6f9e3cc0 perf: optimize infinite list loading 2026-05-15 22:59:00 +08:00
jxxghp
00d37d7bda feat: add context recovery and search parameter persistence logic for resource page refresh 2026-05-15 21:45:47 +08:00
jxxghp
546af84dab Revert "Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)"
This reverts commit 5953496d84.
2026-05-15 21:42:43 +08:00
Aqr-K
5953496d84 Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472) 2026-05-15 21:15:30 +08:00
jxxghp
0fda7c70de fix: scroll message list container to latest 2026-05-15 18:56:37 +08:00
jxxghp
48546e1999 fix: auto scroll message center to latest 2026-05-15 18:44:16 +08:00
jxxghp
06355ff91d fix: prevent event propagation on card menu buttons and implement virtualization locking for overlays in ProgressiveCardGrid 2026-05-15 18:27:56 +08:00
jxxghp
523f8c4cc8 feat: add force scroll option to intelligent message scrolling in ShortcutBar and MessageView 2026-05-15 17:47:50 +08:00
jxxghp
73f6e7482f refactor: constrain dialog heights, standardize code formatting, and update CSS logical properties 2026-05-15 17:44:21 +08:00
66 changed files with 1749 additions and 771 deletions

5
env.d.ts vendored
View File

@@ -4,8 +4,13 @@ declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
action?: string action?: string
subject?: string subject?: string
keepAlive?: boolean
keepAliveKey?: string
layoutWrapperClasses?: string layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw navActiveLink?: RouteLocationRaw
requiresAuth?: boolean
subType?: string
hideFooter?: boolean
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.11.3", "version": "2.12.0",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": "dist/service.js", "bin": "dist/service.js",
@@ -128,4 +128,4 @@
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
}, },
"packageManager": "yarn@1.22.18" "packageManager": "yarn@1.22.18"
} }

View File

@@ -49,7 +49,7 @@ http {
root html; root html;
} }
location ~ ^/api/v1/system/(message|progress/) { location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
# SSE MIME类型设置 # SSE MIME类型设置
default_type text/event-stream; default_type text/event-stream;

View File

@@ -1,5 +1,15 @@
import ColorThief from 'colorthief' import ColorThief from 'colorthief'
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
const DOMINANT_COLOR_CACHE_LIMIT = 100
const colorThief = new ColorThief()
const dominantColorCache = new Map<string, Promise<string>>()
interface DominantColorOptions {
fallback?: string
quality?: number
}
// 将 RGB 转换为十六进制 // 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string { function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format') if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
@@ -14,11 +24,46 @@ function rgbStringToHex(rgbArray: number[]): string {
return `#${toHex(r)}${toHex(g)}${toHex(b)}` return `#${toHex(r)}${toHex(g)}${toHex(b)}`
} }
function getImageCacheKey(image: HTMLImageElement) {
return image.currentSrc || image.src || ''
}
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
if (!key) return colorPromise
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
const firstKey = dominantColorCache.keys().next().value
if (firstKey) dominantColorCache.delete(firstKey)
}
dominantColorCache.set(key, colorPromise)
return colorPromise
}
// 提取主要颜色 // 提取主要颜色
export async function getDominantColor(image: HTMLImageElement): Promise<string> { export async function getDominantColor(
const colorThief = new ColorThief() image: HTMLImageElement | undefined | null,
const dominantColor = colorThief.getColor(image) options: DominantColorOptions = {},
return rgbStringToHex(dominantColor) ): Promise<string> {
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
if (!image) return fallback
const cacheKey = getImageCacheKey(image)
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
if (cachedColor) return cachedColor
const colorPromise = Promise.resolve()
.then(() => {
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
return rgbStringToHex(dominantColor)
})
.catch(error => {
console.warn('Failed to extract dominant color:', error)
return fallback
})
return rememberDominantColor(cacheKey, colorPromise)
} }
// 预加载图片 // 预加载图片

View File

@@ -31,6 +31,10 @@ const props = defineProps({
type: Array as PropType<FileItem[]>, type: Array as PropType<FileItem[]>,
default: () => [], default: () => [],
}, },
active: {
type: Boolean,
default: true,
},
}) })
// 对外事件 // 对外事件
@@ -308,6 +312,7 @@ function stopDrag() {
:refreshpending="refreshPending" :refreshpending="refreshPending"
:sort="sort" :sort="sort"
:showTree="showDirTree" :showTree="showDirTree"
:active="active"
:style="{ flex: 1 }" :style="{ flex: 1 }"
@pathchanged="pathChanged" @pathchanged="pathChanged"
@loading="loadingChanged" @loading="loadingChanged"

View File

@@ -9,14 +9,14 @@ import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { downloaderDict, storageAttributes } from '@/api/constants' import { downloaderDict, storageAttributes } from '@/api/constants'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// 获取i18n实例 // 获取i18n实例
const { t } = useI18n() const { t } = useI18n()
const { useConditionalDataRefresh } = useBackgroundOptimization() const { useConditionalDataRefresh } = useBackground()
// 定义输入 // 定义输入
const props = defineProps({ const props = defineProps({

View File

@@ -252,7 +252,7 @@ const dropdownItems = ref([
</div> </div>
</div> </div>
<div class="absolute bottom-0 right-0"> <div class="absolute bottom-0 right-0">
<IconBtn> <IconBtn @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" /> <VIcon size="small" icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click> <VMenu activator="parent" close-on-content-click>
<VList> <VList>
@@ -273,7 +273,7 @@ const dropdownItems = ref([
<!-- 安装插件进度框 --> <!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" /> <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 --> <!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })"> <VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" /> <VDialogCloseBtn @click="releaseDialog = false" />
<VDivider /> <VDivider />

View File

@@ -475,7 +475,10 @@ watch(
{{ props.plugin?.plugin_desc }} {{ props.plugin?.plugin_desc }}
</div> </div>
</div> </div>
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"> <div
class="relative flex-shrink-0 self-center pb-3"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VAvatar size="48"> <VAvatar size="48">
<VImg <VImg
ref="imageRef" ref="imageRef"
@@ -518,7 +521,7 @@ watch(
</span> </span>
</div> </div>
<div v-if="!props.sortable" class="absolute bottom-0 right-0"> <div v-if="!props.sortable" class="absolute bottom-0 right-0">
<IconBtn> <IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" /> <VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click> <VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList> <VList>
@@ -569,7 +572,7 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" /> <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 --> <!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value"> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })"> <VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" /> <VDialogCloseBtn @click="releaseDialog = false" />
<VDivider /> <VDivider />
@@ -587,13 +590,13 @@ watch(
</VDialog> </VDialog>
<!-- 实时日志弹窗 --> <!-- 实时日志弹窗 -->
<VDialog <VDialog
v-if="loggingDialog" v-if="loggingDialog"
v-model="loggingDialog" v-model="loggingDialog"
scrollable scrollable
max-width="72rem" max-width="72rem"
:fullscreen="!display.mdAndUp.value" :fullscreen="!display.mdAndUp.value"
> >
<VCard> <VCard>
<VDialogCloseBtn @click="loggingDialog = false" /> <VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem> <VCardItem>

View File

@@ -386,7 +386,7 @@ onMounted(() => {
</VBtn> </VBtn>
<!-- 更多选项按钮 --> <!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36"> <VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" /> <VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'"> <VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList> <VList>

View File

@@ -372,7 +372,7 @@ function handleCardClick() {
:ripple="!props.batchMode && !props.sortable" :ripple="!props.batchMode && !props.sortable"
> >
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4"> <div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn> <IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" /> <VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click> <VMenu activator="parent" close-on-content-click>
<VList> <VList>

View File

@@ -202,12 +202,7 @@ onMounted(() => {
<dd class="flex text-sm sm:col-span-2 sm:mt-0"> <dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate"> <span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ appVersion }}</code> <code class="truncate">{{ appVersion }}</code>
<VBtn <VBtn size="x-small" variant="tonal" class="ms-2" @click="clearCache">
size="x-small"
variant="tonal"
class="ms-2"
@click="clearCache"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-refresh" size="14" /> <VIcon icon="mdi-refresh" size="14" />
</template> </template>
@@ -402,7 +397,7 @@ onMounted(() => {
</div> </div>
</VCardText> </VCardText>
</VCard> </VCard>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
<VCard> <VCard>
<VCardItem> <VCardItem>
<VDialogCloseBtn @click="releaseDialog = false" /> <VDialogCloseBtn @click="releaseDialog = false" />
@@ -430,8 +425,8 @@ onMounted(() => {
.markdown-body :deep(h1), .markdown-body :deep(h1),
.markdown-body :deep(h2), .markdown-body :deep(h2),
.markdown-body :deep(h3) { .markdown-body :deep(h3) {
margin-block: 0.5rem;
font-weight: 600; font-weight: 600;
margin-block: 0.5rem;
} }
.markdown-body :deep(h1) { .markdown-body :deep(h1) {
@@ -448,8 +443,8 @@ onMounted(() => {
.markdown-body :deep(ul), .markdown-body :deep(ul),
.markdown-body :deep(ol) { .markdown-body :deep(ol) {
padding-inline-start: 1.5rem;
margin-block: 0.5rem; margin-block: 0.5rem;
padding-inline-start: 1.5rem;
} }
.markdown-body :deep(li) { .markdown-body :deep(li) {
@@ -470,18 +465,20 @@ onMounted(() => {
} }
.markdown-body :deep(code) { .markdown-body :deep(code) {
padding: 0.15rem 0.4rem;
border-radius: 0.25rem; border-radius: 0.25rem;
background-color: rgba(127, 127, 127, 15%);
font-size: 0.875em; font-size: 0.875em;
background-color: rgba(127, 127, 127, 0.15); padding-block: 0.15rem;
padding-inline: 0.4rem;
} }
.markdown-body :deep(pre) { .markdown-body :deep(pre) {
padding: 0.75rem 1rem; border-radius: 0.375rem;
background-color: rgba(127, 127, 127, 15%);
margin-block: 0.5rem; margin-block: 0.5rem;
overflow-x: auto; overflow-x: auto;
border-radius: 0.375rem; padding-block: 0.75rem;
background-color: rgba(127, 127, 127, 0.15); padding-inline: 1rem;
} }
.markdown-body :deep(pre code) { .markdown-body :deep(pre code) {
@@ -490,37 +487,38 @@ onMounted(() => {
} }
.markdown-body :deep(blockquote) { .markdown-body :deep(blockquote) {
padding-inline-start: 1rem; border-inline-start: 3px solid rgba(127, 127, 127, 40%);
color: rgba(127, 127, 127, 80%);
margin-block: 0.5rem; margin-block: 0.5rem;
border-inline-start: 3px solid rgba(127, 127, 127, 0.4); padding-inline-start: 1rem;
color: rgba(127, 127, 127, 0.8);
} }
.markdown-body :deep(hr) { .markdown-body :deep(hr) {
margin-block: 1rem;
border: none; border: none;
border-block-start: 1px solid rgba(127, 127, 127, 0.3); border-block-start: 1px solid rgba(127, 127, 127, 30%);
margin-block: 1rem;
} }
.markdown-body :deep(table) { .markdown-body :deep(table) {
width: 100%;
margin-block: 0.5rem;
border-collapse: collapse; border-collapse: collapse;
inline-size: 100%;
margin-block: 0.5rem;
} }
.markdown-body :deep(th), .markdown-body :deep(th),
.markdown-body :deep(td) { .markdown-body :deep(td) {
padding: 0.4rem 0.75rem; border: 1px solid rgba(127, 127, 127, 30%);
border: 1px solid rgba(127, 127, 127, 0.3); padding-block: 0.4rem;
padding-inline: 0.75rem;
} }
.markdown-body :deep(th) { .markdown-body :deep(th) {
background-color: rgba(127, 127, 127, 10%);
font-weight: 600; font-weight: 600;
background-color: rgba(127, 127, 127, 0.1);
} }
.markdown-body :deep(img) { .markdown-body :deep(img) {
max-width: 100%; block-size: auto;
height: auto; max-inline-size: 100%;
} }
</style> </style>

View File

@@ -14,7 +14,7 @@ import {
TransferDirectoryConf, TransferDirectoryConf,
TransferForm, TransferForm,
} from '@/api/types' } from '@/api/types'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import MediaIdSelector from '../misc/MediaIdSelector.vue' import MediaIdSelector from '../misc/MediaIdSelector.vue'
import ProgressDialog from './ProgressDialog.vue' import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -24,7 +24,7 @@ import { useGlobalSettingsStore } from '@/stores'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization() const { useProgressSSE } = useBackground()
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()

View File

@@ -5,7 +5,7 @@ import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types' import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import CryptoJS from 'crypto-js' import CryptoJS from 'crypto-js'
type TransferTask = TransferQueue['tasks'][number] type TransferTask = TransferQueue['tasks'][number]
@@ -20,7 +20,7 @@ interface MediaTaskGroup {
// 多语言支持 // 多语言支持
const { t } = useI18n() const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization() const { useProgressSSE } = useBackground()
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()

View File

@@ -11,13 +11,14 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue' import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight' import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization() const { useProgressSSE } = useBackground()
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
@@ -43,6 +44,10 @@ const inProps = defineProps({
}, },
sort: String, sort: String,
showTree: Boolean, showTree: Boolean,
active: {
type: Boolean,
default: true,
},
}) })
// 对外事件 // 对外事件
@@ -229,34 +234,45 @@ function changeSelectMode() {
} }
// 调API加载文件夹内的内容 // 调API加载文件夹内的内容
async function list_files() { async function list_files(context: KeepAliveRefreshContext = {}) {
loading.value = true const silentRefresh = Boolean(context.silent && items.value.length > 0)
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/'); const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/')
const prevURI = takeURISnapshot(); const prevURI = takeURISnapshot()
emit('loading', true)
// 参数 if (!silentRefresh) {
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name') loading.value = true
emit('loading', true)
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.list.method || 'get',
data: inProps.item,
} }
// 加载数据 try {
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? [] // 参数
// 如果当前路径已经变化,则放弃此次加载结果 const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
if (prevURI !== takeURISnapshot()) {
return;
}
items.value = data
syncSelectedItems(data)
emit('loading', false)
loading.value = false
// 通知父组件文件列表更新 const config: AxiosRequestConfig<FileItem> = {
emit('items-updated', items.value) url,
method: inProps.endpoints?.list.method || 'get',
data: inProps.item,
}
// 加载数据
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return
}
items.value = data
syncSelectedItems(data)
// 通知父组件文件列表更新
emit('items-updated', items.value)
} catch (error) {
console.error(error)
} finally {
if (!silentRefresh) {
emit('loading', false)
loading.value = false
}
}
} }
// 删除项目 // 删除项目
@@ -642,7 +658,7 @@ function handleProgressMessage(event: MessageEvent) {
} }
} }
// 使用优化的进度SSE连接 // 使用进度SSE连接
const progressSSE = useProgressSSE( const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`, `${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`,
handleProgressMessage, handleProgressMessage,
@@ -663,8 +679,8 @@ function stopLoadingProgress() {
progressSSE.stop() progressSSE.stop()
} }
onMounted(() => { useKeepAliveRefresh(list_files, {
list_files() active: computed(() => inProps.active),
}) })
onUnmounted(() => { onUnmounted(() => {

View File

@@ -218,7 +218,7 @@ onMounted(() => {
<template> <template>
<!-- PC端头部和筛选栏 --> <!-- PC端头部和筛选栏 -->
<div class="search-header d-none d-sm-block"> <div class="search-header d-none d-sm-block">
<VCard class="view-header mb-3"> <VCard class="view-header filter-toolbar-card mb-3" elevation="0">
<div class="d-flex align-center pa-3"> <div class="d-flex align-center pa-3">
<!-- 固定位置资源数量和排序 --> <!-- 固定位置资源数量和排序 -->
<div class="d-flex align-center flex-shrink-0"> <div class="d-flex align-center flex-shrink-0">
@@ -405,7 +405,7 @@ onMounted(() => {
</div> </div>
<!-- 移动端头部和筛选区域 --> <!-- 移动端头部和筛选区域 -->
<VCard class="d-block d-sm-none search-header-mobile mb-3"> <VCard class="d-block d-sm-none search-header-mobile filter-toolbar-card mb-3" elevation="0">
<div class="view-header"> <div class="view-header">
<div class="d-flex align-center flex-wrap pa-2"> <div class="d-flex align-center flex-wrap pa-2">
<div class="d-flex align-center w-100"> <div class="d-flex align-center w-100">
@@ -664,6 +664,13 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
.filter-toolbar-card {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.82);
}
.search-count { .search-count {
font-weight: 500; font-weight: 500;
} }
@@ -695,7 +702,7 @@ onMounted(() => {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
overflow-x: auto; overflow-x: auto;
flex: 1; flex: 1;
width: 0; width: 0;
@@ -722,6 +729,7 @@ onMounted(() => {
.filter-btn { .filter-btn {
min-inline-size: 0; min-inline-size: 0;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: opacity 0.2s; transition: opacity 0.2s;
} }
@@ -770,8 +778,9 @@ onMounted(() => {
.selected-filters { .selected-filters {
overflow: hidden; overflow: hidden;
background-color: rgba(var(--v-theme-surface-variant), 0.08); border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 8px; background-color: rgba(var(--v-theme-surface-variant), 0.05);
padding-block: 7px;
padding-inline: 12px; padding-inline: 12px;
} }
@@ -788,7 +797,7 @@ onMounted(() => {
justify-content: center; justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08); border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px; border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5); background-color: rgba(var(--v-theme-surface-variant), 0.08);
block-size: auto; block-size: auto;
min-block-size: 48px; min-block-size: 48px;
padding-block: 4px; padding-block: 4px;
@@ -814,4 +823,21 @@ onMounted(() => {
.filter-section { .filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08); background-color: rgba(var(--v-theme-surface-variant), 0.08);
} }
@media (width <= 600px) {
.filter-toolbar-card {
border-radius: 8px;
}
.filter-buttons-grid {
gap: 6px;
}
.filter-label {
overflow: hidden;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style> </style>

View File

@@ -38,6 +38,13 @@ interface VirtualCell {
key: ItemKey key: ItemKey
} }
interface VirtualRange {
endIndex: number
endRow: number
startIndex: number
startRow: number
}
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const trackRef = ref<HTMLElement | null>(null) const trackRef = ref<HTMLElement | null>(null)
@@ -45,6 +52,8 @@ const layoutWidth = ref(0)
const viewportTop = ref(0) const viewportTop = ref(0)
const viewportBottom = ref(0) const viewportBottom = ref(0)
const heightVersion = ref(0) const heightVersion = ref(0)
const frozenVisibleRange = ref<VirtualRange | null>(null)
const isOverlayGrid = ref(false)
const itemHeights = new Map<ItemKey, number>() const itemHeights = new Map<ItemKey, number>()
const observedElements = new Map<HTMLElement, ItemKey>() const observedElements = new Map<HTMLElement, ItemKey>()
@@ -53,6 +62,7 @@ const itemRefCallbacks = new Map<ItemKey, (element: Element | ComponentPublicIns
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let itemResizeObserver: ResizeObserver | null = null let itemResizeObserver: ResizeObserver | null = null
let overlayLockObserver: MutationObserver | null = null
let scrollTarget: ScrollTarget | null = null let scrollTarget: ScrollTarget | null = null
let layoutFrameId: number | null = null let layoutFrameId: number | null = null
let scrollFrameId: number | null = null let scrollFrameId: number | null = null
@@ -149,7 +159,18 @@ const rowMetrics = computed(() => {
const totalHeight = computed(() => rowMetrics.value.totalHeight) const totalHeight = computed(() => rowMetrics.value.totalHeight)
const visibleRange = computed(() => { const calculatedVisibleRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value) {
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
return {
endIndex: props.items.length,
endRow: rowCount - 1,
startIndex: 0,
startRow: 0,
}
}
const { heights, offsets, rowCount } = rowMetrics.value const { heights, offsets, rowCount } = rowMetrics.value
if (!props.items.length || rowCount === 0) { if (!props.items.length || rowCount === 0) {
@@ -176,6 +197,8 @@ const visibleRange = computed(() => {
} }
}) })
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
const visibleCells = computed<VirtualCell[]>(() => { const visibleCells = computed<VirtualCell[]>(() => {
const cells: VirtualCell[] = [] const cells: VirtualCell[] = []
@@ -190,7 +213,13 @@ const visibleCells = computed<VirtualCell[]>(() => {
return cells return cells
}) })
const topSpacerHeight = computed(() => rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0) const topSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0
})
const visibleBlockHeight = computed(() => { const visibleBlockHeight = computed(() => {
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) { if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
@@ -206,6 +235,10 @@ const visibleBlockHeight = computed(() => {
}) })
const bottomSpacerHeight = computed(() => { const bottomSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0) return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0)
}) })
@@ -227,6 +260,15 @@ function getComparableKey(item: any, index: number): ItemKey {
return index return index
} }
function getFallbackLayoutWidth() {
if (typeof window === 'undefined') {
return safeMinItemWidth.value
}
// keep-alive 激活首帧可能还拿不到网格宽度,先用视口宽度兜底,避免只渲染一小列。
return Math.max(document.documentElement.clientWidth || window.innerWidth || 0, safeMinItemWidth.value)
}
function findFirstRowAtOrAfterOffset(offsets: number[], heights: number[], offset: number) { function findFirstRowAtOrAfterOffset(offsets: number[], heights: number[], offset: number) {
let low = 0 let low = 0
let high = heights.length - 1 let high = heights.length - 1
@@ -266,6 +308,45 @@ function findLastRowAtOrBeforeOffset(offsets: number[], rowCount: number, offset
return answer return answer
} }
function isDocumentOverlayLocked() {
return typeof document !== 'undefined' && document.documentElement.classList.contains('v-overlay-scroll-blocked')
}
function isGridInsideOverlay() {
return Boolean(containerRef.value?.closest('.v-overlay, .v-overlay__content'))
}
function syncOverlayGridState() {
isOverlayGrid.value = isGridInsideOverlay()
}
function shouldPauseVirtualSync() {
return isDocumentOverlayLocked() && !isOverlayGrid.value
}
function freezeVisibleRange() {
if (frozenVisibleRange.value) {
return
}
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
frozenVisibleRange.value = { ...calculatedVisibleRange.value }
}
function releaseVisibleRange() {
frozenVisibleRange.value = null
}
function handleOverlayLockChange() {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
queueLayoutSync()
}
function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null { function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null {
if (!element || typeof HTMLElement === 'undefined') { if (!element || typeof HTMLElement === 'undefined') {
return null return null
@@ -312,6 +393,11 @@ function ensureItemResizeObserver() {
} }
itemResizeObserver = new ResizeObserver(entries => { itemResizeObserver = new ResizeObserver(entries => {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
let shouldUpdate = false let shouldUpdate = false
let scrollAdjustment = 0 let scrollAdjustment = 0
const currentViewportTop = viewportTop.value const currentViewportTop = viewportTop.value
@@ -470,19 +556,31 @@ function syncLayoutWidth() {
const element = trackRef.value const element = trackRef.value
if (!element) { if (!element) {
layoutWidth.value = 0 if (layoutWidth.value <= 0) {
layoutWidth.value = getFallbackLayoutWidth()
}
return return
} }
layoutWidth.value = element.clientWidth const nextWidth = element.clientWidth
if (nextWidth > 0) {
layoutWidth.value = nextWidth
return
}
if (layoutWidth.value <= 0) {
layoutWidth.value = getFallbackLayoutWidth()
}
} }
function syncViewport() { function syncViewport() {
const element = trackRef.value const element = trackRef.value
if (!element) { if (!element) {
viewportTop.value = 0 if (viewportBottom.value <= viewportTop.value) {
viewportBottom.value = 0 viewportTop.value = 0
viewportBottom.value = typeof window === 'undefined' ? 0 : window.innerHeight
}
return return
} }
@@ -495,8 +593,13 @@ function syncViewport() {
top: 0, top: 0,
} }
viewportTop.value = viewportRect.top - trackRect.top const nextViewportTop = viewportRect.top - trackRect.top
viewportBottom.value = viewportRect.bottom - trackRect.top const nextViewportBottom = viewportRect.bottom - trackRect.top
if (nextViewportBottom > nextViewportTop) {
viewportTop.value = nextViewportTop
viewportBottom.value = nextViewportBottom
}
} }
function queueLayoutSync() { function queueLayoutSync() {
@@ -506,6 +609,15 @@ function queueLayoutSync() {
layoutFrameId = window.requestAnimationFrame(() => { layoutFrameId = window.requestAnimationFrame(() => {
layoutFrameId = null layoutFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断。
syncOverlayGridState()
releaseVisibleRange()
syncLayoutWidth() syncLayoutWidth()
refreshScrollTarget() refreshScrollTarget()
syncViewport() syncViewport()
@@ -520,6 +632,13 @@ function queueViewportSync() {
scrollFrameId = window.requestAnimationFrame(() => { scrollFrameId = window.requestAnimationFrame(() => {
scrollFrameId = null scrollFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
syncViewport() syncViewport()
}) })
} }
@@ -681,6 +800,7 @@ function invalidateMeasurementsForLayoutChange() {
onMounted(() => { onMounted(() => {
mounted = true mounted = true
syncOverlayGridState()
scrollTarget = findScrollTarget() scrollTarget = findScrollTarget()
addScrollListener(scrollTarget) addScrollListener(scrollTarget)
@@ -689,6 +809,14 @@ onMounted(() => {
resizeObserver.observe(trackRef.value) resizeObserver.observe(trackRef.value)
} }
if (typeof MutationObserver !== 'undefined') {
overlayLockObserver = new MutationObserver(handleOverlayLockChange)
overlayLockObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
window.addEventListener('resize', queueLayoutSync, { passive: true }) window.addEventListener('resize', queueLayoutSync, { passive: true })
queueLayoutSync() queueLayoutSync()
@@ -698,6 +826,7 @@ onActivated(() => {
mounted = true mounted = true
refreshScrollTarget() refreshScrollTarget()
queueLayoutSync() queueLayoutSync()
requestAnimationFrame(queueLayoutSync)
}) })
onDeactivated(() => { onDeactivated(() => {
@@ -716,6 +845,8 @@ onUnmounted(() => {
resizeObserver = null resizeObserver = null
itemResizeObserver?.disconnect() itemResizeObserver?.disconnect()
itemResizeObserver = null itemResizeObserver = null
overlayLockObserver?.disconnect()
overlayLockObserver = null
if (layoutFrameId !== null) { if (layoutFrameId !== null) {
window.cancelAnimationFrame(layoutFrameId) window.cancelAnimationFrame(layoutFrameId)

View File

@@ -6,17 +6,10 @@ import { type PropType } from 'vue'
const elementProps = defineProps({ const elementProps = defineProps({
config: Object as PropType<RenderProps>, config: Object as PropType<RenderProps>,
}) })
// key
const componentKey = ref(0)
onActivated(() => {
componentKey.value++
})
</script> </script>
<template> <template>
<Component <Component
:key="componentKey"
:is="elementProps.config?.component" :is="elementProps.config?.component"
v-if="!elementProps.config?.html" v-if="!elementProps.config?.html"
v-bind="elementProps.config?.props" v-bind="elementProps.config?.props"
@@ -34,7 +27,6 @@ onActivated(() => {
/> />
</Component> </Component>
<Component <Component
:key="componentKey"
:is="elementProps.config?.component" :is="elementProps.config?.component"
v-if="elementProps.config?.html" v-if="elementProps.config?.html"
v-bind="elementProps.config?.props" v-bind="elementProps.config?.props"

View File

@@ -60,6 +60,15 @@ const trailingSpaceWidth = computed(() => {
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0) return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
}) })
function getFallbackViewportWidth() {
if (typeof window === 'undefined') {
return itemStep.value * Math.max(props.overscanItems, 1)
}
// keep-alive 激活的首帧偶尔测不到容器宽度,先按视口宽度渲染一屏,避免右侧短暂空白。
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
}
function resolveItemKey(item: any, index: number) { function resolveItemKey(item: any, index: number) {
if (props.getItemKey) { if (props.getItemKey) {
return props.getItemKey(item, startIndex.value + index) return props.getItemKey(item, startIndex.value + index)
@@ -87,7 +96,7 @@ function updateVisibleRange() {
return return
} }
const viewportWidth = element.clientWidth const viewportWidth = element.clientWidth || getFallbackViewportWidth()
if (!viewportWidth || !props.items.length) { if (!viewportWidth || !props.items.length) {
startIndex.value = 0 startIndex.value = 0
endIndex.value = Math.min(props.items.length, props.overscanItems) endIndex.value = Math.min(props.items.length, props.overscanItems)
@@ -185,6 +194,7 @@ onActivated(() => {
} }
nextTick(syncLayoutState) nextTick(syncLayoutState)
requestAnimationFrame(syncLayoutState)
}) })
watch( watch(

View File

@@ -1,14 +1,18 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue' import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
import { sseManagerSingleton } from '@/utils/sseManager' import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
type UseSSEOptions = Partial<SSEManagerOptions> & {
connectDelay?: number
}
/** /**
* *
* SSE连接和定时器iOS后台性能 * SSE连接和定时器
*/ */
export function useBackgroundOptimization() { export function useBackground() {
/** /**
* 使SSE连接 * 使SSE连接
* @param url SSE连接地址 * @param url SSE连接地址
* @param messageHandler * @param messageHandler
* @param listenerId ID * @param listenerId ID
@@ -18,24 +22,30 @@ export function useBackgroundOptimization() {
url: string, url: string,
messageHandler: (event: MessageEvent) => void, messageHandler: (event: MessageEvent) => void,
listenerId: string, listenerId: string,
options?: { options?: UseSSEOptions,
backgroundCloseDelay?: number
reconnectDelay?: number
maxReconnectAttempts?: number
connectDelay?: number // 新增:连接延迟
},
) => { ) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接 // 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options) const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
const isConnected = ref(false) const isConnected = ref(false)
let connectTimer: ReturnType<typeof setTimeout> | null = null let connectTimer: ReturnType<typeof setTimeout> | null = null
let isClosed = false
const statusListenerId = `${listenerId}:status`
manager.addStatusListener(statusListenerId, status => {
isConnected.value = status === 'open'
})
const cleanup = () => { const cleanup = () => {
if (isClosed) return
isClosed = true
if (connectTimer) { if (connectTimer) {
clearTimeout(connectTimer) clearTimeout(connectTimer)
connectTimer = null connectTimer = null
} }
manager.removeStatusListener(statusListenerId)
manager.removeMessageListener(listenerId) manager.removeMessageListener(listenerId)
sseManagerSingleton.closeIndependentManager(url, listenerId) sseManagerSingleton.closeIndependentManager(url, listenerId)
isConnected.value = false isConnected.value = false
@@ -46,11 +56,10 @@ export function useBackgroundOptimization() {
const connectDelay = options?.connectDelay || 100 const connectDelay = options?.connectDelay || 100
connectTimer = setTimeout(() => { connectTimer = setTimeout(() => {
connectTimer = null connectTimer = null
if (isClosed) return
try { try {
manager.addMessageListener(listenerId, event => { manager.addMessageListener(listenerId, messageHandler)
messageHandler(event)
isConnected.value = true
})
} catch (error) { } catch (error) {
console.error('SSE连接建立失败:', error) console.error('SSE连接建立失败:', error)
} }
@@ -69,7 +78,7 @@ export function useBackgroundOptimization() {
} }
/** /**
* 使 * 使
* @param id ID * @param id ID
* @param callback * @param callback
* @param interval * @param interval
@@ -110,25 +119,40 @@ export function useBackgroundOptimization() {
messageHandler: (event: MessageEvent) => void, messageHandler: (event: MessageEvent) => void,
listenerId: string, listenerId: string,
delay: number = 3000, delay: number = 3000,
options?: Parameters<typeof useSSE>[3], options?: UseSSEOptions,
) => { ) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接 // 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options) const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
const isConnected = ref(false)
let connectTimer: ReturnType<typeof setTimeout> | null = null let connectTimer: ReturnType<typeof setTimeout> | null = null
let isClosed = false
const statusListenerId = `${listenerId}:status`
manager.addStatusListener(statusListenerId, status => {
isConnected.value = status === 'open'
})
const cleanup = () => { const cleanup = () => {
if (isClosed) return
isClosed = true
if (connectTimer) { if (connectTimer) {
clearTimeout(connectTimer) clearTimeout(connectTimer)
connectTimer = null connectTimer = null
} }
manager.removeStatusListener(statusListenerId)
manager.removeMessageListener(listenerId) manager.removeMessageListener(listenerId)
sseManagerSingleton.closeIndependentManager(url, listenerId) sseManagerSingleton.closeIndependentManager(url, listenerId)
isConnected.value = false
} }
onMounted(() => { onMounted(() => {
connectTimer = setTimeout(() => { connectTimer = setTimeout(() => {
connectTimer = null connectTimer = null
if (isClosed) return
manager.addMessageListener(listenerId, messageHandler) manager.addMessageListener(listenerId, messageHandler)
}, delay) }, delay)
}) })
@@ -139,6 +163,7 @@ export function useBackgroundOptimization() {
manager, manager,
readyState: () => manager.readyState, readyState: () => manager.readyState,
close: cleanup, close: cleanup,
isConnected,
} }
} }
@@ -189,9 +214,12 @@ export function useBackgroundOptimization() {
isListening = false isListening = false
} }
onUnmounted(() => { // 进度监听有些场景会在用户操作后动态创建;只有 setup 阶段创建时才注册自动卸载钩子。
stopProgress(true) if (getCurrentInstance()) {
}) onUnmounted(() => {
stopProgress(true)
})
}
return { return {
start: startProgress, start: startProgress,

View File

@@ -0,0 +1,98 @@
import { nextTick, onActivated, onMounted, toValue, watch, type MaybeRefOrGetter } from 'vue'
export interface KeepAliveRefreshContext {
/** 重新进入页面时已有旧内容可用,刷新应尽量避免切换主 loading 或清空列表。 */
silent?: boolean
source?: 'activated' | 'tab' | 'manual'
}
type RefreshHandler = (context?: KeepAliveRefreshContext) => void | Promise<void>
interface KeepAliveRefreshOptions {
/**
* 当前内容是否处于可见状态。
* keep-alive 会激活整棵缓存树tab 内组件需要用它避免后台标签页也刷新。
*/
active?: MaybeRefOrGetter<boolean>
/** 是否在 keep-alive 页面重新进入时刷新。 */
refreshOnActivated?: boolean
/** 是否在 tab 从隐藏切回可见时刷新。 */
refreshOnTabActivated?: boolean
}
/**
* keep-alive 页面复用实例时不会重新 mounted这里统一补上重新进入和重新选中 tab 的刷新。
*/
export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveRefreshOptions = {}) {
let mounted = false
let activatedCount = 0
let refreshing = false
let pendingRefresh = false
let refreshScheduled = false
const isActive = () => options.active === undefined || Boolean(toValue(options.active))
async function runRefresh(context: KeepAliveRefreshContext = { silent: true, source: 'manual' }) {
if (!isActive()) return
// 避免路由激活和 tab 激活在同一轮里叠加出并发请求。
if (refreshing) {
pendingRefresh = true
return
}
refreshing = true
try {
await refresh(context)
} finally {
refreshing = false
if (pendingRefresh) {
pendingRefresh = false
await runRefresh(context)
}
}
}
function requestRefresh(source: KeepAliveRefreshContext['source']) {
// 同一轮激活里可能同时触发路由激活和 tab 激活,合并成一次静默刷新。
if (refreshScheduled) return
refreshScheduled = true
void nextTick(async () => {
refreshScheduled = false
await runRefresh({ silent: true, source })
})
}
onMounted(() => {
mounted = true
})
if (options.refreshOnActivated !== false) {
onActivated(() => {
activatedCount += 1
// KeepAlive 首次挂载也会触发 activated初始加载交给页面自己的 mounted 逻辑。
if (activatedCount === 1) return
requestRefresh('activated')
})
}
if (options.active !== undefined && options.refreshOnTabActivated !== false) {
watch(
() => Boolean(toValue(options.active)),
(active, oldActive) => {
if (!mounted || !active || oldActive !== false) return
requestRefresh('tab')
},
{ flush: 'post' },
)
}
return {
refresh: runRefresh,
}
}

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

@@ -64,11 +64,20 @@ const lastY = ref(0)
const lastTime = ref(0) const lastTime = ref(0)
const velocity = ref(0) const velocity = ref(0)
const startedFromBottomArea = ref(false) const startedFromBottomArea = ref(false)
const quickAccessRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
// 插件弹窗相关状态 // 插件弹窗相关状态
const showPluginDataDialog = ref(false) const showPluginDataDialog = ref(false)
const currentPlugin = ref<Plugin | null>(null) const currentPlugin = ref<Plugin | null>(null)
// Vuetify 组件 ref 在不同构建下可能返回组件实例,这里统一解析为真实 DOM 节点。
function getQuickAccessElement() {
const element = quickAccessRef.value
if (!element) return null
return element instanceof HTMLElement ? element : element.$el ?? null
}
// 计算显示状态 // 计算显示状态
const isVisible = computed(() => { const isVisible = computed(() => {
return props.visible return props.visible
@@ -211,20 +220,27 @@ function manageScrollLock() {
if (isVisible.value) { if (isVisible.value) {
// 使用 nextTick 确保 DOM 已经更新 // 使用 nextTick 确保 DOM 已经更新
nextTick(() => { nextTick(() => {
// 先恢复之前的锁定状态,避免重复锁定 const panelElement = getQuickAccessElement()
const scrollableElement = document.querySelector('.all-plugins-grid') if (!panelElement) return
if (scrollableElement) {
// 确保元素存在且可见 // 锁定整层快捷入口,只有插件列表内部允许惯性滚动,避免底部手势漏给首页背景。
if ((scrollableElement as HTMLElement).offsetHeight > 0) { disableBodyScroll(panelElement, {
disableBodyScroll(scrollableElement as HTMLElement) allowTouchMove: el => Boolean((el as HTMLElement).closest('.quick-access-scroll')),
} })
if (typeof document !== 'undefined') {
document.documentElement.classList.add('quick-access-scroll-locked')
} }
}) })
} else { } else {
// 恢复背景滚动 // 恢复背景滚动
const scrollableElement = document.querySelector('.all-plugins-grid') const panelElement = getQuickAccessElement()
if (scrollableElement) { if (panelElement) {
enableBodyScroll(scrollableElement as HTMLElement) enableBodyScroll(panelElement)
}
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('quick-access-scroll-locked')
} }
} }
} }
@@ -254,9 +270,13 @@ onMounted(() => {
// 组件卸载时确保恢复背景滚动 // 组件卸载时确保恢复背景滚动
onUnmounted(() => { onUnmounted(() => {
const scrollableElement = document.querySelector('.all-plugins-grid') const panelElement = getQuickAccessElement()
if (scrollableElement) { if (panelElement) {
enableBodyScroll(scrollableElement as HTMLElement) enableBodyScroll(panelElement)
}
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('quick-access-scroll-locked')
} }
}) })
@@ -297,6 +317,10 @@ function handleTouchMove(event: TouchEvent) {
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭 // 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return if (!startedFromBottomArea.value) return
// 底部关闭手势从第一帧开始接管,防止 iOS 将早期位移传递给背景页面滚动。
event.preventDefault()
event.stopPropagation()
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭 // 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (target.closest('.plugin-grid')) { if (target.closest('.plugin-grid')) {
@@ -319,7 +343,6 @@ function handleTouchMove(event: TouchEvent) {
if (deltaY >= 0) { if (deltaY >= 0) {
// 向上拖拽,更新偏移量 // 向上拖拽,更新偏移量
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE) dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
} else { } else {
// 向下拖拽,停止拖拽 // 向下拖拽,停止拖拽
isDraggingToClose.value = false isDraggingToClose.value = false
@@ -330,7 +353,6 @@ function handleTouchMove(event: TouchEvent) {
if (deltaY > SWIPE_CONFIG.START_THRESHOLD) { if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {
isDraggingToClose.value = true isDraggingToClose.value = true
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE) dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
} }
} }
@@ -366,6 +388,27 @@ function handleTouchEnd() {
startedFromBottomArea.value = false startedFromBottomArea.value = false
} }
// 底部手势区域不参与页面滚动,从触摸开始就阻止事件冒泡到全局下拉监听。
function handleBottomTouchStart(event: TouchEvent) {
if (!props.visible) return
event.stopPropagation()
handleTouchStart(event)
}
function handleBottomTouchMove(event: TouchEvent) {
if (!props.visible) return
handleTouchMove(event)
}
function handleBottomTouchEnd(event: TouchEvent) {
if (!props.visible) return
event.stopPropagation()
handleTouchEnd()
}
// 点击底部空白区域关闭 // 点击底部空白区域关闭
function handleBackdropClick(event: MouseEvent) { function handleBackdropClick(event: MouseEvent) {
const target = event.target as HTMLElement const target = event.target as HTMLElement
@@ -383,6 +426,7 @@ function handleBackdropClick(event: MouseEvent) {
<template> <template>
<VCard <VCard
ref="quickAccessRef"
:ripple="false" :ripple="false"
class="plugin-quick-access" class="plugin-quick-access"
:class="{ 'visible': isVisible }" :class="{ 'visible': isVisible }"
@@ -408,7 +452,7 @@ function handleBackdropClick(event: MouseEvent) {
</div> </div>
<!-- 插件网格 --> <!-- 插件网格 -->
<div class="plugin-grid"> <div class="plugin-grid quick-access-scroll">
<!-- 加载状态 --> <!-- 加载状态 -->
<LoadingBanner v-if="loading" /> <LoadingBanner v-if="loading" />
@@ -457,7 +501,7 @@ function handleBackdropClick(event: MouseEvent) {
</div> </div>
<div v-if="pluginsWithPage.length > 0" class="all-plugins-container"> <div v-if="pluginsWithPage.length > 0" class="all-plugins-container">
<div class="all-plugins-grid"> <div class="all-plugins-grid quick-access-scroll">
<div <div
v-for="plugin in pluginsWithPage" v-for="plugin in pluginsWithPage"
:key="plugin.id" :key="plugin.id"
@@ -500,7 +544,14 @@ function handleBackdropClick(event: MouseEvent) {
</div> </div>
<!-- 底部拖动区域 --> <!-- 底部拖动区域 -->
<div class="bottom-drag-area" @click="handleBackdropClick"> <div
class="bottom-drag-area"
@click="handleBackdropClick"
@touchstart.stop="handleBottomTouchStart"
@touchmove.prevent.stop="handleBottomTouchMove"
@touchend.stop="handleBottomTouchEnd"
@touchcancel.stop="handleBottomTouchEnd"
>
<!-- 底部指示器 --> <!-- 底部指示器 -->
<div class="bottom-indicator"> <div class="bottom-indicator">
<div <div
@@ -767,6 +818,15 @@ function handleBackdropClick(event: MouseEvent) {
cursor: pointer; cursor: pointer;
padding-block: 8px 0; padding-block: 8px 0;
padding-inline: 20px; padding-inline: 20px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
:global(html.quick-access-scroll-locked),
:global(html.quick-access-scroll-locked body) {
overflow: hidden !important;
overscroll-behavior: none;
} }
@media (hover: none) and (pointer: coarse) { @media (hover: none) and (pointer: coarse) {

View File

@@ -9,6 +9,7 @@ type MessageViewExpose = {
pauseSSE?: () => void pauseSSE?: () => void
resumeSSE?: () => void resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void refreshLatestMessages?: () => Promise<void> | void
forceScrollToEnd?: () => void
} }
// 国际化 // 国际化
@@ -67,15 +68,9 @@ const user_message = ref('')
// 发送按钮是否可用 // 发送按钮是否可用
const sendButtonDisabled = ref(false) const sendButtonDisabled = ref(false)
// 消息对话框引用
const messageDialogRef = ref<any>(null)
// 消息视图引用 // 消息视图引用
const messageViewRef = ref<MessageViewExpose | null>(null) const messageViewRef = ref<MessageViewExpose | null>(null)
// 滚动容器引用
const messageContentRef = ref<any>()
// 定义捷径列表 // 定义捷径列表
const shortcuts = [ const shortcuts = [
{ {
@@ -148,58 +143,9 @@ function openDialog(dialogRef: any) {
dialogRef.value = true dialogRef.value = true
} }
// 打开消息弹窗并清除徽章 // 打开消息弹窗
async function openMessageDialog() { function openMessageDialog() {
messageDialog.value = true messageDialog.value = true
// 延迟清除徽章,确保对话框已经打开
setTimeout(async () => {
await clearAppBadge()
}, 500)
// 延迟滚动到底部,确保弹窗完全打开
setTimeout(() => {
forceScrollToEnd()
}, 600)
// 等待对话框打开后恢复SSE连接
nextTick(() => {
messageViewRef.value?.resumeSSE?.()
})
}
// 智能滚动到底部(只有用户在底部附近时才滚动)
function scrollMessageToEnd() {
// 使用更长的延迟确保DOM已更新
setTimeout(() => {
try {
// 查找消息弹窗的滚动容器
const cardText = document.querySelector('.v-dialog .v-card-text')
if (cardText) {
const { scrollTop, scrollHeight, clientHeight } = cardText
// 计算距离底部的距离
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 如果用户距离底部小于1/3屏幕高度认为用户在底部附近执行自动滚动
if (distanceFromBottom <= clientHeight / 3) {
cardText.scrollTop = cardText.scrollHeight
}
}
} catch (error) {
console.error(error)
}
}, 500) // 增加延迟时间
}
// 强制滚动到底部(用于发送消息后)
function forceScrollToEnd() {
setTimeout(() => {
try {
// 查找消息弹窗的滚动容器
const cardText = document.querySelector('.v-dialog .v-card-text')
if (cardText) {
cardText.scrollTop = cardText.scrollHeight
}
} catch (error) {
console.error(error)
}
}, 500)
} }
// 拼接全部日志url // 拼接全部日志url
@@ -221,7 +167,7 @@ async function sendMessage() {
// 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。 // 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。
// await messageViewRef.value?.refreshLatestMessages?.() // await messageViewRef.value?.refreshLatestMessages?.()
forceScrollToEnd() // 发送消息后强制滚动到底部 messageViewRef.value?.forceScrollToEnd?.()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
@@ -240,8 +186,20 @@ defineExpose({
}) })
// 监听消息对话框状态变化 // 监听消息对话框状态变化
watch(messageDialog, newValue => { watch(messageDialog, async newValue => {
if (!newValue && messageViewRef.value?.pauseSSE) { if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
return
}
if (messageViewRef.value?.pauseSSE) {
// 对话框关闭时暂停SSE连接 // 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE() messageViewRef.value.pauseSSE()
} }
@@ -475,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" />
@@ -484,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>
@@ -496,7 +454,6 @@ onMounted(() => {
max-width="50rem" max-width="50rem"
scrollable scrollable
:fullscreen="!display.mdAndUp.value" :fullscreen="!display.mdAndUp.value"
ref="messageDialogRef"
> >
<VCard> <VCard>
<VCardItem> <VCardItem>
@@ -507,8 +464,8 @@ onMounted(() => {
<VDialogCloseBtn @click="messageDialog = false" /> <VDialogCloseBtn @click="messageDialog = false" />
</VCardItem> </VCardItem>
<VDivider /> <VDivider />
<VCardText ref="messageContentRef"> <VCardText>
<MessageView ref="messageViewRef" @scroll="scrollMessageToEnd" /> <MessageView ref="messageViewRef" />
</VCardText> </VCardText>
<VDivider /> <VDivider />
<VCardActions class="pa-4"> <VCardActions class="pa-4">
@@ -535,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

@@ -2,10 +2,10 @@
import { formatDateDifference } from '@core/utils/formatters' import { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types' import { SystemNotification } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
const { t } = useI18n() const { t } = useI18n()
const { useDelayedSSE } = useBackgroundOptimization() const { useDelayedSSE } = useBackground()
// 是否有新消息 // 是否有新消息
const hasNewMessage = ref(false) const hasNewMessage = ref(false)
@@ -39,7 +39,7 @@ function handleMessage(event: MessageEvent) {
} }
} }
// 使用优化的SSE连接延迟3秒启动避免认证问题 // 延迟3秒启动SSE连接避免认证信息尚未准备好。
useDelayedSSE( useDelayedSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message`, `${import.meta.env.VITE_API_BASE_URL}system/message`,
handleMessage, handleMessage,

View File

@@ -2,13 +2,16 @@
import DefaultLayout from './components/DefaultLayout.vue' import DefaultLayout from './components/DefaultLayout.vue'
const route = useRoute() const route = useRoute()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
</script> </script>
<template> <template>
<DefaultLayout> <DefaultLayout>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive :max="12"> <keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" /> <component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive> </keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" /> <component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
</router-view> </router-view>

View File

@@ -1031,7 +1031,7 @@ export default {
doubanGlobalTVRankings: 'Douban Global TV Rankings', doubanGlobalTVRankings: 'Douban Global TV Rankings',
noCategoryContent: 'No content to display in current category', noCategoryContent: 'No content to display in current category',
configureContent: 'Configure Display Content', configureContent: 'Configure Display Content',
customizeContent: 'Customize Content', customizeContent: 'Customize Recommendations',
selectContentToDisplay: 'Select content you want to display on the page', selectContentToDisplay: 'Select content you want to display on the page',
selectAll: 'Select All', selectAll: 'Select All',
selectNone: 'Select None', selectNone: 'Select None',
@@ -1424,8 +1424,7 @@ export default {
llmSupportAudioInputHint: llmSupportAudioInputHint:
'When enabled, incoming audio messages are transcribed before being handled by the AI assistant.', 'When enabled, incoming audio messages are transcribed before being handled by the AI assistant.',
llmSupportAudioOutput: 'Support Audio Output', llmSupportAudioOutput: 'Support Audio Output',
llmSupportAudioOutputHint: llmSupportAudioOutputHint: 'When enabled, the AI assistant can send voice replies on supported channels.',
'When enabled, the AI assistant can send voice replies on supported channels.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)', llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint: llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.', 'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
@@ -1749,7 +1748,7 @@ export default {
userAgent: 'Browser User-Agent', userAgent: 'Browser User-Agent',
userAgentHint: 'User-Agent of the browser with CookieCloud plugin', userAgentHint: 'User-Agent of the browser with CookieCloud plugin',
browserEmulation: 'Browser Emulation', browserEmulation: 'Browser Emulation',
browserEmulationHint: 'Choose how to emulate browser when accessing sites (Playwright or FlareSolverr)', browserEmulationHint: 'Choose how to emulate browser when accessing sites (CloakBrowser or FlareSolverr)',
flaresolverrUrl: 'FlareSolverr URL', flaresolverrUrl: 'FlareSolverr URL',
flaresolverrUrlHint: 'Required when using FlareSolverr, e.g. http://127.0.0.1:8191', flaresolverrUrlHint: 'Required when using FlareSolverr, e.g. http://127.0.0.1:8191',
siteDataRefresh: 'Site Data Refresh', siteDataRefresh: 'Site Data Refresh',
@@ -2959,7 +2958,7 @@ export default {
}, },
transferHistory: { transferHistory: {
title: 'Transfer History', title: 'Transfer History',
searchPlaceholder: 'Search transfer records', searchPlaceholder: 'Search (supports * ? wildcards)',
titleColumn: 'Title', titleColumn: 'Title',
pathColumn: 'Path', pathColumn: 'Path',
modeColumn: 'Mode', modeColumn: 'Mode',
@@ -3067,7 +3066,8 @@ export default {
apiKey: 'API Key', apiKey: 'API Key',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.', qbittorrentApiKeyHint:
'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
category: 'Auto Category Management', category: 'Auto Category Management',
sequentail: 'Sequential Download', sequentail: 'Sequential Download',
force_resume: 'Force Resume', force_resume: 'Force Resume',

View File

@@ -321,7 +321,8 @@ export default {
settingTabs: { settingTabs: {
system: { system: {
title: '系统', title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视', description:
'基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视',
}, },
directory: { directory: {
title: '存储 & 目录', title: '存储 & 目录',
@@ -1025,7 +1026,7 @@ export default {
doubanGlobalTVRankings: '豆瓣全球剧集榜', doubanGlobalTVRankings: '豆瓣全球剧集榜',
noCategoryContent: '当前分类下没有可显示的内容', noCategoryContent: '当前分类下没有可显示的内容',
configureContent: '设置显示内容', configureContent: '设置显示内容',
customizeContent: '自定义内容', customizeContent: '自定义推荐',
selectContentToDisplay: '选择您想在页面显示的内容', selectContentToDisplay: '选择您想在页面显示的内容',
selectAll: '全选', selectAll: '全选',
selectNone: '全不选', selectNone: '全不选',
@@ -1442,7 +1443,8 @@ export default {
audioInputApiKey: '音频输入 API密钥', audioInputApiKey: '音频输入 API密钥',
audioInputApiKeyHint: '音频输入转写使用的 API 密钥', audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
audioInputBaseUrl: '音频输入基础URL', audioInputBaseUrl: '音频输入基础URL',
audioInputBaseUrlHint: '音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1', audioInputBaseUrlHint:
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
audioInputModel: '音频输入模型', audioInputModel: '音频输入模型',
audioInputModelHint: '用于将音频内容转换为文字的模型名称', audioInputModelHint: '用于将音频内容转换为文字的模型名称',
audioInputLanguage: '识别语言', audioInputLanguage: '识别语言',
@@ -1452,7 +1454,8 @@ export default {
audioOutputApiKey: '音频输出 API密钥', audioOutputApiKey: '音频输出 API密钥',
audioOutputApiKeyHint: '文字转语音使用的 API 密钥', audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
audioOutputBaseUrl: '音频输出基础URL', audioOutputBaseUrl: '音频输出基础URL',
audioOutputBaseUrlHint: '音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1', audioOutputBaseUrlHint:
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
audioOutputModel: '音频输出模型', audioOutputModel: '音频输出模型',
audioOutputModelHint: '用于将文字内容转换为语音的模型名称', audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
audioOutputVoice: '语音音色', audioOutputVoice: '语音音色',
@@ -1560,8 +1563,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的图片数据', fanartEnableHint: '使用 fanart.tv 的图片数据',
fanartLang: 'Fanart语言', fanartLang: 'Fanart语言',
fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列', fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列',
recognizePluginFirst: "优先使用插件识别", recognizePluginFirst: '优先使用插件识别',
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别", recognizePluginFirstHint: '优先调用插件识别媒体信息,若插件命中则不再调用原生识别',
mediaRecognizeShare: '共享使用媒体识别数据', mediaRecognizeShare: '共享使用媒体识别数据',
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID识别失败时优先回查共享识别结果', mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID识别失败时优先回查共享识别结果',
githubProxy: 'Github加速代理', githubProxy: 'Github加速代理',
@@ -1694,7 +1697,7 @@ export default {
skipDesc: '跳过刮削,不生成该文件', skipDesc: '跳过刮削,不生成该文件',
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变', missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
overwriteDesc: '始终刮削,已存在则覆盖', overwriteDesc: '始终刮削,已存在则覆盖',
} },
}, },
site: { site: {
siteSync: '站点同步', siteSync: '站点同步',
@@ -1718,7 +1721,7 @@ export default {
siteDataRefresh: '站点数据刷新', siteDataRefresh: '站点数据刷新',
siteOptions: '站点选项', siteOptions: '站点选项',
browserEmulation: '浏览器仿真', browserEmulation: '浏览器仿真',
browserEmulationHint: '站点访问仿真方式,支持 Playwright 或 FlareSolverr', browserEmulationHint: '站点访问仿真方式,支持 CloakBrowser 或 FlareSolverr',
flaresolverrUrl: 'FlareSolverr 服务地址', flaresolverrUrl: 'FlareSolverr 服务地址',
flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效例如http://127.0.0.1:8191', flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站点数据刷新间隔', siteDataRefreshInterval: '站点数据刷新间隔',
@@ -2904,7 +2907,7 @@ export default {
}, },
transferHistory: { transferHistory: {
title: '转移历史', title: '转移历史',
searchPlaceholder: '搜索转移记录', searchPlaceholder: '搜索(支持 * ? 通配符)',
titleColumn: '标题', titleColumn: '标题',
pathColumn: '路径', pathColumn: '路径',
modeColumn: '转移方式', modeColumn: '转移方式',

View File

@@ -1026,7 +1026,7 @@ export default {
doubanGlobalTVRankings: '豆瓣全球劇集榜', doubanGlobalTVRankings: '豆瓣全球劇集榜',
noCategoryContent: '當前分類下沒有可顯示的內容', noCategoryContent: '當前分類下沒有可顯示的內容',
configureContent: '設置顯示內容', configureContent: '設置顯示內容',
customizeContent: '自定義內容', customizeContent: '自定義推薦',
selectContentToDisplay: '選擇您想在頁面顯示的內容', selectContentToDisplay: '選擇您想在頁面顯示的內容',
selectAll: '全選', selectAll: '全選',
selectNone: '全不選', selectNone: '全不選',
@@ -1444,7 +1444,8 @@ export default {
audioInputApiKey: '音頻輸入 API密鑰', audioInputApiKey: '音頻輸入 API密鑰',
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰', audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
audioInputBaseUrl: '音頻輸入基礎URL', audioInputBaseUrl: '音頻輸入基礎URL',
audioInputBaseUrlHint: '音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1', audioInputBaseUrlHint:
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
audioInputModel: '音頻輸入模型', audioInputModel: '音頻輸入模型',
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱', audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
audioInputLanguage: '識別語言', audioInputLanguage: '識別語言',
@@ -1454,7 +1455,8 @@ export default {
audioOutputApiKey: '音頻輸出 API密鑰', audioOutputApiKey: '音頻輸出 API密鑰',
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰', audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
audioOutputBaseUrl: '音頻輸出基礎URL', audioOutputBaseUrl: '音頻輸出基礎URL',
audioOutputBaseUrlHint: '音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1', audioOutputBaseUrlHint:
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
audioOutputModel: '音頻輸出模型', audioOutputModel: '音頻輸出模型',
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱', audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
audioOutputVoice: '語音音色', audioOutputVoice: '語音音色',
@@ -1720,7 +1722,7 @@ export default {
siteDataRefresh: '站點數據刷新', siteDataRefresh: '站點數據刷新',
siteOptions: '站點選項', siteOptions: '站點選項',
browserEmulation: '瀏覽器仿真', browserEmulation: '瀏覽器仿真',
browserEmulationHint: '站點訪問仿真方式,支援 Playwright 或 FlareSolverr', browserEmulationHint: '站點訪問仿真方式,支援 CloakBrowser 或 FlareSolverr',
flaresolverrUrl: 'FlareSolverr 服務地址', flaresolverrUrl: 'FlareSolverr 服務地址',
flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效例如http://127.0.0.1:8191', flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站點數據刷新間隔', siteDataRefreshInterval: '站點數據刷新間隔',
@@ -2906,7 +2908,7 @@ export default {
}, },
transferHistory: { transferHistory: {
title: '轉移歷史', title: '轉移歷史',
searchPlaceholder: '搜索轉移記錄', searchPlaceholder: '搜索(支援 * ? 萬用字元)',
titleColumn: '標題', titleColumn: '標題',
pathColumn: '路徑', pathColumn: '路徑',
modeColumn: '轉移方式', modeColumn: '轉移方式',

View File

@@ -34,7 +34,7 @@ function getApiPath(paths: string[] | string) {
<VPageContentTitle :title="title" /> <VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" /> <PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" /> <MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
<Teleport to="body" v-if="route.path === '/browse'"> <Teleport to="body">
<VScrollToTopBtn /> <VScrollToTopBtn />
</Teleport> </Teleport>
</div> </div>

View File

@@ -280,6 +280,40 @@ async function getPluginDashboardMeta() {
} }
} }
function clearPluginDashboardTimer(pluginDashboardId: string) {
if (!refreshTimers.value[pluginDashboardId]) return
clearTimeout(refreshTimers.value[pluginDashboardId])
delete refreshTimers.value[pluginDashboardId]
}
function schedulePluginDashboardRefresh(item: DashboardItem) {
const pluginDashboardId = buildPluginDashboardId(item.id, item.key)
clearPluginDashboardTimer(pluginDashboardId)
if (
item.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
refreshTimers.value[pluginDashboardId] = setTimeout(() => {
getPluginDashboard(item.id, item.key)
}, item.attrs.refresh * 1000)
}
}
function refreshEnabledPluginDashboards() {
if (!superUser || isNullOrEmptyObject(pluginDashboardMeta.value)) return
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
if (enableConfig.value[pluginDashboardId]) {
getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
}
})
}
// 获取一个插件的仪表板配置项 // 获取一个插件的仪表板配置项
async function getPluginDashboard(id: string, key: string) { async function getPluginDashboard(id: string, key: string) {
try { try {
@@ -309,22 +343,7 @@ async function getPluginDashboard(id: string, key: string) {
} }
const pluginDashboardId = buildPluginDashboardId(id, key) const pluginDashboardId = buildPluginDashboardId(id, key)
// 定时刷新 // 定时刷新
if ( schedulePluginDashboardRefresh(res)
res.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
// 清除之前的定时器
if (refreshTimers.value[pluginDashboardId]) {
clearTimeout(refreshTimers.value[pluginDashboardId])
}
// 设置新的定时器
let timer = setTimeout(() => {
getPluginDashboard(id, key)
}, res.attrs.refresh * 1000)
refreshTimers.value[pluginDashboardId] = timer
}
} }
}) })
} catch (error) { } catch (error) {
@@ -346,10 +365,12 @@ onBeforeMount(async () => {
onActivated(() => { onActivated(() => {
isRequest.value = true isRequest.value = true
refreshEnabledPluginDashboards()
}) })
onDeactivated(() => { onDeactivated(() => {
isRequest.value = false isRequest.value = false
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
}) })
</script> </script>

View File

@@ -5,6 +5,7 @@ import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
import NoDataFound from '@/components/NoDataFound.vue' import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -52,7 +53,7 @@ onMounted(async () => {
registerTabs() registerTabs()
}) })
onActivated(async () => { useKeepAliveRefresh(async () => {
await loadDownloaderSetting() await loadDownloaderSetting()
registerTabs() registerTabs()
}) })
@@ -61,10 +62,10 @@ onActivated(async () => {
<template> <template>
<div v-if="downloaders.length > 0"> <div v-if="downloaders.length > 0">
<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 downloaders" :value="item.name"> <VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<DownloadingListView :name="item.name" /> <DownloadingListView :name="item.name" :active="activeTab === item.name" />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>

View File

@@ -5,9 +5,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils' import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
const display = useDisplay() const display = useDisplay()
const { appMode } = usePWA()
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -21,6 +24,10 @@ const currentCategory = ref(t('recommend.all'))
// 使用动态标签页 // 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab() const { registerHeaderTab } = useDynamicHeaderTab()
function openRecommendSettings() {
dialog.value = true
}
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([ const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([
{ {
apipath: 'recommend/tmdb_trending', apipath: 'recommend/tmdb_trending',
@@ -218,17 +225,12 @@ const categoryItems = computed(() => [
registerHeaderTab({ registerHeaderTab({
items: categoryItems, items: categoryItems,
modelValue: currentCategory, modelValue: currentCategory,
appendButtons: [ })
{
icon: 'mdi-tune', useDynamicButton({
variant: 'text', icon: 'mdi-tune',
color: 'grey', onClick: openRecommendSettings,
class: 'settings-icon-button', show: computed(() => appMode.value),
action: () => {
dialog.value = true
},
},
],
}) })
// 页面是否准备就绪 // 页面是否准备就绪
@@ -346,7 +348,19 @@ onActivated(async () => {
<!-- 快速滚动到顶部按钮 --> <!-- 快速滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/recommend'"> <Teleport to="body" v-if="route.path === '/recommend'">
<VScrollToTopBtn /> <div v-if="!appMode" class="compact-fab-stack">
<VFab
icon="mdi-tune"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openRecommendSettings"
/>
</div>
</Teleport>
<Teleport to="body" v-if="route.path === '/recommend'">
<VScrollToTopBtn :offset-fab="!appMode" />
</Teleport> </Teleport>
</div> </div>
</template> </template>

View File

@@ -11,11 +11,16 @@ import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores/global' import { useGlobalSettingsStore } from '@/stores/global'
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter' import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { appMode } = usePWA()
// 提示框 // 提示框
const toast = useToast() const toast = useToast()
@@ -39,6 +44,14 @@ interface SearchParams {
sites: string sites: string
} }
interface LastSearchContextResponse {
success?: boolean
data?: {
params?: Partial<SearchParams>
results?: Context[]
}
}
const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams' const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams'
function createSearchParams(query: LocationQuery): SearchParams { function createSearchParams(query: LocationQuery): SearchParams {
@@ -106,10 +119,55 @@ function rememberSearchParams(params: SearchParams) {
saveStoredSearchParams(nextParams) saveStoredSearchParams(nextParams)
} }
function applyRememberedSearchParams(params?: Partial<SearchParams> | null, syncActive: boolean = false) {
const nextParams = normalizeSearchParams(params)
if (!hasSearchKeyword(nextParams)) return null
rememberSearchParams(nextParams)
if (syncActive || !hasSearchKeyword(activeSearchParams.value)) {
activeSearchParams.value = { ...nextParams }
}
return nextParams
}
if (hasSearchKeyword(initialSearchParams)) { if (hasSearchKeyword(initialSearchParams)) {
rememberSearchParams(initialSearchParams) rememberSearchParams(initialSearchParams)
} }
async function fetchLastSearchContext() {
try {
const result = (await api.get('search/last/context')) as LastSearchContextResponse
applyRememberedSearchParams(result?.data?.params, true)
return Array.isArray(result?.data?.results) ? result.data.results : []
} catch (error) {
console.warn('读取上次搜索上下文失败,回退到仅加载结果:', error)
const results = await api.get('search/last')
return (results as unknown as Context[]) || []
}
}
async function resolveRefreshSearchParams() {
if (hasSearchKeyword(activeSearchParams.value)) {
return { ...activeSearchParams.value }
}
if (lastSearchParams.value && hasSearchKeyword(lastSearchParams.value)) {
return { ...lastSearchParams.value }
}
const storedParams = loadStoredSearchParams()
if (storedParams) {
applyRememberedSearchParams(storedParams, true)
return { ...storedParams }
}
await fetchLastSearchContext()
if (lastSearchParams.value && hasSearchKeyword(lastSearchParams.value)) {
return { ...lastSearchParams.value }
}
return null
}
// 查询TMDBID或标题 // 查询TMDBID或标题
const keyword = computed(() => activeSearchParams.value.keyword) const keyword = computed(() => activeSearchParams.value.keyword)
@@ -172,6 +230,19 @@ const filteredCardDataList = ref<Array<SearchTorrent>>([])
// 是否刷新过 // 是否刷新过
const isRefreshed = ref(false) const isRefreshed = ref(false)
const viewToggleIcon = computed(() => (viewType.value === 'card' ? 'mdi-view-list-outline' : 'mdi-view-grid-outline'))
// 搜索结果视图切换收纳到页面动态按钮中,和仪表盘的设置按钮保持一致。
function toggleViewType() {
changeViewType(viewType.value === 'card' ? 'row' : 'card')
}
useDynamicButton({
icon: viewToggleIcon,
onClick: toggleViewType,
show: computed(() => appMode.value && isRefreshed.value),
})
// 是否正在重新搜索 // 是否正在重新搜索
const isRefreshing = ref(false) const isRefreshing = ref(false)
@@ -187,6 +258,8 @@ const progressEnabled = ref(false)
// 进度是否激活 // 进度是否激活
const progressActive = ref(false) const progressActive = ref(false)
let progressResetTimer: ReturnType<typeof setTimeout> | null = null
// 是否显示搜索进度 // 是否显示搜索进度
const isSearchProgressVisible = computed( const isSearchProgressVisible = computed(
() => progressActive.value || (!isRefreshed.value && (progressEnabled.value || progressValue.value > 0)), () => progressActive.value || (!isRefreshed.value && (progressEnabled.value || progressValue.value > 0)),
@@ -215,10 +288,12 @@ const errorTitle = ref(t('resource.noData'))
const errorDescription = ref(t('resource.noResourceFound')) const errorDescription = ref(t('resource.noResourceFound'))
let searchEventSource: EventSource | null = null let searchEventSource: EventSource | null = null
let searchStreamIdleTimer: ReturnType<typeof setTimeout> | null = null
const streamPreviewLimit = 24 const streamPreviewLimit = 24
const streamUiFlushDelay = 1000 const streamUiFlushDelay = 1000
const streamPreviewBufferLimit = streamPreviewLimit * 4 const streamPreviewBufferLimit = streamPreviewLimit * 4
const searchStreamIdleTimeout = 90_000
const streamTotalCount = ref(0) const streamTotalCount = ref(0)
const streamPreviewDataList = ref<Array<Context>>([]) const streamPreviewDataList = ref<Array<Context>>([])
@@ -227,6 +302,9 @@ const displayResourceCount = computed(() =>
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value, progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
) )
// 搜索中只显示进度区域,避免结果抬头和进度条同时占用顶部空间。
const showResultHeader = computed(() => isRefreshed.value && !progressActive.value)
let pendingStreamItems: Array<Context> = [] let pendingStreamItems: Array<Context> = []
let streamFlushTimer: ReturnType<typeof setTimeout> | null = null let streamFlushTimer: ReturnType<typeof setTimeout> | null = null
let streamFinalResultApplied = false let streamFinalResultApplied = false
@@ -290,6 +368,7 @@ const watchProgressValue = watch(
// 使用SSE监听加载进度 // 使用SSE监听加载进度
function startLoadingProgress() { function startLoadingProgress() {
clearProgressResetTimer()
watchProgressValue.resume() watchProgressValue.resume()
progressText.value = t('resource.searching') progressText.value = t('resource.searching')
progressValue.value = 0 progressValue.value = 0
@@ -304,18 +383,41 @@ function stopLoadingProgress() {
// 确保进度显示100%,然后再渐进清零 // 确保进度显示100%,然后再渐进清零
progressValue.value = 100 progressValue.value = 100
setTimeout(() => { clearProgressResetTimer()
progressResetTimer = setTimeout(() => {
progressResetTimer = null
progressValue.value = 0 progressValue.value = 0
progressEnabled.value = false progressEnabled.value = false
}, 1500) }, 1500)
} }
function clearProgressResetTimer() {
if (progressResetTimer) {
clearTimeout(progressResetTimer)
progressResetTimer = null
}
}
// 关闭SSE连接 // 关闭SSE连接
function closeSearchEventSource() { function closeSearchEventSource(source?: EventSource) {
if (source && searchEventSource !== source) {
source.close()
return
}
if (searchEventSource) { if (searchEventSource) {
searchEventSource.close() searchEventSource.close()
searchEventSource = null searchEventSource = null
} }
clearSearchStreamIdleTimer()
}
function clearSearchStreamIdleTimer() {
if (searchStreamIdleTimer) {
clearTimeout(searchStreamIdleTimer)
searchStreamIdleTimer = null
}
} }
// 渐进式搜索期间只保留有限预览数据,避免每个批次都触发完整筛选和分组计算。 // 渐进式搜索期间只保留有限预览数据,避免每个批次都触发完整筛选和分组计算。
@@ -510,6 +612,13 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) {
// 按请求搜索 // 按请求搜索
async function searchByRequest(params: SearchParams, requestToken?: string) { async function searchByRequest(params: SearchParams, requestToken?: string) {
const items = await requestSearchResults(params, requestToken)
streamTotalCount.value = items.length
setStreamResults(items)
}
// 静默刷新使用普通请求,保留当前结果直到新数据完整返回,避免返回页面时露出搜索进度态。
async function requestSearchResults(params: SearchParams, requestToken?: string) {
let result: { [key: string]: any } let result: { [key: string]: any }
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符则按照媒体ID格式搜索 // 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符则按照媒体ID格式搜索
if (/^[a-zA-Z]+:/.test(params.keyword)) { if (/^[a-zA-Z]+:/.test(params.keyword)) {
@@ -536,13 +645,11 @@ async function searchByRequest(params: SearchParams, requestToken?: string) {
} }
if (result && result.success) { if (result && result.success) {
streamTotalCount.value = result.data?.length || 0 return (result.data || []) as Context[]
setStreamResults(result.data || [])
} else {
errorDescription.value = result?.message || t('resource.noResourceFound')
streamTotalCount.value = 0
setStreamResults([])
} }
errorDescription.value = result?.message || t('resource.noResourceFound')
throw new Error(errorDescription.value)
} }
// 按流搜索 // 按流搜索
@@ -554,36 +661,48 @@ function searchByStream(params: SearchParams, requestToken?: string) {
const source = new EventSource(buildSearchStreamUrl(params, requestToken)) const source = new EventSource(buildSearchStreamUrl(params, requestToken))
searchEventSource = source searchEventSource = source
const settleSearchStream = (callback: () => void) => {
if (settled) return
settled = true
closeSearchEventSource(source)
callback()
}
const resetIdleTimeout = () => {
clearSearchStreamIdleTimer()
searchStreamIdleTimer = setTimeout(() => {
settleSearchStream(() => reject(new Error(t('resource.noResourceFound'))))
}, searchStreamIdleTimeout)
}
resetIdleTimeout()
source.onmessage = event => { source.onmessage = event => {
if (source !== searchEventSource || settled) return
try { try {
resetIdleTimeout()
const eventData = JSON.parse(event.data) const eventData = JSON.parse(event.data)
handleSearchStreamMessage(eventData) handleSearchStreamMessage(eventData)
if (eventData.type === 'error') { if (eventData.type === 'error') {
settled = true settleSearchStream(resolve)
closeSearchEventSource()
resolve()
return return
} }
if (eventData.type === 'done') { if (eventData.type === 'done') {
settled = true settleSearchStream(resolve)
closeSearchEventSource()
resolve()
} }
} catch (error) { } catch (error) {
settled = true settleSearchStream(() => reject(error))
closeSearchEventSource()
reject(error)
} }
} }
source.onerror = () => { source.onerror = () => {
if (settled) return if (source !== searchEventSource || settled) return
settled = true settleSearchStream(() => reject(new Error(t('resource.noResourceFound'))))
closeSearchEventSource()
reject(new Error(t('resource.noResourceFound')))
} }
}) })
} }
@@ -601,22 +720,26 @@ function changeViewType(newType: string) {
} }
// 获取搜索列表数据 // 获取搜索列表数据
async function fetchData(options: { force?: boolean; params?: SearchParams } = {}) { async function fetchData(options: { force?: boolean; params?: SearchParams; silent?: boolean } = {}) {
const currentSearchParams = { ...(options.params ?? activeSearchParams.value) } const currentSearchParams = { ...(options.params ?? activeSearchParams.value) }
if (hasSearchKeyword(currentSearchParams)) { if (hasSearchKeyword(currentSearchParams)) {
activeSearchParams.value = { ...currentSearchParams } activeSearchParams.value = { ...currentSearchParams }
rememberSearchParams(currentSearchParams) rememberSearchParams(currentSearchParams)
} }
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
const silentRefresh = Boolean(options.silent && isRefreshed.value && rawDataList.value.length > 0)
try { try {
enableFilterAnimation.value = true enableFilterAnimation.value = true
if (!hasSearchKeyword(currentSearchParams)) { if (!hasSearchKeyword(currentSearchParams)) {
// 查询上次搜索结果 // 查询上次搜索结果,并同步可重放的搜索参数
const results = await api.get('search/last', { const results = await fetchLastSearchContext()
params: requestToken ? { _ts: requestToken } : undefined, setStreamResults(results || [])
}) } else if (silentRefresh) {
setStreamResults((results as unknown as Context[]) || []) // keep-alive 重新进入时后台刷新,旧结果继续显示,等新结果完整返回后一次性替换。
const results = await requestSearchResults(currentSearchParams, requestToken)
streamTotalCount.value = results.length
setStreamResults(results)
} else { } else {
resetSearchResults() resetSearchResults()
startLoadingProgress() startLoadingProgress()
@@ -646,11 +769,15 @@ async function fetchData(options: { force?: boolean; params?: SearchParams } = {
// 重新搜索(使用相同参数重新触发搜索) // 重新搜索(使用相同参数重新触发搜索)
async function refreshSearch() { async function refreshSearch() {
if (isRefreshing.value || progressActive.value) return if (isRefreshing.value || progressActive.value) return
const refreshParams = lastSearchParams.value ?? activeSearchParams.value
isRefreshing.value = true isRefreshing.value = true
try { try {
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置 // 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
showingAiResults.value = false showingAiResults.value = false
const refreshParams = await resolveRefreshSearchParams()
if (!refreshParams) {
console.warn('未找到可用于重新搜索的搜索参数')
return
}
await fetchData({ force: true, params: refreshParams }) await fetchData({ force: true, params: refreshParams })
} catch (error) { } catch (error) {
console.error('重新搜索失败:', error) console.error('重新搜索失败:', error)
@@ -952,10 +1079,20 @@ onMounted(async () => {
void fetchData() void fetchData()
}) })
useKeepAliveRefresh(async () => {
if (progressActive.value || isRefreshing.value || isRecommending.value || showingAiResults.value) return
const refreshParams = await resolveRefreshSearchParams()
if (!refreshParams) return
await fetchData({ force: true, params: refreshParams, silent: true })
})
// 卸载时停止轮询 // 卸载时停止轮询
onUnmounted(() => { onUnmounted(() => {
closeSearchEventSource() closeSearchEventSource()
stopLoadingProgress() stopLoadingProgress()
clearProgressResetTimer()
stopAiRecommendPolling() stopAiRecommendPolling()
clearStreamPreviewState() clearStreamPreviewState()
}) })
@@ -1026,105 +1163,98 @@ onUnmounted(() => {
</div> </div>
</VFadeTransition> </VFadeTransition>
<!-- 精简标题栏搜索过后保持挂载加载中由按钮 :disabled / :loading 表达状态 --> <!-- 结果抬头只承载搜索上下文和快捷动作筛选控制交给下方工具条 -->
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3"> <VCard v-if="showResultHeader" class="search-header result-toolbar mb-2" elevation="0">
<div class="search-info-container"> <div class="result-toolbar__content">
<div class="search-title text-moviepilot"> <VAvatar class="result-toolbar__icon" rounded="lg" size="42">
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span> <VIcon icon="mdi-movie-search" size="24" />
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span> </VAvatar>
</div>
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1"> <div class="search-info-container">
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat"> <div class="search-title text-moviepilot">
{{ t('resource.keyword') }}: {{ keyword }} <span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
</VChip> <span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="flat"> </div>
{{ t('resource.title') }}: {{ title }} <div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
</VChip> <VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="tonal">
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="flat"> {{ t('resource.keyword') }}: {{ keyword }}
{{ t('resource.year') }}: {{ year }} </VChip>
</VChip> <VChip v-if="title" class="search-tag" color="primary" size="small" variant="tonal">
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="flat"> {{ t('resource.title') }}: {{ title }}
{{ t('resource.season') }}: {{ season }} </VChip>
</VChip> <VChip v-if="year" class="search-tag" color="primary" size="small" variant="tonal">
{{ t('resource.year') }}: {{ year }}
</VChip>
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="tonal">
{{ t('resource.season') }}: {{ season }}
</VChip>
</div>
</div> </div>
</div> </div>
<VSpacer /> <div class="result-toolbar__actions">
<!-- 重新搜索按钮 -->
<VBtn
variant="text"
size="small"
icon
class="refresh-search-btn"
:loading="isRefreshing"
:disabled="isRefreshing || progressActive"
@click="refreshSearch"
>
<VIcon icon="mdi-refresh" size="20" />
<VTooltip activator="parent" location="top">
{{ t('resource.refreshSearch') }}
</VTooltip>
</VBtn>
<!-- 重新搜索按钮 --> <!-- AI操作按钮 -->
<VBtn <div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container">
variant="text" <div class="ai-toggle-buttons">
size="small" <VBtn
icon variant="text"
class="me-2 refresh-search-btn" size="small"
:loading="isRefreshing" rounded="0"
:disabled="isRefreshing || progressActive" @click="toggleAiRecommend"
@click="refreshSearch" :disabled="isRecommending || !aiStatusChecked"
> height="44"
<VIcon icon="mdi-refresh" size="20" /> class="ps-4 pe-3 ai-recommend-btn"
<VTooltip activator="parent" location="top"> :class="{ 'ai-active': showingAiResults }"
{{ t('resource.refreshSearch') }} >
</VTooltip> <template #prepend>
</VBtn> <VIcon icon="lucide:sparkles" size="18" class="ai-icon" :class="{ 'ai-icon-active': showingAiResults }" />
</template>
<span class="ai-text" :class="{ 'ai-text-active': showingAiResults }">
{{ t('resource.aiRecommend') }}
</span>
</VBtn>
<!-- AI操作按钮组 --> <VExpandXTransition>
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2"> <div v-if="aiRecommended || isRecommending" class="d-flex align-center">
<div class="ai-toggle-buttons"> <div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
<VBtn <VBtn
variant="text" variant="text"
size="small" size="small"
rounded="0" rounded="0"
@click="toggleAiRecommend" :disabled="isRecommending || !aiStatusChecked"
:disabled="isRecommending || !aiStatusChecked" @click="reRecommend"
height="44" height="44"
class="ps-4 pe-3 ai-recommend-btn" min-width="38"
:class="{ 'ai-active': showingAiResults }" class="px-0"
> >
<template #prepend> <VIcon
<VIcon icon="lucide:sparkles" size="18" class="ai-icon" :class="{ 'ai-icon-active': showingAiResults }" /> :icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'"
</template> size="18"
<span class="ai-text" :class="{ 'ai-text-active': showingAiResults }"> class="ai-refresh-icon"
{{ t('resource.aiRecommend') }} />
</span> <VTooltip activator="parent" location="top">
</VBtn> {{ t('resource.reRecommend') }}
</VTooltip>
<VExpandXTransition> </VBtn>
<div v-if="aiRecommended || isRecommending" class="d-flex align-center"> </div>
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div> </VExpandXTransition>
<VBtn </div>
variant="text"
size="small"
rounded="0"
:disabled="isRecommending || !aiStatusChecked"
@click="reRecommend"
height="44"
min-width="38"
class="px-0"
>
<VIcon
:icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'"
size="18"
class="ai-refresh-icon"
/>
<VTooltip activator="parent" location="top">
{{ t('resource.reRecommend') }}
</VTooltip>
</VBtn>
</div>
</VExpandXTransition>
</div>
</div>
<!-- 重新设计的视图切换按钮 -->
<div class="view-toggle-container">
<div class="view-toggle-buttons">
<div class="active-indicator" :class="viewType"></div>
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
</button>
<button class="view-toggle-btn" :class="{ active: viewType === 'row' }" @click="changeViewType('row')">
<VIcon icon="mdi-view-list-outline" :color="viewType === 'row' ? 'primary' : undefined" />
</button>
</div> </div>
</div> </div>
</VCard> </VCard>
@@ -1232,9 +1362,22 @@ onUnmounted(() => {
<!-- 初始加载状态 --> <!-- 初始加载状态 -->
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" /> <LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
<Teleport to="body" v-if="route.path === '/resource'">
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
:icon="viewToggleIcon"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="toggleViewType"
/>
</div>
</Teleport>
<!-- 滚动到顶部按钮 --> <!-- 滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/resource'"> <Teleport to="body" v-if="route.path === '/resource'">
<VScrollToTopBtn /> <VScrollToTopBtn :offset-fab="isRefreshed && !appMode" />
</Teleport> </Teleport>
</div> </div>
</template> </template>
@@ -1345,82 +1488,67 @@ onUnmounted(() => {
} }
} }
/* 精简标题栏样式 */ /* 结果抬头样式 */
.search-header { .search-header {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08); border: 1px solid rgba(var(--v-theme-primary), 0.16);
padding-block: 8px; border-radius: 8px;
padding-inline: 12px; background:
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), rgba(var(--v-theme-surface), 0) 44%),
rgb(var(--v-theme-surface));
} }
.search-info-container { .result-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-block: 12px;
padding-inline: 14px;
}
.result-toolbar__content {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 12px; gap: 12px;
min-inline-size: 0;
} }
.search-title { .result-toolbar__icon {
font-size: 1.2rem; flex: 0 0 auto;
font-weight: 600; background: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
} }
.search-tags { .result-toolbar__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px; gap: 8px;
} }
.search-info-container {
min-inline-size: 0;
}
.search-title {
overflow: hidden;
font-size: 1.1rem;
font-weight: 600;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-tags {
gap: 6px;
}
.search-tag { .search-tag {
max-inline-size: min(100%, 220px);
font-size: 0.75rem; font-size: 0.75rem;
} }
/* 重新设计的视图切换按钮 */
.view-toggle-container {
position: relative;
}
.view-toggle-buttons {
position: relative;
display: flex;
padding: 4px;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
isolation: isolate; /* Create new stacking context */
}
.active-indicator {
position: absolute;
z-index: 1;
border-radius: 6px;
background-color: rgb(var(--v-theme-surface));
block-size: 36px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 12%),
0 1px 2px rgba(0, 0, 0, 24%);
inline-size: 40px;
inset-block-start: 4px;
inset-inline-start: 4px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.active-indicator.row {
transform: translateX(40px);
}
.view-toggle-btn {
position: relative;
z-index: 2; /* Sit on top of indicator */
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
block-size: 36px;
cursor: pointer;
inline-size: 40px;
transition: all 0.2s ease;
}
.view-toggle-btn:hover:not(.active) {
border-radius: 6px;
background-color: rgba(var(--v-theme-primary), 0.05);
}
/* 重新搜索按钮 */ /* 重新搜索按钮 */
.refresh-search-btn { .refresh-search-btn {
border-radius: 8px !important; border-radius: 8px !important;
@@ -1530,12 +1658,31 @@ onUnmounted(() => {
@media (width <= 600px) { @media (width <= 600px) {
.search-header { .search-header {
padding-block: 6px; border-radius: 8px;
padding-inline: 12px; }
.result-toolbar {
align-items: flex-start;
gap: 10px;
padding-block: 10px;
padding-inline: 10px;
}
.result-toolbar__content {
gap: 10px;
}
.result-toolbar__icon {
block-size: 36px !important;
inline-size: 36px !important;
}
.result-toolbar__actions {
gap: 6px;
} }
.search-title { .search-title {
font-size: 1.1rem; font-size: 1rem;
white-space: nowrap; white-space: nowrap;
} }
@@ -1594,30 +1741,6 @@ onUnmounted(() => {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.view-toggle-container {
flex-shrink: 0;
}
.view-toggle-buttons {
padding: 2px;
}
.active-indicator {
block-size: 32px;
inline-size: 36px;
inset-block-start: 2px;
inset-inline-start: 2px;
}
.active-indicator.row {
transform: translateX(36px);
}
.view-toggle-btn {
block-size: 32px;
inline-size: 36px;
}
.refresh-search-btn { .refresh-search-btn {
block-size: 36px !important; block-size: 36px !important;
inline-size: 36px !important; inline-size: 36px !important;

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

@@ -287,6 +287,7 @@ onMounted(() => {
:keyword="subscribeFilter" :keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''" :status-filter="subscribeStatusFilter ?? ''"
:sort-mode="subscribeSortMode" :sort-mode="subscribeSortMode"
:active="activeTab === 'mysub'"
@update:sort-mode="subscribeSortMode = $event" @update:sort-mode="subscribeSortMode = $event"
/> />
</div> </div>

View File

@@ -15,7 +15,6 @@ const route = useRoute()
const { appMode } = usePWA() const { appMode } = usePWA()
const activeTab = ref((route.query.tab as string) || 'list') const activeTab = ref((route.query.tab as string) || 'list')
const listViewKey = ref(0)
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null) const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
// 获取标签页 // 获取标签页
@@ -37,6 +36,10 @@ function openAddWorkflowDialog() {
workflowListViewRef.value?.openAddDialog() workflowListViewRef.value?.openAddDialog()
} }
function refreshWorkflowList() {
workflowListViewRef.value?.refresh()
}
const shareKeywordUpdater = debounce((keyword: string) => { const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim() shareKeyword.value = keyword.trim()
}, 300) }, 300)
@@ -98,14 +101,14 @@ onMounted(() => {
<VWindowItem value="list"> <VWindowItem value="list">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" /> <WorkflowListView ref="workflowListViewRef" />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>
<VWindowItem value="share"> <VWindowItem value="share">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" /> <WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>

View File

@@ -48,6 +48,7 @@ const router = createRouter({
path: '/resource', path: '/resource',
component: () => import('../pages/resource.vue'), component: () => import('../pages/resource.vue'),
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },
@@ -56,6 +57,7 @@ const router = createRouter({
component: () => import('../pages/subscribe.vue'), component: () => import('../pages/subscribe.vue'),
meta: { meta: {
keepAlive: true, keepAlive: true,
keepAliveKey: 'subscribe-movie',
requiresAuth: true, requiresAuth: true,
subType: '电影', subType: '电影',
}, },
@@ -65,6 +67,7 @@ const router = createRouter({
component: () => import('../pages/subscribe.vue'), component: () => import('../pages/subscribe.vue'),
meta: { meta: {
keepAlive: true, keepAlive: true,
keepAliveKey: 'subscribe-tv',
requiresAuth: true, requiresAuth: true,
subType: '电视剧', subType: '电视剧',
}, },
@@ -153,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

@@ -1,21 +1,33 @@
export type SSEConnectionStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed'
export interface SSEManagerOptions {
backgroundCloseDelay: number
reconnectDelay: number
maxReconnectAttempts: number
reconnectBackoffMultiplier: number
maxReconnectDelay: number
}
type SSEMessageListener = (event: MessageEvent) => void
type SSEStatusListener = (status: SSEConnectionStatus) => void
/** /**
* SSE连接管理器 * SSE连接管理器
* 优化后台SSE连接减少iOS系统杀掉应用的概率 * 统一收口 EventSource 生命周期,避免后台常驻连接和重复重连。
*/ */
export class SSEManager { export class SSEManager {
private eventSource: EventSource | null = null private eventSource: EventSource | null = null
private url: string private url: string
private isBackground = false private isBackground = document.hidden
private reconnectTimer: number | null = null private reconnectTimer: number | null = null
private backgroundCloseTimer: number | null = null private backgroundCloseTimer: number | null = null
private listeners: Map<string, (event: MessageEvent) => void> = new Map() private listeners: Map<string, SSEMessageListener> = new Map()
private options: { private statusListeners: Map<string, SSEStatusListener> = new Map()
backgroundCloseDelay: number private options: SSEManagerOptions
reconnectDelay: number
maxReconnectAttempts: number
}
private reconnectAttempts = 0 private reconnectAttempts = 0
private isConnecting = false private isConnecting = false
private isDestroyed = false
private connectionStatus: SSEConnectionStatus = 'idle'
private readonly handleVisibilityChange = () => { private readonly handleVisibilityChange = () => {
if (document.hidden) { if (document.hidden) {
this.handleBackground() this.handleBackground()
@@ -27,12 +39,14 @@ export class SSEManager {
this.destroy() this.destroy()
} }
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) { constructor(url: string, options: Partial<SSEManagerOptions> = {}) {
this.url = url this.url = url
this.options = { this.options = {
backgroundCloseDelay: 5000, // 5秒后关闭后台连接 backgroundCloseDelay: 5000, // 5秒后关闭后台连接
reconnectDelay: 3000, // 3秒后重连 reconnectDelay: 3000, // 3秒后重连
maxReconnectAttempts: 3, maxReconnectAttempts: 3,
reconnectBackoffMultiplier: 1.5,
maxReconnectDelay: 30_000,
...options, ...options,
} }
@@ -50,38 +64,44 @@ export class SSEManager {
} }
private handleBackground() { private handleBackground() {
if (this.isDestroyed) return
this.isBackground = true this.isBackground = true
this.clearReconnectTimer()
// 延迟关闭SSE连接避免频繁切换 // 延迟关闭SSE连接避免频繁切换
if (this.backgroundCloseTimer) { this.clearBackgroundCloseTimer()
clearTimeout(this.backgroundCloseTimer)
}
this.backgroundCloseTimer = window.setTimeout(() => { this.backgroundCloseTimer = window.setTimeout(() => {
if (this.isBackground && this.eventSource) { if (this.isBackground && this.eventSource) {
this.eventSource.close() this.closeCurrentEventSource()
this.eventSource = null this.setConnectionStatus('closed')
} }
}, this.options.backgroundCloseDelay) }, this.options.backgroundCloseDelay)
} }
private handleForeground() { private handleForeground() {
if (this.isDestroyed) return
this.isBackground = false this.isBackground = false
// 清除后台关闭定时器 // 清除后台关闭定时器
if (this.backgroundCloseTimer) { this.clearBackgroundCloseTimer()
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
// 只有在有活跃监听器时才重新建立连接 // 只有在有活跃监听器时才重新建立连接
if (this.listeners.size > 0 && (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)) { if (this.listeners.size > 0 && (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)) {
this.reconnectSSE() this.reconnectSSE(0)
} }
} }
private reconnectSSE(attemptCount = 0) { private reconnectSSE(attemptCount = 0) {
if (attemptCount >= this.options.maxReconnectAttempts) { if (this.isDestroyed || this.isBackground || this.listeners.size === 0) {
return
}
if (attemptCount > this.options.maxReconnectAttempts) {
this.reconnectAttempts = this.options.maxReconnectAttempts
this.setConnectionStatus('closed')
return return
} }
@@ -89,40 +109,38 @@ export class SSEManager {
return return
} }
// 如果没有活跃的监听器,不进行重连 this.clearReconnectTimer()
if (this.listeners.size === 0) { this.closeCurrentEventSource()
return
}
this.isConnecting = true this.isConnecting = true
this.reconnectAttempts = attemptCount this.reconnectAttempts = attemptCount
this.setConnectionStatus('connecting')
try { try {
this.eventSource = new EventSource(this.url) const source = new EventSource(this.url)
this.eventSource = source
this.eventSource.onopen = () => { source.onopen = () => {
if (source !== this.eventSource) return
this.isConnecting = false this.isConnecting = false
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.setConnectionStatus('open')
} }
this.eventSource.onerror = error => { source.onerror = () => {
if (source !== this.eventSource) return
this.isConnecting = false this.isConnecting = false
this.setConnectionStatus('error')
if (this.eventSource?.readyState === EventSource.CLOSED) { if (source.readyState === EventSource.CLOSED) {
// 连接已关闭,尝试重连 this.closeCurrentEventSource()
if (this.reconnectTimer) { this.scheduleReconnect(this.reconnectAttempts + 1)
clearTimeout(this.reconnectTimer)
}
this.reconnectTimer = window.setTimeout(() => {
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE(this.reconnectAttempts + 1)
}
}, this.options.reconnectDelay)
} }
} }
this.eventSource.onmessage = event => { source.onmessage = event => {
if (source !== this.eventSource || this.isDestroyed) return
// 分发消息给所有监听器 // 分发消息给所有监听器
this.listeners.forEach((listener, listenerId) => { this.listeners.forEach((listener, listenerId) => {
try { try {
@@ -135,29 +153,95 @@ export class SSEManager {
} }
} catch (error) { } catch (error) {
this.isConnecting = false this.isConnecting = false
this.setConnectionStatus('error')
// 连接创建失败,尝试重连 // 连接创建失败,尝试重连
if (this.reconnectTimer) { this.scheduleReconnect(this.reconnectAttempts + 1)
clearTimeout(this.reconnectTimer) console.error('SSE: 连接创建失败', error)
}
this.reconnectTimer = window.setTimeout(() => {
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE(this.reconnectAttempts + 1)
}
}, this.options.reconnectDelay)
} }
} }
private scheduleReconnect(attemptCount: number) {
if (this.isDestroyed || this.isBackground || this.listeners.size === 0) {
return
}
if (attemptCount > this.options.maxReconnectAttempts) {
this.reconnectAttempts = this.options.maxReconnectAttempts
this.setConnectionStatus('closed')
return
}
this.clearReconnectTimer()
this.reconnectAttempts = attemptCount
// 失败越多等待越久,避免网络波动时短时间内打满连接。
const reconnectDelay = Math.min(
this.options.reconnectDelay * this.options.reconnectBackoffMultiplier ** Math.max(0, attemptCount - 1),
this.options.maxReconnectDelay,
)
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.reconnectSSE(attemptCount)
}, reconnectDelay)
}
private closeCurrentEventSource() {
if (!this.eventSource) {
return
}
this.eventSource.onopen = null
this.eventSource.onerror = null
this.eventSource.onmessage = null
this.eventSource.close()
this.eventSource = null
this.isConnecting = false
}
private clearReconnectTimer() {
if (!this.reconnectTimer) return
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
private clearBackgroundCloseTimer() {
if (!this.backgroundCloseTimer) return
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
private setConnectionStatus(status: SSEConnectionStatus) {
if (this.connectionStatus === status) return
this.connectionStatus = status
this.statusListeners.forEach((listener, listenerId) => {
try {
listener(status)
} catch (error) {
console.error(`SSE: 状态监听器错误 [${listenerId}]`, error)
}
})
}
/** /**
* 添加消息监听器 * 添加消息监听器
*/ */
addMessageListener(id: string, listener: (event: MessageEvent) => void) { addMessageListener(id: string, listener: SSEMessageListener) {
if (this.isDestroyed) return
this.listeners.set(id, listener) this.listeners.set(id, listener)
// 如果还没有连接且不在后台,现在建立连接 // 如果还没有连接且不在后台,现在建立连接
if (!this.eventSource && !this.isBackground && !this.isConnecting) { if (
this.reconnectSSE() !this.isBackground &&
!this.isConnecting &&
(!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)
) {
this.reconnectSSE(0)
} }
} }
@@ -165,6 +249,8 @@ export class SSEManager {
* 移除消息监听器 * 移除消息监听器
*/ */
removeMessageListener(id: string) { removeMessageListener(id: string) {
if (this.isDestroyed) return
this.listeners.delete(id) this.listeners.delete(id)
// 如果没有监听器了,关闭连接 // 如果没有监听器了,关闭连接
@@ -184,25 +270,17 @@ export class SSEManager {
* 销毁管理器并清理所有引用 * 销毁管理器并清理所有引用
*/ */
destroy() { destroy() {
if (this.isDestroyed) return
this.isDestroyed = true
this.resetConnectionState(true) this.resetConnectionState(true)
this.removeVisibilityListener() this.removeVisibilityListener()
} }
private resetConnectionState(clearListeners = false) { private resetConnectionState(clearListeners = false) {
if (this.eventSource) { this.closeCurrentEventSource()
this.eventSource.close() this.clearReconnectTimer()
this.eventSource = null this.clearBackgroundCloseTimer()
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
if (clearListeners) { if (clearListeners) {
this.listeners.clear() this.listeners.clear()
@@ -210,6 +288,31 @@ export class SSEManager {
this.isConnecting = false this.isConnecting = false
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.setConnectionStatus(this.listeners.size > 0 ? 'closed' : 'idle')
if (clearListeners) {
this.statusListeners.clear()
}
}
/**
* 添加连接状态监听器
*/
addStatusListener(id: string, listener: SSEStatusListener, emitCurrent = true) {
if (this.isDestroyed) return
this.statusListeners.set(id, listener)
if (emitCurrent) {
listener(this.connectionStatus)
}
}
/**
* 移除连接状态监听器
*/
removeStatusListener(id: string) {
this.statusListeners.delete(id)
} }
/** /**
@@ -219,6 +322,13 @@ export class SSEManager {
return this.eventSource?.readyState ?? EventSource.CLOSED return this.eventSource?.readyState ?? EventSource.CLOSED
} }
/**
* 获取内部连接状态
*/
get status(): SSEConnectionStatus {
return this.connectionStatus
}
/** /**
* 获取连接URL * 获取连接URL
*/ */
@@ -230,10 +340,12 @@ export class SSEManager {
* 强制重新连接 * 强制重新连接
*/ */
forceReconnect() { forceReconnect() {
if (this.isDestroyed) return
const hasActiveListeners = this.listeners.size > 0 const hasActiveListeners = this.listeners.size > 0
this.close() this.close()
if (!this.isBackground && hasActiveListeners) { if (!this.isBackground && hasActiveListeners) {
this.reconnectSSE() this.reconnectSSE(0)
} }
} }

View File

@@ -3,11 +3,12 @@ import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils' import { hexToRgb } from '@layouts/utils'
import api from '@/api' import api from '@/api'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -29,8 +30,6 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables, () => vuetifyTheme.current.value.variables,
) )
const chartKey = ref(0)
// 时间序列 // 时间序列
const series = ref([ const series = ref([
{ {
@@ -122,19 +121,15 @@ async function loadCpuData() {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
const { loading } = useDataRefresh( const { loading, refresh } = useDataRefresh(
'analytics-cpu', 'analytics-cpu',
loadCpuData, loadCpuData,
2000, // 2秒间隔 2000, // 2秒间隔
true // 立即执行 true // 立即执行
) )
onActivated(() => { useKeepAliveRefresh(refresh)
nextTick(() => {
chartKey.value += 1
})
})
</script> </script>
<template> <template>
@@ -148,7 +143,7 @@ onActivated(() => {
<VCardTitle>CPU</VCardTitle> <VCardTitle>CPU</VCardTitle>
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" /> <VApexChart type="line" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}{{ current }}%</p> <p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}{{ current }}%</p>
</VCardText> </VCardText>
</VCard> </VCard>

View File

@@ -4,11 +4,12 @@ import { hexToRgb } from '@layouts/utils'
import api from '@/api' import api from '@/api'
import { formatBytes } from '@/@core/utils/formatters' import { formatBytes } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -30,8 +31,6 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables, () => vuetifyTheme.current.value.variables,
) )
const chartKey = ref(0)
// 时间序列 // 时间序列
const series = ref([ const series = ref([
{ {
@@ -127,20 +126,15 @@ async function loadMemoryData() {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
const { loading } = useDataRefresh( const { loading, refresh } = useDataRefresh(
'analytics-memory', 'analytics-memory',
loadMemoryData, loadMemoryData,
3000, // 3秒间隔 3000, // 3秒间隔
true // 立即执行 true // 立即执行
) )
onActivated(() => { useKeepAliveRefresh(refresh)
// 使用nextTick确保DOM准备完成后再更新chartKey
nextTick(() => {
chartKey.value += 1
})
})
</script> </script>
<template> <template>
@@ -154,7 +148,7 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle> <VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VApexChart :key="chartKey" type="area" :options="chartOptions" :series="series" :height="150" /> <VApexChart type="area" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}{{ formatBytes(usedMemory) }}</p> <p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}{{ formatBytes(usedMemory) }}</p>
</VCardText> </VCardText>
</VCard> </VCard>

View File

@@ -3,11 +3,12 @@ import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils' import { hexToRgb } from '@layouts/utils'
import api from '@/api' import api from '@/api'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -29,8 +30,6 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables, () => vuetifyTheme.current.value.variables,
) )
const chartKey = ref(0)
// 时间序列 - 上行和下行流量 // 时间序列 - 上行和下行流量
const series = ref([ const series = ref([
{ {
@@ -160,19 +159,15 @@ async function getNetworkUsage() {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
useDataRefresh( const { refresh } = useDataRefresh(
'dashboard-network', 'dashboard-network',
getNetworkUsage, getNetworkUsage,
2000, // 2秒间隔 2000, // 2秒间隔
true // 立即执行 true // 立即执行
) )
onActivated(() => { useKeepAliveRefresh(refresh)
nextTick(() => {
chartKey.value += 1
})
})
</script> </script>
<template> <template>
@@ -186,7 +181,7 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle> <VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" /> <VApexChart type="line" :options="chartOptions" :series="series" :height="150" />
<div class="d-flex justify-space-between"> <div class="d-flex justify-space-between">
<p class="text-center font-weight-medium mb-0"> <p class="text-center font-weight-medium mb-0">
<span class="text-warning">{{ t('dashboard.upload') }}</span <span class="text-warning">{{ t('dashboard.upload') }}</span

View File

@@ -3,11 +3,11 @@ import { formatSeconds } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { Process } from '@/api/types' import type { Process } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 表头 // 表头
const headers = [ const headers = [
@@ -31,7 +31,7 @@ async function loadProcessList() {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
useDataRefresh( useDataRefresh(
'dashboard-processes', 'dashboard-processes',
loadProcessList, loadProcessList,

View File

@@ -2,11 +2,11 @@
import api from '@/api' import api from '@/api'
import type { ScheduleInfo } from '@/api/types' import type { ScheduleInfo } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -34,7 +34,7 @@ async function loadSchedulerList() {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
useDataRefresh( useDataRefresh(
'dashboard-scheduler', 'dashboard-scheduler',
loadSchedulerList, loadSchedulerList,

View File

@@ -3,11 +3,11 @@ import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { DownloaderInfo } from '@/api/types' import type { DownloaderInfo } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -77,7 +77,7 @@ async function loadDownloaderInfo() {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
const { loading } = useDataRefresh( const { loading } = useDataRefresh(
'analytics-speed', 'analytics-speed',
loadDownloaderInfo, loadDownloaderInfo,

View File

@@ -118,6 +118,7 @@ async function fetchData({ done }: { done: any }) {
page.value++ page.value++
// 返回加载成功 // 返回加载成功
done('ok') done('ok')
await nextTick()
} }
} else { } else {
// 加载一次 // 加载一次

View File

@@ -86,6 +86,7 @@ async function fetchData({ done }: { done: any }) {
page.value++ page.value++
// 返回加载成功 // 返回加载成功
done('ok') done('ok')
await nextTick()
} }
} }
} else { } else {

View File

@@ -13,6 +13,7 @@ import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -739,9 +740,13 @@ const filterPlugins = computed(() => {
}) })
// 获取插件列表数据 // 获取插件列表数据
async function fetchInstalledPlugins() { async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isRefreshed.value
try { try {
loading.value = true if (showLoading) {
loading.value = true
}
dataList.value = await api.get('plugin/', { dataList.value = await api.get('plugin/', {
params: { params: {
state: 'installed', state: 'installed',
@@ -749,17 +754,24 @@ async function fetchInstalledPlugins() {
}) })
// 排序 // 排序
sortPluginOrder() sortPluginOrder()
loading.value = false
isRefreshed.value = true isRefreshed.value = true
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally {
if (showLoading) {
loading.value = false
}
} }
} }
// 获取未安装插件列表数据 // 获取未安装插件列表数据
async function fetchUninstalledPlugins(force: boolean = false) { async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isAppMarketLoaded.value
try { try {
loading.value = true if (showLoading) {
loading.value = true
}
uninstalledList.value = await api.get('plugin/', { uninstalledList.value = await api.get('plugin/', {
params: { params: {
state: 'market', state: 'market',
@@ -776,7 +788,6 @@ async function fetchUninstalledPlugins(force: boolean = false) {
} }
} }
} }
loading.value = false
isRefreshed.value = true isRefreshed.value = true
// 更新插件市场列表 // 更新插件市场列表
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示 // 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
@@ -788,6 +799,10 @@ async function fetchUninstalledPlugins(force: boolean = false) {
isAppMarketLoaded.value = true isAppMarketLoaded.value = true
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally {
if (showLoading) {
loading.value = false
}
} }
} }
@@ -801,10 +816,10 @@ async function getPluginStatistics() {
} }
// 加载所有数据 // 加载所有数据
async function refreshData() { async function refreshData(context: KeepAliveRefreshContext = {}) {
await fetchInstalledPlugins() await fetchInstalledPlugins(context)
await fetchUninstalledPlugins() await fetchUninstalledPlugins(false, context)
getPluginStatistics() await getPluginStatistics()
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中 // 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
await loadPluginFolders() await loadPluginFolders()
} }
@@ -873,17 +888,37 @@ function marketSettingDone() {
// 手动刷新插件市场 // 手动刷新插件市场
async function refreshMarket() { async function refreshMarket() {
isMarketRefreshing.value = true const showMarketLoading = !isAppMarketLoaded.value
if (showMarketLoading) {
isMarketRefreshing.value = true
}
try { try {
await fetchUninstalledPlugins(true) await fetchUninstalledPlugins(true, { silent: isAppMarketLoaded.value, source: 'manual' })
getPluginStatistics() await getPluginStatistics()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
isMarketRefreshing.value = false if (showMarketLoading) {
isMarketRefreshing.value = false
}
} }
} }
async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
if (sortMode.value || isDraggingSortMode.value) return
if (activeTab.value === 'market') {
await fetchUninstalledPlugins(false, context)
await getPluginStatistics()
return
}
await fetchInstalledPlugins(context)
await getPluginStatistics()
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
await loadPluginFolders()
}
function parseLocalRepoPath(repoUrl: string | undefined) { function parseLocalRepoPath(repoUrl: string | undefined) {
if (!repoUrl?.startsWith('local://')) return '' if (!repoUrl?.startsWith('local://')) return ''
@@ -923,6 +958,11 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
function loadMarketMore({ done }: { done: any }) { function loadMarketMore({ done }: { done: any }) {
// 从 dataList 中获取最前面的 20 个元素 // 从 dataList 中获取最前面的 20 个元素
const itemsToMove = sortedUninstalledList.value.splice(0, 20) const itemsToMove = sortedUninstalledList.value.splice(0, 20)
if (itemsToMove.length === 0) {
done('empty')
return
}
displayUninstalledList.value.push(...itemsToMove) displayUninstalledList.value.push(...itemsToMove)
done('ok') done('ok')
} }
@@ -942,6 +982,14 @@ onMounted(async () => {
} }
}) })
const { refresh: refreshKeepAliveData } = useKeepAliveRefresh(refreshActiveTabData)
watch(activeTab, (newTab, oldTab) => {
if (!oldTab || newTab === oldTab) return
refreshKeepAliveData({ silent: true, source: 'tab' })
})
function openPluginSearchDialog() { function openPluginSearchDialog() {
SearchDialog.value = true SearchDialog.value = true
} }
@@ -1669,10 +1717,13 @@ function onDragStartPlugin(evt: any) {
<VWindowItem value="market"> <VWindowItem value="market">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<LoadingBanner v-if="!isAppMarketLoaded || isMarketRefreshing" class="mt-12" /> <LoadingBanner
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
class="mt-12"
/>
<!-- 资源列表 --> <!-- 资源列表 -->
<VInfiniteScroll <VInfiniteScroll
v-if="isAppMarketLoaded && !isMarketRefreshing" v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
mode="intersect" mode="intersect"
side="end" side="end"
:items="displayUninstalledList" :items="displayUninstalledList"

View File

@@ -7,15 +7,17 @@ import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 定义输入参数 // 定义输入参数
const props = defineProps<{ const props = defineProps<{
name: string name: string
active?: boolean
}>() }>()
// 用户 Store // 用户 Store
@@ -28,7 +30,7 @@ const dataList = ref<DownloadingInfo[]>([])
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 获取订阅列表数据 // 获取订阅列表数据
async function fetchData() { async function fetchData(_context: KeepAliveRefreshContext = {}) {
try { try {
dataList.value = await api.get('download/', { params: { name: props.name } }) dataList.value = await api.get('download/', { params: { name: props.name } })
isRefreshed.value = true isRefreshed.value = true
@@ -43,8 +45,9 @@ const loading = ref(false)
// 下拉刷新 // 下拉刷新
function onRefresh() { function onRefresh() {
loading.value = true loading.value = true
fetchData() void fetchData().finally(() => {
loading.value = false loading.value = false
})
} }
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅 // 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
@@ -56,13 +59,19 @@ const filteredDataList = computed(() => {
else return dataList.value.filter(data => data.userid === userName || data.username === userName) else return dataList.value.filter(data => data.userid === userName || data.username === userName)
}) })
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
const { loading: dataLoading } = useDataRefresh( const { loading: dataLoading } = useDataRefresh(
'downloading-list', 'downloading-list',
fetchData, fetchData,
3000, // 3秒间隔 3000, // 3秒间隔
true // 立即执行 false // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
) )
onMounted(fetchData)
useKeepAliveRefresh(fetchData, {
active: computed(() => props.active !== false),
})
</script> </script>
<template> <template>

View File

@@ -14,7 +14,7 @@ import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton' import { useDynamicButton } from '@/composables/useDynamicButton'
import { useAvailableHeight } from '@/composables/useAvailableHeight' import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
// i18n // i18n
@@ -27,7 +27,7 @@ const globalSettingsStore = useGlobalSettingsStore()
const display = useDisplay() const display = useDisplay()
// PWA模式检测 // PWA模式检测
const { appMode } = usePWA() const { appMode } = usePWA()
const { useProgressSSE } = useBackgroundOptimization() const { useProgressSSE } = useBackground()
// 计算列表可用高度 // 计算列表可用高度
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111 // componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
@@ -306,9 +306,11 @@ watch(
}, 1000), }, 1000),
) )
// 获取订阅列表数据 // 获取历史记录数据keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。
async function fetchData(page = currentPage.value, count = itemsPerPage.value) { async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) {
loading.value = true if (!options.silent) {
loading.value = true
}
try { try {
const result: { [key: string]: any } = await api.get('history/transfer', { const result: { [key: string]: any } = await api.get('history/transfer', {
@@ -326,8 +328,11 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
) )
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally {
if (!options.silent) {
loading.value = false
}
} }
loading.value = false
} }
// 根据 type 返回不同的图标 // 根据 type 返回不同的图标
@@ -761,7 +766,7 @@ onActivated(() => {
} }
if (!loading.value) { if (!loading.value) {
fetchData() fetchData(currentPage.value, itemsPerPage.value, { silent: 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()
@@ -37,7 +45,7 @@ const siteSetting = ref<any>({
Site: { Site: {
SITEDATA_REFRESH_INTERVAL: 0, SITEDATA_REFRESH_INTERVAL: 0,
SITE_MESSAGE: false, SITE_MESSAGE: false,
BROWSER_EMULATION: 'playwright', BROWSER_EMULATION: 'cloakbrowser',
FLARESOLVERR_URL: '', FLARESOLVERR_URL: '',
}, },
}) })
@@ -65,7 +73,7 @@ const SiteDataRefreshIntervalItems = [
// 站点访问仿真方式 // 站点访问仿真方式
const BrowserEmulationItems = [ const BrowserEmulationItems = [
{ title: 'Playwright', value: 'playwright' }, { title: 'CloakBrowser', value: 'cloakbrowser' },
{ title: 'FlareSolverr', value: 'flaresolverr' }, { title: 'FlareSolverr', value: 'flaresolverr' },
] ]
@@ -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

@@ -8,6 +8,7 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -118,17 +119,27 @@ const currentFilter = computed(() => {
}) })
// 获取站点列表数据 // 获取站点列表数据
async function fetchData() { async function fetchData(context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isRefreshed.value
try { try {
loading.value = true if (showLoading) {
siteList.value = await api.get('site/') loading.value = true
}
const [sites] = await Promise.all([
api.get<Site[], Site[]>('site/'),
// 站点统计在列表请求期间并行预取,减少刷新时卡片分两轮明显重绘。
fetchSiteStats(),
])
siteList.value = sites
isRefreshed.value = true isRefreshed.value = true
// 获取站点列表后,获取统计数据
await fetchSiteStats()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
loading.value = false if (showLoading) {
loading.value = false
}
} }
} }
@@ -300,11 +311,10 @@ onBeforeMount(() => {
fetchUserData() fetchUserData()
}) })
onActivated(() => { useKeepAliveRefresh(async context => {
if (!loading.value) { if (loading.value) return
fetchData()
fetchUserData() await Promise.all([fetchData(context), fetchUserData()])
}
}) })
watch( watch(

View File

@@ -10,6 +10,7 @@ import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -37,6 +38,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
active: {
type: Boolean,
default: true,
},
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -200,15 +205,21 @@ async function saveSubscribeOrder() {
} }
// 获取订阅列表数据 // 获取订阅列表数据
async function fetchData() { async function fetchData(context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isRefreshed.value
try { try {
loading.value = true if (showLoading) {
loading.value = true
}
dataList.value = await api.get('subscribe/') dataList.value = await api.get('subscribe/')
isRefreshed.value = true isRefreshed.value = true
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
loading.value = false if (showLoading) {
loading.value = false
}
} }
} }
@@ -413,10 +424,8 @@ onUnmounted(() => {
window.removeEventListener('toggle-batch-mode', toggleBatchMode) window.removeEventListener('toggle-batch-mode', toggleBatchMode)
}) })
onActivated(async () => { useKeepAliveRefresh(fetchData, {
if (!loading.value) { active: computed(() => props.active),
fetchData()
}
}) })
defineExpose({ defineExpose({

View File

@@ -47,6 +47,13 @@ const filterParams = reactive({
// 当前Key用于重新加载数据 // 当前Key用于重新加载数据
const currentKey = ref(0) const currentKey = ref(0)
function resetData() {
dataList.value = []
page.value = 1
isRefreshed.value = false
currentKey.value++
}
// TMDB电影风格字典 // TMDB电影风格字典
const tmdbMovieGenreDict: Record<string, string> = { const tmdbMovieGenreDict: Record<string, string> = {
'28': t('tmdb.genreType.action'), '28': t('tmdb.genreType.action'),
@@ -99,11 +106,7 @@ const currentGenreDict = computed(() => {
watch( watch(
filterParams, filterParams,
() => { () => {
// 重置数据 resetData()
dataList.value = []
page.value = 1
isRefreshed.value = false
currentKey.value++
}, },
{ deep: true }, { deep: true },
) )
@@ -170,6 +173,7 @@ async function fetchData({ done }: { done: any }) {
page.value++ page.value++
// 返回加载成功 // 返回加载成功
done('ok') done('ok')
await nextTick()
} }
} else { } else {
// 设置加载中 // 设置加载中

View File

@@ -40,6 +40,13 @@ const filterParams = reactive({
// 当前Key用于重新加载数据 // 当前Key用于重新加载数据
const currentKey = ref(0) const currentKey = ref(0)
function resetData() {
dataList.value = []
page.value = 1
isRefreshed.value = false
currentKey.value++
}
// TMDB电影风格字典 // TMDB电影风格字典
const tmdbMovieGenreDict: Record<string, string> = { const tmdbMovieGenreDict: Record<string, string> = {
'28': t('tmdb.genreType.action'), '28': t('tmdb.genreType.action'),
@@ -94,11 +101,7 @@ watch(
() => props.keyword, () => props.keyword,
newKeyword => { newKeyword => {
keyword.value = newKeyword || '' keyword.value = newKeyword || ''
// 重置页码和数据 resetData()
page.value = 1
dataList.value = []
isRefreshed.value = false
currentKey.value++
}, },
) )
@@ -106,11 +109,7 @@ watch(
watch( watch(
filterParams, filterParams,
() => { () => {
// 重置数据 resetData()
dataList.value = []
page.value = 1
isRefreshed.value = false
currentKey.value++
}, },
{ deep: true }, { deep: true },
) )
@@ -184,6 +183,7 @@ async function fetchData({ done }: { done: any }) {
page.value++ page.value++
// 返回加载成功 // 返回加载成功
done('ok') done('ok')
await nextTick()
} }
} else { } else {
// 设置加载中 // 设置加载中

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
import { useAvailableHeight } from '@/composables/useAvailableHeight' import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
@@ -46,7 +46,7 @@ const props = defineProps<{
const { t } = useI18n() const { t } = useI18n()
const theme = useTheme() const theme = useTheme()
const display = useDisplay() const display = useDisplay()
const { useSSE } = useBackgroundOptimization() const { useSSE } = useBackground()
const DEFAULT_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] const DEFAULT_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const MAX_LOG_LINES = 600 const MAX_LOG_LINES = 600

View File

@@ -3,14 +3,11 @@ import type { Message } from '@/api/types'
import MessageCard from '@/components/cards/MessageCard.vue' import MessageCard from '@/components/cards/MessageCard.vue'
import api from '@/api' import api from '@/api'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useSSE } = useBackgroundOptimization() const { useSSE } = useBackground()
// 定义事件
const emit = defineEmits(['scroll'])
// 消息列表 // 消息列表
const messages = ref<Message[]>([]) const messages = ref<Message[]>([])
@@ -33,6 +30,18 @@ const page = ref(1)
// 存量消息最新时间 // 存量消息最新时间
const lastTime = ref('') const lastTime = ref('')
// 消息列表滚动容器
const messageListRef = ref<any>(null)
// 自动滚动状态
const shouldAutoScroll = ref(true)
const isSyncingScroll = ref(false)
const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let scrollTimer: number | undefined
let scrollReleaseTimer: number | undefined
// 获取消息时间 // 获取消息时间
function getMessageTime(message: Message) { function getMessageTime(message: Message) {
return message.reg_time || message.date || '' return message.reg_time || message.date || ''
@@ -66,6 +75,98 @@ function updateLastTime(message: Message) {
} }
} }
function getScrollContainer() {
const container = messageListRef.value?.$el ?? messageListRef.value
return container instanceof HTMLElement ? container : null
}
function isNearBottom(container: HTMLElement) {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
return distanceFromBottom <= Math.max(MESSAGE_AUTO_SCROLL_THRESHOLD, container.clientHeight / 3)
}
function updateAutoScrollState() {
const container = getScrollContainer()
if (!container || isSyncingScroll.value) {
return
}
shouldAutoScroll.value = isNearBottom(container)
}
function handleScroll() {
updateAutoScrollState()
}
function bindScrollListener() {
const container = getScrollContainer()
if (!container) {
return
}
container.removeEventListener('scroll', handleScroll)
container.addEventListener('scroll', handleScroll, { passive: true })
updateAutoScrollState()
}
function unbindScrollListener() {
getScrollContainer()?.removeEventListener('scroll', handleScroll)
}
function scrollContainerToEnd() {
const container = getScrollContainer()
if (!container) {
return
}
isSyncingScroll.value = true
container.scrollTop = container.scrollHeight
requestAnimationFrame(() => {
const latestContainer = getScrollContainer()
if (!latestContainer) {
isSyncingScroll.value = false
return
}
latestContainer.scrollTop = latestContainer.scrollHeight
shouldAutoScroll.value = true
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
scrollReleaseTimer = window.setTimeout(() => {
isSyncingScroll.value = false
updateAutoScrollState()
}, 80)
})
}
function requestScrollToEnd(force = false) {
if (!force && !shouldAutoScroll.value) {
return
}
if (scrollTimer) {
window.clearTimeout(scrollTimer)
}
scrollTimer = window.setTimeout(() => {
nextTick(() => {
requestAnimationFrame(() => {
scrollContainerToEnd()
})
})
}, force ? 0 : 80)
}
function forceScrollToEnd() {
requestScrollToEnd(true)
}
// 合并消息到当前列表 // 合并消息到当前列表
function mergeMessages(items: Message[]) { function mergeMessages(items: Message[]) {
let hasNewMessage = false let hasNewMessage = false
@@ -95,14 +196,12 @@ function handleSSEMessage(event: MessageEvent) {
if (message) { if (message) {
const object = JSON.parse(message) const object = JSON.parse(message)
if (mergeMessages([object])) { if (mergeMessages([object])) {
nextTick(() => { requestScrollToEnd() // 新消息到达时触发智能滚动
emit('scroll') // 新消息到达时触发智能滚动
})
} }
} }
} }
// 使用优化的SSE连接 // 使用SSE连接
const { manager, isConnected } = useSSE( const { manager, isConnected } = useSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`, `${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
handleSSEMessage, handleSSEMessage,
@@ -137,9 +236,7 @@ async function loadMessages({ done }: { done: any }) {
// 首次加载时滚动到底部 // 首次加载时滚动到底部
if (page.value === 1 && hasNewMessage) { if (page.value === 1 && hasNewMessage) {
nextTick(() => { requestScrollToEnd(true)
emit('scroll')
})
} }
// 页码+1 // 页码+1
page.value++ page.value++
@@ -168,9 +265,7 @@ async function refreshLatestMessages() {
})) as Message[] })) as Message[]
if (mergeMessages(latestMessages)) { if (mergeMessages(latestMessages)) {
nextTick(() => { requestScrollToEnd()
emit('scroll')
})
} }
} catch (error) { } catch (error) {
console.error('刷新最新消息失败:', error) console.error('刷新最新消息失败:', error)
@@ -206,7 +301,7 @@ function compareTime(time1: string, time2: string) {
// 图片加载完成时触发智能滚动 // 图片加载完成时触发智能滚动
function handleImageLoad() { function handleImageLoad() {
emit('scroll') requestScrollToEnd()
} }
// 暂停SSE连接 // 暂停SSE连接
@@ -232,18 +327,32 @@ defineExpose({
pauseSSE, pauseSSE,
resumeSSE, resumeSSE,
refreshLatestMessages, refreshLatestMessages,
forceScrollToEnd,
}) })
onMounted(() => { onMounted(() => {
// 组件挂载后触发一次滚动事件
nextTick(() => { nextTick(() => {
emit('scroll') bindScrollListener()
requestScrollToEnd(true)
}) })
}) })
onBeforeUnmount(() => {
if (scrollTimer) {
window.clearTimeout(scrollTimer)
}
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
unbindScrollListener()
})
</script> </script>
<template> <template>
<VInfiniteScroll <VInfiniteScroll
ref="messageListRef"
:mode="!isLoaded ? 'intersect' : 'manual'" :mode="!isLoaded ? 'intersect' : 'manual'"
side="start" side="start"
:items="messages" :items="messages"

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;
} }

View File

@@ -3,11 +3,11 @@ import { useToast } from 'vue-toastification'
import api from '@/api' import api from '@/api'
import type { ScheduleInfo } from '@/api/types' import type { ScheduleInfo } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackground } from '@/composables/useBackground'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization() const { useDataRefresh } = useBackground()
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -59,7 +59,7 @@ function runCommand(id: string) {
} }
} }
// 使用优化的数据刷新定时器 // 使用数据刷新定时器
useDataRefresh( useDataRefresh(
'scheduler-list', 'scheduler-list',
loadSchedulerList, loadSchedulerList,

View File

@@ -6,6 +6,7 @@ import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue' import NoDataFound from '@/components/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -52,9 +53,7 @@ onMounted(() => {
fetchData() fetchData()
}) })
onActivated(() => { useKeepAliveRefresh(fetchData)
fetchData()
})
function openAddDialog() { function openAddDialog() {
addDialog.value = true addDialog.value = true
@@ -62,6 +61,7 @@ function openAddDialog() {
defineExpose({ defineExpose({
openAddDialog, openAddDialog,
refresh: fetchData,
}) })
</script> </script>
<template> <template>

View File

@@ -110,6 +110,7 @@ async function fetchData({ done }: { done: any }) {
page.value++ page.value++
// 返回加载成功 // 返回加载成功
done('ok') done('ok')
await nextTick()
} }
} else { } else {
// 设置加载中 // 设置加载中
@@ -145,9 +146,8 @@ function removeData(id: string) {
dataList.value = dataList.value.filter(item => item.id !== id) dataList.value = dataList.value.filter(item => item.id !== id)
} }
onActivated(() => { onMounted(() => {
loadEventTypes() loadEventTypes()
fetchData({ done: () => {} })
}) })
</script> </script>