mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
test(media): cover media detail flows (#559)
This commit is contained in:
@@ -832,11 +832,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/pages/media.vue": {
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/pages/resource.vue": {
|
"src/pages/resource.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 6
|
"count": 6
|
||||||
@@ -1025,14 +1020,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/views/discover/MediaDetailView.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 4
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/views/discover/PersonCardListView.vue": {
|
"src/views/discover/PersonCardListView.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import MediaPage from '@/pages/media.vue'
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const MediaDetailViewStub = defineComponent({
|
||||||
|
name: 'MediaDetailView',
|
||||||
|
props: {
|
||||||
|
mediaid: String,
|
||||||
|
title: String,
|
||||||
|
type: String,
|
||||||
|
year: String,
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h('output', { 'aria-label': '媒体详情参数' }, JSON.stringify(props))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renderPage(query: Record<string, string | string[] | null>) {
|
||||||
|
return renderWithProviders(MediaPage, {
|
||||||
|
initialRoute: { path: '/media', query },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MediaDetailView: MediaDetailViewStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedProps() {
|
||||||
|
return JSON.parse(screen.getByRole('status', { name: '媒体详情参数' }).textContent || '{}') as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('media page', () => {
|
||||||
|
it('projects route query values as strings to the detail view', async () => {
|
||||||
|
await renderPage({
|
||||||
|
mediaid: ['tmdb:101', 'ignored'],
|
||||||
|
title: '测试电影',
|
||||||
|
type: '电影',
|
||||||
|
year: '2026',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(projectedProps()).toEqual({
|
||||||
|
mediaid: 'tmdb:101,ignored',
|
||||||
|
title: '测试电影',
|
||||||
|
type: '电影',
|
||||||
|
year: '2026',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps missing query values undefined', async () => {
|
||||||
|
await renderPage({})
|
||||||
|
|
||||||
|
expect(projectedProps()).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MediaDetailView from '@/views/discover/MediaDetailView.vue'
|
import MediaDetailView from '@/views/discover/MediaDetailView.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
// 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 路由参数
|
// 路由参数
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ 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, MediaSeason, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
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'
|
||||||
@@ -64,6 +73,9 @@ const isSubscribed = ref(false)
|
|||||||
// 是否已加载完成
|
// 是否已加载完成
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 主详情请求失败时与合法空媒体分开展示,并允许用户重试。
|
||||||
|
const detailLoadFailed = ref(false)
|
||||||
|
|
||||||
// 存储每一季的集信息
|
// 存储每一季的集信息
|
||||||
const seasonEpisodesInfo = ref({} as { [key: number]: TmdbEpisode[] })
|
const seasonEpisodesInfo = ref({} as { [key: number]: TmdbEpisode[] })
|
||||||
|
|
||||||
@@ -136,6 +148,7 @@ const canScrollEpisodeGroupsForward = ref(false)
|
|||||||
let episodeGroupSeasonRequestId = 0
|
let episodeGroupSeasonRequestId = 0
|
||||||
let seasonNotExistsRequestId = 0
|
let seasonNotExistsRequestId = 0
|
||||||
let episodeExistsRequestId = 0
|
let episodeExistsRequestId = 0
|
||||||
|
let seasonEpisodesRequestGeneration = 0
|
||||||
|
|
||||||
// 计算主题是否为透明
|
// 计算主题是否为透明
|
||||||
const isTransparentTheme = computed(() => {
|
const isTransparentTheme = computed(() => {
|
||||||
@@ -172,7 +185,7 @@ async function querySites() {
|
|||||||
// 查询用户选中的站点
|
// 查询用户选中的站点
|
||||||
async function querySelectedSites() {
|
async function querySelectedSites() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
const result: ApiResponse<{ value?: number[] }> = await api.get('system/setting/public/IndexerSites')
|
||||||
|
|
||||||
selectedSites.value = result.data?.value ?? []
|
selectedSites.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -193,6 +206,9 @@ function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season
|
|||||||
// 调用API查询详情
|
// 调用API查询详情
|
||||||
async function getMediaDetail() {
|
async function getMediaDetail() {
|
||||||
if (mediaProps.mediaid && mediaProps.type) {
|
if (mediaProps.mediaid && mediaProps.type) {
|
||||||
|
detailLoadFailed.value = false
|
||||||
|
isRefreshed.value = false
|
||||||
|
try {
|
||||||
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
|
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
|
||||||
params: {
|
params: {
|
||||||
title: mediaProps.title,
|
title: mediaProps.title,
|
||||||
@@ -200,7 +216,6 @@ async function getMediaDetail() {
|
|||||||
type_name: mediaProps.type,
|
type_name: mediaProps.type,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
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 || ''
|
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
|
||||||
@@ -215,6 +230,12 @@ async function getMediaDetail() {
|
|||||||
// 检查订阅状态
|
// 检查订阅状态
|
||||||
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
|
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
|
||||||
else checkSeasonsSubscribed()
|
else checkSeasonsSubscribed()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
detailLoadFailed.value = true
|
||||||
|
} finally {
|
||||||
|
isRefreshed.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +245,14 @@ async function loadSeasonEpisodes(season: number) {
|
|||||||
loadEpisodeExists()
|
loadEpisodeExists()
|
||||||
// 加载季集信息
|
// 加载季集信息
|
||||||
if (seasonEpisodesInfo.value[season]) return
|
if (seasonEpisodesInfo.value[season]) return
|
||||||
|
const requestGeneration = seasonEpisodesRequestGeneration
|
||||||
try {
|
try {
|
||||||
const params = selectedEpisodeGroup.value ? { episode_group: selectedEpisodeGroup.value } : 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(
|
||||||
seasonEpisodesInfo.value[season] = result || []
|
`tmdb/${mediaDetail.value.tmdb_id}/${season}`,
|
||||||
|
params ? { params } : undefined,
|
||||||
|
)
|
||||||
|
if (requestGeneration === seasonEpisodesRequestGeneration) seasonEpisodesInfo.value[season] = result || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -253,7 +278,7 @@ async function loadEpisodeExists() {
|
|||||||
// 查询当前媒体是否已入库(数据库)
|
// 查询当前媒体是否已入库(数据库)
|
||||||
async function checkExists() {
|
async function checkExists() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
const result: ApiResponse<{ item: { id: string } }> = await api.get('mediaserver/exists', {
|
||||||
params: {
|
params: {
|
||||||
tmdbid: mediaDetail.value.tmdb_id,
|
tmdbid: mediaDetail.value.tmdb_id,
|
||||||
title: mediaDetail.value.title,
|
title: mediaDetail.value.title,
|
||||||
@@ -286,9 +311,7 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
|
|||||||
if (mediaDetail.value?.douban_id && subscribe.doubanid) return mediaDetail.value.douban_id === subscribe.doubanid
|
if (mediaDetail.value?.douban_id && subscribe.doubanid) return mediaDetail.value.douban_id === subscribe.doubanid
|
||||||
if (mediaDetail.value?.bangumi_id && subscribe.bangumiid) return mediaDetail.value.bangumi_id === subscribe.bangumiid
|
if (mediaDetail.value?.bangumi_id && subscribe.bangumiid) return mediaDetail.value.bangumi_id === subscribe.bangumiid
|
||||||
|
|
||||||
const mediaId = mediaDetail.value?.media_id
|
const mediaId = mediaDetail.value?.media_id ? `${mediaDetail.value.mediaid_prefix}:${mediaDetail.value.media_id}` : ''
|
||||||
? `${mediaDetail.value.mediaid_prefix}:${mediaDetail.value.media_id}`
|
|
||||||
: ''
|
|
||||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +368,8 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => [
|
|||||||
|
|
||||||
// 当前选中的剧集组选项
|
// 当前选中的剧集组选项
|
||||||
const selectedEpisodeGroupOption = computed(
|
const selectedEpisodeGroupOption = computed(
|
||||||
() => episodeGroupOptions.value.find(group => group.id === selectedEpisodeGroup.value) ?? episodeGroupOptions.value[0]!,
|
() =>
|
||||||
|
episodeGroupOptions.value.find(group => group.id === selectedEpisodeGroup.value) ?? episodeGroupOptions.value[0]!,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 季列表,第0季排在最后
|
// 季列表,第0季排在最后
|
||||||
@@ -407,6 +431,7 @@ async function setEpisodeGroup(groupId: string) {
|
|||||||
episodeGroupSeasons.value = []
|
episodeGroupSeasons.value = []
|
||||||
episodeGroupSeasonRequestId += 1
|
episodeGroupSeasonRequestId += 1
|
||||||
episodeExistsRequestId += 1
|
episodeExistsRequestId += 1
|
||||||
|
seasonEpisodesRequestGeneration += 1
|
||||||
|
|
||||||
await Promise.all([loadEpisodeGroupSeasons(groupId), checkSeasonsNotExists()])
|
await Promise.all([loadEpisodeGroupSeasons(groupId), checkSeasonsNotExists()])
|
||||||
}
|
}
|
||||||
@@ -473,15 +498,17 @@ const subscribedSeasonNumbers = computed(() =>
|
|||||||
.sort((a, b) => a - b),
|
.sort((a, b) => a - b),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 默认季结构中的可订阅季总数
|
// 默认季结构中的季号集合
|
||||||
const subscribeSeasonTotal = computed(() => mediaDetail.value?.season_info?.length ?? 0)
|
const defaultSubscribeSeasonNumbers = computed(() =>
|
||||||
|
(mediaDetail.value?.season_info ?? []).map(season => season.season_number ?? 0),
|
||||||
|
)
|
||||||
|
|
||||||
// 当前媒体是否已订阅默认季结构中的全部季
|
// 当前媒体是否已订阅默认季结构中的全部季
|
||||||
const isAllSeasonsSubscribed = computed(
|
const isAllSeasonsSubscribed = computed(
|
||||||
() =>
|
() =>
|
||||||
mediaDetail.value.type === '电视剧' &&
|
mediaDetail.value.type === '电视剧' &&
|
||||||
subscribeSeasonTotal.value > 0 &&
|
defaultSubscribeSeasonNumbers.value.length > 0 &&
|
||||||
subscribedSeasonNumbers.value.length >= subscribeSeasonTotal.value,
|
defaultSubscribeSeasonNumbers.value.every(season => seasonsSubscribed.value[season]),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 订阅按钮响应;单季入口同时传递详情页当前选择的剧集组。
|
// 订阅按钮响应;单季入口同时传递详情页当前选择的剧集组。
|
||||||
@@ -490,8 +517,8 @@ function handleSubscribe(season: number | null = null, episodeGroup = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从genres中获取name,使用、分隔
|
// 从genres中获取name,使用、分隔
|
||||||
function getGenresName(genres: any[]) {
|
function getGenresName(genres: Array<string | { name: string }>) {
|
||||||
return genres.map(genre => genre.name).join('、')
|
return genres.map(genre => (typeof genre === 'string' ? genre : genre.name)).join('、')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拼装TheMovieDb地址
|
// 拼装TheMovieDb地址
|
||||||
@@ -500,11 +527,6 @@ function getTheMovieDbLink() {
|
|||||||
return `https://www.themoviedb.org/${mtype}/${mediaDetail.value.tmdb_id}`
|
return `https://www.themoviedb.org/${mtype}/${mediaDetail.value.tmdb_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拼装豆瓣地址
|
|
||||||
function getDoubanLink() {
|
|
||||||
return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理豆瓣链接点击
|
// 处理豆瓣链接点击
|
||||||
async function handleDoubanClick() {
|
async function handleDoubanClick() {
|
||||||
if (mediaDetail.value.douban_id) {
|
if (mediaDetail.value.douban_id) {
|
||||||
@@ -570,8 +592,7 @@ const getProductionCompanies = computed(() => {
|
|||||||
// 获取指定类型的最早发行日期
|
// 获取指定类型的最早发行日期
|
||||||
function getEarliestReleaseDateByType(type: number): MediaRelease | null {
|
function getEarliestReleaseDateByType(type: number): MediaRelease | null {
|
||||||
const filteredDates = mediaDetail.value.release_dates?.filter(date => date.type === type)
|
const filteredDates = mediaDetail.value.release_dates?.filter(date => date.type === type)
|
||||||
if (!filteredDates || filteredDates.length === 0)
|
if (!filteredDates || filteredDates.length === 0) return null
|
||||||
return null
|
|
||||||
|
|
||||||
return filteredDates.reduce((earliest, current) =>
|
return filteredDates.reduce((earliest, current) =>
|
||||||
new Date(current.date) < new Date(earliest.date) ? current : earliest,
|
new Date(current.date) < new Date(earliest.date) ? current : earliest,
|
||||||
@@ -611,7 +632,8 @@ function isEpisodeExists(season: number, episode: number) {
|
|||||||
|
|
||||||
// 计算订阅图标
|
// 计算订阅图标
|
||||||
const getSubscribeIcon = computed(() => {
|
const getSubscribeIcon = computed(() => {
|
||||||
if (mediaDetail.value.type === '电视剧') return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
|
if (mediaDetail.value.type === '电视剧')
|
||||||
|
return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
|
||||||
if (isSubscribed.value) return 'mdi-heart'
|
if (isSubscribed.value) return 'mdi-heart'
|
||||||
else return 'mdi-heart-outline'
|
else return 'mdi-heart-outline'
|
||||||
})
|
})
|
||||||
@@ -669,7 +691,9 @@ function handleSearch(resultType: 'torrent' | 'subtitle' = 'torrent', options: M
|
|||||||
async function handlePlay() {
|
async function handlePlay() {
|
||||||
// 获取播放链接地址
|
// 获取播放链接地址
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
const result: ApiResponse<{ item_id: string; server_id: string; server_type: string; url: string }> = await api.get(
|
||||||
|
`mediaserver/play/${existsItemId.value}`,
|
||||||
|
)
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
// 使用深度链接工具,优先跳转到APP,失败后跳转到网页
|
// 使用深度链接工具,优先跳转到APP,失败后跳转到网页
|
||||||
await openMediaServerItem({
|
await openMediaServerItem({
|
||||||
@@ -683,6 +707,7 @@ async function handlePlay() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
$toast.error('获取播放链接失败!')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +731,11 @@ const subscribeActions = useMediaSubscribe({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 搜索前弹出站点选择框,确认后执行资源或字幕搜索。
|
// 搜索前弹出站点选择框,确认后执行资源或字幕搜索。
|
||||||
async function clickSearch(type: string, resultType: 'torrent' | 'subtitle' = 'torrent', options: MediaSearchOptions = {}) {
|
async function clickSearch(
|
||||||
|
type: string,
|
||||||
|
resultType: 'torrent' | 'subtitle' = 'torrent',
|
||||||
|
options: MediaSearchOptions = {},
|
||||||
|
) {
|
||||||
searchType.value = type
|
searchType.value = type
|
||||||
pendingSearchResultType.value = resultType
|
pendingSearchResultType.value = resultType
|
||||||
pendingSearchOptions.value = options
|
pendingSearchOptions.value = options
|
||||||
@@ -803,10 +832,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="media-actions">
|
<div class="media-actions">
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="
|
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && canSearch"
|
||||||
(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) &&
|
|
||||||
canSearch
|
|
||||||
"
|
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="media-action-button"
|
class="media-action-button"
|
||||||
@@ -827,10 +853,7 @@ onUnmounted(() => {
|
|||||||
</VMenu>
|
</VMenu>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="
|
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && canSearch"
|
||||||
(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) &&
|
|
||||||
canSearch
|
|
||||||
"
|
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
color="info"
|
color="info"
|
||||||
class="media-action-button"
|
class="media-action-button"
|
||||||
@@ -842,7 +865,10 @@ onUnmounted(() => {
|
|||||||
{{ t('media.actions.searchSubtitle') }}
|
{{ t('media.actions.searchSubtitle') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="canSubscribe && (mediaDetail.type === '电影' || mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id)"
|
v-if="
|
||||||
|
canSubscribe &&
|
||||||
|
(mediaDetail.type === '电影' || mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id)
|
||||||
|
"
|
||||||
class="media-action-button"
|
class="media-action-button"
|
||||||
:color="getSubscribeColor"
|
:color="getSubscribeColor"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@@ -950,7 +976,11 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<VIcon icon="mdi-chevron-left" />
|
<VIcon icon="mdi-chevron-left" />
|
||||||
</button>
|
</button>
|
||||||
<div ref="episodeGroupRail" class="episode-group-rail" @scroll.passive="updateEpisodeGroupScrollState">
|
<div
|
||||||
|
ref="episodeGroupRail"
|
||||||
|
class="episode-group-rail"
|
||||||
|
@scroll.passive="updateEpisodeGroupScrollState"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="group in episodeGroupOptions"
|
v-for="group in episodeGroupOptions"
|
||||||
:key="group.id || 'default'"
|
:key="group.id || 'default'"
|
||||||
@@ -1120,7 +1150,9 @@ onUnmounted(() => {
|
|||||||
<span>{{ t('media.info.digitalRelease') }}</span>
|
<span>{{ t('media.info.digitalRelease') }}</span>
|
||||||
<span class="media-fact-value">
|
<span class="media-fact-value">
|
||||||
<span class="flex items-center justify-end">
|
<span class="flex items-center justify-end">
|
||||||
<span class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none">
|
<span
|
||||||
|
class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none"
|
||||||
|
>
|
||||||
{{ getEarliestDigitalReleaseDate.iso_code }}
|
{{ getEarliestDigitalReleaseDate.iso_code }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-1.5">{{ getEarliestDigitalReleaseDate.date.slice(0, 10) }}</span>
|
<span class="ml-1.5">{{ getEarliestDigitalReleaseDate.date.slice(0, 10) }}</span>
|
||||||
@@ -1131,7 +1163,9 @@ onUnmounted(() => {
|
|||||||
<span>{{ t('media.info.physicalRelease') }}</span>
|
<span>{{ t('media.info.physicalRelease') }}</span>
|
||||||
<span class="media-fact-value">
|
<span class="media-fact-value">
|
||||||
<span class="flex items-center justify-end">
|
<span class="flex items-center justify-end">
|
||||||
<span class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none">
|
<span
|
||||||
|
class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none"
|
||||||
|
>
|
||||||
{{ getEarliestPhysicalReleaseDate.iso_code }}
|
{{ getEarliestPhysicalReleaseDate.iso_code }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-1.5">{{ getEarliestPhysicalReleaseDate.date.slice(0, 10) }}</span>
|
<span class="ml-1.5">{{ getEarliestPhysicalReleaseDate.date.slice(0, 10) }}</span>
|
||||||
@@ -1280,7 +1314,17 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
|
v-if="detailLoadFailed"
|
||||||
|
error-code="500"
|
||||||
|
:error-title="t('media.error.title')"
|
||||||
|
:error-description="t('error.networkError')"
|
||||||
|
>
|
||||||
|
<template #button>
|
||||||
|
<VBtn prepend-icon="mdi-refresh" variant="tonal" @click="getMediaDetail">{{ t('common.retry') }}</VBtn>
|
||||||
|
</template>
|
||||||
|
</NoDataFound>
|
||||||
|
<NoDataFound
|
||||||
|
v-else-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
|
||||||
error-code="500"
|
error-code="500"
|
||||||
:error-title="t('media.error.title')"
|
:error-title="t('media.error.title')"
|
||||||
:error-description="t('media.error.noMediaInfo')"
|
:error-description="t('media.error.noMediaInfo')"
|
||||||
@@ -1293,7 +1337,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image: linear-gradient(
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(var(--v-theme-background), 0) 50%,
|
rgba(var(--v-theme-background), 0) 50%,
|
||||||
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
|
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
|
||||||
@@ -1326,10 +1371,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.media-detail-transparent .vue-media-back-image {
|
.media-detail-transparent .vue-media-back-image {
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
|
mask-image:
|
||||||
|
linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
|
||||||
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
|
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
|
||||||
mask-composite: intersect;
|
mask-composite: intersect;
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
|
-webkit-mask-image:
|
||||||
|
linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
|
||||||
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
|
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
|
||||||
-webkit-mask-composite: source-in;
|
-webkit-mask-composite: source-in;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,14 @@ export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造后端无法识别媒体时返回的空详情。 */
|
||||||
|
export function createEmptyMediaInfo(): MediaInfo {
|
||||||
|
return {
|
||||||
|
episode_run_time: [],
|
||||||
|
origin_country: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 构造季选择弹窗使用的最小季信息。 */
|
/** 构造季选择弹窗使用的最小季信息。 */
|
||||||
export function createMediaSeason(overrides: Partial<MediaSeason> = {}): MediaSeason {
|
export function createMediaSeason(overrides: Partial<MediaSeason> = {}): MediaSeason {
|
||||||
seasonSeed += 1
|
seasonSeed += 1
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { HttpResponse, http, type JsonBodyType } from 'msw'
|
|||||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
|
|
||||||
export const mediaApiUrls = {
|
export const mediaApiUrls = {
|
||||||
|
details: (mediaId: string) => new URL(`media/${mediaId}`, API_BASE_URL).href,
|
||||||
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
||||||
exists: new URL('mediaserver/exists', API_BASE_URL).href,
|
exists: new URL('mediaserver/exists', API_BASE_URL).href,
|
||||||
|
existsRemote: new URL('mediaserver/exists_remote', API_BASE_URL).href,
|
||||||
groupSeasons: (episodeGroup: string) => new URL(`media/group/seasons/${episodeGroup}`, API_BASE_URL).href,
|
groupSeasons: (episodeGroup: string) => new URL(`media/group/seasons/${episodeGroup}`, API_BASE_URL).href,
|
||||||
notExists: new URL('mediaserver/notexists', API_BASE_URL).href,
|
notExists: new URL('mediaserver/notexists', API_BASE_URL).href,
|
||||||
|
play: (itemId: string) => new URL(`mediaserver/play/${itemId}`, API_BASE_URL).href,
|
||||||
seasons: new URL('media/seasons', API_BASE_URL).href,
|
seasons: new URL('media/seasons', API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,17 +26,41 @@ export function mediaExistsHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mediaDetailsHandler(
|
export function mediaDetailsHandler(
|
||||||
tmdbId: number,
|
mediaId: number | string,
|
||||||
response: MediaInfo,
|
response: MediaInfo,
|
||||||
status = 200,
|
status = 200,
|
||||||
onRequest: (url: URL) => void = () => {},
|
onRequest: (url: URL) => void = () => {},
|
||||||
) {
|
) {
|
||||||
return http.get(new URL(`media/tmdb:${tmdbId}`, API_BASE_URL).href, ({ request }) => {
|
const normalizedMediaId = typeof mediaId === 'number' ? `tmdb:${mediaId}` : mediaId
|
||||||
|
return http.get(mediaApiUrls.details(normalizedMediaId), ({ request }) => {
|
||||||
onRequest(new URL(request.url))
|
onRequest(new URL(request.url))
|
||||||
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mediaRemoteExistsHandler(
|
||||||
|
response: Record<number, number[]>,
|
||||||
|
status = 200,
|
||||||
|
onRequest: (payload: Record<string, unknown>) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.post(mediaApiUrls.existsRemote, async ({ request }) => {
|
||||||
|
await onRequest((await request.json()) as Record<string, unknown>)
|
||||||
|
return HttpResponse.json(response as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaPlayHandler(
|
||||||
|
itemId: string,
|
||||||
|
response: { data?: Record<string, unknown>; message?: string; success: boolean },
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(mediaApiUrls.play(itemId), () => {
|
||||||
|
onRequest()
|
||||||
|
return HttpResponse.json(response as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function tmdbSeasonEpisodesHandler(
|
export function tmdbSeasonEpisodesHandler(
|
||||||
tmdbId: number,
|
tmdbId: number,
|
||||||
season: number,
|
season: number,
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/pages/recommend.vue',
|
'src/pages/recommend.vue',
|
||||||
'src/pages/discover.vue',
|
'src/pages/discover.vue',
|
||||||
'src/pages/browse.vue',
|
'src/pages/browse.vue',
|
||||||
|
'src/pages/media.vue',
|
||||||
'src/pages/subscribe.vue',
|
'src/pages/subscribe.vue',
|
||||||
'src/views/dashboard/MediaRecommend.vue',
|
'src/views/dashboard/MediaRecommend.vue',
|
||||||
'src/views/discover/MediaCardSlideView.vue',
|
'src/views/discover/MediaCardSlideView.vue',
|
||||||
@@ -301,6 +302,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/views/discover/BangumiView.vue',
|
'src/views/discover/BangumiView.vue',
|
||||||
'src/views/discover/ExtraSourceView.vue',
|
'src/views/discover/ExtraSourceView.vue',
|
||||||
'src/views/discover/MediaCardListView.vue',
|
'src/views/discover/MediaCardListView.vue',
|
||||||
|
'src/views/discover/MediaDetailView.vue',
|
||||||
'src/components/cards/MediaCard.vue',
|
'src/components/cards/MediaCard.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
@@ -380,6 +382,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/pages/media.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/pages/subscribe.vue': {
|
'src/pages/subscribe.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
@@ -428,6 +436,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/views/discover/MediaDetailView.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/pages/browse.vue': {
|
'src/pages/browse.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user