Compare commits

..

16 Commits

Author SHA1 Message Date
jxxghp
b212e066b6 添加 .codex-qa 到 .gitignore 2026-07-02 20:29:31 +08:00
jxxghp
f7d354fca4 Refine mobile subscribe cards and grouped list styling 2026-07-02 20:28:03 +08:00
jxxghp
693b081ecf 删除设计 QA 文档 2026-07-02 19:33:14 +08:00
jxxghp
d5f0fed954 Polish app center grouped menu styling 2026-07-02 19:32:39 +08:00
jxxghp
67ef98bfee Hide dashboard recommendation detail panel 2026-07-02 19:08:07 +08:00
jxxghp
1094d80b9c Refine episode group subscriptions and mobile carousel 2026-07-02 16:34:38 +08:00
jxxghp
98e6481812 Refine dashboard recommendations and transparent cards 2026-07-02 15:47:47 +08:00
jxxghp
7db088eb79 更新 package.json 2026-07-02 14:35:38 +08:00
jxxghp
dec154c042 Rename playback status label to continue watching 2026-07-02 12:42:56 +08:00
jxxghp
d06ce5d984 Refine offline status toasts and theme styling 2026-07-02 12:36:49 +08:00
jxxghp
af604d0c5c 增强离线状态管理,添加服务探测功能和连接状态提示 2026-07-02 11:56:34 +08:00
jxxghp
fa39c3750c Limit dashboard recommendations to TMDB sources 2026-07-02 10:51:43 +08:00
jxxghp
ce64fb03ce Add media recommendations dashboard and episode groups 2026-07-01 17:35:35 +08:00
jxxghp
764f14a7f4 Highlight today in full calendar with primary color 2026-07-01 10:37:13 +08:00
jxxghp
621200b3b2 Refine subscribe files dialog layout 2026-07-01 09:57:53 +08:00
jxxghp
60c46ebbaf Make search site dialog fullscreen on small screens 2026-07-01 09:48:41 +08:00
38 changed files with 2314 additions and 602 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

1
.gitignore vendored
View File

@@ -38,3 +38,4 @@ public/plugin_icon/**
docs-lock/ docs-lock/
.trae/ .trae/
output/ output/
.codex-qa/

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.14.0", "version": "2.14.1",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": "dist/service.js", "bin": "dist/service.js",

View File

@@ -113,10 +113,13 @@ export interface NavLinkProps {
rel?: ATagRelAttrValues rel?: ATagRelAttrValues
} }
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
export interface NavLink extends NavLinkProps, Partial<AclProperties> { export interface NavLink extends NavLinkProps, Partial<AclProperties> {
title: string title: string
full_title?: string full_title?: string
icon?: unknown icon?: unknown
iconColor?: NavMenuIconColor
badgeContent?: string badgeContent?: string
badgeClass?: string badgeClass?: string
disable?: boolean disable?: boolean

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { ensureRenderComplete, removeEl } from './@core/utils/dom' import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api' import api, { type ConnectionAwareRequestConfig } from '@/api'
import { useAuthStore, useGlobalSettingsStore } from '@/stores' import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n' import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n' import { SupportedLocale } from '@/types/i18n'
@@ -23,6 +23,10 @@ import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager' import { themeManager } from '@/utils/themeManager'
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette' import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
import { configureApexChartsTheme } from '@/utils/apexCharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
import {
useGlobalOfflineStatus,
type ConnectionFailureReason,
} from '@/composables/useOfflineStatus'
const LOGIN_WALLPAPER_ROUTE = '/login' const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500 const BACKGROUND_CROSSFADE_DURATION_MS = 1500
@@ -56,6 +60,7 @@ const authStore = useAuthStore()
const isLogin = computed(() => authStore.token) const isLogin = computed(() => authStore.token)
const route = useRoute() const route = useRoute()
const { initializePWA } = usePWA() const { initializePWA } = usePWA()
const offlineStatus = useGlobalOfflineStatus()
// 全局设置store // 全局设置store
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -154,37 +159,145 @@ function handleWindowFocusRenderThrottle() {
} }
} }
// 心跳检测
let heartbeatInterval: number | null = null let heartbeatInterval: number | null = null
let connectionRetryTimer: number | null = null
let connectionProbePromise: Promise<boolean> | null = null
let connectionProbeFailures = 0
let prefersColorSchemeMediaQuery: MediaQueryList | null = null let prefersColorSchemeMediaQuery: MediaQueryList | null = null
// 启动心跳 const SERVER_PROBE_TIMEOUT_MS = 8_000
const startHeartbeat = () => { const SERVER_PROBE_FAILURE_THRESHOLD = 2
// 如果已经有心跳,则先停止 const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
if (heartbeatInterval) {
stopHeartbeat()
}
// 开始心跳任务 /** 清除等待中的服务重连任务。 */
heartbeatInterval = window.setInterval(async () => { function clearConnectionRetryTimer() {
if (!connectionRetryTimer) return
window.clearTimeout(connectionRetryTimer)
connectionRetryTimer = null
}
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
if (!offlineStatus.browserOnline.value) return 'browser-offline'
const errorCode = (error as { code?: string } | null)?.code
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
return 'server-unreachable'
}
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
function scheduleConnectionRetry() {
clearConnectionRetryTimer()
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
connectionRetryTimer = window.setTimeout(() => {
connectionRetryTimer = null
void probeServerConnection()
}, SERVER_RETRY_DELAYS_MS[retryIndex])
}
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
async function probeServerConnection(showChecking = false): Promise<boolean> {
if (!isLogin.value) return false
if (connectionProbePromise) return connectionProbePromise
clearConnectionRetryTimer()
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
const probePromise = (async () => {
try { try {
if (isLogin.value) { await api.get(
await api.get('system/ping') 'system/ping',
} {
skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig,
)
connectionProbeFailures = 0
return true
} catch (error) { } catch (error) {
console.warn('Heartbeat request failed:', error) if (!isLogin.value) {
offlineStatus.markServerOnline()
return false
}
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
connectionProbeFailures = 0
return true
}
connectionProbeFailures += 1
const failureReason = resolveProbeFailureReason(error)
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
offlineStatus.markServerOffline(failureReason)
} else {
offlineStatus.markConnectionChecking(failureReason)
}
scheduleConnectionRetry()
return false
} }
})()
connectionProbePromise = probePromise
try {
return await probePromise
} finally {
if (connectionProbePromise === probePromise) connectionProbePromise = null
}
}
/** 启动即时服务探测和五分钟在线心跳。 */
function startHeartbeat() {
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
void probeServerConnection()
heartbeatInterval = window.setInterval(async () => {
if (isLogin.value) await probeServerConnection()
}, 5 * 60 * 1000) }, 5 * 60 * 1000)
} }
// 停止心跳 /** 停止心跳和等待中的自动重连任务。 */
const stopHeartbeat = () => { function stopHeartbeat() {
if (heartbeatInterval) { if (heartbeatInterval) {
window.clearInterval(heartbeatInterval) window.clearInterval(heartbeatInterval)
heartbeatInterval = null heartbeatInterval = null
} }
clearConnectionRetryTimer()
connectionProbeFailures = 0
} }
watch(
() => offlineStatus.connectionCheckRequestId.value,
() => {
if (isLogin.value) void probeServerConnection(true)
},
)
watch(
() => offlineStatus.connectionStatus.value,
status => {
if (status !== 'online') return
connectionProbeFailures = 0
clearConnectionRetryTimer()
},
)
watch(
() => offlineStatus.browserOnline.value,
browserIsOnline => {
if (!isLogin.value) return
offlineStatus.requestConnectionCheck(browserIsOnline ? undefined : 'browser-offline')
},
)
// 更新data-theme属性以便CSS选择器能正确匹配 // 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) { function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName) document.documentElement.setAttribute('data-theme', themeName)
@@ -223,20 +336,22 @@ function handleSystemThemeChange() {
} }
} }
// 页面重新可见时同步主题,修复后台期间设置被外部修改后的外观漂移。 /** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
function handleVisibilityThemeSync() { function handleVisibilityThemeSync() {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
restoreForegroundRendering() restoreForegroundRendering()
syncThemePreferenceFromStorage() syncThemePreferenceFromStorage()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} else { } else {
throttleBackgroundRendering() throttleBackgroundRendering()
} }
} }
// 页面从缓存或重新聚焦恢复时刷新主题偏好 /** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
function handlePageShowThemeSync() { function handlePageShowThemeSync() {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
restoreForegroundRendering() restoreForegroundRendering()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} }
syncThemePreferenceFromStorage() syncThemePreferenceFromStorage()
} }
@@ -509,6 +624,7 @@ onMounted(async () => {
authenticatedStateTimer = null authenticatedStateTimer = null
} }
stopHeartbeat() stopHeartbeat()
offlineStatus.markServerOnline()
} }
}) })
}) })

View File

@@ -1,4 +1,4 @@
import axios from 'axios' import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
import router from '@/router' import router from '@/router'
import { useAuthStore } from '@/stores' import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer' import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
@@ -9,6 +9,10 @@ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
}) })
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
skipConnectionTracking?: boolean
}
// 声明全局变量类型 // 声明全局变量类型
declare global { declare global {
interface Window { interface Window {
@@ -36,36 +40,38 @@ api.interceptors.request.use(config => {
// 离线状态管理 // 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus() const globalOfflineStatus = useGlobalOfflineStatus()
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
return 'network-error'
}
return null
}
// 添加响应拦截器 // 添加响应拦截器
api.interceptors.response.use( api.interceptors.response.use(
response => { response => {
// 成功响应时,清除应用离线状态并重置连续错误计数 // 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
globalOfflineStatus.setAppOffline(false) globalOfflineStatus.markServerOnline()
globalOfflineStatus.resetConsecutiveErrors()
return response.data return response.data
}, },
error => { (error: AxiosError) => {
if (!error.response) { if (!error.response) {
// 网络错误或请求超时 - 通知离线状态管理系统 const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
const isNetworkError = const failureReason = resolveConnectionFailureReason(error)
error.code === 'NETWORK_ERROR' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED' ||
error.name === 'NetworkError'
if (isNetworkError) { // 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
let reason = 'Network connection failed' if (!requestConfig?.skipConnectionTracking && failureReason) {
if (error.code === 'ECONNABORTED') { globalOfflineStatus.reportNetworkError(failureReason)
reason = 'Request timeout'
}
// 记录网络错误,只有连续三次才会设置为离线模式
globalOfflineStatus.recordNetworkError(reason)
} }
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') { if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
// 网络连接问题 // 网络连接问题
return Promise.reject(new Error('Network connection failed, please check your network status')) return Promise.reject(new Error('Network connection failed, please check your network status'))
} else if (error.code === 'ECONNABORTED') { } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
// 请求超时 // 请求超时
return Promise.reject(new Error('Request timeout, please try again later')) return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') { } else if (error.name === 'AbortError') {

View File

@@ -30,11 +30,11 @@ const progressValue = computed(() => {
return Math.min(Math.max(percent, 0), 100) return Math.min(Math.max(percent, 0), 100)
}) })
// 是否展示继续播放状态 // 是否存在可续播进度
const hasProgress = computed(() => progressValue.value > 0) const hasProgress = computed(() => progressValue.value > 0)
// 顶部状态标签。 // 顶部状态标签,接口返回的是继续观看列表而非实时播放会话
const statusLabel = computed(() => (hasProgress.value ? '正在播放' : '待播放')) const statusLabel = computed(() => (hasProgress.value ? '继续观看' : '待播放'))
// 右上角进度标签。 // 右上角进度标签。
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW')) const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))

View File

@@ -92,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
return null return null
}) })
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
const compactStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
}
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景 // 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => { const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null if (!isEnabledFlag(props.media?.best_version)) return null
@@ -423,45 +434,143 @@ function handleCardClick() {
}" }"
min-height="150" min-height="150"
@click="handleCardClick" @click="handleCardClick"
:ripple="!props.batchMode && !props.sortable" :ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
> >
<div <div
v-if="bestVersionBadge && imageLoaded" v-if="bestVersionBadge && imageLoaded"
class="best-version-badge" class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }" :class="{ 'best-version-badge-full': bestVersionBadge.full }"
> >
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" /> <VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div> </div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4"> <div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop> <IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" /> <VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click> <VMenu activator="parent" close-on-content-click>
<VList> <VList>
<template v-for="(item, i) in dropdownItems" :key="i"> <template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click"> <VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon" /> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title" /> <VListItemTitle v-text="item.title" />
</VListItem> </VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<template v-if="display.xs.value">
<div class="subscribe-card-mobile-media">
<VImg
:src="backdropUrl || posterUrl"
:aspect-ratio="2"
cover
position="top"
@load="imageLoadHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
</template> </template>
</VList> </VImg>
</VMenu>
</IconBtn> <div
</div> v-if="props.media?.username || lastUpdateText"
<template #image> class="subscribe-card-mobile-image-meta"
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top"> :class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
<template #placeholder> >
<div class="w-full h-full"> <div
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" /> v-if="props.media?.username"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
:title="props.media?.username"
>
<VIcon icon="mdi-account" size="14" />
<span>{{ props.media?.username }}</span>
</div>
<div
v-if="lastUpdateText"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
>
<VIcon icon="mdi-download" size="14" />
<span>{{ lastUpdateText }}</span>
</div>
</div> </div>
</template> </div>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div> <div class="subscribe-card-mobile-body">
</template> <div class="subscribe-card-mobile-title">
</VImg> {{ props.media?.name }}
</template> {{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
<div> </div>
<div class="subscribe-card-mobile-footer">
<div class="subscribe-card-mobile-meta">
<div
class="subscribe-card-mobile-state"
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
:title="compactStateDisplay.label"
:aria-label="compactStateDisplay.label"
>
<VIcon :icon="compactStateDisplay.icon" size="18" />
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
</span>
</div>
<IconBtn
v-if="!props.sortable"
class="subscribe-card-mobile-menu"
size="small"
@click.stop
>
<VIcon icon="mdi-dots-horizontal" size="20" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
<VProgressLinear
:model-value="getPercentage()"
:bg-color="compactStateDisplay.color"
:color="compactStateDisplay.color"
bg-opacity="0.18"
height="4"
rounded
/>
</div>
</div>
</div>
</template>
<div v-else>
<VCardText class="flex items-center pt-3 pb-2"> <VCardText class="flex items-center pt-3 pb-2">
<div <div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative" class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
@@ -568,7 +677,7 @@ function handleCardClick() {
color="success" color="success"
/> />
</div> </div>
</div> </div>
</VCard> </VCard>
</div> </div>
</div> </div>
@@ -592,6 +701,151 @@ function handleCardClick() {
border: var(--app-card-light-border); border: var(--app-card-light-border);
} }
.subscribe-card-mobile-media {
position: relative;
overflow: hidden;
aspect-ratio: 2 / 1;
flex-shrink: 0;
inline-size: 100%;
}
.subscribe-card-mobile-media .v-img {
block-size: 100%;
}
.subscribe-card-mobile-image-meta {
position: absolute;
z-index: 2;
font-size: 0.6875rem;
font-weight: 500;
inset: 0;
pointer-events: none;
}
.subscribe-card-mobile-image-meta__item {
position: absolute;
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.25rem;
color: rgba(255, 255, 255, 0.88);
isolation: isolate;
line-height: 1.2;
padding-block: 0.0625rem;
padding-inline: 0.125rem;
}
.subscribe-card-mobile-image-meta__item::before {
position: absolute;
z-index: -1;
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.36);
content: '';
filter: blur(3px);
inset: -0.25rem -0.55rem;
}
.subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 1rem);
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
transition: inset-block-start 0.2s ease;
}
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 4.25rem);
inset-block-start: 0.5rem;
}
.subscribe-card-mobile-image-meta__user span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-card-mobile-image-meta__updated {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.76);
inset-block-end: 0.5rem;
inset-inline-end: 0.5rem;
}
.subscribe-card-mobile-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-title {
display: -webkit-box;
overflow: hidden;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.subscribe-card-mobile-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-block-start: auto;
}
.subscribe-card-mobile-meta {
display: flex;
min-inline-size: 0;
min-block-size: 2rem;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.subscribe-card-mobile-state {
display: flex;
min-inline-size: 0;
align-items: center;
flex: 1 1 auto;
gap: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
}
.subscribe-card-mobile-state span {
overflow: hidden;
text-overflow: ellipsis;
}
.subscribe-card-mobile-progress-text {
flex-shrink: 0;
}
.subscribe-card-mobile-menu {
block-size: 2rem;
min-block-size: 2rem;
inline-size: 2rem;
min-inline-size: 2rem;
flex: 0 0 2rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-progress {
display: flex;
block-size: 4px;
inline-size: 100%;
}
.subscribe-card-mobile-progress .v-progress-linear {
flex: 1 1 auto;
}
.subscribe-card-shell--selected::after { .subscribe-card-shell--selected::after {
position: absolute; position: absolute;
z-index: 5; z-index: 5;
@@ -625,7 +879,7 @@ function handleCardClick() {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
border-radius: inherit; border-radius: inherit;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400 box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
content: ''; content: '';
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
@@ -657,4 +911,30 @@ function handleCardClick() {
background: rgba(255, 255, 255, 22%); background: rgba(255, 255, 255, 22%);
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%); box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
} }
@media (width <= 599px) {
.subscribe-card {
min-block-size: 0 !important;
}
.subscribe-card-paused {
opacity: 1;
}
.subscribe-card-paused .subscribe-card-mobile-media {
filter: saturate(0.65);
opacity: 0.58;
}
.best-version-badge {
inset-inline-start: auto;
inset-inline-end: 0.5rem;
}
.subscribe-card-pending-tint::after {
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
}
}
</style> </style>

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const props = withDefaults(
defineProps<{
modelValue?: boolean
type?: 'offline' | 'online'
}>(),
{
modelValue: true,
type: 'offline',
},
)
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
// 重试连接
const retrying = ref(false)
/** 尝试请求静态资源来触发网络状态重新检测。 */
async function handleRetry() {
if (retrying.value) return
retrying.value = true
try {
await fetch('/favicon.ico?' + new Date().getTime(), {
method: 'HEAD',
cache: 'no-cache',
})
setTimeout(() => {
retrying.value = false
}, 1000)
} catch (error) {
retrying.value = false
}
}
// 状态文本
const statusText = computed(() => {
if (props.type === 'online') {
return t('app.onlineMessage')
}
return getOfflineMessage()
})
// 图标
const statusIcon = computed(() => {
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
})
// 颜色主题
const colorTheme = computed(() => {
return props.type === 'online' ? 'success' : 'error'
})
</script>
<template>
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
<VCard class="offline-dialog">
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
</div>
</div>
<VCardText class="text-center">
<h2 class="offline-title mb-4">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h2>
<p class="offline-message mb-6">
{{ statusText }}
</p>
<div class="action-section mb-6">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="default"
variant="flat"
@click="handleRetry"
>
<VIcon icon="mdi-refresh" class="me-2" />
{{ retrying ? t('common.checking') : t('common.retry') }}
</VBtn>
</div>
<div class="status-indicators">
<VChip
:color="isOnline ? 'success' : 'error'"
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
variant="tonal"
size="small"
class="me-2"
>
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
</VChip>
<VChip
:color="canPerformNetworkAction ? 'success' : 'warning'"
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
variant="tonal"
size="small"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.status-icon-wrapper {
padding-block: 24px 0;
padding-inline: 24px;
text-align: center;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: icon-pulse 3s ease-in-out infinite;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 80px;
inline-size: 80px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: icon-glow 2s ease-in-out infinite alternate;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -3px;
opacity: 0.1;
}
@keyframes icon-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes icon-glow {
0% {
opacity: 0.1;
transform: scale(1);
}
100% {
opacity: 0.3;
transform: scale(1.1);
}
}
</style>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Site } from '@/api/types' import type { Site } from '@/api/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 多语言支持 // 多语言支持
const { t } = useI18n() const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
const props = defineProps({ const props = defineProps({
sites: { sites: {
type: Array as PropType<Site[]>, type: Array as PropType<Site[]>,
@@ -84,7 +88,7 @@ const filteredSites = computed(() => {
</script> </script>
<template> <template>
<!-- Site Selection Dialog --> <!-- Site Selection Dialog -->
<VDialog max-width="40rem" fullscreen-mobile> <VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
<VCard class="site-dialog"> <VCard class="site-dialog">
<VCardItem> <VCardItem>
<template #prepend> <template #prepend>

View File

@@ -2,7 +2,7 @@
import api from '@/api' import api from '@/api'
import type { Site, TorrentInfo, SiteCategory } from '@/api/types' import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters' import { formatFileSize } from '@core/utils/formatters'
import { useDisplay } from 'vuetify' import { useDisplay, useTheme } from 'vuetify'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue' import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -13,6 +13,9 @@ const { t, locale } = useI18n()
// 响应式断点 // 响应式断点
const display = useDisplay() const display = useDisplay()
// 当前主题
const theme = useTheme()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
site: Object as PropType<Site>, site: Object as PropType<Site>,
@@ -92,9 +95,13 @@ const resultSummaryText = computed(() => {
// 是否小屏幕 // 是否小屏幕
const isMobileLayout = computed(() => display.smAndDown.value) const isMobileLayout = computed(() => display.smAndDown.value)
// 是否透明主题
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
// 移动端分页数据 // 移动端分页数据
const mobileResourceList = computed(() => resourceDataList.value) const mobileResourceList = computed(() => resourceDataList.value)
// 获取资源项唯一标识
function getResourceItemKey(item: TorrentInfo, index: number) { function getResourceItemKey(item: TorrentInfo, index: number) {
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}` return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
} }
@@ -188,10 +195,12 @@ watch(
}, },
) )
// 切换移动端搜索栏
function toggleMobileSearch() { function toggleMobileSearch() {
mobileSearchExpanded.value = !mobileSearchExpanded.value mobileSearchExpanded.value = !mobileSearchExpanded.value
} }
// 关闭移动端搜索栏
function closeMobileSearch() { function closeMobileSearch() {
mobileSearchExpanded.value = false mobileSearchExpanded.value = false
} }
@@ -480,7 +489,11 @@ onMounted(() => {
:get-item-key="getResourceItemKey" :get-item-key="getResourceItemKey"
> >
<template #default="{ item }"> <template #default="{ item }">
<VCard class="site-resource-card" variant="flat"> <VCard
class="site-resource-card"
:class="{ 'site-resource-card--transparent': isTransparentTheme }"
variant="flat"
>
<VCardText class="pa-3"> <VCardText class="pa-3">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)"> <button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis"> <div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
@@ -746,7 +759,7 @@ onMounted(() => {
background: var(--site-resource-card-bg); background: var(--site-resource-card-bg);
} }
:global(html[data-theme="transparent"]) .site-resource-card { .site-resource-card--transparent {
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity)); --site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
backdrop-filter: blur(var(--transparent-blur)); backdrop-filter: blur(var(--transparent-blur));

View File

@@ -85,6 +85,14 @@ function resolveImageUrl(url?: string) {
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE) return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
} }
/**
* 将 TMDB 缩略背景图切换为原始尺寸后生成可展示地址。
*/
function resolveHeroImageUrl(url?: string) {
const originalUrl = (url || '').replace(/\/t\/p\/w\d+\//i, '/t/p/original/')
return resolveImageUrl(originalUrl)
}
/** /**
* 根据订阅媒体类型生成集数展示文案。 * 根据订阅媒体类型生成集数展示文案。
*/ */
@@ -251,14 +259,6 @@ const missingCount = computed(() => Math.max(totalCount.value - libraryCount.val
// 顶部统计卡片 // 顶部统计卡片
const statItems = computed<FileStatItem[]>(() => [ const statItems = computed<FileStatItem[]>(() => [
{
key: 'total',
label: t('dialog.subscribeFiles.totalEpisodes'),
value: totalCount.value,
total: totalCount.value,
icon: 'mdi-view-grid-outline',
color: 'primary',
},
{ {
key: 'download', key: 'download',
label: t('dialog.subscribeFiles.downloadedCount'), label: t('dialog.subscribeFiles.downloadedCount'),
@@ -290,7 +290,7 @@ const selectedFiles = computed(() => {
// 顶部主背景图 // 顶部主背景图
const heroBackdropUrl = computed(() => { const heroBackdropUrl = computed(() => {
return resolveImageUrl(selectedEpisode.value?.backdrop || subscribe.value?.backdrop || subscribe.value?.poster) return resolveHeroImageUrl(selectedEpisode.value?.backdrop || subscribe.value?.backdrop || subscribe.value?.poster)
}) })
// 顶部海报图 // 顶部海报图
@@ -392,23 +392,22 @@ onBeforeMount(() => {
<p class="subscribe-files-hero__description"> <p class="subscribe-files-hero__description">
{{ selectedEpisode?.description || subscribe?.description || t('dialog.subscribeFiles.noOverview') }} {{ selectedEpisode?.description || subscribe?.description || t('dialog.subscribeFiles.noOverview') }}
</p> </p>
</div> <div class="subscribe-files-stats">
<div v-for="item in statItems" :key="item.key" class="subscribe-files-stat-card">
<div class="subscribe-files-stats"> <div class="subscribe-files-stat-card__icon">
<div v-for="item in statItems" :key="item.key" class="subscribe-files-stat-card"> <VIcon :icon="item.icon" :color="item.color" size="22" />
<div class="subscribe-files-stat-card__icon"> </div>
<VIcon :icon="item.icon" :color="item.color" size="22" /> <div class="subscribe-files-stat-card__content">
<div class="subscribe-files-stat-card__label">{{ item.label }}</div>
<div class="subscribe-files-stat-card__value">{{ item.value }}/{{ item.total }}</div>
</div>
<VProgressLinear
:model-value="calcPercent(item.value, item.total)"
:color="item.color"
height="3"
rounded
/>
</div> </div>
<div class="subscribe-files-stat-card__content">
<div class="subscribe-files-stat-card__label">{{ item.label }}</div>
<div class="subscribe-files-stat-card__value">{{ item.value }}</div>
</div>
<VProgressLinear
:model-value="calcPercent(item.value, item.total)"
:color="item.color"
height="3"
rounded
/>
</div> </div>
</div> </div>
</div> </div>
@@ -725,7 +724,7 @@ onBeforeMount(() => {
display: grid; display: grid;
align-items: end; align-items: end;
gap: 2rem; gap: 2rem;
grid-template-columns: 15rem minmax(0, 1fr) 14rem; grid-template-columns: 15rem minmax(0, 1fr);
padding: 3.5rem 2.25rem 2rem; padding: 3.5rem 2.25rem 2rem;
} }
@@ -803,6 +802,9 @@ onBeforeMount(() => {
.subscribe-files-stats { .subscribe-files-stats {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-block-start: 1rem;
max-inline-size: 42rem;
} }
.subscribe-files-stat-card { .subscribe-files-stat-card {
@@ -1271,8 +1273,8 @@ onBeforeMount(() => {
} }
.subscribe-files-stats { .subscribe-files-stats {
grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr)); margin-block-start: 0.75rem;
} }
.subscribe-files-stat-card { .subscribe-files-stat-card {
@@ -1282,10 +1284,7 @@ onBeforeMount(() => {
} }
.subscribe-files-stat-card__content { .subscribe-files-stat-card__content {
display: flex; display: block;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
} }
.subscribe-files-stat-card__label, .subscribe-files-stat-card__label,
@@ -1331,14 +1330,6 @@ onBeforeMount(() => {
grid-template-columns: 6.5rem minmax(0, 1fr); grid-template-columns: 6.5rem minmax(0, 1fr);
} }
.subscribe-files-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.subscribe-files-stat-card:first-child {
grid-column: 1 / -1;
}
.subscribe-files-mobile-card__header { .subscribe-files-mobile-card__header {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }

View File

@@ -32,6 +32,7 @@ const emit = defineEmits(['subscribe', 'close'])
const props = defineProps({ const props = defineProps({
media: Object as PropType<MediaInfo>, media: Object as PropType<MediaInfo>,
selectedSeason: Number, selectedSeason: Number,
initialEpisodeGroup: String,
subscribedSeasons: Array as PropType<number[]>, subscribedSeasons: Array as PropType<number[]>,
subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>, subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>,
defaultSubscribeMode: String as PropType<SubscribeMode>, defaultSubscribeMode: String as PropType<SubscribeMode>,
@@ -64,7 +65,7 @@ const isRefreshed = ref(false)
const episodeGroups = ref<{ [key: string]: any }[]>([]) const episodeGroups = ref<{ [key: string]: any }[]>([])
// 当前选择剧集组 // 当前选择剧集组
const episodeGroup = ref('') const episodeGroup = ref(props.initialEpisodeGroup ?? '')
const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [ const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
{ {
@@ -84,12 +85,14 @@ const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
}, },
]) ])
// 获取订阅模式的主题色。
function getSubscribeModeColor(mode: SubscribeMode) { function getSubscribeModeColor(mode: SubscribeMode) {
if (mode === 'normal') return 'primary' if (mode === 'normal') return 'primary'
if (mode === 'best_version') return 'warning' if (mode === 'best_version') return 'warning'
return 'success' return 'success'
} }
// 校验弹窗输入是否为支持的订阅模式。
function isSubscribeMode(value: unknown): value is SubscribeMode { function isSubscribeMode(value: unknown): value is SubscribeMode {
return value === 'normal' || value === 'best_version' || value === 'best_version_full' return value === 'normal' || value === 'best_version' || value === 'best_version_full'
} }
@@ -265,6 +268,7 @@ function getYear(airDate: string) {
return date.getFullYear() return date.getFullYear()
} }
// 切换当前剧集组并清空上一组的派生数据。
function setEpisodeGroup(value: string) { function setEpisodeGroup(value: string) {
if (episodeGroup.value === value) return if (episodeGroup.value === value) return
@@ -273,6 +277,7 @@ function setEpisodeGroup(value: string) {
episodeGroup.value = value episodeGroup.value = value
} }
// 提交当前剧集组下选中的季及其订阅模式。
function subscribeSeasons() { function subscribeSeasons() {
const selectedSeasons = seasonInfos.value.filter(item => { const selectedSeasons = seasonInfos.value.filter(item => {
const seasonNumber = item.season_number ?? null const seasonNumber = item.season_number ?? null
@@ -289,6 +294,7 @@ function subscribeSeasons() {
) )
} }
// 写入指定季的订阅模式。
function setSeasonMode(season: number, mode: SubscribeMode) { function setSeasonMode(season: number, mode: SubscribeMode) {
seasonModes.value = { seasonModes.value = {
...seasonModes.value, ...seasonModes.value,
@@ -296,22 +302,26 @@ function setSeasonMode(season: number, mode: SubscribeMode) {
} }
} }
// 处理用户手动切换指定季的订阅模式。
function updateSeasonMode(season: number, mode: unknown) { function updateSeasonMode(season: number, mode: unknown) {
if (!isSubscribeMode(mode)) return if (!isSubscribeMode(mode)) return
manuallySelectedModeSeasons.value.add(season) manuallySelectedModeSeasons.value.add(season)
setSeasonMode(season, mode) setSeasonMode(season, mode)
} }
// 根据入库状态和系统配置计算指定季的默认订阅模式。
function getDefaultSeasonMode(season: number) { function getDefaultSeasonMode(season: number) {
if (!seasonsNotExisted.value[season]) return 'best_version_full' if (!seasonsNotExisted.value[season]) return 'best_version_full'
return props.defaultSubscribeMode ?? 'normal' return props.defaultSubscribeMode ?? 'normal'
} }
// 确保指定季已初始化订阅模式。
function ensureSeasonMode(season: number) { function ensureSeasonMode(season: number) {
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season)) if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
} }
// 在入库状态刷新后同步尚未手动修改的默认模式。
function syncDefaultSeasonModes() { function syncDefaultSeasonModes() {
seasonsSelected.value.forEach(season => { seasonsSelected.value.forEach(season => {
if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return
@@ -319,14 +329,17 @@ function syncDefaultSeasonModes() {
}) })
} }
// 判断指定季是否已有订阅。
function isSeasonSubscribed(season: number) { function isSeasonSubscribed(season: number) {
return subscribedSeasonSet.value.has(season) return subscribedSeasonSet.value.has(season)
} }
// 判断指定季是否在本次提交选择中。
function isSeasonSelected(season: number) { function isSeasonSelected(season: number) {
return selectedSeasonSet.value.has(season) return selectedSeasonSet.value.has(season)
} }
// 设置指定季的选择状态并同步其订阅模式。
function setSeasonSelected(season: number, selected: boolean | null) { function setSeasonSelected(season: number, selected: boolean | null) {
const nextSeasons = new Set(seasonsSelected.value) const nextSeasons = new Set(seasonsSelected.value)
if (selected) { if (selected) {
@@ -338,10 +351,12 @@ function setSeasonSelected(season: number, selected: boolean | null) {
seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b) seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b)
} }
// 切换指定季的选择状态。
function toggleSeasonSelected(season: number) { function toggleSeasonSelected(season: number) {
setSeasonSelected(season, !isSeasonSelected(season)) setSeasonSelected(season, !isSeasonSelected(season))
} }
// 将入口预选季和已有订阅同步到当前剧集组的可见季列表。
function syncSelectedSeason() { function syncSelectedSeason() {
if (!seasonInfos.value.length) return if (!seasonInfos.value.length) return
@@ -381,7 +396,8 @@ watch(() => props.subscribedSeasons, syncSelectedSeason)
watch(() => props.subscribedSeasonModes, syncSelectedSeason) watch(() => props.subscribedSeasonModes, syncSelectedSeason)
onMounted(async () => { onMounted(async () => {
getMediaSeasons() // 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
if (!episodeGroup.value) getMediaSeasons()
getEpisodeGroups() getEpisodeGroups()
checkSeasonsNotExists() checkSeasonsNotExists()
}) })

View File

@@ -24,6 +24,7 @@ const asyncDashboardOptions = {
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = { const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'), storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'), mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
mediaRecommend: () => import('@/views/dashboard/MediaRecommend.vue'),
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'), weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'), speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'), scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
@@ -68,6 +69,7 @@ function createAsyncDashboardComponent(id: string) {
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。 // 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
const AnalyticsStorage = createAsyncDashboardComponent('storage') const AnalyticsStorage = createAsyncDashboardComponent('storage')
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic') const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
const MediaRecommend = createAsyncDashboardComponent('mediaRecommend')
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview') const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
const AnalyticsSpeed = createAsyncDashboardComponent('speed') const AnalyticsSpeed = createAsyncDashboardComponent('speed')
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler') const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
@@ -202,6 +204,7 @@ onUnmounted(() => {
<!-- 系统内置的仪表板 --> <!-- 系统内置的仪表板 -->
<AnalyticsStorage v-if="config?.id === 'storage'" /> <AnalyticsStorage v-if="config?.id === 'storage'" />
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" /> <AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" /> <AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" /> <AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" /> <AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />

View File

@@ -60,6 +60,9 @@ const trailingSpaceWidth = computed(() => {
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0) return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
}) })
/**
* 获取容器宽度不可用时的兜底视口宽度。
*/
function getFallbackViewportWidth() { function getFallbackViewportWidth() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return itemStep.value * Math.max(props.overscanItems, 1) return itemStep.value * Math.max(props.overscanItems, 1)
@@ -69,6 +72,9 @@ function getFallbackViewportWidth() {
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1)) return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
} }
/**
* 解析虚拟列表项的稳定 key。
*/
function resolveItemKey(item: any, index: number) { function resolveItemKey(item: any, index: number) {
if (props.getItemKey) { if (props.getItemKey) {
return props.getItemKey(item, startIndex.value + index) return props.getItemKey(item, startIndex.value + index)
@@ -77,6 +83,9 @@ function resolveItemKey(item: any, index: number) {
return startIndex.value + index return startIndex.value + index
} }
/**
* 重置滚动状态指示计时器。
*/
function resetScrollIndicatorTimer() { function resetScrollIndicatorTimer() {
isScrolling.value = true isScrolling.value = true
if (scrollTimeout) { if (scrollTimeout) {
@@ -88,6 +97,9 @@ function resetScrollIndicatorTimer() {
}, scrollTimeoutDuration) }, scrollTimeoutDuration)
} }
/**
* 根据当前滚动位置更新虚拟渲染范围。
*/
function updateVisibleRange() { function updateVisibleRange() {
const element = slideContentRef.value const element = slideContentRef.value
if (!element) { if (!element) {
@@ -113,6 +125,9 @@ function updateVisibleRange() {
endIndex.value = Math.max(firstVisible + 1, lastVisible) endIndex.value = Math.max(firstVisible + 1, lastVisible)
} }
/**
* 同步左右导航按钮的可用状态。
*/
function updateDisabledState() { function updateDisabledState() {
const element = slideContentRef.value const element = slideContentRef.value
if (!element) return if (!element) return
@@ -130,11 +145,17 @@ function updateDisabledState() {
} }
} }
/**
* 同步虚拟列表布局与导航状态。
*/
function syncLayoutState() { function syncLayoutState() {
updateVisibleRange() updateVisibleRange()
updateDisabledState() updateDisabledState()
} }
/**
* 按当前可视范围向左或向右滚动一屏。
*/
function slideNext(next: boolean) { function slideNext(next: boolean) {
const element = slideContentRef.value const element = slideContentRef.value
if (!element) return if (!element) return
@@ -159,6 +180,9 @@ function slideNext(next: boolean) {
resetScrollIndicatorTimer() resetScrollIndicatorTimer()
} }
/**
* 处理内容滚动并刷新滚动指示状态。
*/
function handleContentScroll() { function handleContentScroll() {
syncLayoutState() syncLayoutState()
resetScrollIndicatorTimer() resetScrollIndicatorTimer()
@@ -257,28 +281,20 @@ watch(
<VBtn <VBtn
v-show="disabled !== 0 && disabled !== 3 && !isTouch" v-show="disabled !== 0 && disabled !== 3 && !isTouch"
class="nav-button nav-button-left" class="nav-button nav-button-left"
variant="text" variant="tonal"
icon icon="mdi-chevron-left"
color="secondary" color="white"
@click.stop="slideNext(false)" @click.stop="slideNext(false)"
> />
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
</svg>
</VBtn>
<VBtn <VBtn
v-show="disabled !== 2 && disabled !== 3 && !isTouch" v-show="disabled !== 2 && disabled !== 3 && !isTouch"
class="nav-button nav-button-right" class="nav-button nav-button-right"
variant="text" variant="tonal"
icon icon="mdi-chevron-right"
color="secondary" color="white"
@click.stop="slideNext(true)" @click.stop="slideNext(true)"
> />
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</VBtn>
</div> </div>
</div> </div>
</template> </template>
@@ -402,17 +418,18 @@ watch(
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
border: 1px solid rgba(255, 255, 255, 14%);
border-radius: 50%; border-radius: 50%;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
background-color: rgba(var(--v-theme-background), 0.3); background: rgba(8, 18, 28, 52%) !important;
block-size: 36px; block-size: 40px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%); box-shadow: 0 8px 22px rgba(0, 0, 0, 22%);
color: rgb(255, 255, 255);
cursor: pointer; cursor: pointer;
inline-size: 36px; inline-size: 40px;
inset-block-start: 50%; inset-block-start: 50%;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
transform: translateY(-50%); transform: translateY(-50%);
transition: transition:
opacity 0.3s ease, opacity 0.3s ease,
@@ -421,21 +438,22 @@ watch(
box-shadow 0.3s ease, box-shadow 0.3s ease,
border-color 0.3s ease; border-color 0.3s ease;
svg { :deep(.v-icon) {
block-size: 22px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 55%));
fill: currentcolor; font-size: 28px;
filter: none; opacity: 1;
inline-size: 22px; transition: transform 0.3s ease;
opacity: 0.7;
transition: all 0.3s ease;
} }
&:hover { &:hover {
color: rgb(var(--v-theme-primary)); border-color: rgba(255, 255, 255, 28%);
background: rgba(8, 18, 28, 68%) !important;
box-shadow: 0 10px 26px rgba(0, 0, 0, 28%);
color: rgb(255, 255, 255);
transform: translateY(-50%) scale(1.05); transform: translateY(-50%) scale(1.05);
svg { :deep(.v-icon) {
opacity: 1; transform: scale(1.08);
} }
} }
} }

View File

@@ -49,6 +49,7 @@ const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/di
export type SeasonSubscribeModes = Record<number, SubscribeMode> export type SeasonSubscribeModes = Record<number, SubscribeMode>
// 生成跨媒体源稳定的订阅媒体标识。
export function getMediaSubscribeId(media?: MediaInfo) { export function getMediaSubscribeId(media?: MediaInfo) {
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}` if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
if (media?.douban_id) return `douban:${media.douban_id}` if (media?.douban_id) return `douban:${media.douban_id}`
@@ -56,6 +57,7 @@ export function getMediaSubscribeId(media?: MediaInfo) {
return `${media?.mediaid_prefix}:${media?.media_id}` return `${media?.mediaid_prefix}:${media?.media_id}`
} }
// 将订阅模式转换为后端订阅字段。
function getSubscribePayload(mode: SubscribeMode): SubscribePayload { function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
return { return {
best_version: mode === 'normal' ? 0 : 1, best_version: mode === 'normal' ? 0 : 1,
@@ -63,16 +65,19 @@ function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
} }
} }
// 兼容布尔值和数字、字符串形式的开关值。
function isEnabledFlag(value: unknown) { function isEnabledFlag(value: unknown) {
return value === true || value === 1 || value === '1' return value === true || value === 1 || value === '1'
} }
// 从订阅字段解析统一的订阅模式。
export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode { export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode {
if (!isEnabledFlag(subscribe.best_version)) return 'normal' if (!isEnabledFlag(subscribe.best_version)) return 'normal'
return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version' return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version'
} }
// 从默认订阅配置解析订阅模式。
function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode { function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
return getSubscribeMode({ return getSubscribeMode({
best_version: config?.best_version, best_version: config?.best_version,
@@ -80,30 +85,36 @@ function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
}) })
} }
// 获取订阅模式的本地化名称。
function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) { function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
if (mode === 'normal') return t('dialog.subscribeMode.normal') if (mode === 'normal') return t('dialog.subscribeMode.normal')
if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode') if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode')
return t('dialog.subscribeMode.bestVersionFull') return t('dialog.subscribeMode.bestVersionFull')
} }
// 封装媒体卡片与详情页共用的订阅交互。
export function useMediaSubscribe(options: UseMediaSubscribeOptions) { export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const { t } = useI18n() const { t } = useI18n()
const $toast = useToast() const $toast = useToast()
const createConfirm = useConfirm() const createConfirm = useConfirm()
const episodeGroup = ref('') const episodeGroup = ref('')
// 获取调用方当前媒体,避免在异步流程中持有旧对象。
function currentMedia() { function currentMedia() {
return options.media() return options.media()
} }
// 获取当前媒体的统一订阅标识。
function getMediaId() { function getMediaId() {
return getMediaSubscribeId(currentMedia()) return getMediaSubscribeId(currentMedia())
} }
// 获取主订阅入口默认对应的季号。
function getPrimarySeason() { function getPrimarySeason() {
return options.primarySeason?.() ?? currentMedia()?.season ?? null return options.primarySeason?.() ?? currentMedia()?.season ?? null
} }
// 同步调用方状态和订阅状态缓存。
function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') { function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') {
const media = currentMedia() const media = currentMedia()
@@ -143,6 +154,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
} }
// 打开已创建订阅的编辑弹窗。
function openSubscribeEditDialog(subid: number) { function openSubscribeEditDialog(subid: number) {
openSharedDialog( openSharedDialog(
SubscribeEditDialog, SubscribeEditDialog,
@@ -160,6 +172,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
) )
} }
// 打开订阅模式选择弹窗并转换选择结果。
function openSubscribeModeDialog( function openSubscribeModeDialog(
modes: SubscribeMode[], modes: SubscribeMode[],
choose: (payload: SubscribePayload, mode: SubscribeMode) => void, choose: (payload: SubscribePayload, mode: SubscribeMode) => void,
@@ -174,7 +187,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
) )
} }
async function openSubscribeSeasonDialog(selectedSeason?: number | null) { // 打开季订阅弹窗,并保留发起入口当前使用的剧集组。
async function openSubscribeSeasonDialog(selectedSeason?: number | null, initialEpisodeGroup = '') {
const media = currentMedia() const media = currentMedia()
if (!media) return if (!media) return
const defaultSubscribeConfig = await queryDefaultSubscribeConfig() const defaultSubscribeConfig = await queryDefaultSubscribeConfig()
@@ -184,6 +198,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
{ {
media, media,
selectedSeason, selectedSeason,
initialEpisodeGroup,
subscribedSeasons: options.subscribedSeasons?.value ?? [], subscribedSeasons: options.subscribedSeasons?.value ?? [],
subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {}, subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {},
defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig), defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig),
@@ -195,6 +210,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
) )
} }
// 查询系统默认订阅配置。
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> { async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
if (!options.canSubscribe()) return undefined if (!options.canSubscribe()) return undefined
@@ -214,6 +230,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
return undefined return undefined
} }
// 展示订阅新增结果通知。
function showSubscribeAddToast( function showSubscribeAddToast(
result: boolean, result: boolean,
title: string, title: string,
@@ -229,6 +246,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`) else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`)
} }
// 创建指定季和模式的订阅。
async function addSubscribe( async function addSubscribe(
season: number | null = null, season: number | null = null,
payload: SubscribePayload = {}, payload: SubscribePayload = {},
@@ -267,6 +285,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
} }
// 删除指定季的订阅。
async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) { async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) {
if (removeOptions.confirm ?? true) { if (removeOptions.confirm ?? true) {
const confirmed = await createConfirm({ const confirmed = await createConfirm({
@@ -302,6 +321,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
} }
// 检查当前媒体指定季是否已订阅。
async function checkSubscribe(season: number | null = null) { async function checkSubscribe(season: number | null = null) {
try { try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, { const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
@@ -319,6 +339,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
} }
// 查询当前媒体指定季的订阅记录。
async function querySubscribe(season: number | null = null) { async function querySubscribe(season: number | null = null) {
try { try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, { const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
@@ -336,6 +357,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
} }
// 更新已有单季订阅的模式。
async function updateSubscribeMode(season: number, mode: SubscribeMode) { async function updateSubscribeMode(season: number, mode: SubscribeMode) {
const media = currentMedia() const media = currentMedia()
if (!media) return if (!media) return
@@ -368,15 +390,17 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
} }
function handleSeasonSubscribe(season: number) { // 处理单季订阅入口,未订阅时将当前剧集组带入季选择弹窗。
function handleSeasonSubscribe(season: number, initialEpisodeGroup = '') {
if (options.seasonsSubscribed?.value[season]) { if (options.seasonsSubscribed?.value[season]) {
removeSubscribe(season) removeSubscribe(season)
return return
} }
openSubscribeSeasonDialog(season) openSubscribeSeasonDialog(season, initialEpisodeGroup)
} }
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
function handlePrimarySubscribe() { function handlePrimarySubscribe() {
const media = currentMedia() const media = currentMedia()
if (!media) return if (!media) return
@@ -401,15 +425,17 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
addSubscribe(null) addSubscribe(null)
} }
function handleSubscribe(season?: number | null) { // 根据是否指定季号分发主订阅或单季订阅操作。
function handleSubscribe(season?: number | null, initialEpisodeGroup = '') {
if (season !== undefined && season !== null) { if (season !== undefined && season !== null) {
handleSeasonSubscribe(season) handleSeasonSubscribe(season, initialEpisodeGroup)
return return
} }
handlePrimarySubscribe() handlePrimarySubscribe()
} }
// 批量对齐弹窗中选择的季、订阅模式和当前订阅状态。
function subscribeSeasons( function subscribeSeasons(
seasons: MediaSeason[] = [], seasons: MediaSeason[] = [],
seasonExistsStates: { [key: number]: number } = {}, seasonExistsStates: { [key: number]: number } = {},

View File

@@ -1,86 +1,82 @@
import { ref, computed } from 'vue' import { computed, ref } from 'vue'
import { useOnline } from '@vueuse/core' import { useOnline } from '@vueuse/core'
// 全局状态 export type ConnectionStatus = 'online' | 'checking' | 'offline'
const isAppOffline = ref(false) export type ConnectionFailureReason = 'browser-offline' | 'network-error' | 'timeout' | 'server-unreachable'
const appOfflineReason = ref('')
const consecutiveNetworkErrors = ref(0)
const MAX_CONSECUTIVE_ERRORS = 3
// 全局离线状态管理 const browserOnline = useOnline()
const connectionStatus = ref<ConnectionStatus>('online')
const connectionReason = ref<ConnectionFailureReason | null>(null)
const connectionCheckRequestId = ref(0)
const serverSuccessSequence = ref(0)
/** 管理 MoviePilot 服务的全局连接状态。 */
export function useGlobalOfflineStatus() { export function useGlobalOfflineStatus() {
const isOnline = useOnline() const isOnline = computed(() => connectionStatus.value === 'online')
const isChecking = computed(() => connectionStatus.value === 'checking')
const isOffline = computed(() => connectionStatus.value === 'offline')
const canPerformNetworkAction = computed(() => connectionStatus.value !== 'offline')
// 综合离线状态(网络离线 或 应用离线) /** 记录任意 MoviePilot API 成功响应并恢复在线状态。 */
const isOffline = computed(() => !isOnline.value || isAppOffline.value) function markServerOnline() {
connectionStatus.value = 'online'
// 是否可以执行网络操作 connectionReason.value = null
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value) serverSuccessSequence.value += 1
// 设置应用离线状态
const setAppOffline = (offline: boolean, reason?: string) => {
isAppOffline.value = offline
appOfflineReason.value = reason || ''
// 如果设置为在线状态,重置连续错误计数
if (!offline) {
consecutiveNetworkErrors.value = 0
}
} }
// 记录网络错误 /** 将连接状态标记为待确认,但不直接阻断页面操作。 */
const recordNetworkError = (reason?: string) => { function markConnectionChecking(reason?: ConnectionFailureReason) {
consecutiveNetworkErrors.value++ connectionStatus.value = 'checking'
if (reason) connectionReason.value = reason
// 只有连续出现三次网络错误时才设置为离线模式
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
}
} }
// 重置连续错误计数 /** 在权威探测失败后标记 MoviePilot 服务不可达。 */
const resetConsecutiveErrors = () => { function markServerOffline(reason: ConnectionFailureReason = 'server-unreachable') {
consecutiveNetworkErrors.value = 0 connectionStatus.value = 'offline'
connectionReason.value = reason
} }
// 获取离线消息 /** 将普通请求的网络错误降级为待确认状态,并请求一次去重后的服务探测。 */
const getOfflineMessage = () => { function reportNetworkError(reason: ConnectionFailureReason = 'network-error') {
if (!isOnline.value) { markConnectionChecking(reason)
return appOfflineReason.value connectionCheckRequestId.value += 1
} }
if (isAppOffline.value) {
return appOfflineReason.value /** 请求立即检查 MoviePilot 服务连接。 */
} function requestConnectionCheck(reason?: ConnectionFailureReason) {
return '' markConnectionChecking(reason)
connectionCheckRequestId.value += 1
} }
return { return {
browserOnline,
connectionStatus,
connectionReason,
connectionCheckRequestId,
serverSuccessSequence,
isOnline, isOnline,
isChecking,
isOffline, isOffline,
canPerformNetworkAction, canPerformNetworkAction,
setAppOffline, markServerOnline,
recordNetworkError, markConnectionChecking,
resetConsecutiveErrors, markServerOffline,
getOfflineMessage, reportNetworkError,
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value), requestConnectionCheck,
} }
} }
// 单个组件的离线状态 /** 为单个组件提供 MoviePilot 服务连接状态。 */
export function useOfflineStatus(initialMessage?: string) { export function useOfflineStatus() {
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus() const status = useGlobalOfflineStatus()
const message = computed(() => {
if (initialMessage) {
return initialMessage
}
return getOfflineMessage()
})
return { return {
isOnline, browserOnline: status.browserOnline,
isOffline, isOnline: status.isOnline,
canPerformNetworkAction, isChecking: status.isChecking,
message, isOffline: status.isOffline,
canPerformNetworkAction: status.canPerformNetworkAction,
connectionReason: status.connectionReason,
requestConnectionCheck: status.requestConnectionCheck,
} }
} }

View File

@@ -107,7 +107,7 @@ const mainContentPaddingTop = computed(() => {
const showPluginQuickAccess = ref(false) const showPluginQuickAccess = ref(false)
// 离线状态管理 // 离线状态管理
const { setAppOffline, isOffline } = useGlobalOfflineStatus() const { isOffline } = useGlobalOfflineStatus()
// 动态标签页相关 // 动态标签页相关
// 定义动态标签页类型 // 定义动态标签页类型
@@ -227,17 +227,6 @@ onUnmounted(() => {
} }
}) })
/** 处理 Service Worker 推送的离线状态消息。 */
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'OFFLINE_STATUS') {
if (event.data.offline) {
setAppOffline(true, t('common.serverConnectionFailed'))
} else {
setAppOffline(false)
}
}
}
/** 判断当前页面状态是否允许使用主界面下拉快捷入口手势。 */ /** 判断当前页面状态是否允许使用主界面下拉快捷入口手势。 */
const canUsePullGesture = () => { const canUsePullGesture = () => {
// 检查是否在dashboard页面 // 检查是否在dashboard页面
@@ -468,18 +457,10 @@ onMounted(async () => {
await pluginSidebarNavStore.ensureSidebarNav() await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus() appendPluginSidebarMenus()
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
// 组件卸载时清理监听 // 组件卸载时清理监听
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen) window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
}) })
}) })
</script> </script>

View File

@@ -1,66 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus' import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { useToast } from 'vue-toastification'
const OfflineStatusDialog = defineAsyncComponent(() => import('@/components/dialog/OfflineStatusDialog.vue')) const { t } = useI18n()
const toast = useToast()
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
const lastConnectionPromptKey = ref('')
interface Props { const isChecking = computed(() => connectionStatus.value === 'checking')
type?: 'offline' | 'online' const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
} const statusMessage = computed(() => {
if (connectionReason.value === 'browser-offline') return t('app.browserOfflineMessage')
const props = withDefaults(defineProps<Props>(), { if (connectionReason.value === 'timeout') return t('app.serviceTimeoutMessage')
type: 'offline', if (isChecking.value) return t('app.connectionCheckingMessage')
return t('app.serviceUnavailableMessage')
}) })
const { canPerformNetworkAction } = useGlobalOfflineStatus() /** 拼接离线状态提示文案,供 Toast 或 Agent 助手气泡展示。 */
let offlineDialogController: ReturnType<typeof openSharedDialog> | null = null function buildConnectionPromptMessage() {
return `${statusTitle.value}${statusMessage.value}`
}
/** 打开离线状态共享弹窗。 */ /** 根据当前连接状态选择 Toast 级别Agent 助手可用时会由全局 Toast 路由接管。 */
function showOfflineDialog() { function showConnectionPrompt() {
if (offlineDialogController) { const message = buildConnectionPromptMessage()
offlineDialogController.updateProps({ type: props.type }) const options = {
timeout: isChecking.value ? 5000 : 7000,
}
if (isChecking.value) {
toast.warning(message, options)
return return
} }
offlineDialogController = openSharedDialog( toast.error(message, options)
OfflineStatusDialog,
{
type: props.type,
},
{},
{ closeOn: false },
)
} }
/** 关闭离线状态共享弹窗。 */ /** 在连接状态变化时发出一次离线提示,并在恢复在线后允许下一轮提示重新出现。 */
function closeOfflineDialog() { function handleConnectionStatusChange() {
offlineDialogController?.close() if (connectionStatus.value === 'online') {
offlineDialogController = null lastConnectionPromptKey.value = ''
return
}
const promptKey = `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
if (promptKey === lastConnectionPromptKey.value) return
lastConnectionPromptKey.value = promptKey
showConnectionPrompt()
} }
watch( watch([connectionStatus, connectionReason], handleConnectionStatusChange, {
() => canPerformNetworkAction.value, flush: 'post',
canPerform => {
if (canPerform) {
closeOfflineDialog()
return
}
showOfflineDialog()
},
{ immediate: true },
)
watch(
() => props.type,
() => {
offlineDialogController?.updateProps({ type: props.type })
},
)
onUnmounted(() => {
closeOfflineDialog()
}) })
</script> </script>
<template></template> <template>
<span class="d-none" aria-hidden="true" />
</template>

View File

@@ -211,6 +211,13 @@ export default {
restartFailed: 'Restart failed, please check system status', restartFailed: 'Restart failed, please check system status',
offline: 'Application Offline', offline: 'Application Offline',
offlineMessage: 'Network connection lost, some features may be limited', offlineMessage: 'Network connection lost, some features may be limited',
connectionChecking: 'Reconnecting to MoviePilot',
connectionCheckingMessage: 'Checking the service status. You can continue browsing this page.',
serviceUnavailable: 'MoviePilot is temporarily unavailable',
serviceUnavailableMessage: 'The service may be starting up, or the reverse proxy may be temporarily unavailable.',
serviceTimeoutMessage: 'The MoviePilot service timed out. It will retry automatically.',
browserOfflineMessage: 'The browser reports no network connection. Trying MoviePilot directly.',
continueBrowsing: 'Continue browsing',
online: 'Application Online', online: 'Application Online',
onlineMessage: 'Network connection restored', onlineMessage: 'Network connection restored',
}, },
@@ -960,6 +967,13 @@ export default {
storage: 'Storage', storage: 'Storage',
storageSummary: '{available} available / {total} total', storageSummary: '{available} available / {total} total',
mediaStatistic: 'Media Statistics', mediaStatistic: 'Media Statistics',
recommendedMedia: 'Recommended Media',
selectRecommendSource: 'Select recommendation source',
previousRecommend: 'Previous recommendation',
nextRecommend: 'Next recommendation',
showRecommend: 'Show recommendation {index}',
noRecommendations: 'No recommendations from this source',
recommendLoadFailed: 'Failed to load recommendations',
weeklyOverview: 'Import Statistics', weeklyOverview: 'Import Statistics',
realTimeSpeed: 'Real-time Speed', realTimeSpeed: 'Real-time Speed',
scheduler: 'Background Tasks', scheduler: 'Background Tasks',
@@ -1047,6 +1061,14 @@ export default {
specials: 'Specials', specials: 'Specials',
seasonNumber: 'Season {number}', seasonNumber: 'Season {number}',
episodeCount: '{count} Episodes', episodeCount: '{count} Episodes',
episodeGroups: {
select: 'Select Episode Group',
default: 'Default',
summary: '{seasons} Seasons · {episodes} Episodes',
current: 'Current: {name} · {seasons} Seasons · {episodes} Episodes',
previous: 'View previous episode groups',
next: 'View more episode groups',
},
actions: { actions: {
searchResource: 'Search Resource', searchResource: 'Search Resource',
searchSubtitle: 'Search Subtitle', searchSubtitle: 'Search Subtitle',

View File

@@ -207,6 +207,13 @@ export default {
restartFailed: '重启失败,请检查系统状态', restartFailed: '重启失败,请检查系统状态',
offline: '应用已离线', offline: '应用已离线',
offlineMessage: '网络连接已断开,部分功能可能受限', offlineMessage: '网络连接已断开,部分功能可能受限',
connectionChecking: '正在重新连接 MoviePilot',
connectionCheckingMessage: '正在确认服务状态,当前页面仍可继续浏览。',
serviceUnavailable: '暂时无法连接 MoviePilot',
serviceUnavailableMessage: '服务可能正在启动,或反向代理暂时不可用。',
serviceTimeoutMessage: 'MoviePilot 服务响应超时,稍后将自动重试。',
browserOfflineMessage: '浏览器报告网络不可用,正在尝试直接连接 MoviePilot。',
continueBrowsing: '继续浏览',
online: '应用在线', online: '应用在线',
onlineMessage: '网络连接已恢复', onlineMessage: '网络连接已恢复',
}, },
@@ -952,6 +959,13 @@ export default {
storage: '存储空间', storage: '存储空间',
storageSummary: '可用 {available} / 总容量 {total}', storageSummary: '可用 {available} / 总容量 {total}',
mediaStatistic: '媒体统计', mediaStatistic: '媒体统计',
recommendedMedia: '推荐媒体',
selectRecommendSource: '选择推荐媒体来源',
previousRecommend: '上一项推荐',
nextRecommend: '下一项推荐',
showRecommend: '查看第 {index} 项推荐',
noRecommendations: '当前来源暂无推荐媒体',
recommendLoadFailed: '推荐媒体加载失败',
weeklyOverview: '入库统计', weeklyOverview: '入库统计',
realTimeSpeed: '实时速率', realTimeSpeed: '实时速率',
scheduler: '后台任务', scheduler: '后台任务',
@@ -1041,6 +1055,14 @@ export default {
specials: '特别篇', specials: '特别篇',
seasonNumber: '第 {number} 季', seasonNumber: '第 {number} 季',
episodeCount: '{count}集', episodeCount: '{count}集',
episodeGroups: {
select: '选择剧集组',
default: '默认',
summary: '{seasons} 季 · {episodes} 集',
current: '当前:{name} · {seasons} 季 · {episodes} 集',
previous: '查看上一组剧集组',
next: '查看更多剧集组',
},
actions: { actions: {
searchResource: '搜索资源', searchResource: '搜索资源',
searchSubtitle: '搜索字幕', searchSubtitle: '搜索字幕',

View File

@@ -207,6 +207,13 @@ export default {
restartFailed: '重啟失敗,請檢查系統狀態', restartFailed: '重啟失敗,請檢查系統狀態',
offline: '應用已離線', offline: '應用已離線',
offlineMessage: '網絡連接已斷開,部分功能可能受限', offlineMessage: '網絡連接已斷開,部分功能可能受限',
connectionChecking: '正在重新連接 MoviePilot',
connectionCheckingMessage: '正在確認服務狀態,目前頁面仍可繼續瀏覽。',
serviceUnavailable: '暫時無法連接 MoviePilot',
serviceUnavailableMessage: '服務可能正在啟動,或反向代理暫時不可用。',
serviceTimeoutMessage: 'MoviePilot 服務回應逾時,稍後將自動重試。',
browserOfflineMessage: '瀏覽器回報網絡不可用,正在嘗試直接連接 MoviePilot。',
continueBrowsing: '繼續瀏覽',
online: '應用在線', online: '應用在線',
onlineMessage: '網絡連接已恢復', onlineMessage: '網絡連接已恢復',
}, },
@@ -952,6 +959,13 @@ export default {
storage: '存儲空間', storage: '存儲空間',
storageSummary: '可用 {available} / 總容量 {total}', storageSummary: '可用 {available} / 總容量 {total}',
mediaStatistic: '媒體統計', mediaStatistic: '媒體統計',
recommendedMedia: '推薦媒體',
selectRecommendSource: '選擇推薦媒體來源',
previousRecommend: '上一項推薦',
nextRecommend: '下一項推薦',
showRecommend: '查看第 {index} 項推薦',
noRecommendations: '當前來源暫無推薦媒體',
recommendLoadFailed: '推薦媒體加載失敗',
weeklyOverview: '入庫統計', weeklyOverview: '入庫統計',
realTimeSpeed: '實時速率', realTimeSpeed: '實時速率',
scheduler: '後台任務', scheduler: '後台任務',
@@ -1041,6 +1055,14 @@ export default {
specials: '特別篇', specials: '特別篇',
seasonNumber: '第 {number} 季', seasonNumber: '第 {number} 季',
episodeCount: '{count}集', episodeCount: '{count}集',
episodeGroups: {
select: '選擇劇集組',
default: '預設',
summary: '{seasons} 季 · {episodes} 集',
current: '目前:{name} · {seasons} 季 · {episodes} 集',
previous: '查看上一組劇集組',
next: '查看更多劇集組',
},
actions: { actions: {
searchResource: '搜索資源', searchResource: '搜索資源',
searchSubtitle: '搜索字幕', searchSubtitle: '搜索字幕',

View File

@@ -18,7 +18,7 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
// 应用分组以header分组 // 应用分组以header分组
const appGroups = ref<Record<string, NavMenu[]>>({}) const appGroups = ref<Record<string, NavMenu[]>>({})
// 根据header属性对应用进行分类含插件侧栏项与桌面侧栏一致 /** 按菜单 header 聚合内置与插件入口,并保持与桌面侧栏一致的权限过滤结果。 */
async function categorizeApps() { async function categorizeApps() {
const allMenus = getNavMenus(t) const allMenus = getNavMenus(t)
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value) const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
@@ -53,14 +53,14 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="app-settings-container"> <div class="app-settings-container">
<VContainer> <VContainer class="app-settings-content">
<!-- 遍历所有分组 --> <!-- 遍历所有分组 -->
<div v-for="(apps, header) in appGroups" :key="header" class="mb-3"> <section v-for="(apps, header) in appGroups" :key="header" class="settings-section">
<VListSubheader class="ps-1"> <VListSubheader class="settings-section-title">
{{ header }} {{ header }}
</VListSubheader> </VListSubheader>
<!-- 分组内容 - 使用卡片包装 --> <!-- 分组内容 - 使用卡片包装 -->
<VCard variant="flat" class="settings-section-card"> <VCard variant="flat" class="app-grouped-list settings-section-card">
<VList lines="one" class="settings-list"> <VList lines="one" class="settings-list">
<VListItem <VListItem
v-for="(app, appIndex) in apps" v-for="(app, appIndex) in apps"
@@ -71,35 +71,57 @@ onMounted(() => {
rounded="0" rounded="0"
> >
<template #prepend> <template #prepend>
<VAvatar size="42" color="primary" variant="text" class="me-3"> <VAvatar
size="42"
color="primary"
variant="text"
class="settings-list-icon me-3"
:class="`settings-list-icon--tone-${app.iconColor || 'primary'}`"
>
<VIcon :icon="app.icon as string" size="24"></VIcon> <VIcon :icon="app.icon as string" size="24"></VIcon>
</VAvatar> </VAvatar>
</template> </template>
<VListItemTitle class="font-weight-medium"> <VListItemTitle class="settings-list-title font-weight-medium">
{{ app.full_title || app.title }} {{ app.full_title || app.title }}
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle v-if="app.description"> <VListItemSubtitle v-if="app.description" class="settings-list-subtitle">
{{ app.description }} {{ app.description }}
</VListItemSubtitle> </VListItemSubtitle>
<template #append> <template #append>
<VIcon icon="mdi-chevron-right"></VIcon> <VIcon class="settings-list-chevron" icon="mdi-chevron-right"></VIcon>
</template> </template>
<span
v-if="appIndex < apps.length - 1"
class="settings-list-separator"
aria-hidden="true"
></span>
</VListItem> </VListItem>
</VList> </VList>
</VCard> </VCard>
</div> </section>
</VContainer> </VContainer>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.app-settings-container { .app-settings-container {
inline-size: 100%;
margin-block: 0; margin-block: 0;
margin-inline: auto; margin-inline: auto;
max-inline-size: 960px; max-inline-size: 960px;
overflow-x: hidden;
}
.settings-section {
margin-block-end: 12px;
}
.settings-section-title {
padding-inline-start: 4px;
} }
.settings-section-card { .settings-section-card {
@@ -125,7 +147,129 @@ onMounted(() => {
} }
&:hover { &:hover {
background-color: rgba(var(--v-theme-primary), 0.05); background-color: var(--app-grouped-list-hover-background);
}
}
@media (width <= 768px) {
.app-settings-content {
box-sizing: border-box;
max-inline-size: 100%;
overflow-x: hidden;
padding-block: 8px calc(24px + env(safe-area-inset-bottom));
padding-inline: 12px;
}
.settings-section {
margin-block-end: 18px;
}
.settings-section:last-child {
margin-block-end: 0;
}
.settings-section-title {
min-block-size: auto;
padding-block: 0 7px;
padding-inline: 12px;
color: var(--app-grouped-list-header-color);
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.015em;
line-height: 1.25rem;
text-transform: none;
}
.settings-section-card {
max-inline-size: 100%;
overflow-x: hidden;
border-radius: var(--app-grouped-list-radius) !important;
}
.settings-list {
max-inline-size: 100%;
overflow-x: hidden;
background: transparent;
border-radius: inherit !important;
}
.settings-list-item {
max-inline-size: 100%;
min-inline-size: 0;
min-block-size: 58px;
overflow: hidden;
padding-block: 7px;
padding-inline: 12px 9px;
border-block-end: 0 !important;
border-radius: 0 !important;
}
.settings-list-separator {
position: absolute;
background-color: var(--app-grouped-list-separator-color);
block-size: 1px;
inset-block-end: 0;
inset-inline: var(--app-grouped-list-content-offset) 0;
pointer-events: none;
z-index: 2;
}
.settings-list-item:active {
background-color: var(--app-grouped-list-active-background);
}
.settings-list-icon {
--app-grouped-list-icon-rgb: var(--app-grouped-list-icon-primary-rgb);
block-size: var(--app-grouped-list-icon-size) !important;
inline-size: var(--app-grouped-list-icon-size) !important;
border-radius: max(var(--app-control-radius), 8px) !important;
background: rgba(var(--app-grouped-list-icon-rgb), var(--app-grouped-list-icon-opacity)) !important;
color: var(--app-grouped-list-icon-foreground) !important;
}
.settings-list-icon--tone-info {
--app-grouped-list-icon-rgb: var(--app-grouped-list-icon-info-rgb);
}
.settings-list-icon--tone-success {
--app-grouped-list-icon-rgb: var(--app-grouped-list-icon-success-rgb);
}
.settings-list-icon--tone-warning {
--app-grouped-list-icon-rgb: var(--app-grouped-list-icon-warning-rgb);
}
.settings-list-icon--tone-secondary {
--app-grouped-list-icon-rgb: var(--app-grouped-list-icon-secondary-rgb);
}
.settings-list-item :deep(.v-list-item__content) {
min-inline-size: 0;
overflow: hidden;
}
.settings-list-icon :deep(.v-icon) {
font-size: var(--app-grouped-list-icon-glyph-size) !important;
}
.settings-list-title {
font-size: 1rem;
font-weight: 500 !important;
letter-spacing: 0;
line-height: 1.375rem;
}
.settings-list-subtitle {
margin-block-start: 2px;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 0.75rem;
line-height: 1rem;
}
.settings-list-chevron {
color: var(--app-grouped-list-chevron-color);
font-size: 1.375rem;
} }
} }
</style> </style>

View File

@@ -25,9 +25,11 @@ const { t } = useI18n()
const { appMode } = usePWA() const { appMode } = usePWA()
const display = useDisplay() const display = useDisplay()
const userStore = useUserStore() const userStore = useUserStore()
const canAdmin = computed(() => const userPermissionContext = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'), buildUserPermissionContext(userStore.superUser, userStore.permissions),
) )
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery'))
// 路由 // 路由
const route = useRoute() const route = useRoute()
@@ -76,6 +78,7 @@ const DASHBOARD_DESKTOP_DEFAULT_LAYOUT: DashboardGridLayoutConfig = {
cpu: { x: 4, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS }, cpu: { x: 4, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS },
quickActions: { x: 8, y: 22, w: 4, h: 5 }, quickActions: { x: 8, y: 22, w: 4, h: 5 },
systemInfo: { x: 8, y: 27, w: 4, h: 6 }, systemInfo: { x: 8, y: 27, w: 4, h: 6 },
mediaRecommend: { x: 0, y: 33, w: 8, h: 17 },
} }
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。 // 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
@@ -171,6 +174,15 @@ const dashboardConfigs = ref<DashboardItem[]>([
rows: 7, rows: 7,
elements: [], elements: [],
}, },
{
id: 'mediaRecommend',
name: t('dashboard.recommendedMedia'),
key: '',
attrs: {},
cols: { cols: 12, md: 8 },
rows: 17,
elements: [],
},
{ {
id: 'weeklyOverview', id: 'weeklyOverview',
name: t('dashboard.weeklyOverview'), name: t('dashboard.weeklyOverview'),
@@ -287,7 +299,12 @@ const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
// 当前启用且可渲染的仪表板 Grid 项。 // 当前启用且可渲染的仪表板 Grid 项。
const dashboardGridItems = computed<DashboardGridItem[]>(() => const dashboardGridItems = computed<DashboardGridItem[]>(() =>
dashboardConfigs.value dashboardConfigs.value
.filter(item => enableConfig.value[buildPluginDashboardId(item.id, item.key)] && item.cols) .filter(
item =>
enableConfig.value[buildPluginDashboardId(item.id, item.key)] &&
item.cols &&
(item.id !== 'mediaRecommend' || canDiscovery.value),
)
.map(item => { .map(item => {
const id = buildPluginDashboardId(item.id, item.key) const id = buildPluginDashboardId(item.id, item.key)
@@ -378,6 +395,7 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
function getDefaultDashboardEnableConfig(): DashboardEnableConfig { function getDefaultDashboardEnableConfig(): DashboardEnableConfig {
return { return {
mediaStatistic: true, mediaStatistic: true,
mediaRecommend: true,
scheduler: true, scheduler: true,
speed: true, speed: true,
storage: true, storage: true,
@@ -1504,6 +1522,13 @@ onBeforeUnmount(() => {
block-size: 100%; block-size: 100%;
} }
/* 需要默认尺寸约束的组件可挂载此类,用户编辑后统一解除比例和最小高度。 */
.dashboard-grid-item.is-manual-height :deep(.dashboard-grid-adaptive-size),
.dashboard-grid.is-editing :deep(.dashboard-grid-adaptive-size) {
aspect-ratio: auto;
min-block-size: 0;
}
.dashboard-grid-item.is-manual-height :deep(.dashboard-work-card), .dashboard-grid-item.is-manual-height :deep(.dashboard-work-card),
.dashboard-grid.is-editing :deep(.dashboard-work-card) { .dashboard-grid.is-editing :deep(.dashboard-work-card) {
max-block-size: none; max-block-size: none;

View File

@@ -11,6 +11,11 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { getRecommendTabs } from '@/router/i18n-menu' import { getRecommendTabs } from '@/router/i18n-menu'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import {
createBuiltInRecommendSources,
mergeExtraRecommendSources,
type RecommendViewSource,
} from '@/utils/recommendSources'
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue')) const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
@@ -63,86 +68,7 @@ function openRecommendSettings() {
) )
} }
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([ const viewList = reactive<RecommendViewSource[]>(createBuiltInRecommendSources(t))
{
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=' + t('recommend.trendingNow'),
title: t('recommend.trendingNow'),
type: t('recommend.categoryRankings'),
},
{
apipath: 'recommend/douban_showing',
linkurl: '/browse/recommend/douban_showing?title=' + t('recommend.nowShowing'),
title: t('recommend.nowShowing'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/bangumi_calendar',
linkurl: '/browse/recommend/bangumi_calendar?title=' + t('recommend.bangumiDaily'),
title: t('recommend.bangumiDaily'),
type: t('recommend.categoryAnime'),
},
{
apipath: 'recommend/tmdb_movies',
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
title: t('recommend.tmdbHotMovies'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
linkurl: '/browse/recommend/tmdb_tvs??with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
title: t('recommend.tmdbHotTVShows'),
type: t('recommend.categoryTV'),
},
{
apipath: 'recommend/douban_movie_hot',
linkurl: '/browse/recommend/douban_movie_hot?title=' + t('recommend.doubanHotMovies'),
title: t('recommend.doubanHotMovies'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/douban_tv_hot',
linkurl: '/browse/recommend/douban_tv_hot?title=' + t('recommend.doubanHotTVShows'),
title: t('recommend.doubanHotTVShows'),
type: t('recommend.categoryTV'),
},
{
apipath: 'recommend/douban_tv_animation',
linkurl: '/browse/recommend/douban_tv_animation?title=' + t('recommend.doubanHotAnime'),
title: t('recommend.doubanHotAnime'),
type: t('recommend.categoryAnime'),
},
{
apipath: 'recommend/douban_movies',
linkurl: '/browse/recommend/douban_movies?title=' + t('recommend.doubanNewMovies'),
title: t('recommend.doubanNewMovies'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/douban_tvs',
linkurl: '/browse/recommend/douban_tvs?title=' + t('recommend.doubanNewTVShows'),
title: t('recommend.doubanNewTVShows'),
type: t('recommend.categoryTV'),
},
{
apipath: 'recommend/douban_movie_top250',
linkurl: '/browse/recommend/douban_movie_top250?title=' + t('recommend.doubanTop250'),
title: t('recommend.doubanTop250'),
type: t('recommend.categoryRankings'),
},
{
apipath: 'recommend/douban_tv_weekly_chinese',
linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=' + t('recommend.doubanChineseTVRankings'),
title: t('recommend.doubanChineseTVRankings'),
type: t('recommend.categoryRankings'),
},
{
apipath: 'recommend/douban_tv_weekly_global',
linkurl: '/browse/recommend/douban_tv_weekly_global?title=' + t('recommend.doubanGlobalTVRankings'),
title: t('recommend.doubanGlobalTVRankings'),
type: t('recommend.categoryRankings'),
},
])
// 计算当前分类下显示的视图 // 计算当前分类下显示的视图
const filteredViews = computed(() => { const filteredViews = computed(() => {
@@ -175,20 +101,7 @@ const extraRecommendSources = ref<RecommendSource[]>([])
async function loadExtraRecommendSources() { async function loadExtraRecommendSources() {
try { try {
extraRecommendSources.value = await api.get('recommend/source') extraRecommendSources.value = await api.get('recommend/source')
if (extraRecommendSources.value.length > 0) { mergeExtraRecommendSources(viewList, extraRecommendSources.value)
extraRecommendSources.value.map(source => {
if (!viewList.some(item => item.apipath === source.api_path)) {
const querySeparator = source.api_path.includes('?') ? '&' : '?'
const linkUrl = `/browse/${source.api_path}${querySeparator}title=${encodeURIComponent(source.name)}`
viewList.push({
apipath: source.api_path,
linkurl: linkUrl,
title: source.name,
type: source.type,
})
}
})
}
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} }

View File

@@ -2,7 +2,7 @@ import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components' import * as components from 'vuetify/components'
import { VBtn } from 'vuetify/components/VBtn' import { VBtn } from 'vuetify/components/VBtn'
import * as labsComponents from 'vuetify/labs/components' import * as labsComponents from 'vuetify/labs/components'
import AppDialog from '@/components/dialog/AppDialog' import AppDialog from './AppDialog'
import defaults from './defaults' import defaults from './defaults'
import { icons } from './icons' import { icons } from './icons'
import theme from './theme' import theme from './theme'

View File

@@ -2,7 +2,7 @@ import { useGlobalSettingsStore } from '@/stores'
import type { NavMenu, NavMenuTabItem } from '@/@layouts/types' import type { NavMenu, NavMenuTabItem } from '@/@layouts/types'
import type { Composer } from 'vue-i18n' import type { Composer } from 'vue-i18n'
// 构建路由菜单,每次调用时使用当前语言环境 /** 构建当前语言与全局模式对应的主导航菜单。 */
export function getNavMenus(t: Composer['t']): NavMenu[] { export function getNavMenus(t: Composer['t']): NavMenu[] {
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -13,6 +13,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.dashboard'), title: t('navItems.dashboard'),
icon: 'mdi-home-outline', icon: 'mdi-home-outline',
iconColor: 'primary',
to: '/dashboard', to: '/dashboard',
header: t('menu.start'), header: t('menu.start'),
admin: false, admin: false,
@@ -22,6 +23,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.searchResult'), title: t('navItems.searchResult'),
icon: 'mdi-magnify', icon: 'mdi-magnify',
iconColor: 'info',
to: '/resource', to: '/resource',
header: t('menu.start'), header: t('menu.start'),
admin: false, admin: false,
@@ -30,6 +32,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.recommend'), title: t('navItems.recommend'),
icon: 'mdi-star-outline', icon: 'mdi-star-outline',
iconColor: 'primary',
to: '/recommend', to: '/recommend',
header: t('menu.discovery'), header: t('menu.discovery'),
admin: false, admin: false,
@@ -40,6 +43,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.explore'), title: t('navItems.explore'),
icon: 'mdi-apple-safari', icon: 'mdi-apple-safari',
iconColor: 'info',
to: '/discover', to: '/discover',
header: t('menu.discovery'), header: t('menu.discovery'),
admin: false, admin: false,
@@ -51,6 +55,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
title: t('navItems.movie'), title: t('navItems.movie'),
full_title: t('navItems.movieSubscribe'), full_title: t('navItems.movieSubscribe'),
icon: 'mdi-movie-open-outline', icon: 'mdi-movie-open-outline',
iconColor: 'success',
to: '/subscribe/movie', to: '/subscribe/movie',
header: t('menu.subscribe'), header: t('menu.subscribe'),
admin: false, admin: false,
@@ -62,6 +67,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
title: t('navItems.tv'), title: t('navItems.tv'),
full_title: t('navItems.tvSubscribe'), full_title: t('navItems.tvSubscribe'),
icon: 'mdi-television', icon: 'mdi-television',
iconColor: 'warning',
to: '/subscribe/tv', to: '/subscribe/tv',
header: t('menu.subscribe'), header: t('menu.subscribe'),
admin: false, admin: false,
@@ -73,6 +79,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
title: t('navItems.workflow'), title: t('navItems.workflow'),
full_title: t('navItems.workflow'), full_title: t('navItems.workflow'),
icon: 'mdi-state-machine', icon: 'mdi-state-machine',
iconColor: 'primary',
to: '/workflow', to: '/workflow',
header: t('menu.subscribe'), header: t('menu.subscribe'),
admin: true, admin: true,
@@ -84,6 +91,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
title: t('navItems.calendar'), title: t('navItems.calendar'),
full_title: t('navItems.calendar'), full_title: t('navItems.calendar'),
icon: 'mdi-calendar', icon: 'mdi-calendar',
iconColor: 'info',
to: '/calendar', to: '/calendar',
header: t('menu.subscribe'), header: t('menu.subscribe'),
admin: false, admin: false,
@@ -92,6 +100,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.downloadManager'), title: t('navItems.downloadManager'),
icon: 'mdi-download-outline', icon: 'mdi-download-outline',
iconColor: 'info',
to: '/downloading', to: '/downloading',
header: t('menu.organize'), header: t('menu.organize'),
admin: false, admin: false,
@@ -100,6 +109,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.mediaOrganize'), title: t('navItems.mediaOrganize'),
icon: 'mdi-folder-play-outline', icon: 'mdi-folder-play-outline',
iconColor: 'warning',
to: '/history', to: '/history',
header: t('menu.organize'), header: t('menu.organize'),
admin: true, admin: true,
@@ -108,6 +118,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.fileManager'), title: t('navItems.fileManager'),
icon: 'mdi-folder-multiple-outline', icon: 'mdi-folder-multiple-outline',
iconColor: 'success',
to: '/filemanager', to: '/filemanager',
header: t('menu.organize'), header: t('menu.organize'),
admin: true, admin: true,
@@ -116,6 +127,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.pluginManager'), title: t('navItems.pluginManager'),
icon: 'mdi-puzzle-outline', icon: 'mdi-puzzle-outline',
iconColor: 'primary',
to: '/plugins', to: '/plugins',
header: t('menu.system'), header: t('menu.system'),
admin: true, admin: true,
@@ -125,6 +137,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.siteManager'), title: t('navItems.siteManager'),
icon: 'mdi-web', icon: 'mdi-web',
iconColor: 'info',
to: '/site', to: '/site',
header: t('menu.system'), header: t('menu.system'),
admin: true, admin: true,
@@ -133,6 +146,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.userManager'), title: t('navItems.userManager'),
icon: 'mdi-account-group-outline', icon: 'mdi-account-group-outline',
iconColor: 'success',
to: '/user', to: '/user',
header: t('menu.system'), header: t('menu.system'),
admin: true, admin: true,
@@ -143,6 +157,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
{ {
title: t('navItems.settings'), title: t('navItems.settings'),
icon: 'mdi-cog-outline', icon: 'mdi-cog-outline',
iconColor: 'secondary',
to: '/setting', to: '/setting',
header: t('menu.system'), header: t('menu.system'),
admin: true, admin: true,
@@ -154,7 +169,7 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
] ]
} }
// 获取推荐标签页 /** 返回推荐页可用的分类标签。 */
export function getRecommendTabs(t: Composer['t']): NavMenuTabItem[] { export function getRecommendTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ title: t('recommend.all'), icon: 'mdi-filmstrip-box-multiple', tab: t('recommend.all') }, { title: t('recommend.all'), icon: 'mdi-filmstrip-box-multiple', tab: t('recommend.all') },
@@ -165,7 +180,7 @@ export function getRecommendTabs(t: Composer['t']): NavMenuTabItem[] {
] ]
} }
// 获取设置标签页 /** 返回系统设置页的配置标签。 */
export function getSettingTabs(t: Composer['t']): NavMenuTabItem[] { export function getSettingTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ {
@@ -213,7 +228,7 @@ export function getSettingTabs(t: Composer['t']): NavMenuTabItem[] {
] ]
} }
// 获取电影订阅标签页 /** 返回电影订阅页的业务标签。 */
export function getSubscribeMovieTabs(t: Composer['t']): NavMenuTabItem[] { export function getSubscribeMovieTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ {
@@ -229,7 +244,7 @@ export function getSubscribeMovieTabs(t: Composer['t']): NavMenuTabItem[] {
] ]
} }
// 获取电视剧订阅标签页 /** 返回电视剧订阅页的业务标签。 */
export function getSubscribeTvTabs(t: Composer['t']): NavMenuTabItem[] { export function getSubscribeTvTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ {
@@ -250,7 +265,7 @@ export function getSubscribeTvTabs(t: Composer['t']): NavMenuTabItem[] {
] ]
} }
// 获取插件标签页 /** 返回插件管理页的业务标签。 */
export function getPluginTabs(t: Composer['t']): NavMenuTabItem[] { export function getPluginTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ {
@@ -266,7 +281,7 @@ export function getPluginTabs(t: Composer['t']): NavMenuTabItem[] {
] ]
} }
// 获取发现标签页 /** 返回发现页的媒体来源标签。 */
export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] { export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ {
@@ -287,7 +302,7 @@ export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
] ]
} }
// 获取工作流标签页 /** 返回工作流页的业务标签。 */
export function getWorkflowTabs(t: Composer['t']): NavMenuTabItem[] { export function getWorkflowTabs(t: Composer['t']): NavMenuTabItem[] {
return [ return [
{ {

View File

@@ -97,6 +97,25 @@ html {
--app-overlay-shadow: var(--app-elevation-0); --app-overlay-shadow: var(--app-elevation-0);
--app-surface-shadow: var(--app-elevation-0); --app-surface-shadow: var(--app-elevation-0);
--app-surface-hover-shadow: var(--app-elevation-0); --app-surface-hover-shadow: var(--app-elevation-0);
--app-grouped-list-background: rgb(var(--v-theme-surface));
--app-grouped-list-backdrop-filter: none;
--app-grouped-list-border: var(--app-surface-border);
--app-grouped-list-radius: var(--app-theme-surface-radius);
--app-grouped-list-separator-color: rgba(var(--v-theme-on-surface), 0.14);
--app-grouped-list-header-color: rgba(var(--v-theme-on-surface), 0.58);
--app-grouped-list-chevron-color: rgba(var(--v-theme-on-surface), 0.42);
--app-grouped-list-hover-background: rgba(var(--v-theme-primary), 0.05);
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 0.1);
--app-grouped-list-icon-foreground: rgb(var(--v-theme-on-primary));
--app-grouped-list-icon-opacity: 0.92;
--app-grouped-list-icon-size: 36px;
--app-grouped-list-icon-glyph-size: 1.25rem;
--app-grouped-list-content-offset: calc(24px + var(--app-grouped-list-icon-size));
--app-grouped-list-icon-primary-rgb: var(--v-theme-primary);
--app-grouped-list-icon-info-rgb: var(--v-theme-info);
--app-grouped-list-icon-success-rgb: var(--v-theme-success);
--app-grouped-list-icon-warning-rgb: var(--v-theme-warning);
--app-grouped-list-icon-secondary-rgb: var(--v-theme-secondary);
--mp-motion-duration-page: 180ms; --mp-motion-duration-page: 180ms;
--mp-motion-duration-overlay: 160ms; --mp-motion-duration-overlay: 160ms;
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1); --mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -206,6 +225,17 @@ html[data-theme-radius='extra'] {
box-shadow: var(--app-surface-shadow) !important; box-shadow: var(--app-surface-shadow) !important;
} }
@media (width <= 768px) {
// iOS 式分组菜单的共享表面,主题只需覆盖 token 即可适配实色或玻璃背景。
.app-grouped-list.v-card {
--app-surface-radius: var(--app-grouped-list-radius);
border: var(--app-grouped-list-border);
backdrop-filter: var(--app-grouped-list-backdrop-filter) !important;
background-color: var(--app-grouped-list-background) !important;
}
}
// 统一卡片上浮反馈hover 命中区域应放在静止外层,避免上浮后底边反复触发 mouseleave。 // 统一卡片上浮反馈hover 命中区域应放在静止外层,避免上浮后底边反复触发 mouseleave。
.v-card.app-hover-lift-card { .v-card.app-hover-lift-card {
border: var(--app-card-light-border); border: var(--app-card-light-border);
@@ -847,8 +877,52 @@ html[data-theme="transparent"].transparent-glass-realtime .v-theme--transparent
} }
.Vue-Toastification__toast { .Vue-Toastification__toast {
--mp-toast-accent-rgb: var(--v-theme-primary);
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-inline-start: 0.25rem solid rgba(var(--mp-toast-accent-rgb), 0.88);
border-radius: var(--app-overlay-radius); border-radius: var(--app-overlay-radius);
background:
linear-gradient(
135deg,
rgba(var(--mp-toast-accent-rgb), 0.14),
rgba(var(--mp-toast-accent-rgb), 0.05) 42%,
rgba(var(--v-theme-surface), 0.96)
),
rgb(var(--v-theme-surface)) !important;
box-shadow: var(--app-overlay-shadow); box-shadow: var(--app-overlay-shadow);
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
font-family: inherit;
}
.Vue-Toastification__toast--default,
.Vue-Toastification__toast--info {
--mp-toast-accent-rgb: var(--v-theme-info);
}
.Vue-Toastification__toast--success {
--mp-toast-accent-rgb: var(--v-theme-success);
}
.Vue-Toastification__toast--error {
--mp-toast-accent-rgb: var(--v-theme-error);
}
.Vue-Toastification__toast--warning {
--mp-toast-accent-rgb: var(--v-theme-warning);
}
.Vue-Toastification__icon,
.Vue-Toastification__close-button {
color: rgba(var(--mp-toast-accent-rgb), 0.95);
}
.Vue-Toastification__close-button {
opacity: 0.65;
}
.Vue-Toastification__progress-bar {
background-color: rgba(var(--mp-toast-accent-rgb), 0.35);
} }
// 对话框样式 // 对话框样式
@@ -966,6 +1040,13 @@ html[data-theme="transparent"].transparent-glass-realtime .v-theme--transparent
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
} }
@media (width <= 599px) {
.grid-subscribe-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.grid-user-card { .grid-user-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
} }

View File

@@ -11,6 +11,14 @@ html[data-theme="transparent"] {
--transparent-blur: 10px; --transparent-blur: 10px;
--transparent-blur-light: 6px; --transparent-blur-light: 6px;
--transparent-blur-heavy: 16px; --transparent-blur-heavy: 16px;
--app-grouped-list-background: rgba(var(--v-theme-surface), var(--transparent-opacity-light));
--app-grouped-list-backdrop-filter: blur(var(--transparent-blur));
--app-grouped-list-separator-color: rgba(var(--v-theme-on-surface), 0.18);
--app-grouped-list-header-color: rgba(var(--v-theme-on-surface), 0.64);
--app-grouped-list-chevron-color: rgba(var(--v-theme-on-surface), 0.5);
--app-grouped-list-hover-background: rgba(var(--v-theme-primary), 0.08);
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 0.14);
--app-grouped-list-icon-opacity: 0.88;
// 应用、布局、主内容区域 // 应用、布局、主内容区域
.v-application, .v-layout, .v-main, .layout-page-content { .v-application, .v-layout, .v-main, .layout-page-content {
@@ -221,6 +229,18 @@ html[data-theme="transparent"] {
backdrop-filter: blur(var(--transparent-blur)); backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)); background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
} }
.Vue-Toastification__toast {
backdrop-filter: blur(var(--transparent-blur));
background:
linear-gradient(
135deg,
rgba(var(--mp-toast-accent-rgb), 0.16),
rgba(var(--mp-toast-accent-rgb), 0.06) 44%,
rgba(var(--v-theme-surface), var(--transparent-opacity-heavy))
),
rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
}
} }
html[data-theme="transparent"].transparent-background-blur-disabled { html[data-theme="transparent"].transparent-background-blur-disabled {
@@ -233,6 +253,8 @@ html[data-theme="transparent"].transparent-background-blur-disabled {
} }
html[data-theme="transparent"].transparent-glass-lightweight { html[data-theme="transparent"].transparent-glass-lightweight {
--app-grouped-list-backdrop-filter: none;
.global-blur-layer { .global-blur-layer {
display: none !important; display: none !important;
backdrop-filter: none !important; backdrop-filter: none !important;
@@ -295,6 +317,8 @@ html[data-theme="transparent"].transparent-glass-lightweight {
} }
html[data-theme="transparent"].transparent-glass-realtime { html[data-theme="transparent"].transparent-glass-realtime {
--app-grouped-list-backdrop-filter: none;
.background-image.active { .background-image.active {
filter: none; filter: none;
transform: none; transform: none;
@@ -338,6 +362,8 @@ html[data-theme="transparent"].transparent-glass-realtime {
} }
html[data-theme="transparent"].transparent-blur-disabled { html[data-theme="transparent"].transparent-blur-disabled {
--app-grouped-list-backdrop-filter: none;
.layout-vertical-nav, .layout-vertical-nav,
.v-list, .v-list,
.v-card:not(.no-blur), .v-card:not(.no-blur),

View File

@@ -21,6 +21,7 @@ export function navMenuFromPluginSidebarItem(
return { return {
title: item.title, title: item.title,
icon: item.icon, icon: item.icon,
iconColor: 'primary',
to: { to: {
name: 'plugin-app', name: 'plugin-app',
params: { params: {

View File

@@ -0,0 +1,110 @@
import type { RecommendSource } from '@/api/types'
export interface RecommendViewSource {
apipath: string
linkurl: string
title: string
type: string
}
type Translate = (key: string) => string
/** 创建与推荐页面一致的内置媒体来源列表。 */
export function createBuiltInRecommendSources(t: Translate): RecommendViewSource[] {
return [
{
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=' + t('recommend.trendingNow'),
title: t('recommend.trendingNow'),
type: t('recommend.categoryRankings'),
},
{
apipath: 'recommend/douban_showing',
linkurl: '/browse/recommend/douban_showing?title=' + t('recommend.nowShowing'),
title: t('recommend.nowShowing'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/bangumi_calendar',
linkurl: '/browse/recommend/bangumi_calendar?title=' + t('recommend.bangumiDaily'),
title: t('recommend.bangumiDaily'),
type: t('recommend.categoryAnime'),
},
{
apipath: 'recommend/tmdb_movies',
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
title: t('recommend.tmdbHotMovies'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
linkurl:
'/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
title: t('recommend.tmdbHotTVShows'),
type: t('recommend.categoryTV'),
},
{
apipath: 'recommend/douban_movie_hot',
linkurl: '/browse/recommend/douban_movie_hot?title=' + t('recommend.doubanHotMovies'),
title: t('recommend.doubanHotMovies'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/douban_tv_hot',
linkurl: '/browse/recommend/douban_tv_hot?title=' + t('recommend.doubanHotTVShows'),
title: t('recommend.doubanHotTVShows'),
type: t('recommend.categoryTV'),
},
{
apipath: 'recommend/douban_tv_animation',
linkurl: '/browse/recommend/douban_tv_animation?title=' + t('recommend.doubanHotAnime'),
title: t('recommend.doubanHotAnime'),
type: t('recommend.categoryAnime'),
},
{
apipath: 'recommend/douban_movies',
linkurl: '/browse/recommend/douban_movies?title=' + t('recommend.doubanNewMovies'),
title: t('recommend.doubanNewMovies'),
type: t('recommend.categoryMovie'),
},
{
apipath: 'recommend/douban_tvs',
linkurl: '/browse/recommend/douban_tvs?title=' + t('recommend.doubanNewTVShows'),
title: t('recommend.doubanNewTVShows'),
type: t('recommend.categoryTV'),
},
{
apipath: 'recommend/douban_movie_top250',
linkurl: '/browse/recommend/douban_movie_top250?title=' + t('recommend.doubanTop250'),
title: t('recommend.doubanTop250'),
type: t('recommend.categoryRankings'),
},
{
apipath: 'recommend/douban_tv_weekly_chinese',
linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=' + t('recommend.doubanChineseTVRankings'),
title: t('recommend.doubanChineseTVRankings'),
type: t('recommend.categoryRankings'),
},
{
apipath: 'recommend/douban_tv_weekly_global',
linkurl: '/browse/recommend/douban_tv_weekly_global?title=' + t('recommend.doubanGlobalTVRankings'),
title: t('recommend.doubanGlobalTVRankings'),
type: t('recommend.categoryRankings'),
},
]
}
/** 把后端扩展媒体来源合并到现有来源列表,并保持已有顺序。 */
export function mergeExtraRecommendSources(target: RecommendViewSource[], sources: RecommendSource[]) {
sources.forEach(source => {
if (target.some(item => item.apipath === source.api_path)) return
const querySeparator = source.api_path.includes('?') ? '&' : '?'
target.push({
apipath: source.api_path,
linkurl: `/browse/${source.api_path}${querySeparator}title=${encodeURIComponent(source.name)}`,
title: source.name,
type: source.type,
})
})
}

View File

@@ -0,0 +1,639 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
import noImage from '@images/no-image.jpeg'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
const RECOMMEND_SOURCE_STORAGE_KEY = 'MP_DASHBOARD_RECOMMEND_SOURCE'
const RECOMMEND_SLIDE_COUNT = 5
const RECOMMEND_AUTOPLAY_INTERVAL = 8000
const sources = ref<RecommendViewSource[]>(
createBuiltInRecommendSources(t).filter(source => source.apipath.startsWith('recommend/tmdb_')),
)
const storedSourcePath = localStorage.getItem(RECOMMEND_SOURCE_STORAGE_KEY)
const selectedSourcePath = ref(
storedSourcePath && sources.value.some(source => source.apipath === storedSourcePath)
? storedSourcePath
: sources.value[0].apipath,
)
const mediaItems = shallowRef<MediaInfo[]>([])
const mediaCache = new Map<string, MediaInfo[]>()
const activeIndex = ref(0)
const loading = ref(true)
const loadFailed = ref(false)
const isPaused = ref(false)
const touchStartX = ref<number | null>(null)
let requestId = 0
let autoplayTimer: number | null = null
const selectedSource = computed(
() => sources.value.find(source => source.apipath === selectedSourcePath.value) ?? sources.value[0],
)
const activeMedia = computed(() => mediaItems.value[activeIndex.value])
/** 将不同接口包装格式归一化为媒体数组。 */
function normalizeMediaResponse(response: unknown): MediaInfo[] {
if (Array.isArray(response)) return response
if (!response || typeof response !== 'object') return []
const data = (response as { data?: unknown }).data
if (Array.isArray(data)) return data
if (data && typeof data === 'object' && Array.isArray((data as { list?: unknown }).list)) {
return (data as { list: MediaInfo[] }).list
}
return []
}
/** 判断媒体是否具备可展示图片和可进入详情页的标识。 */
function isUsableMedia(item: MediaInfo) {
const hasMediaId = Boolean(item.tmdb_id || item.collection_id)
return Boolean(item.title && (item.backdrop_path || item.poster_path) && hasMediaId)
}
/** 构造轮播项稳定键,兼容 TMDB 媒体与合集。 */
function getMediaKey(item: MediaInfo) {
if (item.collection_id) return `collection:${item.collection_id}`
return getMediaSubscribeId(item)
}
/** 加载指定推荐来源,并缓存当前会话已获取的数据。 */
async function loadMedia(sourcePath = selectedSourcePath.value) {
const cachedItems = mediaCache.get(sourcePath)
if (cachedItems) {
mediaItems.value = cachedItems
activeIndex.value = 0
loading.value = false
loadFailed.value = false
return
}
const currentRequestId = ++requestId
loading.value = true
loadFailed.value = false
try {
const response = await api.get(sourcePath)
if (currentRequestId !== requestId) return
const items = normalizeMediaResponse(response).filter(isUsableMedia).slice(0, RECOMMEND_SLIDE_COUNT)
mediaCache.set(sourcePath, items)
mediaItems.value = items
activeIndex.value = 0
} catch (error) {
if (currentRequestId !== requestId) return
console.error(error)
mediaItems.value = []
loadFailed.value = true
} finally {
if (currentRequestId === requestId) loading.value = false
}
}
/** 切换当前推荐来源并持久化用户选择。 */
function selectSource(source: RecommendViewSource) {
if (selectedSourcePath.value === source.apipath) return
selectedSourcePath.value = source.apipath
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, source.apipath)
void loadMedia(source.apipath)
}
/** 返回经过全局图片缓存与代理设置处理的背景图地址。 */
function getBackdropUrl(item: MediaInfo) {
const sourceUrl = item.backdrop_path || item.poster_path || noImage
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
/** 组合年份、媒体类型与风格标签。 */
function getMediaMeta(item: MediaInfo) {
return [item.year, item.type, ...(item.genres?.slice(0, 3) ?? [])].filter(Boolean).join(' · ')
}
/** 打开当前媒体的详情页面。 */
function goToMediaDetail() {
const item = activeMedia.value
if (!item) return
if (item.collection_id) {
void router.push({ path: `/browse/tmdb/collection/${item.collection_id}`, query: { title: item.title } })
return
}
void router.push({
path: '/media',
query: {
mediaid: getMediaSubscribeId(item),
title: item.title,
type: item.type,
year: item.year,
},
})
}
/** 切换到上一项媒体。 */
function showPrevious() {
if (mediaItems.value.length < 2) return
activeIndex.value = (activeIndex.value - 1 + mediaItems.value.length) % mediaItems.value.length
}
/** 切换到下一项媒体。 */
function showNext() {
if (mediaItems.value.length < 2) return
activeIndex.value = (activeIndex.value + 1) % mediaItems.value.length
}
/** 跳转到指定轮播项。 */
function showSlide(index: number) {
activeIndex.value = index
}
/** 记录触摸起点,供移动端判断横向滑动。 */
function handleTouchStart(event: TouchEvent) {
touchStartX.value = event.changedTouches[0]?.clientX ?? null
}
/** 根据触摸位移切换移动端轮播项。 */
function handleTouchEnd(event: TouchEvent) {
if (touchStartX.value === null) return
const deltaX = (event.changedTouches[0]?.clientX ?? touchStartX.value) - touchStartX.value
touchStartX.value = null
if (Math.abs(deltaX) < 48) return
if (deltaX > 0) showPrevious()
else showNext()
}
/** 启动轮播自动播放,系统减少动态效果时保持静态。 */
function startAutoplay() {
stopAutoplay()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
autoplayTimer = window.setInterval(() => {
if (!isPaused.value) showNext()
}, RECOMMEND_AUTOPLAY_INTERVAL)
}
/** 停止轮播自动播放并清理定时器。 */
function stopAutoplay() {
if (!autoplayTimer) return
window.clearInterval(autoplayTimer)
autoplayTimer = null
}
onMounted(async () => {
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, selectedSourcePath.value)
await loadMedia()
startAutoplay()
})
onActivated(startAutoplay)
onDeactivated(stopAutoplay)
onBeforeUnmount(stopAutoplay)
</script>
<template>
<VCard
class="dashboard-recommend dashboard-grid-adaptive-size dashboard-grid-fill dashboard-grid-no-drag"
:class="{ 'is-loading': loading }"
@mouseenter="isPaused = true"
@mouseleave="isPaused = false"
@focusin="isPaused = true"
@focusout="isPaused = false"
@touchstart.passive="handleTouchStart"
@touchend.passive="handleTouchEnd"
>
<template v-if="loading">
<VSkeletonLoader class="dashboard-recommend-skeleton" type="image" />
</template>
<template v-else-if="mediaItems.length">
<VWindow v-model="activeIndex" class="dashboard-recommend-window" :touch="false">
<VWindowItem v-for="item in mediaItems" :key="getMediaKey(item)" class="dashboard-recommend-slide">
<VImg
:src="getBackdropUrl(item)"
:alt="item.title"
class="dashboard-recommend-image"
cover
eager
@click="goToMediaDetail"
/>
</VWindowItem>
</VWindow>
<div class="dashboard-recommend-shade" aria-hidden="true"></div>
<div class="dashboard-recommend-topbar">
<div class="dashboard-recommend-label">
<VIcon icon="mdi-creation" size="20" color="primary" />
<span>{{ t('dashboard.recommendedMedia') }}</span>
</div>
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
class="dashboard-recommend-source"
variant="tonal"
color="white"
rounded="pill"
append-icon="mdi-chevron-down"
>
<VIcon icon="mdi-movie-open-star-outline" color="primary" start />
<span>{{ selectedSource.title }}</span>
</VBtn>
</template>
<VList density="compact" max-height="360" :aria-label="t('dashboard.selectRecommendSource')">
<VListItem
v-for="source in sources"
:key="source.apipath"
:active="source.apipath === selectedSourcePath"
prepend-icon="mdi-movie-open-star-outline"
:title="source.title"
@click="selectSource(source)"
/>
</VList>
</VMenu>
</div>
<div
class="dashboard-recommend-content"
role="link"
tabindex="0"
@click="goToMediaDetail"
@keydown.enter="goToMediaDetail"
>
<h2 class="dashboard-recommend-title">{{ activeMedia?.title }}</h2>
<div class="dashboard-recommend-meta">{{ activeMedia ? getMediaMeta(activeMedia) : '' }}</div>
<p v-if="activeMedia?.overview" class="dashboard-recommend-overview">{{ activeMedia.overview }}</p>
</div>
<VBtn
class="dashboard-recommend-detail"
variant="outlined"
color="primary"
rounded="pill"
append-icon="mdi-chevron-right"
@click.stop="goToMediaDetail"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="mediaItems.length > 1"
class="dashboard-recommend-arrow dashboard-recommend-arrow--previous"
icon="mdi-chevron-left"
variant="tonal"
color="white"
:aria-label="t('dashboard.previousRecommend')"
@click.stop="showPrevious"
/>
<div v-if="mediaItems.length > 1" class="dashboard-recommend-pagination">
<button
v-for="(_item, index) in mediaItems"
:key="index"
type="button"
class="dashboard-recommend-page"
:class="{ 'is-active': activeIndex === index }"
:aria-label="t('dashboard.showRecommend', { index: index + 1 })"
:aria-current="activeIndex === index ? 'true' : undefined"
@click.stop="showSlide(index)"
></button>
</div>
<VBtn
v-if="mediaItems.length > 1"
class="dashboard-recommend-arrow dashboard-recommend-arrow--next"
icon="mdi-chevron-right"
variant="tonal"
color="white"
:aria-label="t('dashboard.nextRecommend')"
@click.stop="showNext"
/>
</template>
<div v-else class="dashboard-recommend-empty">
<VIcon icon="mdi-image-off-outline" size="38" />
<span>{{ loadFailed ? t('dashboard.recommendLoadFailed') : t('dashboard.noRecommendations') }}</span>
<VBtn v-if="loadFailed" variant="tonal" size="small" @click="loadMedia()">{{ t('common.retry') }}</VBtn>
</div>
</VCard>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-recommend {
position: relative;
overflow: hidden;
aspect-ratio: 2 / 1;
block-size: auto;
min-block-size: 0;
background: rgb(8, 18, 28);
color: white;
isolation: isolate;
}
.dashboard-recommend-window,
.dashboard-recommend-slide,
.dashboard-recommend-image,
.dashboard-recommend-skeleton {
block-size: 100%;
inline-size: 100%;
}
.dashboard-recommend-window {
position: absolute;
inset: 0;
}
.dashboard-recommend-image {
cursor: pointer;
}
.dashboard-recommend-image :deep(.v-img__img) {
object-position: center top;
}
.dashboard-recommend-shade {
position: absolute;
z-index: 1;
background:
linear-gradient(180deg, rgba(3, 8, 14, 0.08) 0%, rgba(5, 12, 19, 0.04) 42%, rgba(5, 14, 22, 0.72) 100%),
linear-gradient(90deg, rgba(5, 14, 22, 0.42) 0%, rgba(5, 14, 22, 0.12) 46%, transparent 68%);
inset: 0;
pointer-events: none;
}
.dashboard-recommend-topbar {
position: absolute;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
inset-block-start: 1.25rem;
inset-inline: 1.4rem;
}
.dashboard-recommend-label {
display: inline-flex;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(8, 18, 28, 0.56);
backdrop-filter: blur(10px);
font-size: 0.84rem;
font-weight: 650;
gap: 0.45rem;
padding: 0.55rem 0.85rem;
}
.dashboard-recommend-source {
max-inline-size: min(320px, 45vw);
background: rgba(8, 18, 28, 0.55) !important;
backdrop-filter: blur(12px);
text-transform: none;
}
.dashboard-recommend-source span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-recommend-content {
position: absolute;
z-index: 2;
max-inline-size: min(640px, 56%);
cursor: pointer;
inset-block-end: 4.8rem;
inset-inline-start: 1.9rem;
outline: none;
}
.dashboard-recommend-content:focus-visible {
border-radius: 10px;
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.48);
}
.dashboard-recommend-title {
margin: 0;
color: rgb(255, 255, 255);
font-size: clamp(1.6rem, 2.5vw, 2.45rem);
font-weight: 750;
letter-spacing: -0.02em;
line-height: 1.15;
text-shadow: 0 3px 20px rgba(0, 0, 0, 0.82), 0 1px 2px rgba(0, 0, 0, 0.72);
}
.dashboard-recommend-meta {
margin-block-start: 0.55rem;
color: rgba(255, 255, 255, 0.76);
font-size: 0.86rem;
}
.dashboard-recommend-overview {
display: -webkit-box;
overflow: hidden;
margin: 0.75rem 0 0;
color: rgba(255, 255, 255, 0.72);
font-size: 0.85rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.65;
text-overflow: ellipsis;
}
.dashboard-recommend-detail {
position: absolute;
z-index: 3;
min-inline-size: 148px;
inset-block-end: 4.9rem;
inset-inline-end: 1.9rem;
text-transform: none;
}
.dashboard-recommend-arrow {
position: absolute;
z-index: 3;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(8, 18, 28, 0.52) !important;
block-size: 40px;
inline-size: 40px;
inset-block-end: 1.15rem;
}
.dashboard-recommend-arrow--previous {
inset-inline-start: 1.4rem;
}
.dashboard-recommend-arrow--next {
inset-inline-end: 1.4rem;
}
.dashboard-recommend-pagination {
position: absolute;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
inset-block-end: 1.8rem;
inset-inline: 25%;
}
.dashboard-recommend-page {
overflow: hidden;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.24);
block-size: 4px;
cursor: pointer;
inline-size: 54px;
padding: 0;
transition: background-color 0.2s ease, inline-size 0.2s ease;
}
.dashboard-recommend-page.is-active {
background: rgb(var(--v-theme-primary));
inline-size: 72px;
}
.dashboard-recommend-empty {
display: flex;
block-size: 100%;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.68);
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 741px) and (hover: hover) {
.dashboard-recommend-topbar,
.dashboard-recommend-arrow {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dashboard-recommend-topbar {
transform: translateY(-4px);
}
.dashboard-recommend-arrow--previous {
transform: translateX(-4px);
}
.dashboard-recommend-arrow--next {
transform: translateX(4px);
}
.dashboard-recommend:hover .dashboard-recommend-topbar,
.dashboard-recommend:focus-within .dashboard-recommend-topbar,
.dashboard-recommend:hover .dashboard-recommend-arrow,
.dashboard-recommend:focus-within .dashboard-recommend-arrow {
opacity: 1;
pointer-events: auto;
transform: none;
}
}
@media (max-width: 740px) {
.dashboard-recommend {
aspect-ratio: auto;
inline-size: 100%;
max-inline-size: 100%;
min-inline-size: 0;
min-block-size: 460px;
}
.dashboard-recommend-topbar {
inset-block-start: 0.85rem;
inset-inline: 0.85rem;
}
.dashboard-recommend-label {
padding: 0.45rem 0.65rem;
}
.dashboard-recommend-source {
max-inline-size: 50vw;
min-inline-size: 0;
padding-inline: 0.7rem;
}
.dashboard-recommend-content {
max-inline-size: calc(100% - 1.7rem);
inset-block-end: 7.5rem;
inset-inline: 0.85rem;
}
.dashboard-recommend-title {
font-size: 1.65rem;
}
.dashboard-recommend-overview {
font-size: 0.8rem;
-webkit-line-clamp: 2;
line-height: 1.5;
}
.dashboard-recommend-detail {
display: none;
}
.dashboard-recommend-arrow {
display: none;
}
.dashboard-recommend-pagination {
gap: 0.3rem;
inset-block-end: 1.75rem;
inset-inline: 22%;
}
.dashboard-recommend-page {
inline-size: 22px;
}
.dashboard-recommend-page.is-active {
inline-size: 32px;
}
}
@media (max-width: 420px) {
.dashboard-recommend-label span {
display: none;
}
.dashboard-recommend-label {
block-size: 40px;
inline-size: 40px;
justify-content: center;
padding: 0;
}
.dashboard-recommend-source {
max-inline-size: 68vw;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-recommend-topbar,
.dashboard-recommend-arrow,
.dashboard-recommend-page {
transition: none;
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
import PersonCardSlideView from './PersonCardSlideView.vue' import PersonCardSlideView from './PersonCardSlideView.vue'
import MediaCardSlideView from './MediaCardSlideView.vue' import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api' import api from '@/api'
import type { MediaInfo, MediaRelease, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types' import type { MediaInfo, MediaRelease, MediaSeason, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
import NoDataFound from '@/components/states/NoDataFound.vue' import NoDataFound from '@/components/states/NoDataFound.vue'
import { formatSeasonLabel } from '@/@core/utils/season' import { formatSeasonLabel } from '@/@core/utils/season'
import router from '@/router' import router from '@/router'
@@ -93,12 +93,50 @@ interface MediaSearchOptions {
episode?: number | null episode?: number | null
} }
interface EpisodeGroupInfo {
id: string
name: string
group_count: number
episode_count: number
}
interface EpisodeGroupOption extends EpisodeGroupInfo {
icon: string
}
// 站点选择后待执行的搜索类型 // 站点选择后待执行的搜索类型
const pendingSearchResultType = ref<'torrent' | 'subtitle'>('torrent') const pendingSearchResultType = ref<'torrent' | 'subtitle'>('torrent')
// 站点选择后待执行的季集参数 // 站点选择后待执行的季集参数
const pendingSearchOptions = ref<MediaSearchOptions>({}) const pendingSearchOptions = ref<MediaSearchOptions>({})
// 可用剧集组
const episodeGroups = ref<EpisodeGroupInfo[]>([])
// 当前选中的剧集组,空字符串表示 TMDB 默认排序
const selectedEpisodeGroup = ref('')
// 当前自定义剧集组的季信息
const episodeGroupSeasons = ref<MediaSeason[]>([])
// 剧集组列表加载状态
const episodeGroupsLoading = ref(false)
// 自定义剧集组季信息加载状态
const episodeGroupSeasonsLoading = ref(false)
// 剧集组横向轨道
const episodeGroupRail = ref<HTMLElement | null>(null)
// 剧集组轨道左右滚动状态
const canScrollEpisodeGroupsBackward = ref(false)
const canScrollEpisodeGroupsForward = ref(false)
// 防止快速切换剧集组时旧请求覆盖新结果
let episodeGroupSeasonRequestId = 0
let seasonNotExistsRequestId = 0
let episodeExistsRequestId = 0
// 计算主题是否为透明 // 计算主题是否为透明
const isTransparentTheme = computed(() => { const isTransparentTheme = computed(() => {
return theme.name.value === 'transparent' return theme.name.value === 'transparent'
@@ -165,6 +203,12 @@ async function getMediaDetail() {
isRefreshed.value = true isRefreshed.value = true
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) {
getEpisodeGroups()
if (selectedEpisodeGroup.value) loadEpisodeGroupSeasons(selectedEpisodeGroup.value)
}
// 检查存在状态 // 检查存在状态
checkExists() checkExists()
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists() if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
@@ -181,7 +225,7 @@ async function loadSeasonEpisodes(season: number) {
// 加载季集信息 // 加载季集信息
if (seasonEpisodesInfo.value[season]) return if (seasonEpisodesInfo.value[season]) return
try { try {
const params = mediaDetail.value.episode_group ? { episode_group: mediaDetail.value.episode_group } : undefined const params = selectedEpisodeGroup.value ? { episode_group: selectedEpisodeGroup.value } : undefined
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined) const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)
seasonEpisodesInfo.value[season] = result || [] seasonEpisodesInfo.value[season] = result || []
} catch (error) { } catch (error) {
@@ -193,9 +237,14 @@ async function loadSeasonEpisodes(season: number) {
async function loadEpisodeExists() { async function loadEpisodeExists() {
// 查询季集存在状态 // 查询季集存在状态
if (!isNullOrEmptyObject(existsEpisodes.value)) return if (!isNullOrEmptyObject(existsEpisodes.value)) return
const requestId = ++episodeExistsRequestId
try { try {
const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, mediaDetail.value) const media = {
existsEpisodes.value = result || {} ...mediaDetail.value,
episode_group: selectedEpisodeGroup.value || '',
}
const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, media)
if (requestId === episodeExistsRequestId) existsEpisodes.value = result || {}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -246,9 +295,15 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
// 检查所有季的缺失状态 // 检查所有季的缺失状态
async function checkSeasonsNotExists() { async function checkSeasonsNotExists() {
if (mediaDetail.value.type !== '电视剧') return if (mediaDetail.value.type !== '电视剧') return
const requestId = ++seasonNotExistsRequestId
seasonsNotExisted.value = {}
try { try {
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value) const media = {
if (result) { ...mediaDetail.value,
episode_group: selectedEpisodeGroup.value || '',
}
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', media)
if (requestId === seasonNotExistsRequestId && result) {
result.forEach(item => { result.forEach(item => {
// 0-已入库 1-部分缺失 2-全部缺失 // 0-已入库 1-部分缺失 2-全部缺失
let state = 0 let state = 0
@@ -268,16 +323,119 @@ async function checkMovieSubscribed() {
isSubscribed.value = await checkSubscribe() isSubscribed.value = await checkSubscribe()
} }
// 默认排序的总集数
const defaultEpisodeCount = computed(() =>
(mediaDetail.value?.season_info ?? []).reduce((total, season) => total + (season.episode_count ?? 0), 0),
)
// 剧集组选项,首项固定为 TMDB 默认排序
const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => [
{
id: '',
name: t('media.episodeGroups.default'),
group_count: mediaDetail.value?.season_info?.length ?? 0,
episode_count: defaultEpisodeCount.value,
icon: 'mdi-layers-outline',
},
...episodeGroups.value.map(group => ({
...group,
icon: 'mdi-folder-play-outline',
})),
])
// 当前选中的剧集组选项
const selectedEpisodeGroupOption = computed(
() => episodeGroupOptions.value.find(group => group.id === selectedEpisodeGroup.value) ?? episodeGroupOptions.value[0]!,
)
// 季列表第0季排在最后 // 季列表第0季排在最后
const getMediaSeasons = computed(() => { const getMediaSeasons = computed(() => {
if (!mediaDetail.value?.season_info) return [] const seasons = selectedEpisodeGroup.value ? episodeGroupSeasons.value : mediaDetail.value?.season_info
return [...mediaDetail.value.season_info].sort((a, b) => { if (!seasons) return []
return [...seasons].sort((a, b) => {
if (a.season_number === 0) return 1 if (a.season_number === 0) return 1
if (b.season_number === 0) return -1 if (b.season_number === 0) return -1
return (a.season_number || 0) - (b.season_number || 0) return (a.season_number || 0) - (b.season_number || 0)
}) })
}) })
// 查询当前媒体可用的剧集组
async function getEpisodeGroups() {
if (!mediaDetail.value.tmdb_id) return
episodeGroupsLoading.value = true
try {
const result: EpisodeGroupInfo[] = await api.get(`media/groups/${mediaDetail.value.tmdb_id}`)
episodeGroups.value = result || []
} catch (error) {
console.error(error)
episodeGroups.value = []
} finally {
episodeGroupsLoading.value = false
nextTick(updateEpisodeGroupScrollState)
}
}
// 查询指定剧集组的季信息,并忽略过期响应
async function loadEpisodeGroupSeasons(groupId: string) {
if (!groupId) {
episodeGroupSeasons.value = []
episodeGroupSeasonsLoading.value = false
return
}
const requestId = ++episodeGroupSeasonRequestId
episodeGroupSeasonsLoading.value = true
try {
const result: MediaSeason[] = await api.get(`media/group/seasons/${groupId}`)
if (requestId === episodeGroupSeasonRequestId) episodeGroupSeasons.value = result || []
} catch (error) {
console.error(error)
if (requestId === episodeGroupSeasonRequestId) episodeGroupSeasons.value = []
} finally {
if (requestId === episodeGroupSeasonRequestId) episodeGroupSeasonsLoading.value = false
}
}
// 切换详情页当前浏览的剧集组
async function setEpisodeGroup(groupId: string) {
if (selectedEpisodeGroup.value === groupId) return
selectedEpisodeGroup.value = groupId
seasonEpisodesInfo.value = {}
existsEpisodes.value = {}
episodeGroupSeasons.value = []
episodeGroupSeasonRequestId += 1
episodeExistsRequestId += 1
await Promise.all([loadEpisodeGroupSeasons(groupId), checkSeasonsNotExists()])
}
// 刷新剧集组横向轨道的左右滚动按钮状态
function updateEpisodeGroupScrollState() {
const rail = episodeGroupRail.value
if (!rail) {
canScrollEpisodeGroupsBackward.value = false
canScrollEpisodeGroupsForward.value = false
return
}
const maxScrollLeft = Math.max(rail.scrollWidth - rail.clientWidth, 0)
canScrollEpisodeGroupsBackward.value = rail.scrollLeft > 4
canScrollEpisodeGroupsForward.value = rail.scrollLeft < maxScrollLeft - 4
}
// 按一屏内可辨识的距离横向滚动剧集组轨道
function scrollEpisodeGroups(direction: 'backward' | 'forward') {
const rail = episodeGroupRail.value
if (!rail) return
rail.scrollBy({
behavior: 'smooth',
left: direction === 'backward' ? -Math.max(rail.clientWidth * 0.72, 240) : Math.max(rail.clientWidth * 0.72, 240),
})
}
// 检查所有季的订阅状态 // 检查所有季的订阅状态
async function checkSeasonsSubscribed() { async function checkSeasonsSubscribed() {
if (mediaDetail.value.type !== '电视剧') return if (mediaDetail.value.type !== '电视剧') return
@@ -307,6 +465,7 @@ async function checkSeasonsSubscribed() {
} }
} }
// 已订阅季号列表
const subscribedSeasonNumbers = computed(() => const subscribedSeasonNumbers = computed(() =>
Object.entries(seasonsSubscribed.value) Object.entries(seasonsSubscribed.value)
.filter(([, subscribed]) => subscribed) .filter(([, subscribed]) => subscribed)
@@ -314,8 +473,10 @@ const subscribedSeasonNumbers = computed(() =>
.sort((a, b) => a - b), .sort((a, b) => a - b),
) )
const subscribeSeasonTotal = computed(() => getMediaSeasons.value.length) // 默认季结构中的可订阅季总数
const subscribeSeasonTotal = computed(() => mediaDetail.value?.season_info?.length ?? 0)
// 当前媒体是否已订阅默认季结构中的全部季
const isAllSeasonsSubscribed = computed( const isAllSeasonsSubscribed = computed(
() => () =>
mediaDetail.value.type === '电视剧' && mediaDetail.value.type === '电视剧' &&
@@ -323,9 +484,9 @@ const isAllSeasonsSubscribed = computed(
subscribedSeasonNumbers.value.length >= subscribeSeasonTotal.value, subscribedSeasonNumbers.value.length >= subscribeSeasonTotal.value,
) )
// 订阅按钮响应 // 订阅按钮响应;单季入口同时传递详情页当前选择的剧集组。
function handleSubscribe(season: number | null = null) { function handleSubscribe(season: number | null = null, episodeGroup = '') {
subscribeActions.handleSubscribe(season) subscribeActions.handleSubscribe(season, episodeGroup)
} }
// 从genres中获取name使用、分隔 // 从genres中获取name使用、分隔
@@ -461,6 +622,7 @@ const getSubscribeColor = computed(() => {
else return 'warning' else return 'warning'
}) })
// 计算订阅按钮文案
const getSubscribeText = computed(() => { const getSubscribeText = computed(() => {
if (mediaDetail.value.type === '电视剧') { if (mediaDetail.value.type === '电视剧') {
if (isAllSeasonsSubscribed.value) return t('media.status.allSeasonsSubscribed') if (isAllSeasonsSubscribed.value) return t('media.status.allSeasonsSubscribed')
@@ -568,6 +730,14 @@ async function handleSubtitleSearch() {
onBeforeMount(() => { onBeforeMount(() => {
getMediaDetail() getMediaDetail()
}) })
onMounted(() => {
window.addEventListener('resize', updateEpisodeGroupScrollState)
})
onUnmounted(() => {
window.removeEventListener('resize', updateEpisodeGroupScrollState)
})
</script> </script>
<template> <template>
@@ -761,7 +931,67 @@ onBeforeMount(() => {
</div> </div>
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">{{ t('media.seasons') }}</h2> <h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">{{ t('media.seasons') }}</h2>
<div v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="flex w-full flex-col space-y-2"> <div v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="flex w-full flex-col space-y-2">
<VExpansionPanels> <div v-if="episodeGroupsLoading || episodeGroupOptions.length > 1" class="episode-group-selector">
<div class="episode-group-label">{{ t('media.episodeGroups.select') }}</div>
<VProgressLinear v-if="episodeGroupsLoading" color="primary" indeterminate rounded />
<template v-else>
<div class="episode-group-rail-shell">
<button
v-if="canScrollEpisodeGroupsBackward"
type="button"
class="episode-group-nav episode-group-nav--backward"
:aria-label="t('media.episodeGroups.previous')"
@click="scrollEpisodeGroups('backward')"
>
<VIcon icon="mdi-chevron-left" />
</button>
<div ref="episodeGroupRail" class="episode-group-rail" @scroll.passive="updateEpisodeGroupScrollState">
<button
v-for="group in episodeGroupOptions"
:key="group.id || 'default'"
type="button"
class="episode-group-option"
:class="{ 'episode-group-option--active': selectedEpisodeGroup === group.id }"
:aria-pressed="selectedEpisodeGroup === group.id"
@click="setEpisodeGroup(group.id)"
>
<VIcon :icon="group.icon" size="small" class="episode-group-option__icon" />
<span class="episode-group-option__text">
<span class="episode-group-option__title">{{ group.name }}</span>
<span class="episode-group-option__meta">
{{
t('media.episodeGroups.summary', {
seasons: group.group_count,
episodes: group.episode_count,
})
}}
</span>
</span>
</button>
</div>
<button
v-if="canScrollEpisodeGroupsForward"
type="button"
class="episode-group-nav episode-group-nav--forward"
:aria-label="t('media.episodeGroups.next')"
@click="scrollEpisodeGroups('forward')"
>
<VIcon icon="mdi-chevron-right" />
</button>
</div>
<div class="episode-group-current">
{{
t('media.episodeGroups.current', {
name: selectedEpisodeGroupOption.name,
seasons: selectedEpisodeGroupOption.group_count,
episodes: selectedEpisodeGroupOption.episode_count,
})
}}
</div>
</template>
</div>
<LoadingBanner v-if="episodeGroupSeasonsLoading" class="mt-3" />
<VExpansionPanels v-else :key="selectedEpisodeGroup || 'default'">
<VExpansionPanel <VExpansionPanel
v-for="season in getMediaSeasons" v-for="season in getMediaSeasons"
:key="season.season_number" :key="season.season_number"
@@ -787,7 +1017,7 @@ onBeforeMount(() => {
class="ms-1" class="ms-1"
:color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'" :color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'"
variant="text" variant="text"
@click.stop="handleSubscribe(season.season_number ?? null)" @click.stop="handleSubscribe(season.season_number ?? null, selectedEpisodeGroup)"
> >
<VIcon <VIcon
:icon="seasonsSubscribed[season.season_number || 0] ? 'mdi-heart' : 'mdi-heart-outline'" :icon="seasonsSubscribed[season.season_number || 0] ? 'mdi-heart' : 'mdi-heart-outline'"
@@ -1273,6 +1503,7 @@ a.crew-name {
.media-overview-left { .media-overview-left {
flex: 1 1 0%; flex: 1 1 0%;
min-inline-size: 0;
} }
@media (width >= 1024px) { @media (width >= 1024px) {
@@ -1281,6 +1512,187 @@ a.crew-name {
} }
} }
.episode-group-selector {
display: grid;
gap: 0.5rem;
margin-block-end: 0.5rem;
min-inline-size: 0;
}
.episode-group-label {
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.75rem;
line-height: 1rem;
}
.episode-group-rail-shell {
position: relative;
min-inline-size: 0;
}
.episode-group-rail {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-block: 0.125rem 0.375rem;
scroll-behavior: smooth;
scroll-snap-type: inline proximity;
scrollbar-width: none;
}
.episode-group-rail::-webkit-scrollbar {
display: none;
}
.episode-group-option {
display: inline-flex;
flex: 0 0 12rem;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-surface), 0.72);
color: rgb(var(--v-theme-on-surface));
gap: 0.625rem;
min-inline-size: 0;
padding-block: 0.625rem;
padding-inline: 0.75rem;
scroll-snap-align: start;
text-align: start;
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.episode-group-option:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
background: rgba(var(--v-theme-primary), 0.08);
}
.episode-group-option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.14);
color: rgb(var(--v-theme-primary));
}
.episode-group-option:focus-visible,
.episode-group-nav:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
outline-offset: 2px;
}
.episode-group-option__icon {
flex: 0 0 auto;
}
.episode-group-option__text {
display: grid;
min-inline-size: 0;
}
.episode-group-option__title,
.episode-group-option__meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-group-option__title {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.125rem;
}
.episode-group-option__meta {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.75rem;
line-height: 1rem;
}
.episode-group-option--active .episode-group-option__meta {
color: rgba(var(--v-theme-primary), 0.82);
}
.episode-group-nav {
position: absolute;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 9999px;
background: rgba(var(--v-theme-surface), 0.92);
block-size: 2.5rem;
color: rgb(var(--v-theme-on-surface));
inline-size: 2.5rem;
inset-block-start: 50%;
transform: translateY(-55%);
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.episode-group-nav:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
.episode-group-nav--backward {
inset-inline-start: -0.5rem;
}
.episode-group-nav--forward {
inset-inline-end: -0.5rem;
}
.episode-group-current {
color: rgba(var(--v-theme-on-surface), 0.66);
font-size: 0.8125rem;
line-height: 1.25rem;
}
.media-detail-transparent .episode-group-option {
backdrop-filter: blur(var(--transparent-blur-light, 6px));
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
}
.media-detail-transparent .episode-group-option:hover {
background: rgba(var(--v-theme-primary), 0.1);
}
.media-detail-transparent .episode-group-option--active {
background: rgba(var(--v-theme-primary), 0.16);
}
.media-detail-transparent .episode-group-nav {
backdrop-filter: blur(var(--transparent-blur, 10px));
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
}
@media (width <= 640px) {
.episode-group-option {
flex-basis: 9.75rem;
padding-block: 0.5625rem;
padding-inline: 0.625rem;
}
.episode-group-nav {
display: none;
}
.episode-group-current {
font-size: 0.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.episode-group-rail {
scroll-behavior: auto;
}
}
.media-overview-right { .media-overview-right {
inline-size: 100%; inline-size: 100%;
margin-block-start: 2rem; margin-block-start: 2rem;

View File

@@ -903,7 +903,7 @@ onActivated(() => {
<style lang="scss"> <style lang="scss">
.v-application .fc { .v-application .fc {
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04); --fc-today-bg-color: rgba(var(--v-theme-primary), 0.06);
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity)); --fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
--fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3); --fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3);
--fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02); --fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);
@@ -911,11 +911,6 @@ onActivated(() => {
--fc-event-border-color: currentcolor; --fc-event-border-color: currentcolor;
} }
// 当天背景渐变
.fc-day-today {
background-image: linear-gradient(to bottom, #af85fd, rgba(var(--v-theme-on-surface), 0.04));
}
.v-application .fc a { .v-application .fc a {
color: inherit; color: inherit;
} }

View File

@@ -11,12 +11,16 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh' import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue')) const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue'))
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
// 响应式断点用于切换订阅卡片网格密度。
const display = useDisplay()
// 用户 Store // 用户 Store
const userStore = useUserStore() const userStore = useUserStore()
@@ -117,6 +121,9 @@ const sortMode = computed({
}) })
const canDragSort = computed(() => sortMode.value && canSortContext.value) const canDragSort = computed(() => sortMode.value && canSortContext.value)
const shouldVirtualizeList = computed(() => !sortMode.value) const shouldVirtualizeList = computed(() => !sortMode.value)
const subscribeGridMinItemWidth = computed(() => (display.xs.value ? 144 : 240))
const subscribeGridEstimatedItemHeight = computed(() => (display.xs.value ? 190 : 300))
const subscribeGridGap = computed(() => (display.xs.value ? 12 : 16))
const scrollToIndex = computed(() => { const scrollToIndex = computed(() => {
if (!props.subid || sortMode.value) { if (!props.subid || sortMode.value) {
return undefined return undefined
@@ -581,8 +588,9 @@ defineExpose({
v-else-if="displayList.length > 0 && shouldVirtualizeList" v-else-if="displayList.length > 0 && shouldVirtualizeList"
:items="displayList" :items="displayList"
:get-item-key="item => item.id" :get-item-key="item => item.id"
:min-item-width="240" :min-item-width="subscribeGridMinItemWidth"
:estimated-item-height="300" :estimated-item-height="subscribeGridEstimatedItemHeight"
:gap="subscribeGridGap"
:scroll-to-index="scrollToIndex" :scroll-to-index="scrollToIndex"
class="px-2" class="px-2"
> >