mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
feat: add AniList browsing and source-aware search
This commit is contained in:
+1
-1
@@ -452,7 +452,7 @@ export interface TmdbEpisode {
|
||||
|
||||
// TMDB人物信息
|
||||
export interface Person {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// ID
|
||||
id?: number
|
||||
|
||||
@@ -540,7 +540,8 @@ onBeforeUnmount(() => {
|
||||
tile
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,9 @@ function getPersonImage() {
|
||||
} else if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.medium
|
||||
} else if (personProps.person?.source === 'anilist') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.large || personInfo.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -33,10 +33,6 @@ const router = useRouter()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
@@ -62,11 +58,6 @@ const hasAdminPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'admin')
|
||||
})
|
||||
|
||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||
const showCollectionSearch = computed(() => {
|
||||
return globalSettings.SEARCH_SOURCE?.includes('themoviedb') || false
|
||||
})
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
@@ -105,6 +96,83 @@ const searchOverlayProps = computed(() =>
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
||||
type MediaSearchType = 'media' | 'collection' | 'person'
|
||||
|
||||
interface MediaSearchSourceOption {
|
||||
label: string
|
||||
name: string
|
||||
value: MediaSearchSource
|
||||
}
|
||||
|
||||
interface MediaSearchAction {
|
||||
type: MediaSearchType
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB。
|
||||
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
|
||||
media: 'themoviedb',
|
||||
collection: 'themoviedb',
|
||||
person: 'themoviedb',
|
||||
})
|
||||
|
||||
// 按后端实际能力限定每类搜索可选的数据源。
|
||||
const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSourceOption[]>>(() => {
|
||||
const themoviedb = {
|
||||
label: 'TMDB',
|
||||
name: t('discoverTabs.themoviedb'),
|
||||
value: 'themoviedb' as const,
|
||||
}
|
||||
const douban = {
|
||||
label: t('discoverTabs.douban'),
|
||||
name: t('discoverTabs.douban'),
|
||||
value: 'douban' as const,
|
||||
}
|
||||
const bangumi = {
|
||||
label: 'Bangumi',
|
||||
name: t('discoverTabs.bangumi'),
|
||||
value: 'bangumi' as const,
|
||||
}
|
||||
const anilist = {
|
||||
label: 'AniList',
|
||||
name: t('discoverTabs.anilist'),
|
||||
value: 'anilist' as const,
|
||||
}
|
||||
|
||||
return {
|
||||
media: [themoviedb, douban, bangumi, anilist],
|
||||
collection: [themoviedb],
|
||||
person: [themoviedb, douban],
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索项及其来源组共用同一份声明,避免显示能力与请求类型不一致。
|
||||
const mediaSearchActions = computed(() => {
|
||||
return [
|
||||
{
|
||||
type: 'media',
|
||||
icon: 'mdi-movie-search',
|
||||
title: `${t('recommend.categoryMovie')}、${t('recommend.categoryTV')}`,
|
||||
description: t('resource.title'),
|
||||
},
|
||||
{
|
||||
type: 'collection',
|
||||
icon: 'mdi-movie-filter',
|
||||
title: t('dialog.searchBar.collections'),
|
||||
description: t('dialog.searchBar.collectionSearch'),
|
||||
},
|
||||
{
|
||||
type: 'person',
|
||||
icon: 'mdi-account-search',
|
||||
title: t('browse.actor'),
|
||||
description: t('dialog.searchBar.actorSearch'),
|
||||
},
|
||||
] satisfies MediaSearchAction[]
|
||||
})
|
||||
|
||||
// 当前尺寸下可见的搜索输入框。
|
||||
const searchWordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -318,8 +386,7 @@ function searchSubtitle() {
|
||||
}
|
||||
|
||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
function searchMedia(searchType: MediaSearchType) {
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
@@ -327,6 +394,7 @@ function searchMedia(searchType: string) {
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType,
|
||||
source: selectedMediaSearchSources[searchType],
|
||||
},
|
||||
})
|
||||
closeSearch()
|
||||
@@ -493,53 +561,52 @@ onMounted(() => {
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
v-for="action in mediaSearchActions"
|
||||
:key="action.type"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
class="search-result-item search-source-result-item mx-2 my-1"
|
||||
@click="searchMedia(action.type)"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
<VIcon :icon="action.icon" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ action.title }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
{{ action.description }}
|
||||
</VListItemSubtitle>
|
||||
<div class="search-item-source-row">
|
||||
<VBtnToggle
|
||||
v-model="selectedMediaSearchSources[action.type]"
|
||||
class="search-item-source-toggle"
|
||||
density="compact"
|
||||
mandatory
|
||||
role="group"
|
||||
selected-class="media-source-button--active"
|
||||
variant="text"
|
||||
:aria-label="t('dialog.searchBar.mediaSourceFor', { type: action.title })"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<VBtn
|
||||
v-for="source in mediaSearchSourceOptions[action.type]"
|
||||
:key="source.value"
|
||||
class="media-source-button"
|
||||
size="x-small"
|
||||
:value="source.value"
|
||||
:aria-label="t('dialog.searchBar.searchWithSource', { source: source.name })"
|
||||
:title="t('dialog.searchBar.searchWithSource', { source: source.name })"
|
||||
>
|
||||
{{ source.label }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
@@ -908,6 +975,69 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.search-item-source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: 0;
|
||||
inline-size: 100%;
|
||||
margin-block-start: 0;
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
block-size 0.15s ease,
|
||||
margin-block-start 0.15s ease;
|
||||
}
|
||||
|
||||
.search-source-result-item:hover .search-item-source-row,
|
||||
.search-source-result-item:focus-within .search-item-source-row {
|
||||
block-size: 28px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-control-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
block-size: 28px;
|
||||
max-inline-size: 100%;
|
||||
opacity: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
pointer-events: none;
|
||||
transform: scale(0.98);
|
||||
transform-origin: center left;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.search-source-result-item:hover .search-item-source-toggle,
|
||||
.search-source-result-item:focus-within .search-item-source-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.media-source-button {
|
||||
block-size: 100% !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72) !important;
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0;
|
||||
min-inline-size: 0 !important;
|
||||
padding-inline: 7px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-source-button:hover {
|
||||
background: var(--app-grouped-list-hover-background) !important;
|
||||
}
|
||||
|
||||
.media-source-button--active {
|
||||
background: var(--app-grouped-list-active-background) !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
margin-block-end: 2px;
|
||||
transition: background-color 0.15s ease;
|
||||
@@ -917,6 +1047,19 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.search-item-source-row {
|
||||
block-size: 28px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
|
||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||
import { screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
async function renderSearchBar() {
|
||||
return renderWithProviders(SearchBarDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
showActivator: true,
|
||||
},
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: {
|
||||
...DEFAULT_PERMISSIONS,
|
||||
admin: false,
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: false,
|
||||
subscribe: false,
|
||||
},
|
||||
superUser: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getSearchItem(title: string): HTMLElement {
|
||||
const item = screen.getByText(title).closest('.v-list-item')
|
||||
if (!(item instanceof HTMLElement)) throw new Error(`Search item not found: ${title}`)
|
||||
return item
|
||||
}
|
||||
|
||||
describe('SearchBarDialog media source selection', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults media searches to TheMovieDB', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '流浪地球{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.path).toBe('/browse/media/search')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'themoviedb',
|
||||
title: '流浪地球',
|
||||
type: 'media',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('places supported sources inside each search item and uses the selected source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '芙莉莲')
|
||||
const mediaItem = getSearchItem('电影、电视剧')
|
||||
const collectionItem = getSearchItem('系列合集')
|
||||
const personItem = getSearchItem('演员')
|
||||
|
||||
const mediaGroup = within(mediaItem).getByRole('group', { name: '电影、电视剧搜索数据源' })
|
||||
const collectionGroup = within(collectionItem).getByRole('group', { name: '系列合集搜索数据源' })
|
||||
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
||||
|
||||
expect(within(mediaGroup).getAllByRole('button')).toHaveLength(4)
|
||||
expect(within(collectionGroup).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(personGroup).getAllByRole('button')).toHaveLength(2)
|
||||
expect(within(mediaGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' })).toHaveClass(
|
||||
'media-source-button--active',
|
||||
)
|
||||
|
||||
await user.click(within(mediaGroup).getByRole('button', { name: '使用 AniList 搜索' }))
|
||||
await user.click(input)
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'anilist',
|
||||
title: '芙莉莲',
|
||||
type: 'media',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('searches actors with the selected supported source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '刘德华')
|
||||
const personItem = getSearchItem('演员')
|
||||
|
||||
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
||||
await user.click(within(personGroup).getByRole('button', { name: '使用 豆瓣 搜索' }))
|
||||
await user.click(personItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'douban',
|
||||
title: '刘德华',
|
||||
type: 'person',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -434,6 +434,7 @@ export default {
|
||||
themoviedb: 'TheMovieDb',
|
||||
douban: 'Douban',
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
},
|
||||
user: {
|
||||
admin: 'Admin',
|
||||
@@ -1204,6 +1205,8 @@ export default {
|
||||
trendingNow: 'Trending Now',
|
||||
nowShowing: 'Now Showing',
|
||||
bangumiDaily: 'Bangumi Daily Release',
|
||||
anilistTrendingNow: 'AniList TRENDING NOW',
|
||||
anilistPopularThisSeason: 'AniList POPULAR THIS SEASON',
|
||||
tmdbHotMovies: 'TMDB Hot Movies',
|
||||
tmdbHotTVShows: 'TMDB Hot TV Shows',
|
||||
doubanHotMovies: 'Douban Hot Movies',
|
||||
@@ -2698,6 +2701,8 @@ export default {
|
||||
emptySearchHint: 'Enter keywords to search',
|
||||
escClose: 'Close',
|
||||
openSearch: 'Open search',
|
||||
mediaSourceFor: '{type} search source',
|
||||
searchWithSource: 'Search with {source}',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: 'Select Sites',
|
||||
@@ -3796,6 +3801,66 @@ export default {
|
||||
date: 'Date',
|
||||
},
|
||||
},
|
||||
anilist: {
|
||||
sort: 'Sort',
|
||||
genre: 'Genre',
|
||||
format: 'Format',
|
||||
season: 'Season',
|
||||
year: 'Year',
|
||||
status: 'Status',
|
||||
country: 'Country',
|
||||
sortType: {
|
||||
popularity: 'Most Popular',
|
||||
trending: 'Trending',
|
||||
score: 'Highest Rated',
|
||||
newest: 'Newest',
|
||||
},
|
||||
formatType: {
|
||||
tv: 'TV',
|
||||
tv_short: 'TV Short',
|
||||
movie: 'Movie',
|
||||
ova: 'OVA',
|
||||
ona: 'ONA',
|
||||
special: 'Special',
|
||||
music: 'Music',
|
||||
},
|
||||
seasonType: {
|
||||
winter: 'Winter',
|
||||
spring: 'Spring',
|
||||
summer: 'Summer',
|
||||
fall: 'Fall',
|
||||
},
|
||||
statusType: {
|
||||
releasing: 'Releasing',
|
||||
finished: 'Finished',
|
||||
not_yet_released: 'Not Yet Released',
|
||||
},
|
||||
countryType: {
|
||||
jp: 'Japan',
|
||||
cn: 'China',
|
||||
kr: 'South Korea',
|
||||
tw: 'Taiwan',
|
||||
},
|
||||
genreType: {
|
||||
action: 'Action',
|
||||
adventure: 'Adventure',
|
||||
comedy: 'Comedy',
|
||||
drama: 'Drama',
|
||||
fantasy: 'Fantasy',
|
||||
horror: 'Horror',
|
||||
mahoushoujo: 'Mahou Shoujo',
|
||||
mecha: 'Mecha',
|
||||
music: 'Music',
|
||||
mystery: 'Mystery',
|
||||
psychological: 'Psychological',
|
||||
romance: 'Romance',
|
||||
scifi: 'Sci-Fi',
|
||||
sliceoflife: 'Slice of Life',
|
||||
sports: 'Sports',
|
||||
supernatural: 'Supernatural',
|
||||
thriller: 'Thriller',
|
||||
},
|
||||
},
|
||||
tmdb: {
|
||||
type: 'Type',
|
||||
sort: 'Sort',
|
||||
|
||||
@@ -428,6 +428,7 @@ export default {
|
||||
themoviedb: 'TheMovieDb',
|
||||
douban: '豆瓣',
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
},
|
||||
user: {
|
||||
admin: '管理员',
|
||||
@@ -1197,6 +1198,8 @@ export default {
|
||||
trendingNow: '流行趋势',
|
||||
nowShowing: '正在热映',
|
||||
bangumiDaily: 'Bangumi每日放送',
|
||||
anilistTrendingNow: 'AniList 当前趋势',
|
||||
anilistPopularThisSeason: 'AniList 本季热门',
|
||||
tmdbHotMovies: 'TMDB热门电影',
|
||||
tmdbHotTVShows: 'TMDB热门电视剧',
|
||||
doubanHotMovies: '豆瓣热门电影',
|
||||
@@ -2652,6 +2655,8 @@ export default {
|
||||
emptySearchHint: '输入关键字开始搜索',
|
||||
escClose: '关闭',
|
||||
openSearch: '打开搜索',
|
||||
mediaSourceFor: '{type}搜索数据源',
|
||||
searchWithSource: '使用 {source} 搜索',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: '选择站点',
|
||||
@@ -3736,6 +3741,66 @@ export default {
|
||||
date: '日期',
|
||||
},
|
||||
},
|
||||
anilist: {
|
||||
sort: '排序',
|
||||
genre: '风格',
|
||||
format: '形式',
|
||||
season: '季度',
|
||||
year: '年份',
|
||||
status: '状态',
|
||||
country: '地区',
|
||||
sortType: {
|
||||
popularity: '热门优先',
|
||||
trending: '趋势优先',
|
||||
score: '评分优先',
|
||||
newest: '最新开播',
|
||||
},
|
||||
formatType: {
|
||||
tv: 'TV',
|
||||
tv_short: '短篇 TV',
|
||||
movie: '剧场版',
|
||||
ova: 'OVA',
|
||||
ona: 'ONA',
|
||||
special: '特别篇',
|
||||
music: '音乐',
|
||||
},
|
||||
seasonType: {
|
||||
winter: '冬季',
|
||||
spring: '春季',
|
||||
summer: '夏季',
|
||||
fall: '秋季',
|
||||
},
|
||||
statusType: {
|
||||
releasing: '连载中',
|
||||
finished: '已完结',
|
||||
not_yet_released: '未播出',
|
||||
},
|
||||
countryType: {
|
||||
jp: '日本',
|
||||
cn: '中国大陆',
|
||||
kr: '韩国',
|
||||
tw: '中国台湾',
|
||||
},
|
||||
genreType: {
|
||||
action: '动作',
|
||||
adventure: '冒险',
|
||||
comedy: '喜剧',
|
||||
drama: '剧情',
|
||||
fantasy: '奇幻',
|
||||
horror: '恐怖',
|
||||
mahoushoujo: '魔法少女',
|
||||
mecha: '机甲',
|
||||
music: '音乐',
|
||||
mystery: '悬疑',
|
||||
psychological: '心理',
|
||||
romance: '爱情',
|
||||
scifi: '科幻',
|
||||
sliceoflife: '日常',
|
||||
sports: '运动',
|
||||
supernatural: '超自然',
|
||||
thriller: '惊悚',
|
||||
},
|
||||
},
|
||||
tmdb: {
|
||||
type: '类型',
|
||||
sort: '排序',
|
||||
|
||||
@@ -428,6 +428,7 @@ export default {
|
||||
themoviedb: 'TheMovieDb',
|
||||
douban: '豆瓣',
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
},
|
||||
user: {
|
||||
admin: '管理員',
|
||||
@@ -1195,6 +1196,8 @@ export default {
|
||||
trendingNow: '流行趨勢',
|
||||
nowShowing: '正在熱映',
|
||||
bangumiDaily: 'Bangumi每日放送',
|
||||
anilistTrendingNow: 'AniList 當前趨勢',
|
||||
anilistPopularThisSeason: 'AniList 本季熱門',
|
||||
tmdbHotMovies: 'TMDB熱門電影',
|
||||
tmdbHotTVShows: 'TMDB熱門電視劇',
|
||||
doubanHotMovies: '豆瓣熱門電影',
|
||||
@@ -2651,6 +2654,8 @@ export default {
|
||||
emptySearchHint: '輸入關鍵字開始搜索',
|
||||
escClose: '關閉',
|
||||
openSearch: '打開搜索',
|
||||
mediaSourceFor: '{type}搜索資料來源',
|
||||
searchWithSource: '使用 {source} 搜索',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: '選擇站點',
|
||||
@@ -3733,6 +3738,66 @@ export default {
|
||||
date: '日期',
|
||||
},
|
||||
},
|
||||
anilist: {
|
||||
sort: '排序',
|
||||
genre: '風格',
|
||||
format: '形式',
|
||||
season: '季度',
|
||||
year: '年份',
|
||||
status: '狀態',
|
||||
country: '地區',
|
||||
sortType: {
|
||||
popularity: '熱門優先',
|
||||
trending: '趨勢優先',
|
||||
score: '評分優先',
|
||||
newest: '最新開播',
|
||||
},
|
||||
formatType: {
|
||||
tv: 'TV',
|
||||
tv_short: '短篇 TV',
|
||||
movie: '劇場版',
|
||||
ova: 'OVA',
|
||||
ona: 'ONA',
|
||||
special: '特別篇',
|
||||
music: '音樂',
|
||||
},
|
||||
seasonType: {
|
||||
winter: '冬季',
|
||||
spring: '春季',
|
||||
summer: '夏季',
|
||||
fall: '秋季',
|
||||
},
|
||||
statusType: {
|
||||
releasing: '連載中',
|
||||
finished: '已完結',
|
||||
not_yet_released: '未播出',
|
||||
},
|
||||
countryType: {
|
||||
jp: '日本',
|
||||
cn: '中國大陸',
|
||||
kr: '韓國',
|
||||
tw: '中國台灣',
|
||||
},
|
||||
genreType: {
|
||||
action: '動作',
|
||||
adventure: '冒險',
|
||||
comedy: '喜劇',
|
||||
drama: '劇情',
|
||||
fantasy: '奇幻',
|
||||
horror: '恐怖',
|
||||
mahoushoujo: '魔法少女',
|
||||
mecha: '機甲',
|
||||
music: '音樂',
|
||||
mystery: '懸疑',
|
||||
psychological: '心理',
|
||||
romance: '愛情',
|
||||
scifi: '科幻',
|
||||
sliceoflife: '日常',
|
||||
sports: '運動',
|
||||
supernatural: '超自然',
|
||||
thriller: '驚悚',
|
||||
},
|
||||
},
|
||||
tmdb: {
|
||||
type: '類型',
|
||||
sort: '排序',
|
||||
|
||||
@@ -100,6 +100,7 @@ async function renderDiscover() {
|
||||
errorHandler: componentError,
|
||||
},
|
||||
stubs: {
|
||||
AniListView: BuiltInViewStub,
|
||||
BangumiView: BuiltInViewStub,
|
||||
DoubanView: BuiltInViewStub,
|
||||
ExtraSourceView: ExtraSourceViewStub,
|
||||
@@ -171,10 +172,16 @@ describe('discover page', () => {
|
||||
await renderDiscover()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['豆瓣', '自定义来源', 'TheMovieDb', 'Bangumi']),
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual([
|
||||
'豆瓣',
|
||||
'自定义来源',
|
||||
'TheMovieDb',
|
||||
'Bangumi',
|
||||
'AniList',
|
||||
]),
|
||||
)
|
||||
expect(configRequested).not.toHaveBeenCalled()
|
||||
expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi'])
|
||||
expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi', 'anilist'])
|
||||
})
|
||||
|
||||
it('loads remote order when local order is absent and backfills localStorage', async () => {
|
||||
@@ -189,7 +196,13 @@ describe('discover page', () => {
|
||||
|
||||
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
|
||||
await waitFor(() =>
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', '自定义来源']),
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual([
|
||||
'Bangumi',
|
||||
'TheMovieDb',
|
||||
'豆瓣',
|
||||
'AniList',
|
||||
'自定义来源',
|
||||
]),
|
||||
)
|
||||
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
|
||||
})
|
||||
@@ -203,7 +216,7 @@ describe('discover page', () => {
|
||||
const { componentError } = await renderDiscover()
|
||||
|
||||
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣']))
|
||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList']))
|
||||
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
|
||||
expect(componentError).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -219,7 +232,7 @@ describe('discover page', () => {
|
||||
await renderDiscover()
|
||||
|
||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源'))
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '可用扩展源'])
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', 'AniList', '可用扩展源'])
|
||||
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
|
||||
})
|
||||
|
||||
@@ -306,7 +319,7 @@ describe('discover page', () => {
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
|
||||
expect(getHeaderItems().map(item => item.title)).toContain('缓存来源')
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '缓存来源'])
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', 'AniList', '缓存来源'])
|
||||
})
|
||||
|
||||
it('replaces the header metadata when a source with the same prefix changes', async () => {
|
||||
@@ -341,11 +354,11 @@ describe('discover page', () => {
|
||||
}),
|
||||
)
|
||||
await renderDiscover()
|
||||
await waitFor(() => expect(getHeaderItems()).toHaveLength(4))
|
||||
await waitFor(() => expect(getHeaderItems()).toHaveLength(5))
|
||||
|
||||
getHeaderConfig().appendButtons[0].action()
|
||||
const { events, tabs } = getDialogCall()
|
||||
const reorderedTabs = [tabs[3], tabs[1], tabs[0], tabs[2]]
|
||||
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2]]
|
||||
await events.save(reorderedTabs)
|
||||
|
||||
const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
|
||||
|
||||
@@ -139,7 +139,9 @@ describe('recommend page', () => {
|
||||
await renderRecommend()
|
||||
|
||||
expect(await screen.findByText('自定义来源')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('recommend-view')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('recommend-view')).toHaveLength(4)
|
||||
expect(screen.getByText('AniList 当前趋势')).toBeInTheDocument()
|
||||
expect(screen.getByText('AniList 本季热门')).toBeInTheDocument()
|
||||
expect(screen.queryByText('重复来源')).not.toBeInTheDocument()
|
||||
expect(remoteConfigRequests).toBe(0)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getDiscoverTabs } from '@/router/i18n-menu'
|
||||
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
|
||||
import DoubanView from '@/views/discover/DoubanView.vue'
|
||||
import BangumiView from '@/views/discover/BangumiView.vue'
|
||||
import AniListView from '@/views/discover/AniListView.vue'
|
||||
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
|
||||
import { DiscoverSource } from '@/api/types'
|
||||
import api from '@/api'
|
||||
@@ -256,6 +257,13 @@ onActivated(async () => {
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="anilist">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AniListView />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
|
||||
@@ -70,6 +70,7 @@ function openRecommendSettings() {
|
||||
|
||||
const builtInRecommendSources = createBuiltInRecommendSources(t)
|
||||
const viewList = reactive<RecommendViewSource[]>([...builtInRecommendSources])
|
||||
const newlyAddedBuiltInPaths = new Set(['anilist/trending', 'anilist/popular-this-season'])
|
||||
|
||||
// 计算当前分类下显示的视图
|
||||
const filteredViews = computed(() => {
|
||||
@@ -109,6 +110,15 @@ function normalizeEnableConfig(value: unknown): Record<string, boolean> | null {
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
/** 为旧版推荐配置补入新增内置榜单,同时保留用户已经明确保存的开关值。 */
|
||||
function enableMissingBuiltInSources() {
|
||||
builtInRecommendSources.forEach(source => {
|
||||
if (newlyAddedBuiltInPaths.has(source.apipath) && !(source.title in enableConfig.value)) {
|
||||
enableConfig.value[source.title] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新扩展推荐源;并发生命周期入口共享请求,成功响应按当前服务端快照替换列表。 */
|
||||
function loadExtraRecommendSources() {
|
||||
if (extraSourcesRequest) return extraSourcesRequest
|
||||
@@ -201,6 +211,7 @@ let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadConfig()
|
||||
enableMissingBuiltInSources()
|
||||
initializeColors()
|
||||
})
|
||||
|
||||
|
||||
@@ -311,6 +311,11 @@ export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
|
||||
tab: 'bangumi',
|
||||
icon: 'mdi-calendar-star-outline',
|
||||
},
|
||||
{
|
||||
title: t('discoverTabs.anilist'),
|
||||
tab: 'anilist',
|
||||
icon: 'mdi-alpha-a-circle-outline',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RecommendSource } from '@/api/types'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import {
|
||||
createBuiltInRecommendSources,
|
||||
mergeExtraRecommendSources,
|
||||
@@ -9,10 +10,31 @@ import { describe, expect, it } from 'vitest'
|
||||
const translate = (key: string) => `translated:${key}`
|
||||
|
||||
describe('recommendSources', () => {
|
||||
it('provides localized AniList ranking titles', () => {
|
||||
const originalLocale = i18n.global.locale.value
|
||||
const titles = {
|
||||
'zh-CN': ['AniList 当前趋势', 'AniList 本季热门'],
|
||||
'zh-TW': ['AniList 當前趨勢', 'AniList 本季熱門'],
|
||||
'en-US': ['AniList TRENDING NOW', 'AniList POPULAR THIS SEASON'],
|
||||
} as const
|
||||
const locales = ['zh-CN', 'zh-TW', 'en-US'] as const
|
||||
|
||||
try {
|
||||
locales.forEach(locale => {
|
||||
const expected = titles[locale]
|
||||
i18n.global.locale.value = locale
|
||||
expect(i18n.global.t('recommend.anilistTrendingNow')).toBe(expected[0])
|
||||
expect(i18n.global.t('recommend.anilistPopularThisSeason')).toBe(expected[1])
|
||||
})
|
||||
} finally {
|
||||
i18n.global.locale.value = originalLocale
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the complete built-in source contract', () => {
|
||||
const sources = createBuiltInRecommendSources(translate)
|
||||
|
||||
expect(sources).toHaveLength(13)
|
||||
expect(sources).toHaveLength(15)
|
||||
expect(sources[0]).toEqual({
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow',
|
||||
@@ -26,6 +48,26 @@ describe('recommendSources', () => {
|
||||
'/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=translated:recommend.tmdbHotTVShows',
|
||||
}),
|
||||
)
|
||||
expect(sources).toContainEqual({
|
||||
apipath: 'anilist/trending',
|
||||
linkurl: '/browse/anilist/trending?title=translated:recommend.anilistTrendingNow',
|
||||
title: 'translated:recommend.anilistTrendingNow',
|
||||
type: 'translated:recommend.categoryAnime',
|
||||
})
|
||||
expect(sources).toContainEqual({
|
||||
apipath: 'anilist/popular-this-season',
|
||||
linkurl: '/browse/anilist/popular-this-season?title=translated:recommend.anilistPopularThisSeason',
|
||||
title: 'translated:recommend.anilistPopularThisSeason',
|
||||
type: 'translated:recommend.categoryAnime',
|
||||
})
|
||||
expect(
|
||||
sources.filter(source => source.type === 'translated:recommend.categoryAnime').map(source => source.apipath),
|
||||
).toEqual([
|
||||
'recommend/bangumi_calendar',
|
||||
'anilist/trending',
|
||||
'anilist/popular-this-season',
|
||||
'recommend/douban_tv_animation',
|
||||
])
|
||||
})
|
||||
|
||||
it('appends extra sources in order and skips duplicate API paths', () => {
|
||||
|
||||
@@ -30,6 +30,18 @@ export function createBuiltInRecommendSources(t: Translate): RecommendViewSource
|
||||
title: t('recommend.bangumiDaily'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'anilist/trending',
|
||||
linkurl: '/browse/anilist/trending?title=' + t('recommend.anilistTrendingNow'),
|
||||
title: t('recommend.anilistTrendingNow'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'anilist/popular-this-season',
|
||||
linkurl: '/browse/anilist/popular-this-season?title=' + t('recommend.anilistPopularThisSeason'),
|
||||
title: t('recommend.anilistPopularThisSeason'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_movies',
|
||||
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
|
||||
@@ -38,8 +50,7 @@ export function createBuiltInRecommendSources(t: Translate): RecommendViewSource
|
||||
},
|
||||
{
|
||||
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'),
|
||||
linkurl: '/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
|
||||
title: t('recommend.tmdbHotTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentKey = ref(0)
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const filterParams = reactive({
|
||||
sort: 'POPULARITY_DESC' as string | null,
|
||||
genre: null as string | null,
|
||||
format: null as string | null,
|
||||
season: null as string | null,
|
||||
season_year: null as number | null,
|
||||
status: null as string | null,
|
||||
country: null as string | null,
|
||||
})
|
||||
|
||||
const sortItems = [
|
||||
{ title: t('anilist.sortType.popularity'), value: 'POPULARITY_DESC' },
|
||||
{ title: t('anilist.sortType.trending'), value: 'TRENDING_DESC' },
|
||||
{ title: t('anilist.sortType.score'), value: 'SCORE_DESC' },
|
||||
{ title: t('anilist.sortType.newest'), value: 'START_DATE_DESC' },
|
||||
]
|
||||
|
||||
const genreItems = [
|
||||
'Action',
|
||||
'Adventure',
|
||||
'Comedy',
|
||||
'Drama',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Mahou Shoujo',
|
||||
'Mecha',
|
||||
'Music',
|
||||
'Mystery',
|
||||
'Psychological',
|
||||
'Romance',
|
||||
'Sci-Fi',
|
||||
'Slice of Life',
|
||||
'Sports',
|
||||
'Supernatural',
|
||||
'Thriller',
|
||||
].map(value => ({ title: t(`anilist.genreType.${value.replaceAll(' ', '').replace('-', '').toLowerCase()}`), value }))
|
||||
|
||||
const formatItems = ['TV', 'TV_SHORT', 'MOVIE', 'OVA', 'ONA', 'SPECIAL', 'MUSIC'].map(value => ({
|
||||
title: t(`anilist.formatType.${value.toLowerCase()}`),
|
||||
value,
|
||||
}))
|
||||
|
||||
const seasonItems = ['WINTER', 'SPRING', 'SUMMER', 'FALL'].map(value => ({
|
||||
title: t(`anilist.seasonType.${value.toLowerCase()}`),
|
||||
value,
|
||||
}))
|
||||
|
||||
const statusItems = ['RELEASING', 'FINISHED', 'NOT_YET_RELEASED'].map(value => ({
|
||||
title: t(`anilist.statusType.${value.toLowerCase()}`),
|
||||
value,
|
||||
}))
|
||||
|
||||
const countryItems = ['JP', 'CN', 'KR', 'TW'].map(value => ({
|
||||
title: t(`anilist.countryType.${value.toLowerCase()}`),
|
||||
value,
|
||||
}))
|
||||
|
||||
const yearItems = Array.from({ length: 15 }, (_, index) => currentYear - index)
|
||||
|
||||
type AniListFilterKey = Exclude<keyof typeof filterParams, 'season_year'>
|
||||
|
||||
const filterGroups = [
|
||||
{ key: 'sort', label: t('anilist.sort'), items: sortItems },
|
||||
{ key: 'format', label: t('anilist.format'), items: formatItems },
|
||||
{ key: 'genre', label: t('anilist.genre'), items: genreItems },
|
||||
{ key: 'season', label: t('anilist.season'), items: seasonItems },
|
||||
{ key: 'season_year', label: t('anilist.year'), items: yearItems.map(value => ({ title: value, value })) },
|
||||
{ key: 'status', label: t('anilist.status'), items: statusItems },
|
||||
{ key: 'country', label: t('anilist.country'), items: countryItems },
|
||||
] as const
|
||||
|
||||
/** 将筛选 Chip 的选择值写入对应 AniList 查询参数。 */
|
||||
function updateFilter(key: (typeof filterGroups)[number]['key'], value: unknown) {
|
||||
if (key === 'season_year') {
|
||||
filterParams.season_year = typeof value === 'number' ? value : null
|
||||
return
|
||||
}
|
||||
filterParams[key as AniListFilterKey] = typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
watch(filterParams, () => {
|
||||
if (!filterParams.sort) {
|
||||
filterParams.sort = 'POPULARITY_DESC'
|
||||
}
|
||||
currentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div
|
||||
v-for="group in filterGroups"
|
||||
:key="group.key"
|
||||
class="flex justify-start align-center"
|
||||
>
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ group.label }}</VLabel>
|
||||
</div>
|
||||
<VChipGroup :model-value="filterParams[group.key]" @update:model-value="updateFilter(group.key, $event)">
|
||||
<VChip
|
||||
v-for="item in group.items"
|
||||
:key="item.value"
|
||||
:color="filterParams[group.key as keyof typeof filterParams] == item.value ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.title }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaCardListView :key="currentKey" apipath="anilist/discover" :params="filterParams" />
|
||||
</template>
|
||||
@@ -566,6 +566,11 @@ function getBangumiLink() {
|
||||
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
|
||||
}
|
||||
|
||||
// 拼装 AniList 地址
|
||||
function getAniListLink() {
|
||||
return `https://anilist.co/anime/${mediaDetail.value.anilist_id}`
|
||||
}
|
||||
|
||||
// 拼装集图片地址
|
||||
function getEpisodeImage(stillPath: string) {
|
||||
if (!stillPath) return ''
|
||||
@@ -963,6 +968,19 @@ onUnmounted(() => {
|
||||
<span class="ms-1">Bangumi</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
v-if="mediaDetail.anilist_id"
|
||||
class="mb-2 mr-2 inline-flex last:mr-0"
|
||||
:href="getAniListLink()"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
|
||||
>
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">AniList</span>
|
||||
</div>
|
||||
</a>
|
||||
</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">
|
||||
@@ -1233,7 +1251,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id" class="media-overview-right">
|
||||
<div v-else-if="mediaDetail.bangumi_id || mediaDetail.anilist_id" class="media-overview-right">
|
||||
<div class="media-facts">
|
||||
<div v-if="mediaDetail.vote_average" class="media-ratings">
|
||||
<VRating v-model="mediaDetail.vote_average" density="compact" length="10" class="ma-2" readonly />
|
||||
@@ -1242,6 +1260,10 @@ onUnmounted(() => {
|
||||
<span>ID</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
|
||||
</div>
|
||||
<div v-if="mediaDetail.anilist_id" class="media-fact">
|
||||
<span>ID</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.anilist_id }}</span>
|
||||
</div>
|
||||
<div v-if="mediaDetail.original_title" class="media-fact">
|
||||
<span>{{ t('media.info.originalTitle') }}</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
|
||||
@@ -1283,6 +1305,14 @@ onUnmounted(() => {
|
||||
type="bangumi"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.anilist_id">
|
||||
<PersonCardSlideView
|
||||
:apipath="`anilist/credits/${mediaDetail.anilist_id}`"
|
||||
:linkurl="`/credits/anilist/credits/${mediaDetail.anilist_id}?title=${t('media.castAndCrew')}&type=anilist`"
|
||||
:title="t('media.castAndCrew')"
|
||||
type="anilist"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
|
||||
@@ -1308,6 +1338,13 @@ onUnmounted(() => {
|
||||
:title="t('media.recommendations')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.anilist_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`anilist/recommend/${mediaDetail.anilist_id}`"
|
||||
:linkurl="`/browse/anilist/recommend/${mediaDetail.anilist_id}?title=${t('media.recommendations')}`"
|
||||
:title="t('media.recommendations')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
|
||||
|
||||
@@ -7,6 +7,8 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -23,6 +25,20 @@ const personProps = defineProps({
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// AniList 人物简介使用 Markdown;禁用原始 HTML,避免第三方内容注入标签或事件属性。
|
||||
const markdown = new MarkdownIt({
|
||||
breaks: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
markdown.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体详情
|
||||
const personDetail = ref<Person>({} as Person)
|
||||
|
||||
@@ -32,6 +48,12 @@ const isRefreshed = ref(false)
|
||||
// 人物图片是否加载
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 仅转换 AniList 的 Markdown 简介,其他数据源保持原有纯文本展示。
|
||||
const personBiographyHtml = computed(() => {
|
||||
if (personProps.source !== 'anilist' || !personDetail.value.biography) return ''
|
||||
return markdown.render(personDetail.value.biography)
|
||||
})
|
||||
|
||||
// 调用API查询详情
|
||||
async function getPersonDetail() {
|
||||
if (personProps.personid) {
|
||||
@@ -41,6 +63,8 @@ async function getPersonDetail() {
|
||||
personDetail.value = await api.get(`douban/person/${personProps.personid}`)
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
personDetail.value = await api.get(`bangumi/person/${personProps.personid}`)
|
||||
} else if (personProps.source === 'anilist') {
|
||||
personDetail.value = await api.get(`anilist/person/${personProps.personid}`)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
@@ -62,6 +86,9 @@ function getPersonImage() {
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
if (!personDetail.value?.images) return personIcon
|
||||
url = personDetail.value?.images?.medium
|
||||
} else if (personProps.source === 'anilist') {
|
||||
if (!personDetail.value?.images) return personIcon
|
||||
url = personDetail.value?.images?.large || personDetail.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
@@ -85,6 +112,8 @@ function getPersonCreditsPath() {
|
||||
apipath = 'douban'
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
apipath = 'bangumi'
|
||||
} else if (personProps.source === 'anilist') {
|
||||
apipath = 'anilist'
|
||||
}
|
||||
return `/browse/${apipath}/person/credits/${personDetail.value.id}?title=${t('person.credits')}`
|
||||
}
|
||||
@@ -96,6 +125,8 @@ function getPersonCreditsApiPath() {
|
||||
apipath = 'douban'
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
apipath = 'bangumi'
|
||||
} else if (personProps.source === 'anilist') {
|
||||
apipath = 'anilist'
|
||||
}
|
||||
return `${apipath}/person/credits/${personDetail.value.id}`
|
||||
}
|
||||
@@ -133,12 +164,17 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
<div class="relative text-left">
|
||||
<div class="group outline-none ring-0" role="button" tabindex="-1">
|
||||
<p class="pt-2 text-sm lg:text-base" style="overflow-wrap: break-word">
|
||||
<div
|
||||
v-if="personProps.source === 'anilist'"
|
||||
class="person-biography pt-2 text-sm lg:text-base"
|
||||
v-html="personBiographyHtml"
|
||||
/>
|
||||
<p v-else class="pt-2 text-sm lg:text-base" style="overflow-wrap: break-word">
|
||||
{{ personDetail.biography }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="person-credits-section">
|
||||
<div class="slider-header">
|
||||
<RouterLink :to="getPersonCreditsPath()" class="slider-title">
|
||||
<span>{{ t('person.credits') }}</span>
|
||||
@@ -155,3 +191,44 @@ onBeforeMount(() => {
|
||||
:error-description="t('error.networkError')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.person-biography {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.person-biography :deep(p),
|
||||
.person-biography :deep(ul),
|
||||
.person-biography :deep(ol),
|
||||
.person-biography :deep(blockquote) {
|
||||
margin-block: 0 0.75rem;
|
||||
}
|
||||
|
||||
.person-biography :deep(ul),
|
||||
.person-biography :deep(ol) {
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
.person-biography :deep(a) {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.person-biography :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.person-biography :deep(blockquote) {
|
||||
border-inline-start: 3px solid rgba(var(--v-theme-on-surface), 0.25);
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
padding-inline-start: 0.75rem;
|
||||
}
|
||||
|
||||
.person-biography :deep(:last-child) {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.person-credits-section {
|
||||
margin-block-start: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import AniListView from '@/views/discover/AniListView.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
|
||||
|
||||
describe('AniListView', () => {
|
||||
let mediaList: ReturnType<typeof createMediaListHarness>
|
||||
|
||||
beforeEach(() => {
|
||||
mediaList = createMediaListHarness()
|
||||
})
|
||||
|
||||
/** 渲染带媒体列表观测桩的 AniList 探索页。 */
|
||||
async function renderView() {
|
||||
return renderWithProviders(AniListView, {
|
||||
global: {
|
||||
stubs: {
|
||||
MediaCardListView: mediaList.stub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('starts with popular sorting and all optional filters cleared', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-22T12:00:00+08:00'))
|
||||
|
||||
await renderView()
|
||||
|
||||
expect(latestMediaListRequest(mediaList)).toEqual({
|
||||
apipath: 'anilist/discover',
|
||||
params: {
|
||||
sort: 'POPULARITY_DESC',
|
||||
genre: null,
|
||||
format: null,
|
||||
season: null,
|
||||
season_year: null,
|
||||
status: null,
|
||||
country: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the shared chip filter pattern and forwards selected values', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderView()
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByText('评分优先'))
|
||||
await user.click(screen.getByText('剧场版'))
|
||||
await user.click(screen.getByText('奇幻'))
|
||||
await user.click(screen.getByText('夏季'))
|
||||
await user.click(screen.getByText('2026'))
|
||||
await user.click(screen.getByText('已完结'))
|
||||
await user.click(screen.getByText('日本'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestMediaListRequest(mediaList)).toEqual({
|
||||
apipath: 'anilist/discover',
|
||||
params: {
|
||||
sort: 'SCORE_DESC',
|
||||
genre: 'Fantasy',
|
||||
format: 'MOVIE',
|
||||
season: 'SUMMER',
|
||||
season_year: 2026,
|
||||
status: 'FINISHED',
|
||||
country: 'JP',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -382,6 +382,26 @@ describe('MediaDetailView detail and actions', () => {
|
||||
expect(screen.queryByLabelText('媒体入口 类似')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders AniList-only facts, external link, credits, and recommendations', async () => {
|
||||
const media = createMediaInfo({
|
||||
anilist_id: 154587,
|
||||
original_title: '葬送のフリーレン',
|
||||
release_date: '2023-09-29',
|
||||
title: '葬送的芙莉莲',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
})
|
||||
await renderDetail({ media, mediaId: 'anilist:154587', type: '电视剧' })
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /葬送的芙莉莲/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('葬送のフリーレン')).toBeInTheDocument()
|
||||
expect(screen.getByText('2023-09-29')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /AniList/ })).toHaveAttribute('href', 'https://anilist.co/anime/154587')
|
||||
expect(screen.getByLabelText('人物入口 anilist')).toHaveAttribute('data-api-path', 'anilist/credits/154587')
|
||||
expect(screen.getByLabelText('媒体入口 推荐')).toHaveAttribute('data-api-path', 'anilist/recommend/154587')
|
||||
expect(screen.queryByLabelText('媒体入口 类似')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions without permissions', async () => {
|
||||
await renderDetail({ permissions: { discovery: true, manage: false, search: false, subscribe: false } })
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import PersonDetailView from '@/views/discover/PersonDetailView.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const MediaCardListViewStub = defineComponent({
|
||||
name: 'MediaCardListView',
|
||||
props: {
|
||||
apipath: String,
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('output', { 'aria-label': '人物作品', 'data-api-path': props.apipath })
|
||||
},
|
||||
})
|
||||
|
||||
describe('PersonDetailView', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
id: 95075,
|
||||
source: 'anilist',
|
||||
name: '种崎敦美',
|
||||
original_name: 'Atsumi Tanezaki',
|
||||
images: { large: 'https://img.example/actor.jpg' },
|
||||
biography: '日本声优',
|
||||
birthday: '1990-09-27',
|
||||
place_of_birth: '大分县',
|
||||
also_known_as: ['Atsumi Tanezaki'],
|
||||
})
|
||||
})
|
||||
|
||||
it('loads AniList staff detail and links to the AniList filmography endpoint', async () => {
|
||||
await renderWithProviders(PersonDetailView, {
|
||||
props: {
|
||||
personid: '95075',
|
||||
source: 'anilist',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MediaCardListView: MediaCardListViewStub,
|
||||
VImg: defineComponent({
|
||||
props: { src: String },
|
||||
setup: props => () => h('img', { src: props.src }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '种崎敦美' })).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('anilist/person/95075')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('人物作品')).toHaveAttribute('data-api-path', 'anilist/person/credits/95075')
|
||||
})
|
||||
expect(screen.getByRole('link', { name: /参演作品/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/browse/anilist/person/credits/95075?title=参演作品',
|
||||
)
|
||||
})
|
||||
|
||||
it('safely renders the AniList biography as Markdown', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
id: 95075,
|
||||
source: 'anilist',
|
||||
name: '种崎敦美',
|
||||
biography: '**日本声优**\n\n[官方网站](https://example.com)\n\n<script>alert(1)</script>',
|
||||
})
|
||||
|
||||
const { container } = await renderWithProviders(PersonDetailView, {
|
||||
props: {
|
||||
personid: '95075',
|
||||
source: 'anilist',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MediaCardListView: MediaCardListViewStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('日本声优')).toHaveProperty('tagName', 'STRONG')
|
||||
expect(screen.getByRole('link', { name: '官方网站' })).toHaveAttribute('href', 'https://example.com')
|
||||
expect(screen.getByRole('link', { name: '官方网站' })).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(container.querySelector('.person-biography script')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user