mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-03 21:41:32 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b212e066b6 | ||
|
|
f7d354fca4 | ||
|
|
693b081ecf | ||
|
|
d5f0fed954 | ||
|
|
67ef98bfee | ||
|
|
1094d80b9c | ||
|
|
98e6481812 | ||
|
|
7db088eb79 | ||
|
|
dec154c042 | ||
|
|
d06ce5d984 | ||
|
|
af604d0c5c | ||
|
|
fa39c3750c | ||
|
|
ce64fb03ce | ||
|
|
764f14a7f4 | ||
|
|
621200b3b2 | ||
|
|
60c46ebbaf |
BIN
.codex-qa/appcenter-mobile-comparison.png
Normal file
BIN
.codex-qa/appcenter-mobile-comparison.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
BIN
.codex-qa/appcenter-mobile-light.png
Normal file
BIN
.codex-qa/appcenter-mobile-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
.codex-qa/appcenter-mobile-transparent.png
Normal file
BIN
.codex-qa/appcenter-mobile-transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ public/plugin_icon/**
|
|||||||
docs-lock/
|
docs-lock/
|
||||||
.trae/
|
.trae/
|
||||||
output/
|
output/
|
||||||
|
.codex-qa/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
3
src/@layouts/types.d.ts
vendored
3
src/@layouts/types.d.ts
vendored
@@ -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
|
||||||
|
|||||||
152
src/App.vue
152
src/App.vue
@@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } = {},
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '搜索字幕',
|
||||||
|
|||||||
@@ -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: '搜索字幕',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
110
src/utils/recommendSources.ts
Normal file
110
src/utils/recommendSources.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
639
src/views/dashboard/MediaRecommend.vue
Normal file
639
src/views/dashboard/MediaRecommend.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user