mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-25 09:33:51 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a4c55050 | ||
|
|
dee5d9d213 | ||
|
|
c5e2b1349f | ||
|
|
0e005c3c7e | ||
|
|
348ae6b313 | ||
|
|
122ecc82fd | ||
|
|
88fad5b764 | ||
|
|
f01971ee3a | ||
|
|
5e8489c620 | ||
|
|
6900042cf7 | ||
|
|
75862c026a | ||
|
|
bbe3368c69 | ||
|
|
587f06eb9f | ||
|
|
7114c63e8f | ||
|
|
2a6f9e3cc0 | ||
|
|
00d37d7bda | ||
|
|
546af84dab | ||
|
|
5953496d84 | ||
|
|
0fda7c70de | ||
|
|
48546e1999 | ||
|
|
06355ff91d | ||
|
|
523f8c4cc8 | ||
|
|
73f6e7482f | ||
|
|
81ab3f9da8 | ||
|
|
d520645a8b | ||
|
|
af67fddce0 | ||
|
|
6d89dad8de | ||
|
|
f3ab2a8eff | ||
|
|
74c980c7a5 | ||
|
|
52fc2557ec | ||
|
|
34124418f8 | ||
|
|
e2d36da299 | ||
|
|
9965428bae | ||
|
|
e62a0b5a8d | ||
|
|
3c926f7485 | ||
|
|
de3f4e6374 | ||
|
|
2e22f6ae86 | ||
|
|
99665c7d79 | ||
|
|
a4a00586c7 | ||
|
|
cf59a07d4b | ||
|
|
8a362d0740 |
5
env.d.ts
vendored
5
env.d.ts
vendored
@@ -4,8 +4,13 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
action?: string
|
||||
subject?: string
|
||||
keepAlive?: boolean
|
||||
keepAliveKey?: string
|
||||
layoutWrapperClasses?: string
|
||||
navActiveLink?: RouteLocationRaw
|
||||
requiresAuth?: boolean
|
||||
subType?: string
|
||||
hideFooter?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.11.2",
|
||||
"version": "2.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -128,4 +128,4 @@
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18"
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ http {
|
||||
root html;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/system/(message|progress/) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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 转换为十六进制
|
||||
function rgbStringToHex(rgbArray: number[]): string {
|
||||
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)}`
|
||||
}
|
||||
|
||||
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> {
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColor = colorThief.getColor(image)
|
||||
return rgbStringToHex(dominantColor)
|
||||
export async function getDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: DominantColorOptions = {},
|
||||
): 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)
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
|
||||
@@ -264,6 +264,8 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.log(`Saved ${target} (${bundle.length} bytes)`)
|
||||
})().catch((err) => {
|
||||
console.error(err)
|
||||
// 构建图标失败时必须终止构建,避免继续发布上一次遗留的超大 icons-bundle。
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
|
||||
147
src/App.vue
147
src/App.vue
@@ -14,6 +14,8 @@ import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
@@ -37,6 +39,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
// 检查是否登录
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -48,6 +51,32 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
|
||||
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
function applyTransparentBackgroundSettings() {
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-poster-opacity',
|
||||
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-blur',
|
||||
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
|
||||
)
|
||||
}
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
@@ -89,9 +118,10 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
signal: backgroundRequestController.signal,
|
||||
})
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
@@ -133,6 +163,42 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
function stopBackgroundLoading() {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
|
||||
if (backgroundRetryTimer) {
|
||||
window.clearTimeout(backgroundRetryTimer)
|
||||
backgroundRetryTimer = null
|
||||
}
|
||||
|
||||
removeBackgroundTimer('background-rotation')
|
||||
}
|
||||
|
||||
async function initializeAuthenticatedState() {
|
||||
if (!isLogin.value) return
|
||||
|
||||
try {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
await globalSettingsStore.initialize()
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
} finally {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAuthenticatedStateInitialization() {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
}
|
||||
|
||||
// 登录后会立刻发生路由切换,稍后再拉取设置可避开导航中止请求。
|
||||
authenticatedStateTimer = window.setTimeout(() => {
|
||||
authenticatedStateTimer = null
|
||||
initializeAuthenticatedState()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
@@ -155,8 +221,6 @@ async function removeLoadingWithStateCheck() {
|
||||
try {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
globalLoadingStateManager.setLoadingState('background-images', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
const pwaController = (window as any).pwaStateController
|
||||
@@ -165,22 +229,7 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(async () => {
|
||||
// 如果已登录,加载用户相关设置
|
||||
if (isLogin.value) {
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
globalLoadingStateManager.setLoadingState('background-images', false)
|
||||
resolve(void 0)
|
||||
}, 50)
|
||||
}),
|
||||
])
|
||||
await initializeAuthenticatedState()
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
@@ -189,7 +238,9 @@ async function removeLoadingWithStateCheck() {
|
||||
animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
checkAndEmitUnreadMessages()
|
||||
if (isLogin.value) {
|
||||
checkAndEmitUnreadMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
@@ -208,7 +259,8 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
setTimeout(() => {
|
||||
backgroundRetryTimer = window.setTimeout(() => {
|
||||
backgroundRetryTimer = null
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
}, retryDelay)
|
||||
}
|
||||
@@ -244,20 +296,51 @@ onMounted(async () => {
|
||||
},
|
||||
)
|
||||
|
||||
// 加载背景图片
|
||||
loadBackgroundImages()
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
loadBackgroundImages()
|
||||
} else if (!isTransparentTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
if (isLogin.value) {
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
|
||||
watch(isLogin, loggedIn => {
|
||||
if (loggedIn) {
|
||||
startHeartbeat()
|
||||
scheduleAuthenticatedStateInitialization()
|
||||
} else {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
stopBackgroundLoading()
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
@@ -266,7 +349,11 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="app-wrapper">
|
||||
<!-- 透明主题背景 -->
|
||||
<div v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)" class="background-container">
|
||||
<div
|
||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
||||
class="background-container"
|
||||
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
@@ -331,11 +418,15 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.background-container.is-transparent-theme .background-image.active {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
/* 全局磨砂层 */
|
||||
.global-blur-layer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(var(--transparent-background-blur, 16px));
|
||||
background-color: rgba(128, 128, 128, 30%);
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface Subscribe {
|
||||
sites: number[]
|
||||
// 是否洗版,数字或者boolean
|
||||
best_version: any
|
||||
// 是否只洗全集整包,数字或者boolean
|
||||
best_version_full?: any
|
||||
// 使用 imdbid 搜索
|
||||
search_imdbid?: any
|
||||
// 当前优先级
|
||||
|
||||
@@ -31,6 +31,10 @@ const props = defineProps({
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -308,6 +312,7 @@ function stopDrag() {
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
:showTree="showDirTree"
|
||||
:active="active"
|
||||
:style="{ flex: 1 }"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
|
||||
@@ -9,14 +9,14 @@ import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict, storageAttributes } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
const { useConditionalDataRefresh } = useBackgroundOptimization()
|
||||
const { useConditionalDataRefresh } = useBackground()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -16,6 +14,10 @@ const display = useDisplay()
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 单个规则组
|
||||
@@ -273,7 +275,7 @@ function onClose() {
|
||||
</VRow>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
@@ -291,7 +293,7 @@ function onClose() {
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
|
||||
@@ -252,7 +252,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -273,7 +273,7 @@ const dropdownItems = ref([
|
||||
<!-- 安装插件进度框 -->
|
||||
<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 })">
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
|
||||
@@ -11,13 +11,15 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
|
||||
import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: Object as PropType<Plugin>,
|
||||
@@ -473,7 +475,10 @@ watch(
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</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">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
@@ -516,7 +521,7 @@ watch(
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -567,7 +572,7 @@ watch(
|
||||
<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 })">
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VDivider />
|
||||
@@ -585,13 +590,13 @@ watch(
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
|
||||
@@ -386,7 +386,7 @@ onMounted(() => {
|
||||
</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" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
||||
<VList>
|
||||
|
||||
@@ -67,6 +67,25 @@ const subscribeState = ref<string>(props.media?.state ?? 'P')
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
||||
|
||||
// 判断后端数字/布尔开关是否启用
|
||||
function isEnabledFlag(value: any) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
// 订阅列表接口通常返回中文媒体类型,插件或缓存数据可能只保留剧集字段
|
||||
function isTvSubscribe(media?: Subscribe) {
|
||||
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
|
||||
}
|
||||
|
||||
// TV 洗版订阅在卡片上展示分集或全集短标签
|
||||
const bestVersionModeLabel = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
|
||||
|
||||
return isEnabledFlag(props.media?.best_version_full)
|
||||
? t('subscribe.bestVersionWholeShort')
|
||||
: t('subscribe.bestVersionEpisodeShort')
|
||||
})
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -353,7 +372,7 @@ function handleCardClick() {
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn>
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -408,8 +427,8 @@ function handleCardClick() {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex align-center">
|
||||
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex min-w-0 max-w-full align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
@@ -424,13 +443,23 @@ function handleCardClick() {
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
|
||||
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
|
||||
<VChip
|
||||
v-if="bestVersionModeLabel"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="me-2 flex-shrink-0"
|
||||
>
|
||||
{{ bestVersionModeLabel }}
|
||||
</VChip>
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span v-if="props.media?.username" class="min-w-0 truncate text-subtitle-2 text-white" :title="props.media?.username">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -123,10 +123,10 @@ onMounted(() => {
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="flex flex-column"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="userEditDialog = true"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
@@ -247,7 +247,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<!-- 独立的邮箱显示 -->
|
||||
<VDivider class="mx-4" />
|
||||
<div>
|
||||
<div class="user-card__footer">
|
||||
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
||||
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
||||
@@ -308,6 +308,16 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* 让邮箱和订阅统计固定在卡片底部,保证同一行用户卡片视觉等高。 */
|
||||
.user-card__footer {
|
||||
flex-shrink: 0;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.admin-decoration {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
|
||||
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -138,9 +138,7 @@ function releaseTime(releaseDate: string) {
|
||||
|
||||
// 强制清除缓存
|
||||
async function clearCache() {
|
||||
await clearCachesAndServiceWorker()
|
||||
// 刷新页面,添加时间戳参数以强制更新
|
||||
reloadWithTimestamp()
|
||||
await clearCacheAndReload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -204,12 +202,7 @@ onMounted(() => {
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ appVersion }}</code>
|
||||
<VBtn
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ms-2"
|
||||
@click="clearCache"
|
||||
>
|
||||
<VBtn size="x-small" variant="tonal" class="ms-2" @click="clearCache">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" size="14" />
|
||||
</template>
|
||||
@@ -404,7 +397,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</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>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
@@ -432,8 +425,8 @@ onMounted(() => {
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-block: 0.5rem;
|
||||
font-weight: 600;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
@@ -450,8 +443,8 @@ onMounted(() => {
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
padding-inline-start: 1.5rem;
|
||||
margin-block: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
@@ -472,18 +465,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(127, 127, 127, 15%);
|
||||
font-size: 0.875em;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
padding-block: 0.15rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 15%);
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
padding-block: 0.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
@@ -492,37 +487,38 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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;
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(hr) {
|
||||
margin-block: 1rem;
|
||||
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) {
|
||||
width: 100%;
|
||||
margin-block: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid rgba(127, 127, 127, 0.3);
|
||||
border: 1px solid rgba(127, 127, 127, 30%);
|
||||
padding-block: 0.4rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th) {
|
||||
background-color: rgba(127, 127, 127, 10%);
|
||||
font-weight: 600;
|
||||
background-color: rgba(127, 127, 127, 0.1);
|
||||
}
|
||||
|
||||
.markdown-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
} from '@/api/types'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -24,7 +24,7 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
const { useProgressSSE } = useBackground()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -89,6 +89,27 @@ const previewLoaded = ref(false)
|
||||
// 预览数据
|
||||
const previewData = ref<ManualTransferPreviewData>()
|
||||
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
}
|
||||
|
||||
function dedupeFileItems(fileItems?: FileItem[]) {
|
||||
if (!fileItems?.length) return []
|
||||
|
||||
const uniqueItems = new Map<string, FileItem>()
|
||||
fileItems.forEach(item => {
|
||||
uniqueItems.set(getFileItemKey(item), item)
|
||||
})
|
||||
|
||||
return Array.from(uniqueItems.values())
|
||||
}
|
||||
|
||||
function getPreviewItemKey(item: ManualTransferPreviewItem) {
|
||||
return [item.source ?? '', item.target ?? '', item.success === false ? 'failed' : 'success'].join('|')
|
||||
}
|
||||
|
||||
const normalizedItems = computed(() => dedupeFileItems(props.items))
|
||||
|
||||
// 分页
|
||||
const previewPage = ref(1)
|
||||
const previewPageSize = ref(10)
|
||||
@@ -128,18 +149,21 @@ const dialogTitle = computed(() => {
|
||||
|
||||
// 副标题
|
||||
const dialogSubtitle = computed(() => {
|
||||
if (props.items) {
|
||||
if (props.items.length > 1) return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
|
||||
return t('dialog.reorganize.singleItemTitle', { path: props.items[0].path })
|
||||
if (normalizedItems.value.length) {
|
||||
if (normalizedItems.value.length > 1) {
|
||||
return t('dialog.reorganize.multipleItemsTitle', { count: normalizedItems.value.length })
|
||||
}
|
||||
|
||||
return t('dialog.reorganize.singleItemTitle', { path: normalizedItems.value[0].path })
|
||||
} else if (props.logids) {
|
||||
return t('dialog.reorganize.multipleItemsTitle', { count: props.logids.length })
|
||||
}
|
||||
})
|
||||
// 禁用指定集数
|
||||
const disableEpisodeDetail = computed(() => {
|
||||
if (props.items) {
|
||||
if (normalizedItems.value.length) {
|
||||
if (transferForm.episode_format) return false
|
||||
return !(props.items.length === 1 && props.items[0].type !== 'dir')
|
||||
return !(normalizedItems.value.length === 1 && normalizedItems.value[0].type !== 'dir')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -249,39 +273,6 @@ function getFileName(path?: string) {
|
||||
return normalizedPath.split('/').pop() || normalizedPath
|
||||
}
|
||||
|
||||
// 获取目录路径
|
||||
function getDirectoryPath(path?: string) {
|
||||
const normalizedPath = normalizePath(path)
|
||||
if (!normalizedPath) return ''
|
||||
if (normalizedPath.endsWith('/')) return normalizedPath
|
||||
|
||||
const parts = normalizedPath.split('/')
|
||||
parts.pop()
|
||||
const joined = parts.join('/')
|
||||
return joined ? `${joined}/` : '/'
|
||||
}
|
||||
|
||||
// 计算公共路径
|
||||
function getCommonPath(paths: string[]) {
|
||||
const validPaths = paths.map(item => normalizePath(item)).filter(Boolean)
|
||||
if (validPaths.length === 0) return ''
|
||||
if (validPaths.length === 1) return validPaths[0]
|
||||
|
||||
const splitPaths = validPaths.map(path => path.split('/'))
|
||||
const commonParts: string[] = []
|
||||
|
||||
for (let index = 0; index < splitPaths[0].length; index++) {
|
||||
const part = splitPaths[0][index]
|
||||
if (splitPaths.every(pathParts => pathParts[index] === part)) {
|
||||
commonParts.push(part)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return commonParts.join('/') || '/'
|
||||
}
|
||||
|
||||
// 获取唯一非空值
|
||||
function getUniqueValues(values: (string | undefined)[]) {
|
||||
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
|
||||
@@ -324,18 +315,6 @@ function getPreviewSeasonNumber(item: ManualTransferPreviewItem) {
|
||||
)
|
||||
}
|
||||
|
||||
// 顶部原始路径
|
||||
const previewSourcePath = computed(() => {
|
||||
const paths = filteredPreviewItems.value.map(item => getDirectoryPath(item.source))
|
||||
return getCommonPath(paths) || '-'
|
||||
})
|
||||
|
||||
// 顶部目的路径
|
||||
const previewTargetPath = computed(() => {
|
||||
const targetDirs = filteredPreviewItems.value.map(item => item.target_dir || getDirectoryPath(item.target))
|
||||
return getCommonPath(targetDirs) || '-'
|
||||
})
|
||||
|
||||
// 顶部媒体信息
|
||||
const previewMediaInfo = computed(() => {
|
||||
const titles = getUniqueValues(filteredPreviewItems.value.map(item => item.title))
|
||||
@@ -415,11 +394,7 @@ const previewFileRows = computed(() => {
|
||||
|
||||
// 是否需要拓宽窗口
|
||||
const previewNeedsWideLayout = computed(() => {
|
||||
const candidates = [
|
||||
previewSourcePath.value,
|
||||
previewTargetPath.value,
|
||||
...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`),
|
||||
]
|
||||
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
|
||||
|
||||
return candidates.some(item => item.length > 72)
|
||||
})
|
||||
@@ -484,29 +459,56 @@ function previewHasFailures(data?: ManualTransferPreviewData) {
|
||||
return (data.summary.failed ?? 0) > 0 || (data.items ?? []).some(item => item.success === false)
|
||||
}
|
||||
|
||||
function getPreviewFailureMessage(data?: ManualTransferPreviewData) {
|
||||
return (
|
||||
data?.items.find(item => item.success === false)?.message ||
|
||||
data?.message ||
|
||||
t('dialog.reorganize.previewRequestFailed')
|
||||
)
|
||||
function getPreviewResultSummaryMessage(data?: ManualTransferPreviewData) {
|
||||
const success = data?.summary.success ?? 0
|
||||
const failed = data?.summary.failed ?? 0
|
||||
|
||||
return [
|
||||
t('dialog.reorganize.previewSuccess', { count: success }),
|
||||
t('dialog.reorganize.previewFailed', { count: failed }),
|
||||
].join(',')
|
||||
}
|
||||
|
||||
function createFailedPreviewData(options: { source?: string; type?: string; title?: string; message?: string }) {
|
||||
const failedItem: ManualTransferPreviewItem = {
|
||||
source: options.source,
|
||||
target: '',
|
||||
success: false,
|
||||
message: options.message || t('dialog.reorganize.previewRequestFailed'),
|
||||
type: options.type,
|
||||
title: options.title,
|
||||
}
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: 1,
|
||||
success: 0,
|
||||
failed: 1,
|
||||
},
|
||||
items: [failedItem],
|
||||
message: failedItem.message,
|
||||
} satisfies ManualTransferPreviewData
|
||||
}
|
||||
|
||||
// 合并多次预览结果
|
||||
function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTransferPreviewData) {
|
||||
if (!incoming) return
|
||||
|
||||
const incomingItems = incoming.items ?? []
|
||||
const incomingSummary = incoming.summary ?? {
|
||||
total: incomingItems.length,
|
||||
success: incomingItems.filter(item => item.success).length,
|
||||
failed: incomingItems.filter(item => item.success === false).length,
|
||||
}
|
||||
const mergedItems = [...(target.items ?? [])]
|
||||
const existingItemKeys = new Set(mergedItems.map(item => getPreviewItemKey(item)))
|
||||
|
||||
target.summary.total += incomingSummary.total ?? 0
|
||||
target.summary.success += incomingSummary.success ?? 0
|
||||
target.summary.failed += incomingSummary.failed ?? 0
|
||||
target.items.push(...incomingItems)
|
||||
;(incoming.items ?? []).forEach(item => {
|
||||
const itemKey = getPreviewItemKey(item)
|
||||
if (existingItemKeys.has(itemKey)) return
|
||||
|
||||
existingItemKeys.add(itemKey)
|
||||
mergedItems.push(item)
|
||||
})
|
||||
|
||||
target.items = mergedItems
|
||||
target.summary.total = mergedItems.length
|
||||
target.summary.success = mergedItems.filter(item => item.success !== false).length
|
||||
target.summary.failed = mergedItems.filter(item => item.success === false).length
|
||||
|
||||
if (incoming.message) {
|
||||
target.message = [target.message, incoming.message].filter(Boolean).join(';')
|
||||
@@ -515,7 +517,7 @@ function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTr
|
||||
|
||||
// 预览整理结果
|
||||
async function previewTransfer() {
|
||||
if (!props.logids && !props.items) return
|
||||
if (!props.logids && !normalizedItems.value.length) return
|
||||
|
||||
previewLoading.value = true
|
||||
resetPreviewState()
|
||||
@@ -525,20 +527,38 @@ async function previewTransfer() {
|
||||
try {
|
||||
const tasks: Promise<void>[] = []
|
||||
|
||||
if (props.items) {
|
||||
if (normalizedItems.value.length) {
|
||||
tasks.push(
|
||||
...props.items.map(async item => {
|
||||
...normalizedItems.value.map(async item => {
|
||||
try {
|
||||
const result = await requestManualTransfer<ManualTransferPreviewData>(
|
||||
createTransferPayload({ item, preview: true }),
|
||||
)
|
||||
if (!result.success) throw new Error(result.message || t('dialog.reorganize.previewRequestFailed'))
|
||||
if (!result.success) {
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: item.path || item.name,
|
||||
type: item.type,
|
||||
title: item.name,
|
||||
message: result.message || t('dialog.reorganize.previewRequestFailed'),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
const label = item.name || item.path
|
||||
throw new Error(`${label}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: item.path || item.name,
|
||||
type: item.type,
|
||||
title: item.name,
|
||||
message: `${item.name || item.path}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -551,12 +571,27 @@ async function previewTransfer() {
|
||||
const result = await requestManualTransfer<ManualTransferPreviewData>(
|
||||
createTransferPayload({ logid, preview: true }),
|
||||
)
|
||||
if (!result.success) throw new Error(result.message || t('dialog.reorganize.previewRequestFailed'))
|
||||
if (!result.success) {
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: `历史记录 ${logid}`,
|
||||
message: result.message || t('dialog.reorganize.previewRequestFailed'),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
throw new Error(`历史记录 ${logid}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: `历史记录 ${logid}`,
|
||||
message: `历史记录 ${logid}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -564,13 +599,13 @@ async function previewTransfer() {
|
||||
|
||||
await Promise.all(tasks)
|
||||
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
throw new Error(getPreviewFailureMessage(mergedPreviewData))
|
||||
}
|
||||
|
||||
previewData.value = mergedPreviewData
|
||||
previewLoaded.value = true
|
||||
nextTick(() => updatePreviewPageSize())
|
||||
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||
}
|
||||
} catch (error: any) {
|
||||
previewVisible.value = false
|
||||
resetPreviewState()
|
||||
@@ -691,14 +726,14 @@ function stopLoadingProgress() {
|
||||
|
||||
// 整理文件
|
||||
async function transfer(background: boolean = false) {
|
||||
if (!props.logids && !props.items) return
|
||||
if (!props.logids && !normalizedItems.value.length) return
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
|
||||
// 文件整理
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
if (normalizedItems.value.length) {
|
||||
for (const item of normalizedItems.value) {
|
||||
if (!background) {
|
||||
// 如果是文件,计算MD5
|
||||
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
|
||||
@@ -1014,18 +1049,6 @@ onUnmounted(() => {
|
||||
{{ previewData.message }}
|
||||
</div>
|
||||
<div class="preview-summary-grid">
|
||||
<div class="preview-overview-card preview-overview-card--path">
|
||||
<span class="preview-overview-card__label">{{ t('dialog.reorganize.previewSourcePath') }}</span>
|
||||
<span class="preview-overview-card__value preview-overview-card__value--path">{{
|
||||
previewSourcePath
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="preview-overview-card preview-overview-card--path">
|
||||
<span class="preview-overview-card__label">{{ t('dialog.reorganize.previewTargetPath') }}</span>
|
||||
<span class="preview-overview-card__value preview-overview-card__value--path">{{
|
||||
previewTargetPath
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="preview-overview-card">
|
||||
<span class="preview-overview-card__label">{{ t('dialog.reorganize.previewMediaName') }}</span>
|
||||
<span class="preview-overview-card__value">{{ previewMediaInfo.title }}</span>
|
||||
@@ -1054,6 +1077,7 @@ onUnmounted(() => {
|
||||
v-for="(item, index) in pagedPreviewRows"
|
||||
:key="`${item.source}-${item.target}-${index}`"
|
||||
class="preview-file-row"
|
||||
:class="{ 'preview-file-row--failed': item.success === false }"
|
||||
>
|
||||
<div class="preview-file-row__card preview-file-row__card--source">
|
||||
<span class="preview-file-row__label">{{ t('dialog.reorganize.previewBeforeColumn') }}</span>
|
||||
@@ -1067,6 +1091,9 @@ onUnmounted(() => {
|
||||
<span class="preview-file-row__label">{{ t('dialog.reorganize.previewAfterColumn') }}</span>
|
||||
<span class="preview-file-row__name">{{ item.targetName }}</span>
|
||||
<span class="preview-file-row__path">{{ item.target || '-' }}</span>
|
||||
<span v-if="item.success === false && item.message" class="preview-file-row__message">
|
||||
{{ item.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1260,13 +1287,13 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
border: 1px solid rgba(var(--v-theme-info), 0.16);
|
||||
border-radius: 0.875rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 1rem;
|
||||
color: rgb(var(--v-theme-error));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
padding-block: 0.75rem;
|
||||
padding-inline: 0.875rem;
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-summary-grid {
|
||||
@@ -1286,10 +1313,6 @@ onUnmounted(() => {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-overview-card--path {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.preview-overview-card__label {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
@@ -1306,14 +1329,6 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-overview-card__value--path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__scroll {
|
||||
display: flex;
|
||||
overflow: hidden auto;
|
||||
@@ -1385,6 +1400,10 @@ onUnmounted(() => {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.preview-file-row--failed {
|
||||
background: rgba(var(--v-theme-error), 0.04);
|
||||
}
|
||||
|
||||
.preview-file-row__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1420,6 +1439,16 @@ onUnmounted(() => {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.preview-file-row--failed .preview-file-row__card--target .preview-file-row__name {
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.preview-file-row__message {
|
||||
color: rgb(var(--v-theme-error));
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview-file-row__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1451,10 +1480,6 @@ onUnmounted(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-overview-card--path {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.preview-file-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -94,6 +95,10 @@ const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
function getResourceItemKey(item: TorrentInfo, index: number) {
|
||||
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
|
||||
}
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
if (!page_url) return
|
||||
@@ -465,98 +470,115 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
|
||||
<VCard
|
||||
v-for="(item, index) in mobileResourceList"
|
||||
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
|
||||
class="mb-3"
|
||||
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
|
||||
<ProgressiveCardGrid
|
||||
:items="mobileResourceList"
|
||||
:columns="1"
|
||||
:gap="12"
|
||||
:estimated-item-height="320"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
<template #default="{ item }">
|
||||
<VCard>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, chipIndex) in item.labels"
|
||||
:key="chipIndex"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<VChip
|
||||
v-if="item.hit_and_run"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1 text-white bg-black"
|
||||
>
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.freedate_diff"
|
||||
variant="elevated"
|
||||
color="secondary"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, chipIndex) in item.labels"
|
||||
:key="chipIndex"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||
@@ -669,6 +691,15 @@ onMounted(() => {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-resource-mobile {
|
||||
overflow-y: auto;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-mobile__list {
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ const subscribeForm = ref<Subscribe>({
|
||||
username: '',
|
||||
sites: [],
|
||||
best_version: undefined,
|
||||
best_version_full: undefined,
|
||||
current_priority: 0,
|
||||
downloader: '',
|
||||
date: '',
|
||||
@@ -226,6 +227,7 @@ async function getSubscribeInfo() {
|
||||
const result: Subscribe = await api.get(`subscribe/${props.subid}`)
|
||||
subscribeForm.value = result
|
||||
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
|
||||
subscribeForm.value.best_version_full = subscribeForm.value.best_version_full === 1
|
||||
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
|
||||
// 加载剧集组
|
||||
if (subscribeForm.value.type == '电视剧') getEpisodeGroups()
|
||||
@@ -273,6 +275,16 @@ const targetDirectories = computed(() => {
|
||||
return downloadDirectories.value.map(item => item.download_path)
|
||||
})
|
||||
|
||||
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
|
||||
const isTvSubscribe = computed(() => props.type === '电视剧' || subscribeForm.value.type === '电视剧')
|
||||
|
||||
watch(
|
||||
() => subscribeForm.value.best_version,
|
||||
bestVersion => {
|
||||
if (!bestVersion) subscribeForm.value.best_version_full = false
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
queryFilterRuleGroups()
|
||||
loadDownloadDirectories()
|
||||
@@ -426,6 +438,14 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="isTvSubscribe && subscribeForm.best_version" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.best_version_full"
|
||||
:label="t('dialog.subscribeEdit.bestVersionFull')"
|
||||
:hint="t('dialog.subscribeEdit.bestVersionFullHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.search_imdbid"
|
||||
|
||||
@@ -5,12 +5,22 @@ import api from '@/api'
|
||||
import { FileItem, TransferQueue } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
type TransferTask = TransferQueue['tasks'][number]
|
||||
|
||||
interface MediaTaskGroup {
|
||||
media: TransferQueue['media']
|
||||
titleYear: string
|
||||
tasks: TransferTask[]
|
||||
total: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
const { useProgressSSE } = useBackground()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -29,9 +39,6 @@ const overallProgress = ref({
|
||||
// 文件进度映射
|
||||
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
|
||||
|
||||
// 数据可刷新标志
|
||||
const refreshFlag = ref(false)
|
||||
|
||||
// 进度是否激活
|
||||
const progressActive = ref(false)
|
||||
|
||||
@@ -58,49 +65,58 @@ function getStateColor(state: string) {
|
||||
else return 'error'
|
||||
}
|
||||
|
||||
// 从dataList中提取所有的媒体信息,合并相同title_year的记录
|
||||
const mediaList = computed(() => {
|
||||
const mediaMap = new Map<string, any>()
|
||||
// 按媒体聚合队列,避免模板中按 tab 重复扫描 dataList
|
||||
const mediaTaskGroups = computed<MediaTaskGroup[]>(() => {
|
||||
const groupMap = new Map<string, MediaTaskGroup>()
|
||||
|
||||
dataList.value.forEach(item => {
|
||||
const titleYear = item.media.title_year || ''
|
||||
if (!mediaMap.has(titleYear)) {
|
||||
mediaMap.set(titleYear, item.media)
|
||||
let group = groupMap.get(titleYear)
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
media: item.media,
|
||||
titleYear,
|
||||
tasks: [],
|
||||
total: 0,
|
||||
completed: 0,
|
||||
}
|
||||
groupMap.set(titleYear, group)
|
||||
}
|
||||
|
||||
group.tasks.push(...item.tasks)
|
||||
group.total += item.tasks.length
|
||||
group.completed += item.tasks.filter(task => task.state === 'completed').length
|
||||
})
|
||||
|
||||
return Array.from(mediaMap.values())
|
||||
return Array.from(groupMap.values())
|
||||
})
|
||||
|
||||
// 从dataList中提取所有的媒体信息,合并相同title_year的记录
|
||||
const mediaList = computed(() => {
|
||||
return mediaTaskGroups.value.map(group => group.media)
|
||||
})
|
||||
|
||||
// 按media计算总数和完成数,返回 x/x
|
||||
function getMediaCount(title_year: string) {
|
||||
// 按title_year查询出所有media列表
|
||||
const medias = dataList.value.filter(item => item.media.title_year === title_year)
|
||||
// 计算media下任务的总数
|
||||
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
|
||||
// 计算media下任务的完成数
|
||||
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
|
||||
return `${completed} / ${total}`
|
||||
const group = mediaTaskGroups.value.find(item => item.titleYear === title_year)
|
||||
return `${group?.completed ?? 0} / ${group?.total ?? 0}`
|
||||
}
|
||||
|
||||
// 根据媒体信息获取对应的整理任务,合并相同title_year的所有任务
|
||||
const activeTasks = computed(() => {
|
||||
const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)
|
||||
return tasks
|
||||
return mediaTaskGroups.value.find(item => item.titleYear === activeTab.value)?.tasks ?? []
|
||||
})
|
||||
|
||||
// 根据媒体title_year获取对应的任务列表
|
||||
function getTasksByMedia(title_year: string) {
|
||||
return dataList.value.filter(item => item.media.title_year === title_year).flatMap(item => item.tasks)
|
||||
return mediaTaskGroups.value.find(item => item.titleYear === title_year)?.tasks ?? []
|
||||
}
|
||||
|
||||
// 计算整体进度
|
||||
const overallProgressComputed = computed(() => {
|
||||
if (dataList.value.length === 0) return 0
|
||||
|
||||
const allTasks = dataList.value.flatMap(item => item.tasks)
|
||||
const totalTasks = allTasks.length
|
||||
const completedTasks = allTasks.filter(task => task.state === 'completed').length
|
||||
const totalTasks = mediaTaskGroups.value.reduce((total, group) => total + group.total, 0)
|
||||
const completedTasks = mediaTaskGroups.value.reduce((total, group) => total + group.completed, 0)
|
||||
|
||||
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
|
||||
})
|
||||
|
||||
@@ -11,13 +11,14 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
const { useProgressSSE } = useBackground()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -43,6 +44,10 @@ const inProps = defineProps({
|
||||
},
|
||||
sort: String,
|
||||
showTree: Boolean,
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -107,6 +112,47 @@ const currentItem = ref<FileItem>()
|
||||
// 选中的项目
|
||||
const selected = ref<FileItem[]>([])
|
||||
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? inProps.item.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
}
|
||||
|
||||
function dedupeFileItems(fileItems: FileItem[]) {
|
||||
const uniqueItems = new Map<string, FileItem>()
|
||||
fileItems.forEach(item => {
|
||||
uniqueItems.set(getFileItemKey(item), item)
|
||||
})
|
||||
|
||||
return Array.from(uniqueItems.values())
|
||||
}
|
||||
|
||||
function syncSelectedItems(nextItems: FileItem[] = items.value) {
|
||||
if (!selected.value.length) return
|
||||
|
||||
const currentItemMap = new Map(nextItems.map(item => [getFileItemKey(item), item]))
|
||||
selected.value = dedupeFileItems(selected.value)
|
||||
.map(item => currentItemMap.get(getFileItemKey(item)))
|
||||
.filter((item): item is FileItem => !!item)
|
||||
}
|
||||
|
||||
const selectedKeys = computed(() => new Set(selected.value.map(item => getFileItemKey(item))))
|
||||
|
||||
function isSelected(item: FileItem) {
|
||||
return selectedKeys.value.has(getFileItemKey(item))
|
||||
}
|
||||
|
||||
function setItemSelected(item: FileItem, checked: boolean) {
|
||||
const itemKey = getFileItemKey(item)
|
||||
|
||||
if (checked) {
|
||||
if (!selectedKeys.value.has(itemKey)) {
|
||||
selected.value = [...selected.value, item]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selected.value = selected.value.filter(selectedItem => getFileItemKey(selectedItem) !== itemKey)
|
||||
}
|
||||
|
||||
// 识别结果
|
||||
const nameTestResult = ref<Context>()
|
||||
|
||||
@@ -119,26 +165,46 @@ const dropdownItems = ref<{ [key: string]: any }[]>([])
|
||||
// 进度是否激活
|
||||
const progressActive = ref(false)
|
||||
|
||||
// 通用过滤
|
||||
const getFilteredItems = (type: 'dir' | 'file') => {
|
||||
const filterValue = filter.value
|
||||
if (!filterValue) {
|
||||
return items.value.filter(item => item.type === type)
|
||||
}
|
||||
|
||||
if (ignoreCase.value) {
|
||||
const lowerCaseFilter = filterValue.toLowerCase()
|
||||
return items.value.filter(item => item.type === type && item.name.toLowerCase().includes(lowerCaseFilter))
|
||||
} else {
|
||||
return items.value.filter(item => item.type === type && item.name.includes(filterValue))
|
||||
}
|
||||
// 将 glob 模式转换为正则表达式
|
||||
function globToRegex(pattern: string, flags: string = ''): RegExp {
|
||||
const regexStr = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.')
|
||||
return new RegExp(`^${regexStr}$`, flags)
|
||||
}
|
||||
|
||||
// 通用过滤
|
||||
const filteredItems = computed(() => {
|
||||
const filterValue = filter.value
|
||||
if (!filterValue) {
|
||||
return items.value
|
||||
}
|
||||
|
||||
// 通配符模式
|
||||
if (filterValue.includes('*') || filterValue.includes('?')) {
|
||||
const flags = ignoreCase.value ? 'i' : ''
|
||||
const regex = globToRegex(filterValue, flags)
|
||||
return items.value.filter(item => regex.test(item.name ?? ''))
|
||||
}
|
||||
|
||||
// 子字符串模式
|
||||
if (ignoreCase.value) {
|
||||
const lowerCaseFilter = filterValue.toLowerCase()
|
||||
return items.value.filter(item => (item.name ?? '').toLowerCase().includes(lowerCaseFilter))
|
||||
} else {
|
||||
return items.value.filter(item => (item.name ?? '').includes(filterValue))
|
||||
}
|
||||
})
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() => getFilteredItems('dir'))
|
||||
const dirs = computed(() => filteredItems.value.filter(item => item.type === 'dir'))
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() => getFilteredItems('file'))
|
||||
const files = computed(() => filteredItems.value.filter(item => item.type === 'file'))
|
||||
|
||||
// 虚拟列表数据,保持引用稳定,避免模板内联展开数组导致虚拟列表重算。
|
||||
const displayItems = computed(() => [...dirs.value, ...files.value])
|
||||
// 是否文件
|
||||
const isFile = computed(() => inProps.item.type == 'file')
|
||||
|
||||
@@ -168,33 +234,45 @@ function changeSelectMode() {
|
||||
}
|
||||
|
||||
// 调API加载文件夹内的内容
|
||||
async function list_files() {
|
||||
loading.value = true
|
||||
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
|
||||
const prevURI = takeURISnapshot();
|
||||
emit('loading', true)
|
||||
async function list_files(context: KeepAliveRefreshContext = {}) {
|
||||
const silentRefresh = Boolean(context.silent && items.value.length > 0)
|
||||
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/')
|
||||
const prevURI = takeURISnapshot()
|
||||
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: inProps.endpoints?.list.method || 'get',
|
||||
data: inProps.item,
|
||||
if (!silentRefresh) {
|
||||
loading.value = true
|
||||
emit('loading', true)
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
|
||||
// 如果当前路径已经变化,则放弃此次加载结果
|
||||
if (prevURI !== takeURISnapshot()) {
|
||||
return;
|
||||
}
|
||||
items.value = data
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
try {
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
// 通知父组件文件列表更新
|
||||
emit('items-updated', items.value)
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
@@ -265,13 +343,7 @@ function changePath(item: FileItem) {
|
||||
// 点击列表项
|
||||
function listItemClick(item: FileItem) {
|
||||
if (selectMode.value) {
|
||||
if (selected.value.includes(item)) {
|
||||
selected.value = selected.value.filter(i => i !== item)
|
||||
} else {
|
||||
selected.value.push(item)
|
||||
}
|
||||
// 去重
|
||||
selected.value = Array.from(new Set(selected.value))
|
||||
setItemSelected(item, !isSelected(item))
|
||||
return false
|
||||
}
|
||||
changePath(item)
|
||||
@@ -416,7 +488,7 @@ function showTransfer(item: FileItem) {
|
||||
|
||||
// 显示批量整理对话框
|
||||
function showBatchTransfer() {
|
||||
transferItems.value = selected.value
|
||||
transferItems.value = dedupeFileItems(selected.value)
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
@@ -453,6 +525,7 @@ watch(
|
||||
async () => {
|
||||
// 清空列表
|
||||
items.value = []
|
||||
selected.value = []
|
||||
// 关闭弹窗
|
||||
nameTestResult.value = undefined
|
||||
nameTestDialog.value = false
|
||||
@@ -585,7 +658,7 @@ function handleProgressMessage(event: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的进度SSE连接
|
||||
// 使用进度SSE连接
|
||||
const progressSSE = useProgressSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`,
|
||||
handleProgressMessage,
|
||||
@@ -606,8 +679,8 @@ function stopLoadingProgress() {
|
||||
progressSSE.stop()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
useKeepAliveRefresh(list_files, {
|
||||
active: computed(() => inProps.active),
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -639,8 +712,8 @@ onUnmounted(() => {
|
||||
flat
|
||||
density="compact"
|
||||
variant="plain"
|
||||
:placeholder="t('common.search')"
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
:placeholder="t('file.filterPlaceholder')"
|
||||
:prepend-inner-icon="(filter.includes('*') || filter.includes('?')) ? 'mdi-asterisk' : 'mdi-filter-outline'"
|
||||
class="mx-2"
|
||||
rounded
|
||||
/>
|
||||
@@ -699,14 +772,18 @@ onUnmounted(() => {
|
||||
class="text-high-emphasis file-list-container"
|
||||
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
|
||||
>
|
||||
<VVirtualScroll :items="[...dirs, ...files]" style="block-size: 100%">
|
||||
<VVirtualScroll :items="displayItems" style="block-size: 100%">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||
<template #prepend>
|
||||
<VListItemAction v-if="selectMode">
|
||||
<VCheckbox v-model="selected" :value="item" />
|
||||
<VCheckbox
|
||||
:model-value="isSelected(item)"
|
||||
@update:model-value="setItemSelected(item, !!$event)"
|
||||
@click.stop
|
||||
/>
|
||||
</VListItemAction>
|
||||
<template v-else>
|
||||
<VIcon
|
||||
|
||||
@@ -14,6 +14,11 @@ const display = useDisplay()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
|
||||
type TreeRow =
|
||||
| { type: 'root'; key: string; level: number }
|
||||
| { type: 'loading'; key: string; path: string; level: number }
|
||||
| { type: 'directory'; key: string; dir: FileItem; level: number }
|
||||
|
||||
// 计算列表可用高度
|
||||
// componentOffset = FileToolbar(48) = 48
|
||||
const { availableHeight } = useAvailableHeight(48, 300)
|
||||
@@ -132,37 +137,6 @@ async function loadRootDirectories() {
|
||||
await loadSubdirectories('/')
|
||||
}
|
||||
|
||||
// 检索所有目录节点
|
||||
function getAllDirectories() {
|
||||
const allDirs: { dir: FileItem; level: number; parentPath: string }[] = []
|
||||
|
||||
// 添加根目录的子目录
|
||||
if (treeCache.value['/']) {
|
||||
treeCache.value['/'].forEach(dir => {
|
||||
allDirs.push({ dir, level: 0, parentPath: '/' })
|
||||
addSubdirectories(dir.path || '', 1, allDirs)
|
||||
})
|
||||
}
|
||||
|
||||
return allDirs
|
||||
}
|
||||
|
||||
// 递归添加子目录
|
||||
function addSubdirectories(
|
||||
parentPath: string,
|
||||
level: number,
|
||||
result: { dir: FileItem; level: number; parentPath: string }[],
|
||||
) {
|
||||
if (treeCache.value[parentPath]) {
|
||||
treeCache.value[parentPath].forEach(dir => {
|
||||
result.push({ dir, level, parentPath })
|
||||
if (isFolderExpanded(dir.path || '')) {
|
||||
addSubdirectories(dir.path || '', level + 1, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前路径变化,自动展开当前路径
|
||||
watch(
|
||||
() => props.currentPath,
|
||||
@@ -224,38 +198,51 @@ const rootDirectories = computed(() => {
|
||||
return treeCache.value['/'] || []
|
||||
})
|
||||
|
||||
// 扁平化的目录树
|
||||
const flattenedDirectories = computed(() => {
|
||||
return getAllDirectories()
|
||||
})
|
||||
// 只生成当前可见的目录行,避免折叠/隐藏节点继续留在 DOM 中
|
||||
const visibleTreeRows = computed<TreeRow[]>(() => {
|
||||
const rows: TreeRow[] = [{ type: 'root', key: 'root', level: 0 }]
|
||||
|
||||
// 检查路径是否为指定目录的子目录或后代
|
||||
function isChildOrDescendant(path: string, ancestorPath: string) {
|
||||
if (!path || !ancestorPath) return false
|
||||
if (ancestorPath === '/') return true
|
||||
|
||||
// 确保路径以斜杠结尾,便于比较
|
||||
const normalizedPath = path.endsWith('/') ? path : path + '/'
|
||||
const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/'
|
||||
|
||||
// 检查路径是否以祖先路径开头,但不是祖先路径本身
|
||||
return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath
|
||||
}
|
||||
|
||||
// 计算目录相对于其祖先的缩进级别
|
||||
function getIndentLevel(path: string, ancestorPath: string) {
|
||||
if (!path || !ancestorPath) return 0
|
||||
|
||||
// 根目录特殊处理
|
||||
if (ancestorPath === '/') {
|
||||
return path.split('/').filter(p => p).length - 1
|
||||
if (loading.value['/']) {
|
||||
rows.push({ type: 'loading', key: 'loading:/', path: '/', level: 0 })
|
||||
return rows
|
||||
}
|
||||
|
||||
// 计算路径中斜杠的数量差异
|
||||
const pathParts = path.split('/').filter(p => p).length
|
||||
const ancestorParts = ancestorPath.split('/').filter(p => p).length
|
||||
rootDirectories.value.forEach(dir => addVisibleDirectoryRows(dir, 0, rows))
|
||||
|
||||
return pathParts - ancestorParts
|
||||
return rows
|
||||
})
|
||||
|
||||
function addVisibleDirectoryRows(dir: FileItem, level: number, rows: TreeRow[]) {
|
||||
const path = dir.path || ''
|
||||
|
||||
rows.push({
|
||||
type: 'directory',
|
||||
key: path || `${level}:${dir.name}`,
|
||||
dir,
|
||||
level,
|
||||
})
|
||||
|
||||
if (!path || !isFolderExpanded(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (loading.value[path]) {
|
||||
rows.push({
|
||||
type: 'loading',
|
||||
key: `loading:${path}`,
|
||||
path,
|
||||
level: level + 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
treeCache.value[path]?.forEach(child => addVisibleDirectoryRows(child, level + 1, rows))
|
||||
}
|
||||
|
||||
function getTreeRowStyle(level: number) {
|
||||
return {
|
||||
paddingInlineStart: level > 0 ? `${16 + level * 12}px` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时初始加载
|
||||
@@ -267,117 +254,75 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
||||
<div class="tree-container">
|
||||
<!-- 根目录项 -->
|
||||
<div
|
||||
class="tree-item root-item"
|
||||
:class="{ 'active': currentPath === '/' }"
|
||||
@click="
|
||||
handleFolderClick({
|
||||
storage: storage,
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
})
|
||||
"
|
||||
>
|
||||
<div class="folder-content">
|
||||
<VIcon icon="mdi-home" class="me-2" color="primary" />
|
||||
<span>{{ t('file.rootDirectory') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载根目录 -->
|
||||
<div v-if="loading['/']" class="tree-loading">
|
||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
||||
<span>{{ t('file.loadingDirectoryStructure') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 目录树结构 -->
|
||||
<template v-else>
|
||||
<!-- 一级目录(根目录下的目录) -->
|
||||
<div v-for="directory in rootDirectories" :key="directory.path" class="tree-item-container">
|
||||
<!-- 目录项 -->
|
||||
<div class="tree-item" :class="{ 'active': currentPath === directory.path }">
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(directory.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[directory.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
<div class="folder-content" @click.stop="handleFolderClick(directory)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(directory.path || ''))"
|
||||
:color="currentPath === directory.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="folder-name">
|
||||
{{ directory.name }}
|
||||
</span>
|
||||
</div>
|
||||
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
v-if="item.type === 'root'"
|
||||
:key="item.key"
|
||||
class="tree-item root-item"
|
||||
:class="{ 'active': currentPath === '/' }"
|
||||
@click="
|
||||
handleFolderClick({
|
||||
storage: storage,
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
})
|
||||
"
|
||||
>
|
||||
<div class="folder-content">
|
||||
<VIcon icon="mdi-home" class="me-2" color="primary" />
|
||||
<span>{{ t('file.rootDirectory') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子目录容器 - 如果该目录被展开,显示其所有子目录 -->
|
||||
<div v-if="isFolderExpanded(directory.path || '')">
|
||||
<!-- 加载中状态 -->
|
||||
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
|
||||
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
|
||||
<span class="text-caption">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type === 'loading'"
|
||||
:key="item.key"
|
||||
class="tree-loading"
|
||||
:style="getTreeRowStyle(item.level)"
|
||||
>
|
||||
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
|
||||
<span class="text-caption">
|
||||
{{ item.path === '/' ? t('file.loadingDirectoryStructure') : t('common.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 所有层级的子目录列表 -->
|
||||
<div v-else>
|
||||
<!-- 遍历所有扁平化的目录列表,查找对应层级的目录 -->
|
||||
<div
|
||||
v-for="item in flattenedDirectories"
|
||||
:key="item.dir.path"
|
||||
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
|
||||
class="tree-item"
|
||||
:class="{ 'active': currentPath === item.dir.path }"
|
||||
:style="{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }"
|
||||
>
|
||||
<!-- 展开/折叠按钮 -->
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[item.dir.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹图标和名称 -->
|
||||
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
|
||||
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="folder-name">
|
||||
{{ item.dir.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:key="item.key"
|
||||
class="tree-item"
|
||||
:class="{ 'active': currentPath === item.dir.path }"
|
||||
:style="getTreeRowStyle(item.level)"
|
||||
>
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[item.dir.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
|
||||
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="folder-name">
|
||||
{{ item.dir.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</VVirtualScroll>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -402,8 +347,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
overflow: hidden auto;
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.tree-item-container {
|
||||
|
||||
@@ -218,7 +218,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<!-- PC端头部和筛选栏 -->
|
||||
<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 flex-shrink-0">
|
||||
@@ -405,7 +405,7 @@ onMounted(() => {
|
||||
</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="d-flex align-center flex-wrap pa-2">
|
||||
<div class="d-flex align-center w-100">
|
||||
@@ -664,6 +664,13 @@ onMounted(() => {
|
||||
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 {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -695,7 +702,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
@@ -722,6 +729,7 @@ onMounted(() => {
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
@@ -770,8 +778,9 @@ onMounted(() => {
|
||||
|
||||
.selected-filters {
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
padding-block: 8px;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.05);
|
||||
padding-block: 7px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
@@ -788,7 +797,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
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;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
@@ -814,4 +823,21 @@ onMounted(() => {
|
||||
.filter-section {
|
||||
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>
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import api from '@/api'
|
||||
import { DashboardItem } from '@/api/types'
|
||||
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
|
||||
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
|
||||
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
|
||||
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
|
||||
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
|
||||
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
|
||||
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
|
||||
import AnalyticsNetwork from '@/views/dashboard/AnalyticsNetwork.vue'
|
||||
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
|
||||
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
|
||||
const DashboardSkeleton = {
|
||||
setup() {
|
||||
const SkeletonLoader = resolveComponent('VSkeletonLoader')
|
||||
|
||||
// 用 render 函数避免 runtime-only Vue 为异步 loadingComponent 解析模板。
|
||||
return () => h(SkeletonLoader, { type: 'card' })
|
||||
},
|
||||
}
|
||||
|
||||
const asyncDashboardOptions = {
|
||||
loadingComponent: DashboardSkeleton,
|
||||
}
|
||||
|
||||
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
|
||||
const AnalyticsStorage = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsStorage.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsMediaStatistic = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsWeeklyOverview = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsSpeed = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsScheduler = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsCpu = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsCpu.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsMemory = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsMemory.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const AnalyticsNetwork = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/AnalyticsNetwork.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const MediaServerLibrary = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const MediaServerPlaying = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
const MediaServerLatest = defineAsyncComponent({
|
||||
loader: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 仪表板配置
|
||||
@@ -53,9 +102,7 @@ const dynamicPluginComponent = defineAsyncComponent({
|
||||
}
|
||||
},
|
||||
// 加载中显示的组件
|
||||
loadingComponent: {
|
||||
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
|
||||
},
|
||||
loadingComponent: DashboardSkeleton,
|
||||
// 添加错误处理
|
||||
errorComponent: {
|
||||
template: `
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,10 @@ import { type PropType } from 'vue'
|
||||
const elementProps = defineProps({
|
||||
config: Object as PropType<RenderProps>,
|
||||
})
|
||||
// key
|
||||
const componentKey = ref(0)
|
||||
|
||||
onActivated(() => {
|
||||
componentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:key="componentKey"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="!elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
@@ -34,7 +27,6 @@ onActivated(() => {
|
||||
/>
|
||||
</Component>
|
||||
<Component
|
||||
:key="componentKey"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
|
||||
@@ -60,6 +60,15 @@ const trailingSpaceWidth = computed(() => {
|
||||
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) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
@@ -87,7 +96,7 @@ function updateVisibleRange() {
|
||||
return
|
||||
}
|
||||
|
||||
const viewportWidth = element.clientWidth
|
||||
const viewportWidth = element.clientWidth || getFallbackViewportWidth()
|
||||
if (!viewportWidth || !props.items.length) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = Math.min(props.items.length, props.overscanItems)
|
||||
@@ -185,6 +194,7 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
nextTick(syncLayoutState)
|
||||
requestAnimationFrame(syncLayoutState)
|
||||
})
|
||||
|
||||
watch(
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
import { sseManagerSingleton } from '@/utils/sseManager'
|
||||
import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
|
||||
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 messageHandler 消息处理函数
|
||||
* @param listenerId 监听器ID(用于区分不同的监听器)
|
||||
@@ -18,24 +22,30 @@ export function useBackgroundOptimization() {
|
||||
url: string,
|
||||
messageHandler: (event: MessageEvent) => void,
|
||||
listenerId: string,
|
||||
options?: {
|
||||
backgroundCloseDelay?: number
|
||||
reconnectDelay?: number
|
||||
maxReconnectAttempts?: number
|
||||
connectDelay?: number // 新增:连接延迟
|
||||
},
|
||||
options?: UseSSEOptions,
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
const isConnected = ref(false)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isClosed = false
|
||||
const statusListenerId = `${listenerId}:status`
|
||||
|
||||
manager.addStatusListener(statusListenerId, status => {
|
||||
isConnected.value = status === 'open'
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (isClosed) return
|
||||
|
||||
isClosed = true
|
||||
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeStatusListener(statusListenerId)
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
isConnected.value = false
|
||||
@@ -46,11 +56,10 @@ export function useBackgroundOptimization() {
|
||||
const connectDelay = options?.connectDelay || 100
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
if (isClosed) return
|
||||
|
||||
try {
|
||||
manager.addMessageListener(listenerId, event => {
|
||||
messageHandler(event)
|
||||
isConnected.value = true
|
||||
})
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
} catch (error) {
|
||||
console.error('SSE连接建立失败:', error)
|
||||
}
|
||||
@@ -69,7 +78,7 @@ export function useBackgroundOptimization() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用优化的定时器
|
||||
* 使用定时器
|
||||
* @param id 定时器ID
|
||||
* @param callback 回调函数
|
||||
* @param interval 间隔时间(毫秒)
|
||||
@@ -110,25 +119,40 @@ export function useBackgroundOptimization() {
|
||||
messageHandler: (event: MessageEvent) => void,
|
||||
listenerId: string,
|
||||
delay: number = 3000,
|
||||
options?: Parameters<typeof useSSE>[3],
|
||||
options?: UseSSEOptions,
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
const isConnected = ref(false)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isClosed = false
|
||||
const statusListenerId = `${listenerId}:status`
|
||||
|
||||
manager.addStatusListener(statusListenerId, status => {
|
||||
isConnected.value = status === 'open'
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (isClosed) return
|
||||
|
||||
isClosed = true
|
||||
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeStatusListener(statusListenerId)
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
if (isClosed) return
|
||||
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}, delay)
|
||||
})
|
||||
@@ -139,6 +163,7 @@ export function useBackgroundOptimization() {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: cleanup,
|
||||
isConnected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +214,12 @@ export function useBackgroundOptimization() {
|
||||
isListening = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopProgress(true)
|
||||
})
|
||||
// 进度监听有些场景会在用户操作后动态创建;只有 setup 阶段创建时才注册自动卸载钩子。
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
stopProgress(true)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
start: startProgress,
|
||||
98
src/composables/useKeepAliveRefresh.ts
Normal file
98
src/composables/useKeepAliveRefresh.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -57,18 +57,23 @@ export interface WizardData {
|
||||
model: string
|
||||
thinkingLevel: string
|
||||
supportImageInput: boolean
|
||||
supportAudioInputOutput: boolean
|
||||
supportAudioInput: boolean
|
||||
supportAudioOutput: boolean
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
voiceApiKey: string
|
||||
voiceBaseUrl: string
|
||||
voiceSttModel: string
|
||||
voiceTtsModel: string
|
||||
voiceTtsVoice: string
|
||||
voiceLanguage: string
|
||||
voiceReplyWithText: boolean
|
||||
audioInputProvider: string
|
||||
audioInputApiKey: string
|
||||
audioInputBaseUrl: string
|
||||
audioInputModel: string
|
||||
audioInputLanguage: string
|
||||
audioOutputProvider: string
|
||||
audioOutputApiKey: string
|
||||
audioOutputBaseUrl: string
|
||||
audioOutputModel: string
|
||||
audioOutputVoice: string
|
||||
audioOutputIncludeText: boolean
|
||||
jobInterval: number
|
||||
retryTransfer: boolean
|
||||
recommendEnabled: boolean
|
||||
@@ -238,18 +243,23 @@ const wizardData = ref<WizardData>({
|
||||
model: 'deepseek-chat',
|
||||
thinkingLevel: 'off',
|
||||
supportImageInput: true,
|
||||
supportAudioInputOutput: false,
|
||||
supportAudioInput: false,
|
||||
supportAudioOutput: false,
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
voiceApiKey: '',
|
||||
voiceBaseUrl: '',
|
||||
voiceSttModel: 'gpt-4o-mini-transcribe',
|
||||
voiceTtsModel: 'gpt-4o-mini-tts',
|
||||
voiceTtsVoice: 'alloy',
|
||||
voiceLanguage: 'zh',
|
||||
voiceReplyWithText: false,
|
||||
audioInputProvider: 'openai',
|
||||
audioInputApiKey: '',
|
||||
audioInputBaseUrl: '',
|
||||
audioInputModel: 'gpt-4o-mini-transcribe',
|
||||
audioInputLanguage: 'zh',
|
||||
audioOutputProvider: 'openai',
|
||||
audioOutputApiKey: '',
|
||||
audioOutputBaseUrl: '',
|
||||
audioOutputModel: 'gpt-4o-mini-tts',
|
||||
audioOutputVoice: 'alloy',
|
||||
audioOutputIncludeText: false,
|
||||
jobInterval: 0,
|
||||
retryTransfer: false,
|
||||
recommendEnabled: false,
|
||||
@@ -1430,18 +1440,23 @@ export function useSetupWizard() {
|
||||
LLM_MODEL: wizardData.value.agent.model,
|
||||
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
|
||||
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
|
||||
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
|
||||
LLM_SUPPORT_AUDIO_INPUT: wizardData.value.agent.supportAudioInput,
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
|
||||
LLM_API_KEY: wizardData.value.agent.apiKey,
|
||||
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
|
||||
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
||||
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
|
||||
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
|
||||
AI_VOICE_STT_MODEL: wizardData.value.agent.voiceSttModel,
|
||||
AI_VOICE_TTS_MODEL: wizardData.value.agent.voiceTtsModel,
|
||||
AI_VOICE_TTS_VOICE: wizardData.value.agent.voiceTtsVoice,
|
||||
AI_VOICE_LANGUAGE: wizardData.value.agent.voiceLanguage,
|
||||
AI_VOICE_REPLY_WITH_TEXT: wizardData.value.agent.voiceReplyWithText,
|
||||
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
|
||||
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
|
||||
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
|
||||
AUDIO_INPUT_MODEL: wizardData.value.agent.audioInputModel,
|
||||
AUDIO_INPUT_LANGUAGE: wizardData.value.agent.audioInputLanguage,
|
||||
AUDIO_OUTPUT_PROVIDER: wizardData.value.agent.audioOutputProvider || 'openai',
|
||||
AUDIO_OUTPUT_API_KEY: wizardData.value.agent.audioOutputApiKey || null,
|
||||
AUDIO_OUTPUT_BASE_URL: wizardData.value.agent.audioOutputBaseUrl || null,
|
||||
AUDIO_OUTPUT_MODEL: wizardData.value.agent.audioOutputModel,
|
||||
AUDIO_OUTPUT_VOICE: wizardData.value.agent.audioOutputVoice,
|
||||
AUDIO_OUTPUT_INCLUDE_TEXT: wizardData.value.agent.audioOutputIncludeText,
|
||||
AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,
|
||||
AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,
|
||||
AI_RECOMMEND_ENABLED:
|
||||
@@ -1538,18 +1553,23 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.model = result.data.LLM_MODEL || ''
|
||||
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
|
||||
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
|
||||
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
|
||||
wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT)
|
||||
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
||||
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
|
||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
|
||||
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''
|
||||
wizardData.value.agent.voiceSttModel = result.data.AI_VOICE_STT_MODEL || 'gpt-4o-mini-transcribe'
|
||||
wizardData.value.agent.voiceTtsModel = result.data.AI_VOICE_TTS_MODEL || 'gpt-4o-mini-tts'
|
||||
wizardData.value.agent.voiceTtsVoice = result.data.AI_VOICE_TTS_VOICE || 'alloy'
|
||||
wizardData.value.agent.voiceLanguage = result.data.AI_VOICE_LANGUAGE || 'zh'
|
||||
wizardData.value.agent.voiceReplyWithText = Boolean(result.data.AI_VOICE_REPLY_WITH_TEXT)
|
||||
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
||||
wizardData.value.agent.audioInputModel = result.data.AUDIO_INPUT_MODEL || 'gpt-4o-mini-transcribe'
|
||||
wizardData.value.agent.audioInputLanguage = result.data.AUDIO_INPUT_LANGUAGE || 'zh'
|
||||
wizardData.value.agent.audioOutputProvider = result.data.AUDIO_OUTPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioOutputApiKey = result.data.AUDIO_OUTPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioOutputBaseUrl = result.data.AUDIO_OUTPUT_BASE_URL || ''
|
||||
wizardData.value.agent.audioOutputModel = result.data.AUDIO_OUTPUT_MODEL || 'gpt-4o-mini-tts'
|
||||
wizardData.value.agent.audioOutputVoice = result.data.AUDIO_OUTPUT_VOICE || 'alloy'
|
||||
wizardData.value.agent.audioOutputIncludeText = Boolean(result.data.AUDIO_OUTPUT_INCLUDE_TEXT)
|
||||
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
|
||||
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
|
||||
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)
|
||||
|
||||
33
src/composables/useSilentSettingRefresh.ts
Normal file
33
src/composables/useSilentSettingRefresh.ts
Normal 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)),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -51,9 +51,25 @@ export const clearCachesAndServiceWorker = async (): Promise<void> => {
|
||||
/**
|
||||
* 清除缓存并刷新
|
||||
*/
|
||||
const clearCacheAndReload = async (): Promise<void> => {
|
||||
await clearCachesAndServiceWorker()
|
||||
reloadWithTimestamp()
|
||||
export const clearCacheAndReload = async (): Promise<void> => {
|
||||
let isReloading = false
|
||||
const reload = () => {
|
||||
if (isReloading) return
|
||||
isReloading = true
|
||||
reloadWithTimestamp()
|
||||
}
|
||||
|
||||
const reloadTimer = window.setTimeout(reload, 3000)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
clearCachesAndServiceWorker(),
|
||||
new Promise(resolve => window.setTimeout(resolve, 2500)),
|
||||
])
|
||||
} finally {
|
||||
window.clearTimeout(reloadTimer)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,11 +64,20 @@ const lastY = ref(0)
|
||||
const lastTime = ref(0)
|
||||
const velocity = ref(0)
|
||||
const startedFromBottomArea = ref(false)
|
||||
const quickAccessRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
|
||||
|
||||
// 插件弹窗相关状态
|
||||
const showPluginDataDialog = ref(false)
|
||||
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(() => {
|
||||
return props.visible
|
||||
@@ -211,20 +220,27 @@ function manageScrollLock() {
|
||||
if (isVisible.value) {
|
||||
// 使用 nextTick 确保 DOM 已经更新
|
||||
nextTick(() => {
|
||||
// 先恢复之前的锁定状态,避免重复锁定
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
// 确保元素存在且可见
|
||||
if ((scrollableElement as HTMLElement).offsetHeight > 0) {
|
||||
disableBodyScroll(scrollableElement as HTMLElement)
|
||||
}
|
||||
const panelElement = getQuickAccessElement()
|
||||
if (!panelElement) return
|
||||
|
||||
// 锁定整层快捷入口,只有插件列表内部允许惯性滚动,避免底部手势漏给首页背景。
|
||||
disableBodyScroll(panelElement, {
|
||||
allowTouchMove: el => Boolean((el as HTMLElement).closest('.quick-access-scroll')),
|
||||
})
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.add('quick-access-scroll-locked')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 恢复背景滚动
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
enableBodyScroll(scrollableElement as HTMLElement)
|
||||
const panelElement = getQuickAccessElement()
|
||||
if (panelElement) {
|
||||
enableBodyScroll(panelElement)
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('quick-access-scroll-locked')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,9 +270,13 @@ onMounted(() => {
|
||||
|
||||
// 组件卸载时确保恢复背景滚动
|
||||
onUnmounted(() => {
|
||||
const scrollableElement = document.querySelector('.all-plugins-grid')
|
||||
if (scrollableElement) {
|
||||
enableBodyScroll(scrollableElement as HTMLElement)
|
||||
const panelElement = getQuickAccessElement()
|
||||
if (panelElement) {
|
||||
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 开始的触摸才处理上滑关闭
|
||||
if (!startedFromBottomArea.value) return
|
||||
|
||||
// 底部关闭手势从第一帧开始接管,防止 iOS 将早期位移传递给背景页面滚动。
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.plugin-grid')) {
|
||||
@@ -319,7 +343,6 @@ function handleTouchMove(event: TouchEvent) {
|
||||
if (deltaY >= 0) {
|
||||
// 向上拖拽,更新偏移量
|
||||
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
|
||||
event.preventDefault()
|
||||
} else {
|
||||
// 向下拖拽,停止拖拽
|
||||
isDraggingToClose.value = false
|
||||
@@ -330,7 +353,6 @@ function handleTouchMove(event: TouchEvent) {
|
||||
if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {
|
||||
isDraggingToClose.value = true
|
||||
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +388,27 @@ function handleTouchEnd() {
|
||||
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) {
|
||||
const target = event.target as HTMLElement
|
||||
@@ -383,6 +426,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
ref="quickAccessRef"
|
||||
:ripple="false"
|
||||
class="plugin-quick-access"
|
||||
:class="{ 'visible': isVisible }"
|
||||
@@ -408,7 +452,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
</div>
|
||||
|
||||
<!-- 插件网格 -->
|
||||
<div class="plugin-grid">
|
||||
<div class="plugin-grid quick-access-scroll">
|
||||
<!-- 加载状态 -->
|
||||
<LoadingBanner v-if="loading" />
|
||||
|
||||
@@ -457,7 +501,7 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
</div>
|
||||
|
||||
<div v-if="pluginsWithPage.length > 0" class="all-plugins-container">
|
||||
<div class="all-plugins-grid">
|
||||
<div class="all-plugins-grid quick-access-scroll">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
@@ -500,7 +544,14 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
</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
|
||||
@@ -767,6 +818,15 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
cursor: pointer;
|
||||
padding-block: 8px 0;
|
||||
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) {
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import NameTestView from '@/views/system/NameTestView.vue'
|
||||
import NetTestView from '@/views/system/NetTestView.vue'
|
||||
import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import RuleTestView from '@/views/system/RuleTestView.vue'
|
||||
import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import WordsView from '@/views/system/WordsView.vue'
|
||||
import CacheView from '@/views/system/CacheView.vue'
|
||||
import AccountSettingService from '@/views/system/ServiceView.vue'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
@@ -18,6 +9,7 @@ type MessageViewExpose = {
|
||||
pauseSSE?: () => void
|
||||
resumeSSE?: () => void
|
||||
refreshLatestMessages?: () => Promise<void> | void
|
||||
forceScrollToEnd?: () => void
|
||||
}
|
||||
|
||||
// 国际化
|
||||
@@ -26,6 +18,17 @@ const { t } = useI18n()
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
|
||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
|
||||
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
|
||||
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
|
||||
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
|
||||
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
|
||||
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
|
||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||
|
||||
// App捷径
|
||||
const appsMenu = ref(false)
|
||||
|
||||
@@ -65,15 +68,9 @@ const user_message = ref('')
|
||||
// 发送按钮是否可用
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 消息对话框引用
|
||||
const messageDialogRef = ref<any>(null)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
||||
|
||||
// 滚动容器引用
|
||||
const messageContentRef = ref<any>()
|
||||
|
||||
// 定义捷径列表
|
||||
const shortcuts = [
|
||||
{
|
||||
@@ -146,58 +143,9 @@ function openDialog(dialogRef: any) {
|
||||
dialogRef.value = true
|
||||
}
|
||||
|
||||
// 打开消息弹窗并清除徽章
|
||||
async function openMessageDialog() {
|
||||
// 打开消息弹窗
|
||||
function openMessageDialog() {
|
||||
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
|
||||
@@ -219,7 +167,7 @@ async function sendMessage() {
|
||||
|
||||
// 发送成功后主动同步最新一页消息,避免SSE短暂断流时界面停留在旧状态。
|
||||
// await messageViewRef.value?.refreshLatestMessages?.()
|
||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -238,8 +186,20 @@ defineExpose({
|
||||
})
|
||||
|
||||
// 监听消息对话框状态变化
|
||||
watch(messageDialog, newValue => {
|
||||
if (!newValue && messageViewRef.value?.pauseSSE) {
|
||||
watch(messageDialog, async newValue => {
|
||||
if (newValue) {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (messageViewRef.value?.pauseSSE) {
|
||||
// 对话框关闭时暂停SSE连接
|
||||
messageViewRef.value.pauseSSE()
|
||||
}
|
||||
@@ -473,7 +433,7 @@ onMounted(() => {
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCard class="system-health-dialog-card">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
@@ -482,7 +442,7 @@ onMounted(() => {
|
||||
<VDialogCloseBtn @click="systemTestDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="system-health-dialog-body pa-0">
|
||||
<ModuleTestView />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -494,7 +454,6 @@ onMounted(() => {
|
||||
max-width="50rem"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
ref="messageDialogRef"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
@@ -505,8 +464,8 @@ onMounted(() => {
|
||||
<VDialogCloseBtn @click="messageDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText ref="messageContentRef">
|
||||
<MessageView ref="messageViewRef" @scroll="scrollMessageToEnd" />
|
||||
<VCardText>
|
||||
<MessageView ref="messageViewRef" />
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardActions class="pa-4">
|
||||
@@ -533,3 +492,24 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-health-dialog-body {
|
||||
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
|
||||
min-block-size: 0;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { SystemNotification } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { useDelayedSSE } = useBackgroundOptimization()
|
||||
const { useDelayedSSE } = useBackground()
|
||||
|
||||
// 是否有新消息
|
||||
const hasNewMessage = ref(false)
|
||||
@@ -39,7 +39,7 @@ function handleMessage(event: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的SSE连接,延迟3秒启动,避免认证问题
|
||||
// 延迟3秒启动SSE连接,避免认证信息尚未准备好。
|
||||
useDelayedSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message`,
|
||||
handleMessage,
|
||||
|
||||
@@ -58,6 +58,8 @@ const customCSS = ref('')
|
||||
// 透明度相关
|
||||
const transparencyOpacity = ref(parseFloat(localStorage.getItem('transparency-opacity') || '0.3'))
|
||||
const transparencyBlur = ref(parseFloat(localStorage.getItem('transparency-blur') || '10'))
|
||||
const backgroundPosterOpacity = ref(parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'))
|
||||
const backgroundBlur = ref(parseFloat(localStorage.getItem('transparency-background-blur') || '16'))
|
||||
const transparencyLevel = ref(localStorage.getItem('transparency-level') || 'medium')
|
||||
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
|
||||
const showTransparencyDialog = ref(false)
|
||||
@@ -383,6 +385,15 @@ async function saveCustomCSS() {
|
||||
function applyTransparencySettings() {
|
||||
const root = document.documentElement
|
||||
|
||||
if (!Number.isFinite(backgroundPosterOpacity.value)) {
|
||||
backgroundPosterOpacity.value = 1
|
||||
}
|
||||
backgroundPosterOpacity.value = Math.min(1, Math.max(0, backgroundPosterOpacity.value))
|
||||
if (!Number.isFinite(backgroundBlur.value)) {
|
||||
backgroundBlur.value = 16
|
||||
}
|
||||
backgroundBlur.value = Math.min(30, Math.max(0, backgroundBlur.value))
|
||||
|
||||
// 设置CSS变量
|
||||
root.style.setProperty('--transparent-opacity', transparencyOpacity.value.toString())
|
||||
root.style.setProperty('--transparent-opacity-light', (transparencyOpacity.value * 0.67).toString())
|
||||
@@ -390,10 +401,14 @@ function applyTransparencySettings() {
|
||||
root.style.setProperty('--transparent-blur', `${transparencyBlur.value}px`)
|
||||
root.style.setProperty('--transparent-blur-light', `${transparencyBlur.value * 0.6}px`)
|
||||
root.style.setProperty('--transparent-blur-heavy', `${transparencyBlur.value * 1.6}px`)
|
||||
root.style.setProperty('--transparent-background-poster-opacity', (1 - backgroundPosterOpacity.value).toString())
|
||||
root.style.setProperty('--transparent-background-blur', `${backgroundBlur.value}px`)
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('transparency-opacity', transparencyOpacity.value.toString())
|
||||
localStorage.setItem('transparency-blur', transparencyBlur.value.toString())
|
||||
localStorage.setItem('transparency-background-poster-opacity', backgroundPosterOpacity.value.toString())
|
||||
localStorage.setItem('transparency-background-blur', backgroundBlur.value.toString())
|
||||
}
|
||||
|
||||
// 调整透明度预设
|
||||
@@ -434,10 +449,22 @@ function onBlurChange() {
|
||||
transparencyLevel.value = ''
|
||||
}
|
||||
|
||||
// 背景海报透明度变化处理
|
||||
function onBackgroundPosterOpacityChange() {
|
||||
applyTransparencySettings()
|
||||
}
|
||||
|
||||
// 背景磨砂变化处理
|
||||
function onBackgroundBlurChange() {
|
||||
applyTransparencySettings()
|
||||
}
|
||||
|
||||
// 重置透明度设置
|
||||
function resetTransparencySettings() {
|
||||
transparencyOpacity.value = 0.3
|
||||
transparencyBlur.value = 10
|
||||
backgroundPosterOpacity.value = 0
|
||||
backgroundBlur.value = 16
|
||||
transparencyLevel.value = 'medium'
|
||||
applyTransparencySettings()
|
||||
}
|
||||
@@ -821,6 +848,38 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 背景海报透明度滑动条 -->
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2">{{ t('theme.backgroundPosterOpacity') }}</span>
|
||||
<span class="text-caption">{{ Math.round(backgroundPosterOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<VSlider
|
||||
v-model="backgroundPosterOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
color="primary"
|
||||
@update:model-value="onBackgroundPosterOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 背景磨砂滑动条 -->
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2">{{ t('theme.backgroundBlur') }}</span>
|
||||
<span class="text-caption">{{ backgroundBlur }}px</span>
|
||||
</div>
|
||||
<VSlider
|
||||
v-model="backgroundBlur"
|
||||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
color="primary"
|
||||
@update:model-value="onBackgroundBlurChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 预设按钮 -->
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import DefaultLayout from './components/DefaultLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="12">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
|
||||
<keep-alive :max="24">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
</router-view>
|
||||
|
||||
@@ -149,6 +149,8 @@ export default {
|
||||
transparencyAdjust: 'Transparency Adjustment',
|
||||
transparencyOpacity: 'Opacity',
|
||||
transparencyBlur: 'Blur',
|
||||
backgroundPosterOpacity: 'Background Opacity',
|
||||
backgroundBlur: 'Background Frosted Blur',
|
||||
transparencyReset: 'Reset',
|
||||
transparencyLow: 'Low Transparency',
|
||||
transparencyMedium: 'Medium Transparency',
|
||||
@@ -982,6 +984,8 @@ export default {
|
||||
ranking: 'Ranking',
|
||||
noStatisticsData: 'No share statistics data available',
|
||||
bestVersion: 'Version Upgrading',
|
||||
bestVersionEpisodeShort: 'Episode',
|
||||
bestVersionWholeShort: 'Full',
|
||||
completed: 'Completed',
|
||||
subscribing: 'Subscribing',
|
||||
notStarted: 'Not Started',
|
||||
@@ -1027,7 +1031,7 @@ export default {
|
||||
doubanGlobalTVRankings: 'Douban Global TV Rankings',
|
||||
noCategoryContent: 'No content to display in current category',
|
||||
configureContent: 'Configure Display Content',
|
||||
customizeContent: 'Customize Content',
|
||||
customizeContent: 'Customize Recommendations',
|
||||
selectContentToDisplay: 'Select content you want to display on the page',
|
||||
selectAll: 'Select All',
|
||||
selectNone: 'Select None',
|
||||
@@ -1416,9 +1420,11 @@ export default {
|
||||
llmSupportImageInput: 'Model Supports Image Input',
|
||||
llmSupportImageInputHint:
|
||||
'When enabled, message images are sent to the LLM as multimodal image input. When disabled, images are saved locally as attachments and only the file path is passed to the AI assistant.',
|
||||
llmSupportAudioInputOutput: 'Support Audio Input and Output',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'When enabled, the AI assistant can transcribe incoming audio messages and reply with voice on supported channels.',
|
||||
llmSupportAudioInput: 'Support Audio Input',
|
||||
llmSupportAudioInputHint:
|
||||
'When enabled, incoming audio messages are transcribed before being handled by the AI assistant.',
|
||||
llmSupportAudioOutput: 'Support Audio Output',
|
||||
llmSupportAudioOutputHint: 'When enabled, the AI assistant can send voice replies on supported channels.',
|
||||
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
|
||||
@@ -1439,23 +1445,36 @@ export default {
|
||||
llmProviderDeviceCode: 'Device Code',
|
||||
llmProviderOpenAuthPage: 'Open Authorization Page',
|
||||
llmProviderCheckAuthStatus: 'Check Authorization Status',
|
||||
aiVoiceApiKey: 'Audio API Key',
|
||||
aiVoiceApiKeyHint:
|
||||
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
|
||||
aiVoiceBaseUrl: 'Audio Base URL',
|
||||
aiVoiceBaseUrlHint:
|
||||
'Base URL used for audio transcription and speech synthesis. Falls back to the current LLM base URL when left blank.',
|
||||
aiVoiceSttModel: 'Audio Transcription Model',
|
||||
aiVoiceSttModelHint: 'Model name used to convert audio content into text.',
|
||||
aiVoiceTtsModel: 'Speech Synthesis Model',
|
||||
aiVoiceTtsModelHint: 'Model name used to convert text content into speech.',
|
||||
aiVoiceTtsVoice: 'Voice Preset',
|
||||
aiVoiceTtsVoiceHint: 'Speaker or voice preset used for speech synthesis.',
|
||||
aiVoiceLanguage: 'Recognition Language',
|
||||
aiVoiceLanguageHint:
|
||||
audioInputProvider: 'Audio Input Provider',
|
||||
audioInputProviderHint:
|
||||
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
|
||||
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
|
||||
audioProviderChatAudio: 'Chat Audio Compatible',
|
||||
audioProviderMimo: 'Xiaomi MiMo',
|
||||
audioInputApiKey: 'Audio Input API Key',
|
||||
audioInputApiKeyHint: 'API key used for audio transcription.',
|
||||
audioInputBaseUrl: 'Audio Input Base URL',
|
||||
audioInputBaseUrlHint:
|
||||
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
|
||||
audioInputModel: 'Audio Input Model',
|
||||
audioInputModelHint: 'Model name used to convert audio content into text.',
|
||||
audioInputLanguage: 'Recognition Language',
|
||||
audioInputLanguageHint:
|
||||
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
|
||||
aiVoiceReplyWithText: 'Include Text with Voice Replies',
|
||||
aiVoiceReplyWithTextHint: 'When sending a voice reply, also include the text version of the response.',
|
||||
audioOutputProvider: 'Audio Output Provider',
|
||||
audioOutputProviderHint:
|
||||
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
|
||||
audioOutputApiKey: 'Audio Output API Key',
|
||||
audioOutputApiKeyHint: 'API key used for speech synthesis.',
|
||||
audioOutputBaseUrl: 'Audio Output Base URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
|
||||
audioOutputModel: 'Audio Output Model',
|
||||
audioOutputModelHint: 'Model name used to convert text content into speech.',
|
||||
audioOutputVoice: 'Voice Preset',
|
||||
audioOutputVoiceHint: 'Speaker or voice preset used for speech synthesis.',
|
||||
audioOutputIncludeText: 'Include Text with Voice Replies',
|
||||
audioOutputIncludeTextHint: 'When sending a voice reply, also include the text version of the response.',
|
||||
llmTestAction: 'Test Call',
|
||||
llmTestSuccessToast: 'LLM test call succeeded',
|
||||
llmTestFailedToast: 'LLM test call failed',
|
||||
@@ -1729,7 +1748,7 @@ export default {
|
||||
userAgent: 'Browser User-Agent',
|
||||
userAgentHint: 'User-Agent of the browser with CookieCloud plugin',
|
||||
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',
|
||||
flaresolverrUrlHint: 'Required when using FlareSolverr, e.g. http://127.0.0.1:8191',
|
||||
siteDataRefresh: 'Site Data Refresh',
|
||||
@@ -2525,8 +2544,6 @@ export default {
|
||||
previewTotal: 'Total {count}',
|
||||
previewSuccess: 'Success {count}',
|
||||
previewFailed: 'Failed {count}',
|
||||
previewSourcePath: 'Source Path',
|
||||
previewTargetPath: 'Target Path',
|
||||
previewMediaInfo: 'Media',
|
||||
previewMediaName: 'Name',
|
||||
previewMediaType: 'Type',
|
||||
@@ -2576,6 +2593,8 @@ export default {
|
||||
savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',
|
||||
bestVersion: 'Version Upgrade',
|
||||
bestVersionHint: 'Perform version upgrade subscription based on upgrade priorities',
|
||||
bestVersionFull: 'Full Season Upgrade',
|
||||
bestVersionFullHint: 'Only download full-season packs and do not split packs by episode',
|
||||
searchImdbid: 'Search Using ImdbID',
|
||||
searchImdbidHint: 'Use ImdbID for precise resource searching',
|
||||
showEditDialog: 'Edit More Rules When Subscribing',
|
||||
@@ -2725,6 +2744,7 @@ export default {
|
||||
close: 'Close',
|
||||
loadingDirectoryStructure: 'Loading directory structure...',
|
||||
reorganize: 'Reorganize',
|
||||
filterPlaceholder: 'Filter (supports * ? wildcards)',
|
||||
},
|
||||
person: {
|
||||
alias: 'Also Known As:',
|
||||
@@ -2938,7 +2958,7 @@ export default {
|
||||
},
|
||||
transferHistory: {
|
||||
title: 'Transfer History',
|
||||
searchPlaceholder: 'Search transfer records',
|
||||
searchPlaceholder: 'Search (supports * ? wildcards)',
|
||||
titleColumn: 'Title',
|
||||
pathColumn: 'Path',
|
||||
modeColumn: 'Mode',
|
||||
@@ -3046,7 +3066,8 @@ export default {
|
||||
apiKey: 'API Key',
|
||||
username: 'Username',
|
||||
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',
|
||||
sequentail: 'Sequential Download',
|
||||
force_resume: 'Force Resume',
|
||||
|
||||
@@ -149,6 +149,8 @@ export default {
|
||||
transparencyAdjust: '透明度调整',
|
||||
transparencyOpacity: '透明度',
|
||||
transparencyBlur: '模糊度',
|
||||
backgroundPosterOpacity: '背景透明度',
|
||||
backgroundBlur: '背景磨砂效果',
|
||||
transparencyReset: '重置',
|
||||
transparencyLow: '低透明度',
|
||||
transparencyMedium: '中等透明度',
|
||||
@@ -319,7 +321,8 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: '系统',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
description:
|
||||
'基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
},
|
||||
directory: {
|
||||
title: '存储 & 目录',
|
||||
@@ -976,6 +979,8 @@ export default {
|
||||
ranking: '排名',
|
||||
noStatisticsData: '暂无分享统计数据',
|
||||
bestVersion: '洗版中',
|
||||
bestVersionEpisodeShort: '分集',
|
||||
bestVersionWholeShort: '全集',
|
||||
completed: '订阅完成',
|
||||
subscribing: '订阅中',
|
||||
notStarted: '未开始',
|
||||
@@ -1021,7 +1026,7 @@ export default {
|
||||
doubanGlobalTVRankings: '豆瓣全球剧集榜',
|
||||
noCategoryContent: '当前分类下没有可显示的内容',
|
||||
configureContent: '设置显示内容',
|
||||
customizeContent: '自定义内容',
|
||||
customizeContent: '自定义推荐',
|
||||
selectContentToDisplay: '选择您想在页面显示的内容',
|
||||
selectAll: '全选',
|
||||
selectNone: '全不选',
|
||||
@@ -1408,9 +1413,10 @@ export default {
|
||||
llmSupportImageInput: '模型支持图片输入',
|
||||
llmSupportImageInputHint:
|
||||
'启用后,消息中的图片会按多模态图片发送给 LLM;关闭后图片会作为附件保存到本地,并将文件路径提供给智能助手处理',
|
||||
llmSupportAudioInputOutput: '支持音频输入输出',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'启用后,智能助手可以转写用户发送的音频消息,并在支持的渠道上回复语音',
|
||||
llmSupportAudioInput: '支持音频输入',
|
||||
llmSupportAudioInputHint: '启用后,智能助手会将用户发送的音频消息转写为文字再处理',
|
||||
llmSupportAudioOutput: '支持音频输出',
|
||||
llmSupportAudioOutputHint: '启用后,智能助手可以在支持的渠道上发送语音回复',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
||||
@@ -1429,20 +1435,33 @@ export default {
|
||||
llmProviderDeviceCode: '设备码',
|
||||
llmProviderOpenAuthPage: '打开授权页面',
|
||||
llmProviderCheckAuthStatus: '检查授权状态',
|
||||
aiVoiceApiKey: '音频 API密钥',
|
||||
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
|
||||
aiVoiceBaseUrl: '音频基础URL',
|
||||
aiVoiceBaseUrlHint: '音频转写与语音合成接口的基础URL,留空时回退到当前 LLM 基础 URL',
|
||||
aiVoiceSttModel: '音频转写模型',
|
||||
aiVoiceSttModelHint: '用于将音频内容转换为文字的模型名称',
|
||||
aiVoiceTtsModel: '语音合成模型',
|
||||
aiVoiceTtsModelHint: '用于将文字内容转换为语音的模型名称',
|
||||
aiVoiceTtsVoice: '语音音色',
|
||||
aiVoiceTtsVoiceHint: '语音合成使用的发音人或音色标识',
|
||||
aiVoiceLanguage: '识别语言',
|
||||
aiVoiceLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||
aiVoiceReplyWithText: '语音回复附带文字',
|
||||
aiVoiceReplyWithTextHint: '发送语音回复时,同时附带一份文字内容',
|
||||
audioInputProvider: '音频输入提供商',
|
||||
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
||||
audioProviderChatAudio: 'Chat Audio 兼容',
|
||||
audioProviderMimo: '小米 MiMo',
|
||||
audioInputApiKey: '音频输入 API密钥',
|
||||
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
|
||||
audioInputBaseUrl: '音频输入基础URL',
|
||||
audioInputBaseUrlHint:
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
audioInputModel: '音频输入模型',
|
||||
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
|
||||
audioInputLanguage: '识别语言',
|
||||
audioInputLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||
audioOutputProvider: '音频输出提供商',
|
||||
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioOutputApiKey: '音频输出 API密钥',
|
||||
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
|
||||
audioOutputBaseUrl: '音频输出基础URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
audioOutputModel: '音频输出模型',
|
||||
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
|
||||
audioOutputVoice: '语音音色',
|
||||
audioOutputVoiceHint: '语音合成使用的发音人或音色标识',
|
||||
audioOutputIncludeText: '语音回复附带文字',
|
||||
audioOutputIncludeTextHint: '发送语音回复时,同时附带一份文字内容',
|
||||
llmTestAction: '测试调用',
|
||||
llmTestSuccessToast: 'LLM 调用测试成功',
|
||||
llmTestFailedToast: 'LLM 调用测试失败',
|
||||
@@ -1544,8 +1563,8 @@ export default {
|
||||
fanartEnableHint: '使用 fanart.tv 的图片数据',
|
||||
fanartLang: 'Fanart语言',
|
||||
fanartLangHint: '设置Fanart图片的语言偏好,多选时按优先级顺序排列',
|
||||
recognizePluginFirst: "优先使用插件识别",
|
||||
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
|
||||
recognizePluginFirst: '优先使用插件识别',
|
||||
recognizePluginFirstHint: '优先调用插件识别媒体信息,若插件命中则不再调用原生识别',
|
||||
mediaRecognizeShare: '共享使用媒体识别数据',
|
||||
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID,识别失败时优先回查共享识别结果',
|
||||
githubProxy: 'Github加速代理',
|
||||
@@ -1678,7 +1697,7 @@ export default {
|
||||
skipDesc: '跳过刮削,不生成该文件',
|
||||
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
|
||||
overwriteDesc: '始终刮削,已存在则覆盖',
|
||||
}
|
||||
},
|
||||
},
|
||||
site: {
|
||||
siteSync: '站点同步',
|
||||
@@ -1702,7 +1721,7 @@ export default {
|
||||
siteDataRefresh: '站点数据刷新',
|
||||
siteOptions: '站点选项',
|
||||
browserEmulation: '浏览器仿真',
|
||||
browserEmulationHint: '站点访问仿真方式,支持 Playwright 或 FlareSolverr',
|
||||
browserEmulationHint: '站点访问仿真方式,支持 CloakBrowser 或 FlareSolverr',
|
||||
flaresolverrUrl: 'FlareSolverr 服务地址',
|
||||
flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效,例如:http://127.0.0.1:8191',
|
||||
siteDataRefreshInterval: '站点数据刷新间隔',
|
||||
@@ -2480,8 +2499,6 @@ export default {
|
||||
previewTotal: '总数 {count}',
|
||||
previewSuccess: '成功 {count}',
|
||||
previewFailed: '失败 {count}',
|
||||
previewSourcePath: '原始路径',
|
||||
previewTargetPath: '目的路径',
|
||||
previewMediaInfo: '媒体信息',
|
||||
previewMediaName: '名称',
|
||||
previewMediaType: '类型',
|
||||
@@ -2531,8 +2548,10 @@ export default {
|
||||
savePathHint: '指定该订阅的下载保存路径,留空自动使用设定的下载目录',
|
||||
bestVersion: '洗版',
|
||||
bestVersionHint: '根据洗版优先级进行洗版订阅',
|
||||
bestVersionFull: '全集洗版',
|
||||
bestVersionFullHint: '只下载覆盖全集的整包资源,不按单集拆包下载',
|
||||
searchImdbid: '使用 ImdbID 搜索',
|
||||
searchImdbidHint: '开使用 ImdbID 精确搜索资源',
|
||||
searchImdbidHint: '开启后使用 ImdbID 精确搜索资源',
|
||||
showEditDialog: '订阅时编辑更多规则',
|
||||
showEditDialogHint: '添加订阅时显示此编辑订阅对话框',
|
||||
include: '包含(关键字、正则式)',
|
||||
@@ -2680,6 +2699,7 @@ export default {
|
||||
close: '关闭',
|
||||
loadingDirectoryStructure: '加载目录结构...',
|
||||
reorganize: '整理',
|
||||
filterPlaceholder: '搜索(支持 * ? 通配符)',
|
||||
},
|
||||
person: {
|
||||
alias: '别名:',
|
||||
@@ -2887,7 +2907,7 @@ export default {
|
||||
},
|
||||
transferHistory: {
|
||||
title: '转移历史',
|
||||
searchPlaceholder: '搜索转移记录',
|
||||
searchPlaceholder: '搜索(支持 * ? 通配符)',
|
||||
titleColumn: '标题',
|
||||
pathColumn: '路径',
|
||||
modeColumn: '转移方式',
|
||||
|
||||
@@ -149,6 +149,8 @@ export default {
|
||||
transparencyAdjust: '透明度調整',
|
||||
transparencyOpacity: '透明度',
|
||||
transparencyBlur: '模糊度',
|
||||
backgroundPosterOpacity: '背景透明度',
|
||||
backgroundBlur: '背景磨砂效果',
|
||||
transparencyReset: '重置',
|
||||
transparencyLow: '低透明度',
|
||||
transparencyMedium: '中等透明度',
|
||||
@@ -977,6 +979,8 @@ export default {
|
||||
ranking: '排名',
|
||||
noStatisticsData: '暫無分享統計數據',
|
||||
bestVersion: '洗版中',
|
||||
bestVersionEpisodeShort: '分集',
|
||||
bestVersionWholeShort: '全集',
|
||||
completed: '訂閱完成',
|
||||
subscribing: '訂閱中',
|
||||
notStarted: '未開始',
|
||||
@@ -1022,7 +1026,7 @@ export default {
|
||||
doubanGlobalTVRankings: '豆瓣全球劇集榜',
|
||||
noCategoryContent: '當前分類下沒有可顯示的內容',
|
||||
configureContent: '設置顯示內容',
|
||||
customizeContent: '自定義內容',
|
||||
customizeContent: '自定義推薦',
|
||||
selectContentToDisplay: '選擇您想在頁面顯示的內容',
|
||||
selectAll: '全選',
|
||||
selectNone: '全不選',
|
||||
@@ -1410,9 +1414,10 @@ export default {
|
||||
llmSupportImageInput: '模型支援圖片輸入',
|
||||
llmSupportImageInputHint:
|
||||
'啟用後,消息中的圖片會按多模態圖片發送給 LLM;關閉後圖片會作為附件保存到本地,並將檔案路徑提供給智能助手處理',
|
||||
llmSupportAudioInputOutput: '支援音頻輸入輸出',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'啟用後,智能助手可以轉寫用戶發送的音頻消息,並在支援的渠道上回覆語音',
|
||||
llmSupportAudioInput: '支援音頻輸入',
|
||||
llmSupportAudioInputHint: '啟用後,智能助手會將用戶發送的音頻消息轉寫為文字再處理',
|
||||
llmSupportAudioOutput: '支援音頻輸出',
|
||||
llmSupportAudioOutputHint: '啟用後,智能助手可以在支援的渠道上發送語音回覆',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
|
||||
@@ -1431,20 +1436,33 @@ export default {
|
||||
llmProviderDeviceCode: '設備碼',
|
||||
llmProviderOpenAuthPage: '開啟授權頁面',
|
||||
llmProviderCheckAuthStatus: '檢查授權狀態',
|
||||
aiVoiceApiKey: '音頻 API密鑰',
|
||||
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
|
||||
aiVoiceBaseUrl: '音頻基礎URL',
|
||||
aiVoiceBaseUrlHint: '音頻轉寫與語音合成接口的基礎URL,留空時回退到當前 LLM 基礎 URL',
|
||||
aiVoiceSttModel: '音頻轉寫模型',
|
||||
aiVoiceSttModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
||||
aiVoiceTtsModel: '語音合成模型',
|
||||
aiVoiceTtsModelHint: '用於將文字內容轉換為語音的模型名稱',
|
||||
aiVoiceTtsVoice: '語音音色',
|
||||
aiVoiceTtsVoiceHint: '語音合成使用的發音人或音色標識',
|
||||
aiVoiceLanguage: '識別語言',
|
||||
aiVoiceLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
||||
aiVoiceReplyWithText: '語音回覆附帶文字',
|
||||
aiVoiceReplyWithTextHint: '發送語音回覆時,同時附帶一份文字內容',
|
||||
audioInputProvider: '音頻輸入提供商',
|
||||
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
||||
audioProviderChatAudio: 'Chat Audio 兼容',
|
||||
audioProviderMimo: '小米 MiMo',
|
||||
audioInputApiKey: '音頻輸入 API密鑰',
|
||||
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
|
||||
audioInputBaseUrl: '音頻輸入基礎URL',
|
||||
audioInputBaseUrlHint:
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
audioInputModel: '音頻輸入模型',
|
||||
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
||||
audioInputLanguage: '識別語言',
|
||||
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
||||
audioOutputProvider: '音頻輸出提供商',
|
||||
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioOutputApiKey: '音頻輸出 API密鑰',
|
||||
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
|
||||
audioOutputBaseUrl: '音頻輸出基礎URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
audioOutputModel: '音頻輸出模型',
|
||||
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
|
||||
audioOutputVoice: '語音音色',
|
||||
audioOutputVoiceHint: '語音合成使用的發音人或音色標識',
|
||||
audioOutputIncludeText: '語音回覆附帶文字',
|
||||
audioOutputIncludeTextHint: '發送語音回覆時,同時附帶一份文字內容',
|
||||
llmTestAction: '測試調用',
|
||||
llmTestSuccessToast: 'LLM 調用測試成功',
|
||||
llmTestFailedToast: 'LLM 調用測試失敗',
|
||||
@@ -1704,7 +1722,7 @@ export default {
|
||||
siteDataRefresh: '站點數據刷新',
|
||||
siteOptions: '站點選項',
|
||||
browserEmulation: '瀏覽器仿真',
|
||||
browserEmulationHint: '站點訪問仿真方式,支援 Playwright 或 FlareSolverr',
|
||||
browserEmulationHint: '站點訪問仿真方式,支援 CloakBrowser 或 FlareSolverr',
|
||||
flaresolverrUrl: 'FlareSolverr 服務地址',
|
||||
flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效,例如:http://127.0.0.1:8191',
|
||||
siteDataRefreshInterval: '站點數據刷新間隔',
|
||||
@@ -2482,8 +2500,6 @@ export default {
|
||||
previewTotal: '總數 {count}',
|
||||
previewSuccess: '成功 {count}',
|
||||
previewFailed: '失敗 {count}',
|
||||
previewSourcePath: '原始路徑',
|
||||
previewTargetPath: '目的路徑',
|
||||
previewMediaInfo: '媒體資訊',
|
||||
previewMediaName: '名稱',
|
||||
previewMediaType: '類型',
|
||||
@@ -2533,6 +2549,8 @@ export default {
|
||||
savePathHint: '指定該訂閱的下載儲存路徑,留空自動使用設定的下載目錄',
|
||||
bestVersion: '洗版',
|
||||
bestVersionHint: '根據洗版優先級進行洗版訂閱',
|
||||
bestVersionFull: '全集洗版',
|
||||
bestVersionFullHint: '只下載覆蓋全集的整包資源,不按單集拆包下載',
|
||||
searchImdbid: '使用 ImdbID 搜索',
|
||||
searchImdbidHint: '開使用 ImdbID 精確搜索資源',
|
||||
showEditDialog: '訂閱時編輯更多規則',
|
||||
@@ -2682,6 +2700,7 @@ export default {
|
||||
close: '關閉',
|
||||
loadingDirectoryStructure: '加載目錄結構...',
|
||||
reorganize: '整理',
|
||||
filterPlaceholder: '搜尋(支援 * ? 萬用字元)',
|
||||
},
|
||||
person: {
|
||||
alias: '別名:',
|
||||
@@ -2889,7 +2908,7 @@ export default {
|
||||
},
|
||||
transferHistory: {
|
||||
title: '轉移歷史',
|
||||
searchPlaceholder: '搜索轉移記錄',
|
||||
searchPlaceholder: '搜索(支援 * ? 萬用字元)',
|
||||
titleColumn: '標題',
|
||||
pathColumn: '路徑',
|
||||
modeColumn: '轉移方式',
|
||||
|
||||
65
src/main.ts
65
src/main.ts
@@ -13,32 +13,47 @@ import i18n from '@/plugins/i18n'
|
||||
import App from '@/App.vue'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { loadRemoteComponents } from './utils/federationLoader'
|
||||
|
||||
// 5. 其他插件和功能模块
|
||||
// 4. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 6. 注册自定义组件
|
||||
// 5. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
|
||||
import PageContentTitle from './@core/components/PageContentTitle.vue'
|
||||
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
// 6. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
|
||||
// 8. 状态恢复插件
|
||||
// 7. 状态恢复插件
|
||||
import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
|
||||
// 9. 后台优化工具
|
||||
import { backgroundManager } from '@/utils/backgroundManager'
|
||||
import { sseManagerSingleton } from '@/utils/sseManager'
|
||||
function runWhenBrowserIdle(callback: () => void, timeout = 1500) {
|
||||
const requestIdle = globalThis.requestIdleCallback
|
||||
if (requestIdle) {
|
||||
requestIdle(callback, { timeout })
|
||||
return
|
||||
}
|
||||
|
||||
const iconBundlePromise = import('@/@iconify/icons-bundle').catch(error => {
|
||||
console.error('Failed to load icon bundle', error)
|
||||
})
|
||||
globalThis.setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
function loadIconBundle() {
|
||||
import('@/@iconify/icons-bundle').catch(error => {
|
||||
console.error('Failed to load icon bundle', error)
|
||||
})
|
||||
}
|
||||
|
||||
function loadRemoteComponentsAfterLogin() {
|
||||
import('./utils/federationLoader')
|
||||
.then(({ loadRemoteComponents }) => loadRemoteComponents())
|
||||
.catch(error => {
|
||||
console.error('Failed to load remote components', error)
|
||||
})
|
||||
}
|
||||
|
||||
let remoteComponentsInitialized = false
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
await import('./ace-config')
|
||||
@@ -70,11 +85,6 @@ const app = createApp(App)
|
||||
// 1. 注册pinia
|
||||
app.use(pinia)
|
||||
|
||||
// 异步加载远程组件(不阻塞启动)
|
||||
loadRemoteComponents().catch(error => {
|
||||
console.error('Failed to load remote components', error)
|
||||
})
|
||||
|
||||
// 2. 注册 UI 框架
|
||||
app.use(vuetify)
|
||||
|
||||
@@ -105,11 +115,20 @@ app
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
|
||||
await iconBundlePromise
|
||||
app.mount('#app')
|
||||
|
||||
// 页面卸载时清理后台管理器
|
||||
window.addEventListener('beforeunload', () => {
|
||||
backgroundManager.destroy()
|
||||
sseManagerSingleton.closeAllManagers()
|
||||
// 图标全集很大,延后到首屏挂载后的空闲时间加载,避免阻塞登录页首次渲染。
|
||||
runWhenBrowserIdle(loadIconBundle)
|
||||
|
||||
// 插件远程入口只在登录后有用,延后初始化可以减少未登录首屏请求和解析成本。
|
||||
router.isReady().then(() => {
|
||||
const loadIfAuthenticated = () => {
|
||||
if (!remoteComponentsInitialized && pinia.state.value.auth?.token) {
|
||||
remoteComponentsInitialized = true
|
||||
runWhenBrowserIdle(loadRemoteComponentsAfterLogin)
|
||||
}
|
||||
}
|
||||
|
||||
loadIfAuthenticated()
|
||||
router.afterEach(loadIfAuthenticated)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ function getApiPath(paths: string[] | string) {
|
||||
<VPageContentTitle :title="title" />
|
||||
<PersonCardListView v-if="type === 'person'" :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 />
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
@@ -309,22 +343,7 @@ async function getPluginDashboard(id: string, key: string) {
|
||||
}
|
||||
const pluginDashboardId = buildPluginDashboardId(id, key)
|
||||
// 定时刷新
|
||||
if (
|
||||
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
|
||||
}
|
||||
schedulePluginDashboardRefresh(res)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -346,10 +365,12 @@ onBeforeMount(async () => {
|
||||
|
||||
onActivated(() => {
|
||||
isRequest.value = true
|
||||
refreshEnabledPluginDashboards()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
isRequest.value = false
|
||||
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -52,7 +53,7 @@ onMounted(async () => {
|
||||
registerTabs()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
useKeepAliveRefresh(async () => {
|
||||
await loadDownloaderSetting()
|
||||
registerTabs()
|
||||
})
|
||||
@@ -61,10 +62,10 @@ onActivated(async () => {
|
||||
<template>
|
||||
<div v-if="downloaders.length > 0">
|
||||
<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>
|
||||
<div>
|
||||
<DownloadingListView :name="item.name" />
|
||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { authState, userState } from '@/stores/types'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
@@ -20,9 +20,6 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
//用户 Store
|
||||
const userStore = useUserStore()
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 获取有权限的菜单
|
||||
const navMenus = computed(() => getNavMenus(t))
|
||||
|
||||
@@ -373,9 +370,6 @@ async function handleLoginSuccess(response: any) {
|
||||
authStore.login(authPayLoad)
|
||||
userStore.loginUser(userPayload)
|
||||
|
||||
// 登录后加载用户相关的全局设置
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
|
||||
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||
|
||||
const display = useDisplay()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -21,6 +24,10 @@ const currentCategory = ref(t('recommend.all'))
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
function openRecommendSettings() {
|
||||
dialog.value = true
|
||||
}
|
||||
|
||||
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([
|
||||
{
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
@@ -218,17 +225,12 @@ const categoryItems = computed(() => [
|
||||
registerHeaderTab({
|
||||
items: categoryItems,
|
||||
modelValue: currentCategory,
|
||||
appendButtons: [
|
||||
{
|
||||
icon: 'mdi-tune',
|
||||
variant: 'text',
|
||||
color: 'grey',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
dialog.value = true
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-tune',
|
||||
onClick: openRecommendSettings,
|
||||
show: computed(() => appMode.value),
|
||||
})
|
||||
|
||||
// 页面是否准备就绪
|
||||
@@ -346,7 +348,19 @@ onActivated(async () => {
|
||||
|
||||
<!-- 快速滚动到顶部按钮 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,11 +11,16 @@ import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores/global'
|
||||
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 提示框
|
||||
const toast = useToast()
|
||||
|
||||
@@ -39,6 +44,16 @@ interface SearchParams {
|
||||
sites: string
|
||||
}
|
||||
|
||||
interface LastSearchContextResponse {
|
||||
success?: boolean
|
||||
data?: {
|
||||
params?: Partial<SearchParams>
|
||||
results?: Context[]
|
||||
}
|
||||
}
|
||||
|
||||
const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams'
|
||||
|
||||
function createSearchParams(query: LocationQuery): SearchParams {
|
||||
return {
|
||||
keyword: query?.keyword?.toString() ?? '',
|
||||
@@ -51,15 +66,107 @@ function createSearchParams(query: LocationQuery): SearchParams {
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchParamsKey(params: SearchParams): string {
|
||||
return JSON.stringify(params)
|
||||
function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchParams {
|
||||
return {
|
||||
keyword: params?.keyword?.toString() ?? '',
|
||||
type: params?.type?.toString() ?? '',
|
||||
area: params?.area?.toString() ?? '',
|
||||
title: params?.title?.toString() ?? '',
|
||||
year: params?.year?.toString() ?? '',
|
||||
season: params?.season?.toString() ?? '',
|
||||
sites: params?.sites?.toString() ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function hasSearchKeyword(params: SearchParams): boolean {
|
||||
return params.keyword.trim().length > 0
|
||||
}
|
||||
|
||||
function createSearchRequestToken(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
const activeSearchParams = ref<SearchParams>(createSearchParams(route.query))
|
||||
function loadStoredSearchParams(): SearchParams | null {
|
||||
try {
|
||||
const rawParams = localStorage.getItem(resourceSearchParamsStorageKey)
|
||||
if (!rawParams) return null
|
||||
|
||||
const params = normalizeSearchParams(JSON.parse(rawParams) as Partial<SearchParams>)
|
||||
return hasSearchKeyword(params) ? params : null
|
||||
} catch (error) {
|
||||
console.warn('读取资源搜索参数失败:', error)
|
||||
localStorage.removeItem(resourceSearchParamsStorageKey)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredSearchParams(params: SearchParams) {
|
||||
if (!hasSearchKeyword(params)) return
|
||||
localStorage.setItem(resourceSearchParamsStorageKey, JSON.stringify(params))
|
||||
}
|
||||
|
||||
const initialSearchParams = createSearchParams(route.query)
|
||||
const activeSearchParams = ref<SearchParams>(initialSearchParams)
|
||||
const lastSearchParams = ref<SearchParams | null>(
|
||||
hasSearchKeyword(initialSearchParams) ? { ...initialSearchParams } : loadStoredSearchParams(),
|
||||
)
|
||||
|
||||
function rememberSearchParams(params: SearchParams) {
|
||||
if (!hasSearchKeyword(params)) return
|
||||
|
||||
const nextParams = { ...params }
|
||||
lastSearchParams.value = 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)) {
|
||||
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或标题
|
||||
const keyword = computed(() => activeSearchParams.value.keyword)
|
||||
@@ -123,6 +230,19 @@ const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||
// 是否刷新过
|
||||
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)
|
||||
|
||||
@@ -138,6 +258,8 @@ const progressEnabled = ref(false)
|
||||
// 进度是否激活
|
||||
const progressActive = ref(false)
|
||||
|
||||
let progressResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 是否显示搜索进度
|
||||
const isSearchProgressVisible = computed(
|
||||
() => progressActive.value || (!isRefreshed.value && (progressEnabled.value || progressValue.value > 0)),
|
||||
@@ -166,10 +288,12 @@ const errorTitle = ref(t('resource.noData'))
|
||||
const errorDescription = ref(t('resource.noResourceFound'))
|
||||
|
||||
let searchEventSource: EventSource | null = null
|
||||
let searchStreamIdleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const streamPreviewLimit = 24
|
||||
const streamUiFlushDelay = 1000
|
||||
const streamPreviewBufferLimit = streamPreviewLimit * 4
|
||||
const searchStreamIdleTimeout = 90_000
|
||||
|
||||
const streamTotalCount = ref(0)
|
||||
const streamPreviewDataList = ref<Array<Context>>([])
|
||||
@@ -178,6 +302,9 @@ const displayResourceCount = computed(() =>
|
||||
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
|
||||
)
|
||||
|
||||
// 搜索中只显示进度区域,避免结果抬头和进度条同时占用顶部空间。
|
||||
const showResultHeader = computed(() => isRefreshed.value && !progressActive.value)
|
||||
|
||||
let pendingStreamItems: Array<Context> = []
|
||||
let streamFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let streamFinalResultApplied = false
|
||||
@@ -241,6 +368,7 @@ const watchProgressValue = watch(
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
clearProgressResetTimer()
|
||||
watchProgressValue.resume()
|
||||
progressText.value = t('resource.searching')
|
||||
progressValue.value = 0
|
||||
@@ -255,18 +383,41 @@ function stopLoadingProgress() {
|
||||
|
||||
// 确保进度显示100%,然后再渐进清零
|
||||
progressValue.value = 100
|
||||
setTimeout(() => {
|
||||
clearProgressResetTimer()
|
||||
progressResetTimer = setTimeout(() => {
|
||||
progressResetTimer = null
|
||||
progressValue.value = 0
|
||||
progressEnabled.value = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function clearProgressResetTimer() {
|
||||
if (progressResetTimer) {
|
||||
clearTimeout(progressResetTimer)
|
||||
progressResetTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭SSE连接
|
||||
function closeSearchEventSource() {
|
||||
function closeSearchEventSource(source?: EventSource) {
|
||||
if (source && searchEventSource !== source) {
|
||||
source.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (searchEventSource) {
|
||||
searchEventSource.close()
|
||||
searchEventSource = null
|
||||
}
|
||||
|
||||
clearSearchStreamIdleTimer()
|
||||
}
|
||||
|
||||
function clearSearchStreamIdleTimer() {
|
||||
if (searchStreamIdleTimer) {
|
||||
clearTimeout(searchStreamIdleTimer)
|
||||
searchStreamIdleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 渐进式搜索期间只保留有限预览数据,避免每个批次都触发完整筛选和分组计算。
|
||||
@@ -461,6 +612,13 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) {
|
||||
|
||||
// 按请求搜索
|
||||
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 }
|
||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
||||
if (/^[a-zA-Z]+:/.test(params.keyword)) {
|
||||
@@ -487,13 +645,11 @@ async function searchByRequest(params: SearchParams, requestToken?: string) {
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
streamTotalCount.value = result.data?.length || 0
|
||||
setStreamResults(result.data || [])
|
||||
} else {
|
||||
errorDescription.value = result?.message || t('resource.noResourceFound')
|
||||
streamTotalCount.value = 0
|
||||
setStreamResults([])
|
||||
return (result.data || []) as Context[]
|
||||
}
|
||||
|
||||
errorDescription.value = result?.message || t('resource.noResourceFound')
|
||||
throw new Error(errorDescription.value)
|
||||
}
|
||||
|
||||
// 按流搜索
|
||||
@@ -505,36 +661,48 @@ function searchByStream(params: SearchParams, requestToken?: string) {
|
||||
const source = new EventSource(buildSearchStreamUrl(params, requestToken))
|
||||
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 => {
|
||||
if (source !== searchEventSource || settled) return
|
||||
|
||||
try {
|
||||
resetIdleTimeout()
|
||||
const eventData = JSON.parse(event.data)
|
||||
handleSearchStreamMessage(eventData)
|
||||
|
||||
if (eventData.type === 'error') {
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
resolve()
|
||||
settleSearchStream(resolve)
|
||||
return
|
||||
}
|
||||
|
||||
if (eventData.type === 'done') {
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
resolve()
|
||||
settleSearchStream(resolve)
|
||||
}
|
||||
} catch (error) {
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
reject(error)
|
||||
settleSearchStream(() => reject(error))
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (settled) return
|
||||
if (source !== searchEventSource || settled) return
|
||||
|
||||
settled = true
|
||||
closeSearchEventSource()
|
||||
reject(new Error(t('resource.noResourceFound')))
|
||||
settleSearchStream(() => reject(new Error(t('resource.noResourceFound'))))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -552,18 +720,26 @@ function changeViewType(newType: string) {
|
||||
}
|
||||
|
||||
// 获取搜索列表数据
|
||||
async function fetchData(options: { force?: boolean } = {}) {
|
||||
const currentSearchParams = { ...activeSearchParams.value }
|
||||
async function fetchData(options: { force?: boolean; params?: SearchParams; silent?: boolean } = {}) {
|
||||
const currentSearchParams = { ...(options.params ?? activeSearchParams.value) }
|
||||
if (hasSearchKeyword(currentSearchParams)) {
|
||||
activeSearchParams.value = { ...currentSearchParams }
|
||||
rememberSearchParams(currentSearchParams)
|
||||
}
|
||||
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
|
||||
const silentRefresh = Boolean(options.silent && isRefreshed.value && rawDataList.value.length > 0)
|
||||
|
||||
try {
|
||||
enableFilterAnimation.value = true
|
||||
if (!currentSearchParams.keyword) {
|
||||
// 查询上次搜索结果
|
||||
const results = await api.get('search/last', {
|
||||
params: requestToken ? { _ts: requestToken } : undefined,
|
||||
})
|
||||
setStreamResults((results as unknown as Context[]) || [])
|
||||
if (!hasSearchKeyword(currentSearchParams)) {
|
||||
// 查询上次搜索结果,并同步可重放的搜索参数
|
||||
const results = await fetchLastSearchContext()
|
||||
setStreamResults(results || [])
|
||||
} else if (silentRefresh) {
|
||||
// keep-alive 重新进入时后台刷新,旧结果继续显示,等新结果完整返回后一次性替换。
|
||||
const results = await requestSearchResults(currentSearchParams, requestToken)
|
||||
streamTotalCount.value = results.length
|
||||
setStreamResults(results)
|
||||
} else {
|
||||
resetSearchResults()
|
||||
startLoadingProgress()
|
||||
@@ -597,7 +773,12 @@ async function refreshSearch() {
|
||||
try {
|
||||
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
|
||||
showingAiResults.value = false
|
||||
await fetchData({ force: true })
|
||||
const refreshParams = await resolveRefreshSearchParams()
|
||||
if (!refreshParams) {
|
||||
console.warn('未找到可用于重新搜索的搜索参数')
|
||||
return
|
||||
}
|
||||
await fetchData({ force: true, params: refreshParams })
|
||||
} catch (error) {
|
||||
console.error('重新搜索失败:', error)
|
||||
} finally {
|
||||
@@ -885,7 +1066,7 @@ watch(
|
||||
if (Object.keys(query).length === 0) return
|
||||
|
||||
const nextSearchParams = createSearchParams(query)
|
||||
if (getSearchParamsKey(nextSearchParams) === getSearchParamsKey(activeSearchParams.value)) return
|
||||
if (!hasSearchKeyword(nextSearchParams)) return
|
||||
|
||||
activeSearchParams.value = nextSearchParams
|
||||
void fetchData()
|
||||
@@ -898,10 +1079,20 @@ onMounted(async () => {
|
||||
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(() => {
|
||||
closeSearchEventSource()
|
||||
stopLoadingProgress()
|
||||
clearProgressResetTimer()
|
||||
stopAiRecommendPolling()
|
||||
clearStreamPreviewState()
|
||||
})
|
||||
@@ -972,105 +1163,98 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 精简标题栏:搜索过后保持挂载,加载中由按钮 :disabled / :loading 表达状态 -->
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||
<div class="search-info-container">
|
||||
<div class="search-title text-moviepilot">
|
||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
|
||||
</div>
|
||||
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
|
||||
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
|
||||
{{ t('resource.keyword') }}: {{ keyword }}
|
||||
</VChip>
|
||||
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="flat">
|
||||
{{ t('resource.title') }}: {{ title }}
|
||||
</VChip>
|
||||
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="flat">
|
||||
{{ t('resource.year') }}: {{ year }}
|
||||
</VChip>
|
||||
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="flat">
|
||||
{{ t('resource.season') }}: {{ season }}
|
||||
</VChip>
|
||||
<!-- 结果抬头:只承载搜索上下文和快捷动作,筛选控制交给下方工具条。 -->
|
||||
<VCard v-if="showResultHeader" class="search-header result-toolbar mb-2" elevation="0">
|
||||
<div class="result-toolbar__content">
|
||||
<VAvatar class="result-toolbar__icon" rounded="lg" size="42">
|
||||
<VIcon icon="mdi-movie-search" size="24" />
|
||||
</VAvatar>
|
||||
|
||||
<div class="search-info-container">
|
||||
<div class="search-title text-moviepilot">
|
||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
|
||||
</div>
|
||||
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
|
||||
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="tonal">
|
||||
{{ t('resource.keyword') }}: {{ keyword }}
|
||||
</VChip>
|
||||
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="tonal">
|
||||
{{ t('resource.title') }}: {{ title }}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 重新搜索按钮 -->
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
icon
|
||||
class="me-2 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操作按钮组 -->
|
||||
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container">
|
||||
<div class="ai-toggle-buttons">
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="0"
|
||||
@click="toggleAiRecommend"
|
||||
:disabled="isRecommending || !aiStatusChecked"
|
||||
height="44"
|
||||
class="ps-4 pe-3 ai-recommend-btn"
|
||||
:class="{ 'ai-active': showingAiResults }"
|
||||
>
|
||||
<template #prepend>
|
||||
<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操作按钮组 -->
|
||||
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
|
||||
<div class="ai-toggle-buttons">
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="0"
|
||||
@click="toggleAiRecommend"
|
||||
:disabled="isRecommending || !aiStatusChecked"
|
||||
height="44"
|
||||
class="ps-4 pe-3 ai-recommend-btn"
|
||||
:class="{ 'ai-active': showingAiResults }"
|
||||
>
|
||||
<template #prepend>
|
||||
<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>
|
||||
|
||||
<VExpandXTransition>
|
||||
<div v-if="aiRecommended || isRecommending" class="d-flex align-center">
|
||||
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
|
||||
<VBtn
|
||||
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>
|
||||
<VExpandXTransition>
|
||||
<div v-if="aiRecommended || isRecommending" class="d-flex align-center">
|
||||
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
|
||||
<VBtn
|
||||
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>
|
||||
</VCard>
|
||||
@@ -1149,14 +1333,19 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
||||
<VVirtualScroll renderless :items="filteredRowDataList" :item-height="240">
|
||||
<template #default="{ item, index, itemRef }">
|
||||
<div :ref="itemRef" :key="getTorrentItemKey(item, index)">
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
:items="filteredRowDataList"
|
||||
:columns="1"
|
||||
:gap="8"
|
||||
:estimated-item-height="240"
|
||||
:overscan-rows="6"
|
||||
:get-item-key="getTorrentItemKey"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
@@ -1173,9 +1362,22 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 初始加载状态 -->
|
||||
<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'">
|
||||
<VScrollToTopBtn />
|
||||
<VScrollToTopBtn :offset-fab="isRefreshed && !appMode" />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1286,82 +1488,67 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 精简标题栏样式 */
|
||||
/* 结果抬头样式 */
|
||||
.search-header {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.16);
|
||||
border-radius: 8px;
|
||||
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;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
.result-toolbar__icon {
|
||||
flex: 0 0 auto;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
max-inline-size: min(100%, 220px);
|
||||
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 {
|
||||
border-radius: 8px !important;
|
||||
@@ -1471,12 +1658,31 @@ onUnmounted(() => {
|
||||
|
||||
@media (width <= 600px) {
|
||||
.search-header {
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1535,30 +1741,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
block-size: 36px !important;
|
||||
inline-size: 36px !important;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
|
||||
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
|
||||
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
|
||||
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
|
||||
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
|
||||
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
|
||||
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
|
||||
import { getSettingTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
@@ -17,6 +10,35 @@ const route = useRoute()
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
const settingTabs = computed(() => getSettingTabs(t))
|
||||
|
||||
// 设置页的每个大类都很重,按标签页拆包,避免进入设置时一次性下载全部配置面板。
|
||||
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
|
||||
const AccountSettingDirectory = defineAsyncComponent(() => import('@/views/setting/AccountSettingDirectory.vue'))
|
||||
const AccountSettingSite = defineAsyncComponent(() => import('@/views/setting/AccountSettingSite.vue'))
|
||||
const AccountSettingRule = defineAsyncComponent(() => import('@/views/setting/AccountSettingRule.vue'))
|
||||
const AccountSettingSearch = defineAsyncComponent(() => import('@/views/setting/AccountSettingSearch.vue'))
|
||||
const AccountSettingSubscribe = defineAsyncComponent(() => import('@/views/setting/AccountSettingSubscribe.vue'))
|
||||
const AccountSettingNotification = defineAsyncComponent(() => import('@/views/setting/AccountSettingNotification.vue'))
|
||||
|
||||
const visitedTabs = ref(new Set<string>())
|
||||
|
||||
const settingTabComponents = [
|
||||
{ value: 'system', component: AccountSettingSystem },
|
||||
{ value: 'directory', component: AccountSettingDirectory },
|
||||
{ value: 'site', component: AccountSettingSite },
|
||||
{ value: 'rule', component: AccountSettingRule },
|
||||
{ value: 'search', component: AccountSettingSearch },
|
||||
{ value: 'subscribe', component: AccountSettingSubscribe },
|
||||
{ value: 'notification', component: AccountSettingNotification },
|
||||
]
|
||||
|
||||
function markTabVisited(tab: string) {
|
||||
if (!tab) return
|
||||
|
||||
const nextTabs = new Set(visitedTabs.value)
|
||||
nextTabs.add(tab)
|
||||
visitedTabs.value = nextTabs
|
||||
}
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -32,71 +54,23 @@ onMounted(() => {
|
||||
if (!activeTab.value && settingTabs.value.length > 0) {
|
||||
activeTab.value = settingTabs.value[0].tab
|
||||
}
|
||||
markTabVisited(activeTab.value)
|
||||
})
|
||||
|
||||
watch(activeTab, markTabVisited, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<!-- 系统 -->
|
||||
<VWindowItem value="system">
|
||||
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingSystem />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 目录 -->
|
||||
<VWindowItem value="directory">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingDirectory />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 站点 -->
|
||||
<VWindowItem value="site">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingSite />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 规则 -->
|
||||
<VWindowItem value="rule">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingRule />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<VWindowItem value="search">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingSearch />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 订阅 -->
|
||||
<VWindowItem value="subscribe">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingSubscribe />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 通知 -->
|
||||
<VWindowItem value="notification">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingNotification />
|
||||
<component
|
||||
:is="item.component"
|
||||
v-if="visitedTabs.has(item.value)"
|
||||
:active="activeTab === item.value"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
@@ -20,6 +16,14 @@ const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 非默认标签页和弹窗按需加载,避免进入订阅列表时同步下载分享/统计相关代码。
|
||||
const SubscribePopularView = defineAsyncComponent(() => import('@/views/subscribe/SubscribePopularView.vue'))
|
||||
const SubscribeShareView = defineAsyncComponent(() => import('@/views/subscribe/SubscribeShareView.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeShareStatisticsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/SubscribeShareStatisticsDialog.vue'),
|
||||
)
|
||||
|
||||
const subType = route.meta.subType?.toString()
|
||||
const subId = ref(route.query.id as string)
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
@@ -283,6 +287,7 @@ onMounted(() => {
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
:active="activeTab === 'mysub'"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'list')
|
||||
const listViewKey = ref(0)
|
||||
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
|
||||
|
||||
// 获取标签页
|
||||
@@ -37,6 +36,10 @@ function openAddWorkflowDialog() {
|
||||
workflowListViewRef.value?.openAddDialog()
|
||||
}
|
||||
|
||||
function refreshWorkflowList() {
|
||||
workflowListViewRef.value?.refresh()
|
||||
}
|
||||
|
||||
const shareKeywordUpdater = debounce((keyword: string) => {
|
||||
shareKeyword.value = keyword.trim()
|
||||
}, 300)
|
||||
@@ -98,14 +101,14 @@ onMounted(() => {
|
||||
<VWindowItem value="list">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
|
||||
<WorkflowListView ref="workflowListViewRef" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
@@ -48,6 +48,7 @@ const router = createRouter({
|
||||
path: '/resource',
|
||||
component: () => import('../pages/resource.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -56,6 +57,7 @@ const router = createRouter({
|
||||
component: () => import('../pages/subscribe.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
keepAliveKey: 'subscribe-movie',
|
||||
requiresAuth: true,
|
||||
subType: '电影',
|
||||
},
|
||||
@@ -65,6 +67,7 @@ const router = createRouter({
|
||||
component: () => import('../pages/subscribe.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
keepAliveKey: 'subscribe-tv',
|
||||
requiresAuth: true,
|
||||
subType: '电视剧',
|
||||
},
|
||||
@@ -153,6 +156,7 @@ const router = createRouter({
|
||||
path: '/setting',
|
||||
component: () => import('../pages/setting.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,56 +11,61 @@ export class BackgroundManager {
|
||||
runInBackground?: boolean
|
||||
}> = new Map()
|
||||
|
||||
private readonly activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
|
||||
private readonly handleVisibilityChange = () => {
|
||||
const wasBackground = this.isBackground
|
||||
this.isBackground = document.hidden
|
||||
|
||||
if (this.isBackground && !wasBackground) {
|
||||
console.log('Background: 进入后台,暂停定时器')
|
||||
this.pauseAllTimers()
|
||||
} else if (!this.isBackground && wasBackground) {
|
||||
console.log('Background: 回到前台,恢复定时器')
|
||||
this.resumeAllTimers()
|
||||
}
|
||||
}
|
||||
private readonly handleBeforeUnload = () => {
|
||||
this.destroy()
|
||||
}
|
||||
private readonly updateActivity = () => {
|
||||
this.lastActivityTime = Date.now()
|
||||
}
|
||||
|
||||
private isBackground = false
|
||||
private isDestroyed = false
|
||||
private lastActivityTime = Date.now()
|
||||
private activityTimer: ReturnType<typeof setInterval> | null = null
|
||||
private isInitialized = false
|
||||
|
||||
constructor() {
|
||||
private ensureInitialized() {
|
||||
if (this.isInitialized || this.isDestroyed) return
|
||||
|
||||
this.isInitialized = true
|
||||
this.isBackground = document.hidden
|
||||
this.setupVisibilityListener()
|
||||
this.setupActivityTracking()
|
||||
}
|
||||
|
||||
private setupVisibilityListener() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const wasBackground = this.isBackground
|
||||
this.isBackground = document.hidden
|
||||
|
||||
if (this.isBackground && !wasBackground) {
|
||||
console.log('Background: 进入后台,暂停定时器')
|
||||
this.pauseAllTimers()
|
||||
} else if (!this.isBackground && wasBackground) {
|
||||
console.log('Background: 回到前台,恢复定时器')
|
||||
this.resumeAllTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// 页面卸载时清理
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.destroy()
|
||||
})
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
private setupActivityTracking() {
|
||||
// 跟踪用户活动
|
||||
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
|
||||
|
||||
const updateActivity = () => {
|
||||
this.lastActivityTime = Date.now()
|
||||
}
|
||||
|
||||
events.forEach(event => {
|
||||
document.addEventListener(event, updateActivity, { passive: true })
|
||||
// 按需跟踪用户活动,避免应用启动时就注册一批全局监听。
|
||||
this.activityEvents.forEach(event => {
|
||||
document.addEventListener(event, this.updateActivity, { passive: true })
|
||||
})
|
||||
}
|
||||
|
||||
// 定期更新活动状态
|
||||
this.activityTimer = setInterval(() => {
|
||||
// 如果超过5分钟没有活动,可以考虑减少后台活动
|
||||
const inactiveTime = Date.now() - this.lastActivityTime
|
||||
if (inactiveTime > 5 * 60 * 1000) {
|
||||
console.log('Background: 用户长时间不活跃')
|
||||
}
|
||||
}, 60000) // 每分钟检查一次
|
||||
private removeLifecycleListeners() {
|
||||
if (!this.isInitialized) return
|
||||
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload)
|
||||
this.activityEvents.forEach(event => {
|
||||
document.removeEventListener(event, this.updateActivity)
|
||||
})
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +81,9 @@ export class BackgroundManager {
|
||||
} = {}
|
||||
) {
|
||||
const { runInBackground = false, skipInitialRun = false } = options
|
||||
|
||||
if (this.isDestroyed) return
|
||||
this.ensureInitialized()
|
||||
|
||||
this.removeTimer(id)
|
||||
|
||||
@@ -122,6 +130,11 @@ export class BackgroundManager {
|
||||
}
|
||||
this.timers.delete(id)
|
||||
console.log(`Background: 移除定时器 ${id}`)
|
||||
|
||||
// 没有任务时释放监听,首屏只导入模块不会产生常驻开销。
|
||||
if (this.timers.size === 0) {
|
||||
this.removeLifecycleListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,11 +250,8 @@ export class BackgroundManager {
|
||||
})
|
||||
this.timers.clear()
|
||||
|
||||
// 清理活动跟踪定时器
|
||||
if (this.activityTimer) {
|
||||
clearInterval(this.activityTimer)
|
||||
this.activityTimer = null
|
||||
}
|
||||
// 清理按需注册的生命周期与活动监听
|
||||
this.removeLifecycleListeners()
|
||||
|
||||
console.log('Background: 管理器已销毁')
|
||||
}
|
||||
@@ -273,4 +283,4 @@ export function removeBackgroundTimer(id: string) {
|
||||
|
||||
export function getBackgroundTimerStatus(id: string) {
|
||||
return backgroundManager.getTimerStatus(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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连接,减少iOS系统杀掉应用的概率
|
||||
* 统一收口 EventSource 生命周期,避免后台常驻连接和重复重连。
|
||||
*/
|
||||
export class SSEManager {
|
||||
private eventSource: EventSource | null = null
|
||||
private url: string
|
||||
private isBackground = false
|
||||
private isBackground = document.hidden
|
||||
private reconnectTimer: number | null = null
|
||||
private backgroundCloseTimer: number | null = null
|
||||
private listeners: Map<string, (event: MessageEvent) => void> = new Map()
|
||||
private options: {
|
||||
backgroundCloseDelay: number
|
||||
reconnectDelay: number
|
||||
maxReconnectAttempts: number
|
||||
}
|
||||
private listeners: Map<string, SSEMessageListener> = new Map()
|
||||
private statusListeners: Map<string, SSEStatusListener> = new Map()
|
||||
private options: SSEManagerOptions
|
||||
private reconnectAttempts = 0
|
||||
private isConnecting = false
|
||||
private isDestroyed = false
|
||||
private connectionStatus: SSEConnectionStatus = 'idle'
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
@@ -27,12 +39,14 @@ export class SSEManager {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
|
||||
constructor(url: string, options: Partial<SSEManagerOptions> = {}) {
|
||||
this.url = url
|
||||
this.options = {
|
||||
backgroundCloseDelay: 5000, // 5秒后关闭后台连接
|
||||
reconnectDelay: 3000, // 3秒后重连
|
||||
maxReconnectAttempts: 3,
|
||||
reconnectBackoffMultiplier: 1.5,
|
||||
maxReconnectDelay: 30_000,
|
||||
...options,
|
||||
}
|
||||
|
||||
@@ -50,38 +64,44 @@ export class SSEManager {
|
||||
}
|
||||
|
||||
private handleBackground() {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
this.isBackground = true
|
||||
this.clearReconnectTimer()
|
||||
|
||||
// 延迟关闭SSE连接,避免频繁切换
|
||||
if (this.backgroundCloseTimer) {
|
||||
clearTimeout(this.backgroundCloseTimer)
|
||||
}
|
||||
this.clearBackgroundCloseTimer()
|
||||
|
||||
this.backgroundCloseTimer = window.setTimeout(() => {
|
||||
if (this.isBackground && this.eventSource) {
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
this.closeCurrentEventSource()
|
||||
this.setConnectionStatus('closed')
|
||||
}
|
||||
}, this.options.backgroundCloseDelay)
|
||||
}
|
||||
|
||||
private handleForeground() {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
this.isBackground = false
|
||||
|
||||
// 清除后台关闭定时器
|
||||
if (this.backgroundCloseTimer) {
|
||||
clearTimeout(this.backgroundCloseTimer)
|
||||
this.backgroundCloseTimer = null
|
||||
}
|
||||
this.clearBackgroundCloseTimer()
|
||||
|
||||
// 只有在有活跃监听器时才重新建立连接
|
||||
if (this.listeners.size > 0 && (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)) {
|
||||
this.reconnectSSE()
|
||||
this.reconnectSSE(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
|
||||
}
|
||||
|
||||
@@ -89,40 +109,38 @@ export class SSEManager {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有活跃的监听器,不进行重连
|
||||
if (this.listeners.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearReconnectTimer()
|
||||
this.closeCurrentEventSource()
|
||||
this.isConnecting = true
|
||||
this.reconnectAttempts = attemptCount
|
||||
this.setConnectionStatus('connecting')
|
||||
|
||||
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.reconnectAttempts = 0
|
||||
this.setConnectionStatus('open')
|
||||
}
|
||||
|
||||
this.eventSource.onerror = error => {
|
||||
source.onerror = () => {
|
||||
if (source !== this.eventSource) return
|
||||
|
||||
this.isConnecting = false
|
||||
this.setConnectionStatus('error')
|
||||
|
||||
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
||||
// 连接已关闭,尝试重连
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
}
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
if (!this.isBackground && this.listeners.size > 0) {
|
||||
this.reconnectSSE(this.reconnectAttempts + 1)
|
||||
}
|
||||
}, this.options.reconnectDelay)
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
this.closeCurrentEventSource()
|
||||
this.scheduleReconnect(this.reconnectAttempts + 1)
|
||||
}
|
||||
}
|
||||
|
||||
this.eventSource.onmessage = event => {
|
||||
source.onmessage = event => {
|
||||
if (source !== this.eventSource || this.isDestroyed) return
|
||||
|
||||
// 分发消息给所有监听器
|
||||
this.listeners.forEach((listener, listenerId) => {
|
||||
try {
|
||||
@@ -135,29 +153,95 @@ export class SSEManager {
|
||||
}
|
||||
} catch (error) {
|
||||
this.isConnecting = false
|
||||
this.setConnectionStatus('error')
|
||||
|
||||
// 连接创建失败,尝试重连
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
}
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
if (!this.isBackground && this.listeners.size > 0) {
|
||||
this.reconnectSSE(this.reconnectAttempts + 1)
|
||||
}
|
||||
}, this.options.reconnectDelay)
|
||||
this.scheduleReconnect(this.reconnectAttempts + 1)
|
||||
console.error('SSE: 连接创建失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 如果还没有连接且不在后台,现在建立连接
|
||||
if (!this.eventSource && !this.isBackground && !this.isConnecting) {
|
||||
this.reconnectSSE()
|
||||
if (
|
||||
!this.isBackground &&
|
||||
!this.isConnecting &&
|
||||
(!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)
|
||||
) {
|
||||
this.reconnectSSE(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +249,8 @@ export class SSEManager {
|
||||
* 移除消息监听器
|
||||
*/
|
||||
removeMessageListener(id: string) {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
this.listeners.delete(id)
|
||||
|
||||
// 如果没有监听器了,关闭连接
|
||||
@@ -184,25 +270,17 @@ export class SSEManager {
|
||||
* 销毁管理器并清理所有引用
|
||||
*/
|
||||
destroy() {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
this.isDestroyed = true
|
||||
this.resetConnectionState(true)
|
||||
this.removeVisibilityListener()
|
||||
}
|
||||
|
||||
private resetConnectionState(clearListeners = false) {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.backgroundCloseTimer) {
|
||||
clearTimeout(this.backgroundCloseTimer)
|
||||
this.backgroundCloseTimer = null
|
||||
}
|
||||
this.closeCurrentEventSource()
|
||||
this.clearReconnectTimer()
|
||||
this.clearBackgroundCloseTimer()
|
||||
|
||||
if (clearListeners) {
|
||||
this.listeners.clear()
|
||||
@@ -210,6 +288,31 @@ export class SSEManager {
|
||||
|
||||
this.isConnecting = false
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内部连接状态
|
||||
*/
|
||||
get status(): SSEConnectionStatus {
|
||||
return this.connectionStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接URL
|
||||
*/
|
||||
@@ -230,10 +340,12 @@ export class SSEManager {
|
||||
* 强制重新连接
|
||||
*/
|
||||
forceReconnect() {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
const hasActiveListeners = this.listeners.size > 0
|
||||
this.close()
|
||||
if (!this.isBackground && hasActiveListeners) {
|
||||
this.reconnectSSE()
|
||||
this.reconnectSSE(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -29,8 +30,6 @@ const variableTheme = controlledComputed(
|
||||
() => vuetifyTheme.current.value.variables,
|
||||
)
|
||||
|
||||
const chartKey = ref(0)
|
||||
|
||||
// 时间序列
|
||||
const series = ref([
|
||||
{
|
||||
@@ -122,19 +121,15 @@ async function loadCpuData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
const { loading } = useDataRefresh(
|
||||
// 使用数据刷新定时器
|
||||
const { loading, refresh } = useDataRefresh(
|
||||
'analytics-cpu',
|
||||
loadCpuData,
|
||||
2000, // 2秒间隔
|
||||
true // 立即执行
|
||||
)
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
chartKey.value += 1
|
||||
})
|
||||
})
|
||||
useKeepAliveRefresh(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -148,7 +143,7 @@ onActivated(() => {
|
||||
<VCardTitle>CPU</VCardTitle>
|
||||
</VCardItem>
|
||||
<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>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { formatBytes } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -30,8 +31,6 @@ const variableTheme = controlledComputed(
|
||||
() => vuetifyTheme.current.value.variables,
|
||||
)
|
||||
|
||||
const chartKey = ref(0)
|
||||
|
||||
// 时间序列
|
||||
const series = ref([
|
||||
{
|
||||
@@ -127,20 +126,15 @@ async function loadMemoryData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
const { loading } = useDataRefresh(
|
||||
// 使用数据刷新定时器
|
||||
const { loading, refresh } = useDataRefresh(
|
||||
'analytics-memory',
|
||||
loadMemoryData,
|
||||
3000, // 3秒间隔
|
||||
true // 立即执行
|
||||
)
|
||||
|
||||
onActivated(() => {
|
||||
// 使用nextTick确保DOM准备完成后再更新chartKey
|
||||
nextTick(() => {
|
||||
chartKey.value += 1
|
||||
})
|
||||
})
|
||||
useKeepAliveRefresh(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -154,7 +148,7 @@ onActivated(() => {
|
||||
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<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>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -29,8 +30,6 @@ const variableTheme = controlledComputed(
|
||||
() => vuetifyTheme.current.value.variables,
|
||||
)
|
||||
|
||||
const chartKey = ref(0)
|
||||
|
||||
// 时间序列 - 上行和下行流量
|
||||
const series = ref([
|
||||
{
|
||||
@@ -160,19 +159,15 @@ async function getNetworkUsage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
useDataRefresh(
|
||||
// 使用数据刷新定时器
|
||||
const { refresh } = useDataRefresh(
|
||||
'dashboard-network',
|
||||
getNetworkUsage,
|
||||
2000, // 2秒间隔
|
||||
true // 立即执行
|
||||
)
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
chartKey.value += 1
|
||||
})
|
||||
})
|
||||
useKeepAliveRefresh(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -186,7 +181,7 @@ onActivated(() => {
|
||||
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<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">
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<span class="text-warning">{{ t('dashboard.upload') }}</span
|
||||
|
||||
@@ -3,11 +3,11 @@ import { formatSeconds } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Process } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 表头
|
||||
const headers = [
|
||||
@@ -31,7 +31,7 @@ async function loadProcessList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
// 使用数据刷新定时器
|
||||
useDataRefresh(
|
||||
'dashboard-processes',
|
||||
loadProcessList,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import api from '@/api'
|
||||
import type { ScheduleInfo } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -34,7 +34,7 @@ async function loadSchedulerList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
// 使用数据刷新定时器
|
||||
useDataRefresh(
|
||||
'dashboard-scheduler',
|
||||
loadSchedulerList,
|
||||
|
||||
@@ -3,11 +3,11 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -77,7 +77,7 @@ async function loadDownloaderInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
// 使用数据刷新定时器
|
||||
const { loading } = useDataRefresh(
|
||||
'analytics-speed',
|
||||
loadDownloaderInfo,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -67,9 +68,18 @@ onActivated(() => {
|
||||
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<div class="grid gap-4 grid-media-card mx-3 mb-3" tabindex="0">
|
||||
<PosterCard v-for="item in data" :key="item.id" :media="item" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
:items="data"
|
||||
:get-item-key="item => item.id || item.link || item.title"
|
||||
:min-item-width="144"
|
||||
:item-aspect-ratio="1.5"
|
||||
class="mx-3 mb-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PosterCard :media="item" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -69,9 +70,18 @@ onActivated(() => {
|
||||
</template>
|
||||
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<div class="grid gap-4 grid-backdrop-card mx-3 mb-3" tabindex="0">
|
||||
<LibraryCard v-for="item in libraryList" :key="item.id" :media="item" height="10rem" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
:items="libraryList"
|
||||
:get-item-key="item => item.id || item.name"
|
||||
:min-item-width="240"
|
||||
:estimated-item-height="160"
|
||||
class="mx-3 mb-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<LibraryCard :media="item" height="10rem" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||
import BackdropCard from '@/components/cards/BackdropCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -70,9 +71,18 @@ onActivated(() => {
|
||||
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<div class="grid gap-4 grid-backdrop-card mx-3 mb-3" tabindex="0">
|
||||
<BackdropCard v-for="item in playingList" :key="item.id" :media="item" height="10rem" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
:items="playingList"
|
||||
:get-item-key="item => item.id || item.link || item.title"
|
||||
:min-item-width="240"
|
||||
:estimated-item-height="160"
|
||||
class="mx-3 mb-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<BackdropCard :media="item" height="10rem" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -118,6 +118,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
await nextTick()
|
||||
}
|
||||
} else {
|
||||
// 加载一次
|
||||
@@ -157,6 +158,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:item-aspect-ratio="1.5"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
@@ -86,6 +86,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -123,7 +124,13 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<ProgressiveCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:item-aspect-ratio="1.5"
|
||||
:get-item-key="item => item.id"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PersonCard :person="item" />
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getPluginTabs } from '@/router/i18n-menu'
|
||||
import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const PluginAppCard = defineAsyncComponent(() => import('@/components/cards/PluginAppCard.vue'))
|
||||
const PluginMarketSettingDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketSettingDialog.vue'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
@@ -737,9 +740,13 @@ const filterPlugins = computed(() => {
|
||||
})
|
||||
|
||||
// 获取插件列表数据
|
||||
async function fetchInstalledPlugins() {
|
||||
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||||
const showLoading = !context.silent || !isRefreshed.value
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
dataList.value = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'installed',
|
||||
@@ -747,17 +754,24 @@ async function fetchInstalledPlugins() {
|
||||
})
|
||||
// 排序
|
||||
sortPluginOrder()
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (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 {
|
||||
loading.value = true
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
uninstalledList.value = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'market',
|
||||
@@ -774,7 +788,6 @@ async function fetchUninstalledPlugins(force: boolean = false) {
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
// 更新插件市场列表
|
||||
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
|
||||
@@ -786,6 +799,10 @@ async function fetchUninstalledPlugins(force: boolean = false) {
|
||||
isAppMarketLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,10 +816,10 @@ async function getPluginStatistics() {
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
async function refreshData() {
|
||||
await fetchInstalledPlugins()
|
||||
await fetchUninstalledPlugins()
|
||||
getPluginStatistics()
|
||||
async function refreshData(context: KeepAliveRefreshContext = {}) {
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await getPluginStatistics()
|
||||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||||
await loadPluginFolders()
|
||||
}
|
||||
@@ -871,17 +888,37 @@ function marketSettingDone() {
|
||||
|
||||
// 手动刷新插件市场
|
||||
async function refreshMarket() {
|
||||
isMarketRefreshing.value = true
|
||||
const showMarketLoading = !isAppMarketLoaded.value
|
||||
if (showMarketLoading) {
|
||||
isMarketRefreshing.value = true
|
||||
}
|
||||
try {
|
||||
await fetchUninstalledPlugins(true)
|
||||
getPluginStatistics()
|
||||
await fetchUninstalledPlugins(true, { silent: isAppMarketLoaded.value, source: 'manual' })
|
||||
await getPluginStatistics()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} 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) {
|
||||
if (!repoUrl?.startsWith('local://')) return ''
|
||||
|
||||
@@ -921,6 +958,11 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
|
||||
function loadMarketMore({ done }: { done: any }) {
|
||||
// 从 dataList 中获取最前面的 20 个元素
|
||||
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
||||
if (itemsToMove.length === 0) {
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
displayUninstalledList.value.push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
@@ -940,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() {
|
||||
SearchDialog.value = true
|
||||
}
|
||||
@@ -1536,7 +1586,7 @@ function onDragStartPlugin(evt: any) {
|
||||
<!-- 混合排序列表(文件夹和插件) -->
|
||||
<template v-if="!currentFolder">
|
||||
<!-- 主列表:使用draggable进行混合排序 -->
|
||||
<draggable
|
||||
<Draggable
|
||||
v-if="canDragSort"
|
||||
v-model="mixedSortList"
|
||||
@end="saveMixedSortOrder"
|
||||
@@ -1565,12 +1615,13 @@ function onDragStartPlugin(evt: any) {
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledMainList"
|
||||
:items="mixedSortList"
|
||||
:get-item-key="item => `${item.type}:${item.id}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="180"
|
||||
:scroll-to-index="installedScrollToIndex"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
@@ -1597,7 +1648,7 @@ function onDragStartPlugin(evt: any) {
|
||||
|
||||
<template v-else>
|
||||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||||
<draggable
|
||||
<Draggable
|
||||
v-if="canDragSort"
|
||||
v-model="draggableFolderPlugins"
|
||||
@end="saveFolderPluginOrder"
|
||||
@@ -1623,12 +1674,13 @@ function onDragStartPlugin(evt: any) {
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||
:items="draggableFolderPlugins"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="180"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
@@ -1665,10 +1717,13 @@ function onDragStartPlugin(evt: any) {
|
||||
<VWindowItem value="market">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<LoadingBanner v-if="!isAppMarketLoaded || isMarketRefreshing" class="mt-12" />
|
||||
<LoadingBanner
|
||||
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
class="mt-12"
|
||||
/>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-if="isAppMarketLoaded && !isMarketRefreshing"
|
||||
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="displayUninstalledList"
|
||||
|
||||
@@ -7,15 +7,17 @@ import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
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 { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
// 用户 Store
|
||||
@@ -28,7 +30,7 @@ const dataList = ref<DownloadingInfo[]>([])
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
async function fetchData(_context: KeepAliveRefreshContext = {}) {
|
||||
try {
|
||||
dataList.value = await api.get('download/', { params: { name: props.name } })
|
||||
isRefreshed.value = true
|
||||
@@ -43,8 +45,9 @@ const loading = ref(false)
|
||||
// 下拉刷新
|
||||
function onRefresh() {
|
||||
loading.value = true
|
||||
fetchData()
|
||||
loading.value = false
|
||||
void fetchData().finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
|
||||
@@ -56,13 +59,19 @@ const filteredDataList = computed(() => {
|
||||
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
|
||||
})
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
// 使用数据刷新定时器
|
||||
const { loading: dataLoading } = useDataRefresh(
|
||||
'downloading-list',
|
||||
fetchData,
|
||||
3000, // 3秒间隔
|
||||
true // 立即执行
|
||||
false // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
|
||||
)
|
||||
|
||||
onMounted(fetchData)
|
||||
|
||||
useKeepAliveRefresh(fetchData, {
|
||||
active: computed(() => props.active !== false),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// i18n
|
||||
@@ -27,7 +27,7 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
const { useProgressSSE } = useBackground()
|
||||
|
||||
// 计算列表可用高度
|
||||
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
|
||||
@@ -180,9 +180,10 @@ const pageRange = [
|
||||
{ title: '100', value: 100 },
|
||||
{ title: '500', value: 500 },
|
||||
{ title: '1000', value: 1000 },
|
||||
{ title: 'All', value: -1 },
|
||||
]
|
||||
|
||||
const pageRangeValues = pageRange.map(item => item.value)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<TransferHistory[]>([])
|
||||
|
||||
@@ -209,7 +210,7 @@ const groupBy = ref<any>([
|
||||
])
|
||||
|
||||
// 每页条数
|
||||
const itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))
|
||||
const itemsPerPage = ref<number>(ensurePageSize(route.query.itemsPerPage, 50))
|
||||
|
||||
// 当前页码
|
||||
const currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))
|
||||
@@ -226,6 +227,9 @@ const progressValue = ref(0)
|
||||
// 是否已刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 是否已完成首次激活
|
||||
const hasActivatedOnce = ref(false)
|
||||
|
||||
// 删除确认对话框
|
||||
const deleteConfirmDialog = ref(false)
|
||||
|
||||
@@ -270,7 +274,7 @@ const TransferDict: { [key: string]: string } = {
|
||||
// 分页提示
|
||||
const pageTip = computed(() => {
|
||||
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
|
||||
const end = itemsPerPage.value * currentPage.value === -1 ? 'ALL' : itemsPerPage.value * currentPage.value
|
||||
const end = Math.min(itemsPerPage.value * currentPage.value, totalItems.value)
|
||||
return {
|
||||
begin,
|
||||
end,
|
||||
@@ -280,7 +284,7 @@ const pageTip = computed(() => {
|
||||
// 分页总数
|
||||
const totalPage = computed(() => {
|
||||
const total = Math.ceil(totalItems.value / itemsPerPage.value)
|
||||
return total
|
||||
return Math.max(1, total)
|
||||
})
|
||||
|
||||
// 切换页签
|
||||
@@ -302,9 +306,11 @@ watch(
|
||||
}, 1000),
|
||||
)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
|
||||
loading.value = true
|
||||
// 获取历史记录数据,keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。
|
||||
async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('history/transfer', {
|
||||
@@ -322,8 +328,11 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (!options.silent) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 根据 type 返回不同的图标
|
||||
@@ -663,6 +672,11 @@ function ensureNumber(value: any, defaultValue: number = 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
function ensurePageSize(value: any, defaultValue: number = 50) {
|
||||
const pageSize = ensureNumber(value, defaultValue)
|
||||
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
|
||||
}
|
||||
|
||||
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
|
||||
const selectedCountsGroupedByTitle = computed(() => {
|
||||
return selected.value.reduce(
|
||||
@@ -745,6 +759,17 @@ onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (!hasActivatedOnce.value) {
|
||||
hasActivatedOnce.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!loading.value) {
|
||||
fetchData(currentPage.value, itemsPerPage.value, { silent: true })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAiRedoProgress()
|
||||
})
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
<!-- eslint-disable sonarjs/no-duplicate-string -->
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import draggable from 'vuedraggable'
|
||||
import { VRow } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import { TransferDirectoryConf, StorageConf } from '@/api/types'
|
||||
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
||||
import StorageCard from '@/components/cards/StorageCard.vue'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { global: globalTheme } = useTheme()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 拖拽排序和分类编辑弹窗按需加载,避免设置框架预加载目录页时带上这些交互依赖。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
const CategoryEditDialog = defineAsyncComponent(() => import('@/components/dialog/CategoryEditDialog.vue'))
|
||||
|
||||
// 所有下载目录
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
@@ -246,12 +255,17 @@ async function saveSystemSettings(value: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
await Promise.all([loadDirectories(), loadStorages(), loadMediaCategories(), loadSystemSettings()])
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
loadStorages()
|
||||
loadMediaCategories()
|
||||
loadSystemSettings()
|
||||
loadPageData()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(loadPageData, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -264,7 +278,7 @@ onMounted(() => {
|
||||
<VCardSubtitle>{{ t('setting.directory.storageDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="storages"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
@@ -274,7 +288,7 @@ onMounted(() => {
|
||||
<template #item="{ element }">
|
||||
<StorageCard :storage="element" @close="removeStorage(element)" @done="loadStorages" />
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
@@ -309,7 +323,7 @@ onMounted(() => {
|
||||
<VCardSubtitle>{{ t('setting.directory.directoryDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="directories"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
@@ -331,7 +345,7 @@ onMounted(() => {
|
||||
@close="removeDirectory(element)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import draggable from 'vuedraggable'
|
||||
import type { NotificationConf, NotificationSwitchConf } from '@/api/types'
|
||||
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { notificationSwitchDict } from '@/api/constants'
|
||||
import { useTheme, useDisplay } from 'vuetify'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -15,6 +14,17 @@ const display = useDisplay()
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 通知渠道排序和进度弹窗按需加载,避免通知设置 chunk 直接包含拖拽库。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 初始化模板配置字典
|
||||
const templateConfigs = ref<Record<string, string>>({
|
||||
organizeSuccess: '{}',
|
||||
@@ -306,12 +316,22 @@ function getNotificationSwitchText(type: string | undefined) {
|
||||
return notificationSwitchDict[type]
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
await Promise.all([
|
||||
loadNotificationSetting(),
|
||||
loadNotificationSwitchs(),
|
||||
loadNotificationTime(),
|
||||
loadTemplateConfigs(),
|
||||
])
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadNotificationSetting()
|
||||
loadNotificationSwitchs()
|
||||
loadNotificationTime()
|
||||
loadTemplateConfigs()
|
||||
loadPageData()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(loadPageData, {
|
||||
active: computed(() => props.active && !editorVisible.value),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -324,7 +344,7 @@ onMounted(() => {
|
||||
<VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="notifications"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
@@ -339,7 +359,7 @@ onMounted(() => {
|
||||
@close="removeNotification(element)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
|
||||
@@ -2,17 +2,27 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
|
||||
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 拖拽库和导入弹窗只在规则编辑交互中需要,拆出设置页入口 chunk。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
||||
|
||||
// 自定义规则列表
|
||||
const customRules = ref<CustomRule[]>([])
|
||||
|
||||
@@ -363,12 +373,17 @@ async function saveTorrentPriority() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
await Promise.all([loadMediaCategories(), queryCustomRules(), queryFilterRuleGroups(), queryTorrentPriority()])
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadMediaCategories()
|
||||
queryCustomRules()
|
||||
queryFilterRuleGroups()
|
||||
queryTorrentPriority()
|
||||
loadPageData()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(loadPageData, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -381,7 +396,7 @@ onMounted(() => {
|
||||
<VCardSubtitle>{{ t('setting.rule.customRulesDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="customRules"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
@@ -396,7 +411,7 @@ onMounted(() => {
|
||||
@change="onRuleChange"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
@@ -432,7 +447,7 @@ onMounted(() => {
|
||||
<VCardSubtitle>{{ t('setting.rule.priorityRuleGroupsDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="filterRuleGroups"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
@@ -449,7 +464,7 @@ onMounted(() => {
|
||||
@change="changeRuleGroup"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
|
||||
@@ -3,10 +3,18 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { FilterRuleGroup, Site } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -176,12 +184,16 @@ async function loadSystemSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
await Promise.all([querySites(), queryFilterRuleGroups(), querySelectedSites(), loadSearchSetting(), loadSystemSettings()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySites()
|
||||
queryFilterRuleGroups()
|
||||
querySelectedSites()
|
||||
loadSearchSetting()
|
||||
loadSystemSettings()
|
||||
loadPageData()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(loadPageData, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,10 +3,18 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -37,7 +45,7 @@ const siteSetting = ref<any>({
|
||||
Site: {
|
||||
SITEDATA_REFRESH_INTERVAL: 0,
|
||||
SITE_MESSAGE: false,
|
||||
BROWSER_EMULATION: 'playwright',
|
||||
BROWSER_EMULATION: 'cloakbrowser',
|
||||
FLARESOLVERR_URL: '',
|
||||
},
|
||||
})
|
||||
@@ -65,7 +73,7 @@ const SiteDataRefreshIntervalItems = [
|
||||
|
||||
// 站点访问仿真方式
|
||||
const BrowserEmulationItems = [
|
||||
{ title: 'Playwright', value: 'playwright' },
|
||||
{ title: 'CloakBrowser', value: 'cloakbrowser' },
|
||||
{ title: 'FlareSolverr', value: 'flaresolverr' },
|
||||
]
|
||||
|
||||
@@ -122,6 +130,10 @@ async function saveSiteSetting(value: { [key: string]: any }) {
|
||||
onMounted(() => {
|
||||
loadSiteSettings()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(loadSiteSettings, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -4,10 +4,18 @@ import api from '@/api'
|
||||
import type { FilterRuleGroup, Site } from '@/api/types'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -184,12 +192,22 @@ async function saveSubscribeSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
await Promise.all([
|
||||
querySites(),
|
||||
queryFilterRuleGroups(),
|
||||
querySelectedRssSites(),
|
||||
querySubscribeRules(),
|
||||
loadSystemSettings(),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
querySites()
|
||||
queryFilterRuleGroups()
|
||||
querySelectedRssSites()
|
||||
querySubscribeRules()
|
||||
loadSystemSettings()
|
||||
loadPageData()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(loadPageData, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<!-- eslint-disable sonarjs/no-duplicate-string -->
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { VRow } from 'vuetify/lib/components/index.mjs'
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import { DownloaderConf, MediaServerConf } from '@/api/types'
|
||||
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
|
||||
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
|
||||
const display = useDisplay()
|
||||
const theme = useTheme()
|
||||
@@ -22,6 +20,17 @@ const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 下载器/媒体服务器排序和进度弹窗按需加载,降低系统设置页入口解析量。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 系统设置项
|
||||
const SystemSettings = ref<any>({
|
||||
// 基础设置
|
||||
@@ -43,17 +52,22 @@ const SystemSettings = ref<any>({
|
||||
LLM_MODEL: 'deepseek-chat',
|
||||
LLM_THINKING_LEVEL: 'off',
|
||||
LLM_SUPPORT_IMAGE_INPUT: false,
|
||||
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: false,
|
||||
LLM_SUPPORT_AUDIO_INPUT: false,
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: false,
|
||||
LLM_API_KEY: null,
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
LLM_BASE_URL_PRESET: null,
|
||||
AI_VOICE_API_KEY: null,
|
||||
AI_VOICE_BASE_URL: null,
|
||||
AI_VOICE_STT_MODEL: 'gpt-4o-mini-transcribe',
|
||||
AI_VOICE_TTS_MODEL: 'gpt-4o-mini-tts',
|
||||
AI_VOICE_TTS_VOICE: 'alloy',
|
||||
AI_VOICE_LANGUAGE: 'zh',
|
||||
AI_VOICE_REPLY_WITH_TEXT: false,
|
||||
AUDIO_INPUT_PROVIDER: 'openai',
|
||||
AUDIO_INPUT_API_KEY: null,
|
||||
AUDIO_INPUT_BASE_URL: null,
|
||||
AUDIO_INPUT_MODEL: 'gpt-4o-mini-transcribe',
|
||||
AUDIO_INPUT_LANGUAGE: 'zh',
|
||||
AUDIO_OUTPUT_PROVIDER: 'openai',
|
||||
AUDIO_OUTPUT_API_KEY: null,
|
||||
AUDIO_OUTPUT_BASE_URL: null,
|
||||
AUDIO_OUTPUT_MODEL: 'gpt-4o-mini-tts',
|
||||
AUDIO_OUTPUT_VOICE: 'alloy',
|
||||
AUDIO_OUTPUT_INCLUDE_TEXT: false,
|
||||
AI_AGENT_RETRY_TRANSFER: false,
|
||||
AI_RECOMMEND_ENABLED: false,
|
||||
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||
@@ -110,6 +124,12 @@ const SystemSettings = ref<any>({
|
||||
},
|
||||
})
|
||||
|
||||
const audioProviderItems = computed(() => [
|
||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
||||
])
|
||||
|
||||
// 刮削配置
|
||||
const scrapingConfig = [
|
||||
{
|
||||
@@ -179,6 +199,9 @@ const advancedDialog = ref(false)
|
||||
const savingBasic = ref(false)
|
||||
const testingLlm = ref(false)
|
||||
|
||||
// 智能助手配置项较多,默认收起以降低基础设置页的视觉占用。
|
||||
const aiAgentSettingsCollapsed = ref(true)
|
||||
|
||||
type LlmSettingsSnapshot = {
|
||||
AI_AGENT_ENABLE: boolean
|
||||
LLM_PROVIDER: string
|
||||
@@ -832,12 +855,11 @@ async function saveScrapingSwitchs() {
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadDownloaderSetting()
|
||||
loadMediaServerSetting()
|
||||
loadSystemSettings()
|
||||
loadScrapingSwitchs()
|
||||
})
|
||||
async function loadPageData() {
|
||||
await Promise.all([loadDownloaderSetting(), loadMediaServerSetting(), loadSystemSettings(), loadScrapingSwitchs()])
|
||||
}
|
||||
|
||||
onMounted(loadPageData)
|
||||
|
||||
onActivated(async () => {
|
||||
isRequest.value = true
|
||||
@@ -851,6 +873,16 @@ onBeforeUnmount(() => {
|
||||
invalidateLlmTestState()
|
||||
})
|
||||
|
||||
useSilentSettingRefresh(
|
||||
async () => {
|
||||
if (progressDialog.value || advancedDialog.value || testingLlm.value || savingBasic.value) return
|
||||
await loadPageData()
|
||||
},
|
||||
{
|
||||
active: computed(() => props.active),
|
||||
},
|
||||
)
|
||||
|
||||
watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
if (snapshotKey !== previousSnapshotKey) invalidateLlmTestState()
|
||||
})
|
||||
@@ -985,7 +1017,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
variant="outlined"
|
||||
:class="['mt-6', isTransparentTheme ? 'ai-agent-settings-card-transparent' : 'ai-agent-settings-card']"
|
||||
>
|
||||
<VCardItem class="pb-2">
|
||||
<VCardItem class="pb-3">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" size="40">
|
||||
<VIcon icon="mdi-robot-outline" />
|
||||
@@ -997,374 +1029,457 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.system.aiAgentSectionDesc') }}
|
||||
</VCardSubtitle>
|
||||
<template #append>
|
||||
<VTooltip location="top">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
v-bind="props"
|
||||
:icon="aiAgentSettingsCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'"
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
:aria-label="aiAgentSettingsCollapsed ? t('setting.about.expand') : t('setting.about.collapse')"
|
||||
@click="aiAgentSettingsCollapsed = !aiAgentSettingsCollapsed"
|
||||
/>
|
||||
</template>
|
||||
<span>{{
|
||||
aiAgentSettingsCollapsed ? t('setting.about.expand') : t('setting.about.collapse')
|
||||
}}</span>
|
||||
</VTooltip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
|
||||
:label="t('setting.system.aiAgentEnable')"
|
||||
:hint="t('setting.system.aiAgentEnableHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
|
||||
:label="t('setting.system.aiAgentGlobal')"
|
||||
:hint="t('setting.system.aiAgentGlobalHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
|
||||
:label="t('setting.system.aiAgentVerbose')"
|
||||
:hint="t('setting.system.aiAgentVerboseHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
persistent-hint
|
||||
:items="llmProviderItems"
|
||||
:loading="loadingLlmProviders"
|
||||
prepend-inner-icon="mdi-robot"
|
||||
@update:model-value="handleLlmProviderChanged"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
|
||||
<VCombobox
|
||||
:model-value="SystemSettings.Basic.LLM_BASE_URL"
|
||||
@update:model-value="
|
||||
(value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
setBaseUrlPreset(value.id, value.value)
|
||||
} else {
|
||||
setBaseUrlPreset('', value || '')
|
||||
}
|
||||
}
|
||||
"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
:placeholder="selectedLlmProvider?.default_base_url || 'https://api.deepseek.com'"
|
||||
:items="llmBaseUrlPresetItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||
:label="selectedLlmProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||
:hint="selectedLlmProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||
persistent-hint
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0" cols="12">
|
||||
<VAlert type="info" variant="tonal">
|
||||
<div class="d-flex flex-column flex-md-row justify-space-between ga-3">
|
||||
<div>
|
||||
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
|
||||
<div class="text-body-2">
|
||||
{{ selectedLlmProvider?.description || t('setting.system.llmProviderAuthHint') }}
|
||||
</div>
|
||||
<div v-if="providerConnected" class="text-body-2 mt-2">
|
||||
{{
|
||||
t('setting.system.llmProviderConnectedAs', {
|
||||
label: llmProviderAuthLabel || selectedLlmProvider?.name,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn
|
||||
v-for="method in llmProviderAuthMethods"
|
||||
:key="method.id"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-account-arrow-right-outline"
|
||||
@click="startProviderAuth(method.id)"
|
||||
>
|
||||
{{ method.label }}
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="providerConnected"
|
||||
color="error"
|
||||
variant="text"
|
||||
prepend-icon="mdi-link-off"
|
||||
@click="disconnectProviderAuth"
|
||||
>
|
||||
{{ t('setting.system.llmProviderDisconnect') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<div>
|
||||
<VExpandTransition>
|
||||
<VCardText v-show="!aiAgentSettingsCollapsed" class="pt-2">
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
|
||||
:label="t('setting.system.aiAgentEnable')"
|
||||
:hint="t('setting.system.aiAgentEnableHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
|
||||
:label="t('setting.system.aiAgentGlobal')"
|
||||
:hint="t('setting.system.aiAgentGlobalHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
|
||||
:label="t('setting.system.aiAgentVerbose')"
|
||||
:hint="t('setting.system.aiAgentVerboseHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
persistent-hint
|
||||
:items="llmProviderItems"
|
||||
:loading="loadingLlmProviders"
|
||||
prepend-inner-icon="mdi-robot"
|
||||
@update:model-value="handleLlmProviderChanged"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
|
||||
<VCombobox
|
||||
:model-value="SystemSettings.Basic.LLM_MODEL"
|
||||
:model-value="SystemSettings.Basic.LLM_BASE_URL"
|
||||
@update:model-value="
|
||||
(val: any) => {
|
||||
SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val
|
||||
handleLlmModelChanged()
|
||||
(value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
setBaseUrlPreset(value.id, value.value)
|
||||
} else {
|
||||
setBaseUrlPreset('', value || '')
|
||||
}
|
||||
}
|
||||
"
|
||||
:label="t('setting.system.llmModel')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:placeholder="t('setting.system.llmModelHint')"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
:placeholder="selectedLlmProvider?.default_base_url || 'https://api.deepseek.com'"
|
||||
:items="llmBaseUrlPresetItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
persistent-hint
|
||||
:items="llmModels"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:loading="loadingModels"
|
||||
prepend-inner-icon="mdi-brain"
|
||||
prepend-inner-icon="mdi-link"
|
||||
>
|
||||
<template #append-inner>
|
||||
<VBtn
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
@click="refreshLlmModels(true)"
|
||||
:disabled="!canRefreshModels"
|
||||
/>
|
||||
<template #item="{ props, item }">
|
||||
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||
:label="selectedLlmProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||
:hint="selectedLlmProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||
persistent-hint
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0" cols="12">
|
||||
<VAlert type="info" variant="tonal">
|
||||
<div class="d-flex flex-column flex-md-row justify-space-between ga-3">
|
||||
<div>
|
||||
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
|
||||
<div class="text-body-2">
|
||||
{{ selectedLlmProvider?.description || t('setting.system.llmProviderAuthHint') }}
|
||||
</div>
|
||||
<div v-if="providerConnected" class="text-body-2 mt-2">
|
||||
{{
|
||||
t('setting.system.llmProviderConnectedAs', {
|
||||
label: llmProviderAuthLabel || selectedLlmProvider?.name,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="selectedLlmModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||
{{ selectedLlmModelInfo }}
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn
|
||||
v-for="method in llmProviderAuthMethods"
|
||||
:key="method.id"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-account-arrow-right-outline"
|
||||
@click="startProviderAuth(method.id)"
|
||||
>
|
||||
{{ method.label }}
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="providerConnected"
|
||||
color="error"
|
||||
variant="text"
|
||||
prepend-icon="mdi-link-off"
|
||||
@click="disconnectProviderAuth"
|
||||
>
|
||||
{{ t('setting.system.llmProviderDisconnect') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<div class="d-flex justify-end mt-2">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-connection"
|
||||
:disabled="!canTestLlm"
|
||||
:loading="testingLlm"
|
||||
class="llm-test-trigger"
|
||||
@click="testLlmConnection"
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<div>
|
||||
<VCombobox
|
||||
:model-value="SystemSettings.Basic.LLM_MODEL"
|
||||
@update:model-value="
|
||||
(val: any) => {
|
||||
SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val
|
||||
handleLlmModelChanged()
|
||||
}
|
||||
"
|
||||
:label="t('setting.system.llmModel')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:placeholder="t('setting.system.llmModelHint')"
|
||||
persistent-hint
|
||||
:items="llmModels"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:loading="loadingModels"
|
||||
prepend-inner-icon="mdi-brain"
|
||||
>
|
||||
{{ t('setting.system.llmTestAction') }}
|
||||
</VBtn>
|
||||
<template #append-inner>
|
||||
<VBtn
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
@click="refreshLlmModels(true)"
|
||||
:disabled="!canRefreshModels"
|
||||
/>
|
||||
</template>
|
||||
</VCombobox>
|
||||
|
||||
<VAlert v-if="selectedLlmModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||
{{ selectedLlmModelInfo }}
|
||||
</VAlert>
|
||||
|
||||
<div class="d-flex justify-end mt-2">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-connection"
|
||||
:disabled="!canTestLlm"
|
||||
:loading="testingLlm"
|
||||
class="llm-test-trigger"
|
||||
@click="testLlmConnection"
|
||||
>
|
||||
{{ t('setting.system.llmTestAction') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
|
||||
:label="t('setting.system.llmMaxContextTokens')"
|
||||
:hint="t('setting.system.llmMaxContextTokensHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-counter"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
|
||||
:label="t('setting.system.llmThinking')"
|
||||
:hint="t('setting.system.llmThinkingHint')"
|
||||
:items="thinkingLevelItems"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.AI_AGENT_JOB_INTERVAL"
|
||||
:label="t('setting.system.aiAgentJobInterval')"
|
||||
:hint="t('setting.system.aiAgentJobIntervalHint')"
|
||||
persistent-hint
|
||||
:items="[
|
||||
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
|
||||
{ title: t('setting.system.aiAgentJobInterval1h'), value: 1 },
|
||||
{ title: t('setting.system.aiAgentJobInterval3h'), value: 3 },
|
||||
{ title: t('setting.system.aiAgentJobInterval6h'), value: 6 },
|
||||
{ title: t('setting.system.aiAgentJobInterval12h'), value: 12 },
|
||||
{ title: t('setting.system.aiAgentJobInterval24h'), value: 24 },
|
||||
{ title: t('setting.system.aiAgentJobInterval1w'), value: 168 },
|
||||
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
|
||||
]"
|
||||
prepend-inner-icon="mdi-timer-outline"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_SUPPORT_IMAGE_INPUT"
|
||||
:label="t('setting.system.llmSupportImageInput')"
|
||||
:hint="t('setting.system.llmSupportImageInputHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
:label="t('setting.system.llmSupportAudioInputOutput')"
|
||||
:hint="t('setting.system.llmSupportAudioInputOutputHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AI_VOICE_API_KEY"
|
||||
:label="t('setting.system.aiVoiceApiKey')"
|
||||
:hint="t('setting.system.aiVoiceApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AI_VOICE_BASE_URL"
|
||||
:label="t('setting.system.aiVoiceBaseUrl')"
|
||||
:hint="t('setting.system.aiVoiceBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AI_VOICE_STT_MODEL"
|
||||
:label="t('setting.system.aiVoiceSttModel')"
|
||||
:hint="t('setting.system.aiVoiceSttModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AI_VOICE_TTS_MODEL"
|
||||
:label="t('setting.system.aiVoiceTtsModel')"
|
||||
:hint="t('setting.system.aiVoiceTtsModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AI_VOICE_TTS_VOICE"
|
||||
:label="t('setting.system.aiVoiceTtsVoice')"
|
||||
:hint="t('setting.system.aiVoiceTtsVoiceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-voice"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AI_VOICE_LANGUAGE"
|
||||
:label="t('setting.system.aiVoiceLanguage')"
|
||||
:hint="t('setting.system.aiVoiceLanguageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT_OUTPUT"
|
||||
cols="12"
|
||||
>
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_VOICE_REPLY_WITH_TEXT"
|
||||
:label="t('setting.system.aiVoiceReplyWithText')"
|
||||
:hint="t('setting.system.aiVoiceReplyWithTextHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER"
|
||||
:label="t('setting.system.aiAgentRetryTransfer')"
|
||||
:hint="t('setting.system.aiAgentRetryTransferHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
:label="t('setting.system.aiRecommendEnabled')"
|
||||
:hint="t('setting.system.aiRecommendEnabledHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextarea
|
||||
v-model="SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE"
|
||||
:label="t('setting.system.aiRecommendUserPreference')"
|
||||
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
|
||||
persistent-hint
|
||||
rows="1"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-account-heart"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.AI_RECOMMEND_MAX_ITEMS"
|
||||
:label="t('setting.system.aiRecommendMaxItems')"
|
||||
:hint="t('setting.system.aiRecommendMaxItemsHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-format-list-numbered"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
|
||||
:label="t('setting.system.llmMaxContextTokens')"
|
||||
:hint="t('setting.system.llmMaxContextTokensHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-counter"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
|
||||
:label="t('setting.system.llmThinking')"
|
||||
:hint="t('setting.system.llmThinkingHint')"
|
||||
:items="thinkingLevelItems"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.AI_AGENT_JOB_INTERVAL"
|
||||
:label="t('setting.system.aiAgentJobInterval')"
|
||||
:hint="t('setting.system.aiAgentJobIntervalHint')"
|
||||
persistent-hint
|
||||
:items="[
|
||||
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
|
||||
{ title: t('setting.system.aiAgentJobInterval1h'), value: 1 },
|
||||
{ title: t('setting.system.aiAgentJobInterval3h'), value: 3 },
|
||||
{ title: t('setting.system.aiAgentJobInterval6h'), value: 6 },
|
||||
{ title: t('setting.system.aiAgentJobInterval12h'), value: 12 },
|
||||
{ title: t('setting.system.aiAgentJobInterval24h'), value: 24 },
|
||||
{ title: t('setting.system.aiAgentJobInterval1w'), value: 168 },
|
||||
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
|
||||
]"
|
||||
prepend-inner-icon="mdi-timer-outline"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_SUPPORT_IMAGE_INPUT"
|
||||
:label="t('setting.system.llmSupportImageInput')"
|
||||
:hint="t('setting.system.llmSupportImageInputHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT"
|
||||
:label="t('setting.system.llmSupportAudioInput')"
|
||||
:hint="t('setting.system.llmSupportAudioInputHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
:label="t('setting.system.llmSupportAudioOutput')"
|
||||
:hint="t('setting.system.llmSupportAudioOutputHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.AUDIO_INPUT_PROVIDER"
|
||||
:label="t('setting.system.audioInputProvider')"
|
||||
:hint="t('setting.system.audioInputProviderHint')"
|
||||
:items="audioProviderItems"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-microphone-message"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_INPUT_MODEL"
|
||||
:label="t('setting.system.audioInputModel')"
|
||||
:hint="t('setting.system.audioInputModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_INPUT_API_KEY"
|
||||
:label="t('setting.system.audioInputApiKey')"
|
||||
:hint="t('setting.system.audioInputApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_INPUT_BASE_URL"
|
||||
:label="t('setting.system.audioInputBaseUrl')"
|
||||
:hint="t('setting.system.audioInputBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_INPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_INPUT_LANGUAGE"
|
||||
:label="t('setting.system.audioInputLanguage')"
|
||||
:hint="t('setting.system.audioInputLanguageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.AUDIO_OUTPUT_PROVIDER"
|
||||
:label="t('setting.system.audioOutputProvider')"
|
||||
:hint="t('setting.system.audioOutputProviderHint')"
|
||||
:items="audioProviderItems"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-voice"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_OUTPUT_MODEL"
|
||||
:label="t('setting.system.audioOutputModel')"
|
||||
:hint="t('setting.system.audioOutputModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_OUTPUT_API_KEY"
|
||||
:label="t('setting.system.audioOutputApiKey')"
|
||||
:hint="t('setting.system.audioOutputApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_OUTPUT_BASE_URL"
|
||||
:label="t('setting.system.audioOutputBaseUrl')"
|
||||
:hint="t('setting.system.audioOutputBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.AUDIO_OUTPUT_VOICE"
|
||||
:label="t('setting.system.audioOutputVoice')"
|
||||
:hint="t('setting.system.audioOutputVoiceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-voice"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.LLM_SUPPORT_AUDIO_OUTPUT"
|
||||
cols="12"
|
||||
>
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AUDIO_OUTPUT_INCLUDE_TEXT"
|
||||
:label="t('setting.system.audioOutputIncludeText')"
|
||||
:hint="t('setting.system.audioOutputIncludeTextHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER"
|
||||
:label="t('setting.system.aiAgentRetryTransfer')"
|
||||
:hint="t('setting.system.aiAgentRetryTransferHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
:label="t('setting.system.aiRecommendEnabled')"
|
||||
:hint="t('setting.system.aiRecommendEnabledHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextarea
|
||||
v-model="SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE"
|
||||
:label="t('setting.system.aiRecommendUserPreference')"
|
||||
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
|
||||
persistent-hint
|
||||
rows="1"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-account-heart"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.AI_RECOMMEND_MAX_ITEMS"
|
||||
:label="t('setting.system.aiRecommendMaxItems')"
|
||||
:hint="t('setting.system.aiRecommendMaxItemsHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-format-list-numbered"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VExpandTransition>
|
||||
</VCard>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -1405,7 +1520,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VCardSubtitle>{{ t('setting.system.downloadersDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="downloaders"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
@@ -1421,7 +1536,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
:allow-refresh="isRequest"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
@@ -1456,7 +1571,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VCardSubtitle>{{ t('setting.system.mediaServersDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="mediaServers"
|
||||
handle=".cursor-move"
|
||||
item-key="name"
|
||||
@@ -1471,7 +1586,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
@change="onMediaServerChange"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
|
||||
@@ -116,6 +116,12 @@ const thinkingLevelItems = computed(() => [
|
||||
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
|
||||
])
|
||||
|
||||
const audioProviderItems = computed(() => [
|
||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
||||
])
|
||||
|
||||
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
||||
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
|
||||
const selectedModelInfo = computed(() => {
|
||||
@@ -390,20 +396,41 @@ onMounted(async () => {
|
||||
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.supportAudioInputOutput"
|
||||
:label="t('setting.system.llmSupportAudioInputOutput')"
|
||||
:hint="t('setting.system.llmSupportAudioInputOutputHint')"
|
||||
v-model="wizardData.agent.supportAudioInput"
|
||||
:label="t('setting.system.llmSupportAudioInput')"
|
||||
:hint="t('setting.system.llmSupportAudioInputHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<template v-if="wizardData.agent.supportAudioInputOutput">
|
||||
<template v-if="wizardData.agent.supportAudioInput">
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="wizardData.agent.audioInputProvider"
|
||||
:label="t('setting.system.audioInputProvider')"
|
||||
:hint="t('setting.system.audioInputProviderHint')"
|
||||
:items="audioProviderItems"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-microphone-message"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceApiKey"
|
||||
:label="t('setting.system.aiVoiceApiKey')"
|
||||
:hint="t('setting.system.aiVoiceApiKeyHint')"
|
||||
v-model="wizardData.agent.audioInputModel"
|
||||
:label="t('setting.system.audioInputModel')"
|
||||
:hint="t('setting.system.audioInputModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.audioInputApiKey"
|
||||
:label="t('setting.system.audioInputApiKey')"
|
||||
:hint="t('setting.system.audioInputApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
@@ -412,9 +439,9 @@ onMounted(async () => {
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceBaseUrl"
|
||||
:label="t('setting.system.aiVoiceBaseUrl')"
|
||||
:hint="t('setting.system.aiVoiceBaseUrlHint')"
|
||||
v-model="wizardData.agent.audioInputBaseUrl"
|
||||
:label="t('setting.system.audioInputBaseUrl')"
|
||||
:hint="t('setting.system.audioInputBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
@@ -422,29 +449,32 @@ onMounted(async () => {
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceSttModel"
|
||||
:label="t('setting.system.aiVoiceSttModel')"
|
||||
:hint="t('setting.system.aiVoiceSttModelHint')"
|
||||
v-model="wizardData.agent.audioInputLanguage"
|
||||
:label="t('setting.system.audioInputLanguage')"
|
||||
:hint="t('setting.system.audioInputLanguageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceTtsModel"
|
||||
:label="t('setting.system.aiVoiceTtsModel')"
|
||||
:hint="t('setting.system.aiVoiceTtsModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.supportAudioOutput"
|
||||
:label="t('setting.system.llmSupportAudioOutput')"
|
||||
:hint="t('setting.system.llmSupportAudioOutputHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<template v-if="wizardData.agent.supportAudioOutput">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceTtsVoice"
|
||||
:label="t('setting.system.aiVoiceTtsVoice')"
|
||||
:hint="t('setting.system.aiVoiceTtsVoiceHint')"
|
||||
<VSelect
|
||||
v-model="wizardData.agent.audioOutputProvider"
|
||||
:label="t('setting.system.audioOutputProvider')"
|
||||
:hint="t('setting.system.audioOutputProviderHint')"
|
||||
:items="audioProviderItems"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-voice"
|
||||
/>
|
||||
@@ -452,19 +482,50 @@ onMounted(async () => {
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceLanguage"
|
||||
:label="t('setting.system.aiVoiceLanguage')"
|
||||
:hint="t('setting.system.aiVoiceLanguageHint')"
|
||||
v-model="wizardData.agent.audioOutputModel"
|
||||
:label="t('setting.system.audioOutputModel')"
|
||||
:hint="t('setting.system.audioOutputModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.audioOutputApiKey"
|
||||
:label="t('setting.system.audioOutputApiKey')"
|
||||
:hint="t('setting.system.audioOutputApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.audioOutputBaseUrl"
|
||||
:label="t('setting.system.audioOutputBaseUrl')"
|
||||
:hint="t('setting.system.audioOutputBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.audioOutputVoice"
|
||||
:label="t('setting.system.audioOutputVoice')"
|
||||
:hint="t('setting.system.audioOutputVoiceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-voice"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.voiceReplyWithText"
|
||||
:label="t('setting.system.aiVoiceReplyWithText')"
|
||||
:hint="t('setting.system.aiVoiceReplyWithTextHint')"
|
||||
v-model="wizardData.agent.audioOutputIncludeText"
|
||||
:label="t('setting.system.audioOutputIncludeText')"
|
||||
:hint="t('setting.system.audioOutputIncludeTextHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'
|
||||
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -25,6 +22,12 @@ const route = useRoute()
|
||||
// APP 模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 拖拽排序和站点弹窗都不是站点列表首屏必需,打开对应功能时再加载。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const SiteAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/SiteAddEditDialog.vue'))
|
||||
const SiteStatisticsDialog = defineAsyncComponent(() => import('@/components/dialog/SiteStatisticsDialog.vue'))
|
||||
const SiteImportDialog = defineAsyncComponent(() => import('@/components/dialog/SiteImportDialog.vue'))
|
||||
|
||||
// 站点列表
|
||||
const siteList = ref<Site[]>([])
|
||||
|
||||
@@ -116,17 +119,27 @@ const currentFilter = computed(() => {
|
||||
})
|
||||
|
||||
// 获取站点列表数据
|
||||
async function fetchData() {
|
||||
async function fetchData(context: KeepAliveRefreshContext = {}) {
|
||||
const showLoading = !context.silent || !isRefreshed.value
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
siteList.value = await api.get('site/')
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
const [sites] = await Promise.all([
|
||||
api.get<Site[], Site[]>('site/'),
|
||||
// 站点统计在列表请求期间并行预取,减少刷新时卡片分两轮明显重绘。
|
||||
fetchSiteStats(),
|
||||
])
|
||||
siteList.value = sites
|
||||
isRefreshed.value = true
|
||||
// 获取站点列表后,获取统计数据
|
||||
await fetchSiteStats()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +311,10 @@ onBeforeMount(() => {
|
||||
fetchUserData()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (!loading.value) {
|
||||
fetchData()
|
||||
fetchUserData()
|
||||
}
|
||||
useKeepAliveRefresh(async context => {
|
||||
if (loading.value) return
|
||||
|
||||
await Promise.all([fetchData(context), fetchUserData()])
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -402,7 +414,7 @@ useDynamicButton({
|
||||
</VAlert>
|
||||
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<draggable
|
||||
<Draggable
|
||||
v-if="draggableSiteList.length > 0 && canDragSort"
|
||||
v-model="draggableSiteList"
|
||||
@end="savaSitesPriority"
|
||||
@@ -421,12 +433,13 @@ useDynamicButton({
|
||||
@refresh-stats="handleRefreshStats"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
|
||||
:items="draggableSiteList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="168"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -37,6 +38,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
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 {
|
||||
loading.value = true
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
dataList.value = await api.get('subscribe/')
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,10 +424,8 @@ onUnmounted(() => {
|
||||
window.removeEventListener('toggle-batch-mode', toggleBatchMode)
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (!loading.value) {
|
||||
fetchData()
|
||||
}
|
||||
useKeepAliveRefresh(fetchData, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
@@ -518,6 +527,7 @@ defineExpose({
|
||||
:items="displayList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="240"
|
||||
:estimated-item-height="300"
|
||||
:scroll-to-index="scrollToIndex"
|
||||
class="px-2"
|
||||
>
|
||||
|
||||
@@ -47,6 +47,13 @@ const filterParams = reactive({
|
||||
// 当前Key(用于重新加载数据)
|
||||
const currentKey = ref(0)
|
||||
|
||||
function resetData() {
|
||||
dataList.value = []
|
||||
page.value = 1
|
||||
isRefreshed.value = false
|
||||
currentKey.value++
|
||||
}
|
||||
|
||||
// TMDB电影风格字典
|
||||
const tmdbMovieGenreDict: Record<string, string> = {
|
||||
'28': t('tmdb.genreType.action'),
|
||||
@@ -99,11 +106,7 @@ const currentGenreDict = computed(() => {
|
||||
watch(
|
||||
filterParams,
|
||||
() => {
|
||||
// 重置数据
|
||||
dataList.value = []
|
||||
page.value = 1
|
||||
isRefreshed.value = false
|
||||
currentKey.value++
|
||||
resetData()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -170,6 +173,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
await nextTick()
|
||||
}
|
||||
} else {
|
||||
// 设置加载中
|
||||
|
||||
@@ -40,6 +40,13 @@ const filterParams = reactive({
|
||||
// 当前Key(用于重新加载数据)
|
||||
const currentKey = ref(0)
|
||||
|
||||
function resetData() {
|
||||
dataList.value = []
|
||||
page.value = 1
|
||||
isRefreshed.value = false
|
||||
currentKey.value++
|
||||
}
|
||||
|
||||
// TMDB电影风格字典
|
||||
const tmdbMovieGenreDict: Record<string, string> = {
|
||||
'28': t('tmdb.genreType.action'),
|
||||
@@ -94,11 +101,7 @@ watch(
|
||||
() => props.keyword,
|
||||
newKeyword => {
|
||||
keyword.value = newKeyword || ''
|
||||
// 重置页码和数据
|
||||
page.value = 1
|
||||
dataList.value = []
|
||||
isRefreshed.value = false
|
||||
currentKey.value++
|
||||
resetData()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -106,11 +109,7 @@ watch(
|
||||
watch(
|
||||
filterParams,
|
||||
() => {
|
||||
// 重置数据
|
||||
dataList.value = []
|
||||
page.value = 1
|
||||
isRefreshed.value = false
|
||||
currentKey.value++
|
||||
resetData()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -184,6 +183,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
await nextTick()
|
||||
}
|
||||
} else {
|
||||
// 设置加载中
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -46,7 +46,7 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const theme = useTheme()
|
||||
const display = useDisplay()
|
||||
const { useSSE } = useBackgroundOptimization()
|
||||
const { useSSE } = useBackground()
|
||||
|
||||
const DEFAULT_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
const MAX_LOG_LINES = 600
|
||||
|
||||
@@ -3,14 +3,11 @@ import type { Message } from '@/api/types'
|
||||
import MessageCard from '@/components/cards/MessageCard.vue'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useSSE } = useBackgroundOptimization()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['scroll'])
|
||||
const { useSSE } = useBackground()
|
||||
|
||||
// 消息列表
|
||||
const messages = ref<Message[]>([])
|
||||
@@ -33,6 +30,18 @@ const page = ref(1)
|
||||
// 存量消息最新时间
|
||||
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) {
|
||||
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[]) {
|
||||
let hasNewMessage = false
|
||||
@@ -95,14 +196,12 @@ function handleSSEMessage(event: MessageEvent) {
|
||||
if (message) {
|
||||
const object = JSON.parse(message)
|
||||
if (mergeMessages([object])) {
|
||||
nextTick(() => {
|
||||
emit('scroll') // 新消息到达时触发智能滚动
|
||||
})
|
||||
requestScrollToEnd() // 新消息到达时触发智能滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的SSE连接
|
||||
// 使用SSE连接
|
||||
const { manager, isConnected } = useSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
|
||||
handleSSEMessage,
|
||||
@@ -137,9 +236,7 @@ async function loadMessages({ done }: { done: any }) {
|
||||
|
||||
// 首次加载时滚动到底部
|
||||
if (page.value === 1 && hasNewMessage) {
|
||||
nextTick(() => {
|
||||
emit('scroll')
|
||||
})
|
||||
requestScrollToEnd(true)
|
||||
}
|
||||
// 页码+1
|
||||
page.value++
|
||||
@@ -168,9 +265,7 @@ async function refreshLatestMessages() {
|
||||
})) as Message[]
|
||||
|
||||
if (mergeMessages(latestMessages)) {
|
||||
nextTick(() => {
|
||||
emit('scroll')
|
||||
})
|
||||
requestScrollToEnd()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新最新消息失败:', error)
|
||||
@@ -206,7 +301,7 @@ function compareTime(time1: string, time2: string) {
|
||||
|
||||
// 图片加载完成时触发智能滚动
|
||||
function handleImageLoad() {
|
||||
emit('scroll')
|
||||
requestScrollToEnd()
|
||||
}
|
||||
|
||||
// 暂停SSE连接
|
||||
@@ -232,18 +327,32 @@ defineExpose({
|
||||
pauseSSE,
|
||||
resumeSSE,
|
||||
refreshLatestMessages,
|
||||
forceScrollToEnd,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 组件挂载后触发一次滚动事件
|
||||
nextTick(() => {
|
||||
emit('scroll')
|
||||
bindScrollListener()
|
||||
requestScrollToEnd(true)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollTimer) {
|
||||
window.clearTimeout(scrollTimer)
|
||||
}
|
||||
|
||||
if (scrollReleaseTimer) {
|
||||
window.clearTimeout(scrollReleaseTimer)
|
||||
}
|
||||
|
||||
unbindScrollListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VInfiniteScroll
|
||||
ref="messageListRef"
|
||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||
side="start"
|
||||
:items="messages"
|
||||
|
||||
@@ -231,6 +231,10 @@ onMounted(getModules)
|
||||
.system-health-check {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
@@ -316,6 +320,7 @@ onMounted(getModules)
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { ScheduleInfo } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackgroundOptimization()
|
||||
const { useDataRefresh } = useBackground()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -59,7 +59,7 @@ function runCommand(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的数据刷新定时器
|
||||
// 使用数据刷新定时器
|
||||
useDataRefresh(
|
||||
'scheduler-list',
|
||||
loadSchedulerList,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { User } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import UserCard from '@/components/cards/UserCard.vue'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
@@ -80,17 +81,19 @@ useDynamicButton({
|
||||
<!-- 加载中提示 -->
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<!-- 用户卡片网格 -->
|
||||
<div v-if="allUsers.length > 0 && isRefreshed" class="grid gap-4 grid-user-card px-2">
|
||||
<ProgressiveCardGrid
|
||||
v-if="allUsers.length > 0 && isRefreshed"
|
||||
:items="allUsers"
|
||||
:min-item-width="288"
|
||||
:estimated-item-height="260"
|
||||
:get-item-key="user => user.id"
|
||||
class="px-2"
|
||||
>
|
||||
<!-- 普通用户卡片 -->
|
||||
<UserCard
|
||||
v-for="user in allUsers"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
:users="allUsers"
|
||||
@remove="loadAllUsers"
|
||||
@save="loadAllUsers"
|
||||
/>
|
||||
</div>
|
||||
<template #default="{ item }">
|
||||
<UserCard :user="item" :users="allUsers" @remove="loadAllUsers" @save="loadAllUsers" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-if="allUsers.length === 0 && isRefreshed">
|
||||
|
||||
@@ -6,6 +6,7 @@ import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -52,9 +53,7 @@ onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
fetchData()
|
||||
})
|
||||
useKeepAliveRefresh(fetchData)
|
||||
|
||||
function openAddDialog() {
|
||||
addDialog.value = true
|
||||
@@ -62,6 +61,7 @@ function openAddDialog() {
|
||||
|
||||
defineExpose({
|
||||
openAddDialog,
|
||||
refresh: fetchData,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -110,6 +110,7 @@ async function fetchData({ done }: { done: any }) {
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
await nextTick()
|
||||
}
|
||||
} else {
|
||||
// 设置加载中
|
||||
@@ -145,9 +146,8 @@ function removeData(id: string) {
|
||||
dataList.value = dataList.value.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
onMounted(() => {
|
||||
loadEventTypes()
|
||||
fetchData({ done: () => {} })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user