Add media recommendations dashboard and episode groups

This commit is contained in:
jxxghp
2026-07-01 17:35:35 +08:00
parent 764f14a7f4
commit ce64fb03ce
10 changed files with 1297 additions and 138 deletions

View File

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

View File

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

View File

@@ -960,6 +960,13 @@ export default {
storage: 'Storage',
storageSummary: '{available} available / {total} total',
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',
realTimeSpeed: 'Real-time Speed',
scheduler: 'Background Tasks',
@@ -1047,6 +1054,14 @@ export default {
specials: 'Specials',
seasonNumber: 'Season {number}',
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: {
searchResource: 'Search Resource',
searchSubtitle: 'Search Subtitle',

View File

@@ -952,6 +952,13 @@ export default {
storage: '存储空间',
storageSummary: '可用 {available} / 总容量 {total}',
mediaStatistic: '媒体统计',
recommendedMedia: '推荐媒体',
selectRecommendSource: '选择推荐媒体来源',
previousRecommend: '上一项推荐',
nextRecommend: '下一项推荐',
showRecommend: '查看第 {index} 项推荐',
noRecommendations: '当前来源暂无推荐媒体',
recommendLoadFailed: '推荐媒体加载失败',
weeklyOverview: '入库统计',
realTimeSpeed: '实时速率',
scheduler: '后台任务',
@@ -1041,6 +1048,14 @@ export default {
specials: '特别篇',
seasonNumber: '第 {number} 季',
episodeCount: '{count}集',
episodeGroups: {
select: '选择剧集组',
default: '默认',
summary: '{seasons} 季 · {episodes} 集',
current: '当前:{name} · {seasons} 季 · {episodes} 集',
previous: '查看上一组剧集组',
next: '查看更多剧集组',
},
actions: {
searchResource: '搜索资源',
searchSubtitle: '搜索字幕',

View File

@@ -952,6 +952,13 @@ export default {
storage: '存儲空間',
storageSummary: '可用 {available} / 總容量 {total}',
mediaStatistic: '媒體統計',
recommendedMedia: '推薦媒體',
selectRecommendSource: '選擇推薦媒體來源',
previousRecommend: '上一項推薦',
nextRecommend: '下一項推薦',
showRecommend: '查看第 {index} 項推薦',
noRecommendations: '當前來源暫無推薦媒體',
recommendLoadFailed: '推薦媒體加載失敗',
weeklyOverview: '入庫統計',
realTimeSpeed: '實時速率',
scheduler: '後台任務',
@@ -1041,6 +1048,14 @@ export default {
specials: '特別篇',
seasonNumber: '第 {number} 季',
episodeCount: '{count}集',
episodeGroups: {
select: '選擇劇集組',
default: '預設',
summary: '{seasons} 季 · {episodes} 集',
current: '目前:{name} · {seasons} 季 · {episodes} 集',
previous: '查看上一組劇集組',
next: '查看更多劇集組',
},
actions: {
searchResource: '搜索資源',
searchSubtitle: '搜索字幕',

View File

@@ -25,9 +25,11 @@ const { t } = useI18n()
const { appMode } = usePWA()
const display = useDisplay()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
const userPermissionContext = computed(() =>
buildUserPermissionContext(userStore.superUser, userStore.permissions),
)
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery'))
// 路由
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 },
quickActions: { x: 8, y: 22, w: 4, h: 5 },
systemInfo: { x: 8, y: 27, w: 4, h: 6 },
mediaRecommend: { x: 0, y: 33, w: 12, h: 23 },
}
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
@@ -171,6 +174,15 @@ const dashboardConfigs = ref<DashboardItem[]>([
rows: 7,
elements: [],
},
{
id: 'mediaRecommend',
name: t('dashboard.recommendedMedia'),
key: '',
attrs: {},
cols: { cols: 12 },
rows: 23,
elements: [],
},
{
id: 'weeklyOverview',
name: t('dashboard.weeklyOverview'),
@@ -287,7 +299,12 @@ const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
// 当前启用且可渲染的仪表板 Grid 项。
const dashboardGridItems = computed<DashboardGridItem[]>(() =>
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 => {
const id = buildPluginDashboardId(item.id, item.key)
@@ -378,6 +395,7 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
function getDefaultDashboardEnableConfig(): DashboardEnableConfig {
return {
mediaStatistic: true,
mediaRecommend: true,
scheduler: true,
speed: true,
storage: true,

View File

@@ -11,6 +11,11 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { getRecommendTabs } from '@/router/i18n-menu'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import {
createBuiltInRecommendSources,
mergeExtraRecommendSources,
type RecommendViewSource,
} from '@/utils/recommendSources'
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 }[]>([
{
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 viewList = reactive<RecommendViewSource[]>(createBuiltInRecommendSources(t))
// 计算当前分类下显示的视图
const filteredViews = computed(() => {
@@ -175,20 +101,7 @@ const extraRecommendSources = ref<RecommendSource[]>([])
async function loadExtraRecommendSources() {
try {
extraRecommendSources.value = await api.get('recommend/source')
if (extraRecommendSources.value.length > 0) {
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,
})
}
})
}
mergeExtraRecommendSources(viewList, extraRecommendSources.value)
} catch (error) {
console.log(error)
}

View File

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

View File

@@ -0,0 +1,640 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaInfo, RecommendSource } from '@/api/types'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import {
createBuiltInRecommendSources,
mergeExtraRecommendSources,
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))
const selectedSourcePath = ref(localStorage.getItem(RECOMMEND_SOURCE_STORAGE_KEY) || 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])
/** 返回与媒体来源匹配的 Material Design 图标。 */
function getSourceIcon(source: RecommendViewSource) {
if (source.apipath.includes('bangumi')) return 'mdi-television-play'
if (source.apipath.includes('douban')) return 'mdi-star-circle-outline'
if (source.apipath.includes('tmdb')) return 'mdi-movie-open-star-outline'
return 'mdi-compass-outline'
}
/** 将不同接口包装格式归一化为媒体数组。 */
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.douban_id ||
item.bangumi_id ||
item.collection_id ||
(item.mediaid_prefix && item.media_id),
)
return Boolean(item.title && (item.backdrop_path || item.poster_path) && hasMediaId)
}
/** 构造轮播项稳定键,兼容合集与扩展媒体来源。 */
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
}
}
/** 加载后端扩展的推荐来源。 */
async function loadExtraSources() {
try {
const extraSources: RecommendSource[] = (await api.get('recommend/source')) ?? []
mergeExtraRecommendSources(sources.value, extraSources)
} catch (error) {
console.error(error)
}
}
/** 切换当前推荐来源并持久化用户选择。 */
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).replace('original', 'w1280')
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 () => {
await Promise.all([loadExtraSources(), loadMedia()])
if (!sources.value.some(source => source.apipath === selectedSourcePath.value)) {
selectSource(sources.value[0])
}
startAutoplay()
})
onActivated(startAutoplay)
onDeactivated(stopAutoplay)
onBeforeUnmount(stopAutoplay)
</script>
<template>
<VCard
class="dashboard-recommend 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="getSourceIcon(selectedSource)" 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="getSourceIcon(source)"
: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>
.dashboard-recommend {
position: relative;
overflow: hidden;
block-size: 100%;
min-block-size: 520px;
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%;
min-block-size: 520px;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.68);
flex-direction: column;
gap: 0.75rem;
}
@media (max-width: 740px) {
.dashboard-recommend {
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 {
min-inline-size: 124px;
inset-block-end: 3.8rem;
inset-inline-end: 0.85rem;
}
.dashboard-recommend-arrow {
block-size: 36px;
inline-size: 36px;
inset-block-end: 0.75rem;
}
.dashboard-recommend-arrow--previous {
inset-inline-start: 0.85rem;
}
.dashboard-recommend-arrow--next {
inset-inline-end: 0.85rem;
}
.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;
}
.dashboard-recommend-detail {
inset-inline: 0.85rem;
inline-size: calc(100% - 1.7rem);
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-recommend-page {
transition: none;
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
import PersonCardSlideView from './PersonCardSlideView.vue'
import MediaCardSlideView from './MediaCardSlideView.vue'
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 { formatSeasonLabel } from '@/@core/utils/season'
import router from '@/router'
@@ -93,12 +93,50 @@ interface MediaSearchOptions {
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 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(() => {
return theme.name.value === 'transparent'
@@ -165,6 +203,12 @@ async function getMediaDetail() {
isRefreshed.value = true
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()
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
@@ -181,7 +225,7 @@ async function loadSeasonEpisodes(season: number) {
// 加载季集信息
if (seasonEpisodesInfo.value[season]) return
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)
seasonEpisodesInfo.value[season] = result || []
} catch (error) {
@@ -193,9 +237,14 @@ async function loadSeasonEpisodes(season: number) {
async function loadEpisodeExists() {
// 查询季集存在状态
if (!isNullOrEmptyObject(existsEpisodes.value)) return
const requestId = ++episodeExistsRequestId
try {
const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, mediaDetail.value)
existsEpisodes.value = result || {}
const media = {
...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) {
console.error(error)
}
@@ -246,9 +295,15 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
// 检查所有季的缺失状态
async function checkSeasonsNotExists() {
if (mediaDetail.value.type !== '电视剧') return
const requestId = ++seasonNotExistsRequestId
seasonsNotExisted.value = {}
try {
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value)
if (result) {
const media = {
...mediaDetail.value,
episode_group: selectedEpisodeGroup.value || '',
}
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', media)
if (requestId === seasonNotExistsRequestId && result) {
result.forEach(item => {
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
@@ -268,16 +323,119 @@ async function checkMovieSubscribed() {
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季排在最后
const getMediaSeasons = computed(() => {
if (!mediaDetail.value?.season_info) return []
return [...mediaDetail.value.season_info].sort((a, b) => {
const seasons = selectedEpisodeGroup.value ? episodeGroupSeasons.value : mediaDetail.value?.season_info
if (!seasons) return []
return [...seasons].sort((a, b) => {
if (a.season_number === 0) return 1
if (b.season_number === 0) return -1
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() {
if (mediaDetail.value.type !== '电视剧') return
@@ -307,6 +465,7 @@ async function checkSeasonsSubscribed() {
}
}
// 已订阅季号列表
const subscribedSeasonNumbers = computed(() =>
Object.entries(seasonsSubscribed.value)
.filter(([, subscribed]) => subscribed)
@@ -314,8 +473,10 @@ const subscribedSeasonNumbers = computed(() =>
.sort((a, b) => a - b),
)
const subscribeSeasonTotal = computed(() => getMediaSeasons.value.length)
// 默认季结构中的可订阅季总数
const subscribeSeasonTotal = computed(() => mediaDetail.value?.season_info?.length ?? 0)
// 当前媒体是否已订阅默认季结构中的全部季
const isAllSeasonsSubscribed = computed(
() =>
mediaDetail.value.type === '电视剧' &&
@@ -461,6 +622,7 @@ const getSubscribeColor = computed(() => {
else return 'warning'
})
// 计算订阅按钮文案
const getSubscribeText = computed(() => {
if (mediaDetail.value.type === '电视剧') {
if (isAllSeasonsSubscribed.value) return t('media.status.allSeasonsSubscribed')
@@ -568,6 +730,14 @@ async function handleSubtitleSearch() {
onBeforeMount(() => {
getMediaDetail()
})
onMounted(() => {
window.addEventListener('resize', updateEpisodeGroupScrollState)
})
onUnmounted(() => {
window.removeEventListener('resize', updateEpisodeGroupScrollState)
})
</script>
<template>
@@ -761,7 +931,67 @@ onBeforeMount(() => {
</div>
<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">
<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
v-for="season in getMediaSeasons"
:key="season.season_number"
@@ -1273,6 +1503,7 @@ a.crew-name {
.media-overview-left {
flex: 1 1 0%;
min-inline-size: 0;
}
@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 {
inline-size: 100%;
margin-block-start: 2rem;