feat: add AniList browsing and source-aware search

This commit is contained in:
jxxghp
2026-07-22 12:53:58 +08:00
parent c1bb54a31e
commit 5cf0dc116a
21 changed files with 1038 additions and 65 deletions
+1 -1
View File
@@ -452,7 +452,7 @@ export interface TmdbEpisode {
// TMDB人物信息 // TMDB人物信息
export interface Person { export interface Person {
// 来源:themoviedb、douban、bangumi // 来源:themoviedb、douban、bangumi、anilist
source?: string source?: string
// ID // ID
id?: number id?: number
+2 -1
View File
@@ -540,7 +540,8 @@ onBeforeUnmount(() => {
tile tile
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError" 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> </VAvatar>
</VCard> </VCard>
</div> </div>
+3
View File
@@ -38,6 +38,9 @@ function getPersonImage() {
} else if (personProps.person?.source === 'bangumi') { } else if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.images) return personIcon if (!personInfo.value?.images) return personIcon
url = personInfo.value?.images?.medium 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 { } else {
return personIcon return personIcon
} }
+191 -48
View File
@@ -3,7 +3,7 @@ import api from '@/api'
import type { Site, Plugin, Subscribe } from '@/api/types' import type { Site, Plugin, Subscribe } from '@/api/types'
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu' import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types' import { NavMenu } from '@/@layouts/types'
import { useUserStore, useGlobalSettingsStore } from '@/stores' import { useUserStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue' import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
@@ -33,10 +33,6 @@ const router = useRouter()
// 用户 Store // 用户 Store
const userStore = useUserStore() const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前用户名 // 当前用户名
const userName = userStore.userName const userName = userStore.userName
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions)) const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -62,11 +58,6 @@ const hasAdminPermission = computed(() => {
return hasPermission(userPermissions.value, 'admin') return hasPermission(userPermissions.value, 'admin')
}) })
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
const showCollectionSearch = computed(() => {
return globalSettings.SEARCH_SOURCE?.includes('themoviedb') || false
})
// 所有订阅数据 // 所有订阅数据
const SubscribeItems = ref<Subscribe[]>([]) const SubscribeItems = ref<Subscribe[]>([])
@@ -105,6 +96,83 @@ const searchOverlayProps = computed(() =>
// 搜索词 // 搜索词
const searchWord = ref<string | null>(null) 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) const searchWordInput = ref<HTMLInputElement | null>(null)
@@ -318,8 +386,7 @@ function searchSubtitle() {
} }
/** 跳转到指定类型的媒体搜索结果页。 */ /** 跳转到指定类型的媒体搜索结果页。 */
function searchMedia(searchType: string) { function searchMedia(searchType: MediaSearchType) {
// 搜索类型 media/person
if (!searchWord.value || !hasDiscoveryPermission.value) return if (!searchWord.value || !hasDiscoveryPermission.value) return
saveRecentSearches(searchWord.value) saveRecentSearches(searchWord.value)
router.push({ router.push({
@@ -327,6 +394,7 @@ function searchMedia(searchType: string) {
query: { query: {
title: searchWord.value, title: searchWord.value,
type: searchType, type: searchType,
source: selectedMediaSearchSources[searchType],
}, },
}) })
closeSearch() closeSearch()
@@ -493,53 +561,52 @@ onMounted(() => {
{{ t('common.media') }} {{ t('common.media') }}
</VListSubheader> </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 <VListItem
v-if="showCollectionSearch" v-for="action in mediaSearchActions"
:key="action.type"
density="comfortable" density="comfortable"
link link
@click="searchMedia('collection')" class="search-result-item search-source-result-item mx-2 my-1"
class="search-result-item mx-2 my-1" @click="searchMedia(action.type)"
> >
<template #prepend> <template #prepend>
<div class="result-icon-wrapper"> <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> </div>
</template> </template>
<VListItemTitle class="font-weight-medium text-body-2">{{ <VListItemTitle class="font-weight-medium text-body-2">
t('dialog.searchBar.collections') {{ action.title }}
}}</VListItemTitle> </VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis"> <VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span> {{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }} {{ action.description }}
</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') }}
</VListItemSubtitle> </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> </VListItem>
</template> </template>
@@ -908,6 +975,69 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
background: transparent !important; 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 { .search-result-item {
margin-block-end: 2px; margin-block-end: 2px;
transition: background-color 0.15s ease; 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); 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 { .result-icon-wrapper {
display: flex; display: flex;
align-items: center; 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',
})
})
})
})
+65
View File
@@ -434,6 +434,7 @@ export default {
themoviedb: 'TheMovieDb', themoviedb: 'TheMovieDb',
douban: 'Douban', douban: 'Douban',
bangumi: 'Bangumi', bangumi: 'Bangumi',
anilist: 'AniList',
}, },
user: { user: {
admin: 'Admin', admin: 'Admin',
@@ -1204,6 +1205,8 @@ export default {
trendingNow: 'Trending Now', trendingNow: 'Trending Now',
nowShowing: 'Now Showing', nowShowing: 'Now Showing',
bangumiDaily: 'Bangumi Daily Release', bangumiDaily: 'Bangumi Daily Release',
anilistTrendingNow: 'AniList TRENDING NOW',
anilistPopularThisSeason: 'AniList POPULAR THIS SEASON',
tmdbHotMovies: 'TMDB Hot Movies', tmdbHotMovies: 'TMDB Hot Movies',
tmdbHotTVShows: 'TMDB Hot TV Shows', tmdbHotTVShows: 'TMDB Hot TV Shows',
doubanHotMovies: 'Douban Hot Movies', doubanHotMovies: 'Douban Hot Movies',
@@ -2698,6 +2701,8 @@ export default {
emptySearchHint: 'Enter keywords to search', emptySearchHint: 'Enter keywords to search',
escClose: 'Close', escClose: 'Close',
openSearch: 'Open search', openSearch: 'Open search',
mediaSourceFor: '{type} search source',
searchWithSource: 'Search with {source}',
}, },
searchSite: { searchSite: {
selectSites: 'Select Sites', selectSites: 'Select Sites',
@@ -3796,6 +3801,66 @@ export default {
date: 'Date', 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: { tmdb: {
type: 'Type', type: 'Type',
sort: 'Sort', sort: 'Sort',
+65
View File
@@ -428,6 +428,7 @@ export default {
themoviedb: 'TheMovieDb', themoviedb: 'TheMovieDb',
douban: '豆瓣', douban: '豆瓣',
bangumi: 'Bangumi', bangumi: 'Bangumi',
anilist: 'AniList',
}, },
user: { user: {
admin: '管理员', admin: '管理员',
@@ -1197,6 +1198,8 @@ export default {
trendingNow: '流行趋势', trendingNow: '流行趋势',
nowShowing: '正在热映', nowShowing: '正在热映',
bangumiDaily: 'Bangumi每日放送', bangumiDaily: 'Bangumi每日放送',
anilistTrendingNow: 'AniList 当前趋势',
anilistPopularThisSeason: 'AniList 本季热门',
tmdbHotMovies: 'TMDB热门电影', tmdbHotMovies: 'TMDB热门电影',
tmdbHotTVShows: 'TMDB热门电视剧', tmdbHotTVShows: 'TMDB热门电视剧',
doubanHotMovies: '豆瓣热门电影', doubanHotMovies: '豆瓣热门电影',
@@ -2652,6 +2655,8 @@ export default {
emptySearchHint: '输入关键字开始搜索', emptySearchHint: '输入关键字开始搜索',
escClose: '关闭', escClose: '关闭',
openSearch: '打开搜索', openSearch: '打开搜索',
mediaSourceFor: '{type}搜索数据源',
searchWithSource: '使用 {source} 搜索',
}, },
searchSite: { searchSite: {
selectSites: '选择站点', selectSites: '选择站点',
@@ -3736,6 +3741,66 @@ export default {
date: '日期', 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: { tmdb: {
type: '类型', type: '类型',
sort: '排序', sort: '排序',
+65
View File
@@ -428,6 +428,7 @@ export default {
themoviedb: 'TheMovieDb', themoviedb: 'TheMovieDb',
douban: '豆瓣', douban: '豆瓣',
bangumi: 'Bangumi', bangumi: 'Bangumi',
anilist: 'AniList',
}, },
user: { user: {
admin: '管理員', admin: '管理員',
@@ -1195,6 +1196,8 @@ export default {
trendingNow: '流行趨勢', trendingNow: '流行趨勢',
nowShowing: '正在熱映', nowShowing: '正在熱映',
bangumiDaily: 'Bangumi每日放送', bangumiDaily: 'Bangumi每日放送',
anilistTrendingNow: 'AniList 當前趨勢',
anilistPopularThisSeason: 'AniList 本季熱門',
tmdbHotMovies: 'TMDB熱門電影', tmdbHotMovies: 'TMDB熱門電影',
tmdbHotTVShows: 'TMDB熱門電視劇', tmdbHotTVShows: 'TMDB熱門電視劇',
doubanHotMovies: '豆瓣熱門電影', doubanHotMovies: '豆瓣熱門電影',
@@ -2651,6 +2654,8 @@ export default {
emptySearchHint: '輸入關鍵字開始搜索', emptySearchHint: '輸入關鍵字開始搜索',
escClose: '關閉', escClose: '關閉',
openSearch: '打開搜索', openSearch: '打開搜索',
mediaSourceFor: '{type}搜索資料來源',
searchWithSource: '使用 {source} 搜索',
}, },
searchSite: { searchSite: {
selectSites: '選擇站點', selectSites: '選擇站點',
@@ -3733,6 +3738,66 @@ export default {
date: '日期', 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: { tmdb: {
type: '類型', type: '類型',
sort: '排序', sort: '排序',
+21 -8
View File
@@ -100,6 +100,7 @@ async function renderDiscover() {
errorHandler: componentError, errorHandler: componentError,
}, },
stubs: { stubs: {
AniListView: BuiltInViewStub,
BangumiView: BuiltInViewStub, BangumiView: BuiltInViewStub,
DoubanView: BuiltInViewStub, DoubanView: BuiltInViewStub,
ExtraSourceView: ExtraSourceViewStub, ExtraSourceView: ExtraSourceViewStub,
@@ -171,10 +172,16 @@ describe('discover page', () => {
await renderDiscover() await renderDiscover()
await waitFor(() => 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(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 () => { 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(configRequested).toHaveBeenCalledOnce())
await waitFor(() => 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)) expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
}) })
@@ -203,7 +216,7 @@ describe('discover page', () => {
const { componentError } = await renderDiscover() const { componentError } = await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce()) 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(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
expect(componentError).not.toHaveBeenCalled() expect(componentError).not.toHaveBeenCalled()
}) })
@@ -219,7 +232,7 @@ describe('discover page', () => {
await renderDiscover() await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源')) 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') expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
}) })
@@ -306,7 +319,7 @@ describe('discover page', () => {
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1)) await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
expect(getHeaderItems().map(item => item.title)).toContain('缓存来源') 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 () => { it('replaces the header metadata when a source with the same prefix changes', async () => {
@@ -341,11 +354,11 @@ describe('discover page', () => {
}), }),
) )
await renderDiscover() await renderDiscover()
await waitFor(() => expect(getHeaderItems()).toHaveLength(4)) await waitFor(() => expect(getHeaderItems()).toHaveLength(5))
getHeaderConfig().appendButtons[0].action() getHeaderConfig().appendButtons[0].action()
const { events, tabs } = getDialogCall() 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) await events.save(reorderedTabs)
const expectedOrder = reorderedTabs.map(item => ({ name: item.name })) const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
+3 -1
View File
@@ -139,7 +139,9 @@ describe('recommend page', () => {
await renderRecommend() await renderRecommend()
expect(await screen.findByText('自定义来源')).toBeInTheDocument() 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(screen.queryByText('重复来源')).not.toBeInTheDocument()
expect(remoteConfigRequests).toBe(0) expect(remoteConfigRequests).toBe(0)
}) })
+8
View File
@@ -3,6 +3,7 @@ import { getDiscoverTabs } from '@/router/i18n-menu'
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue' import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
import DoubanView from '@/views/discover/DoubanView.vue' import DoubanView from '@/views/discover/DoubanView.vue'
import BangumiView from '@/views/discover/BangumiView.vue' import BangumiView from '@/views/discover/BangumiView.vue'
import AniListView from '@/views/discover/AniListView.vue'
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue' import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
import { DiscoverSource } from '@/api/types' import { DiscoverSource } from '@/api/types'
import api from '@/api' import api from '@/api'
@@ -256,6 +257,13 @@ onActivated(async () => {
</div> </div>
</transition> </transition>
</VWindowItem> </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"> <VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
+11
View File
@@ -70,6 +70,7 @@ function openRecommendSettings() {
const builtInRecommendSources = createBuiltInRecommendSources(t) const builtInRecommendSources = createBuiltInRecommendSources(t)
const viewList = reactive<RecommendViewSource[]>([...builtInRecommendSources]) const viewList = reactive<RecommendViewSource[]>([...builtInRecommendSources])
const newlyAddedBuiltInPaths = new Set(['anilist/trending', 'anilist/popular-this-season'])
// //
const filteredViews = computed(() => { const filteredViews = computed(() => {
@@ -109,6 +110,15 @@ function normalizeEnableConfig(value: unknown): Record<string, boolean> | null {
return Object.fromEntries(entries) 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() { function loadExtraRecommendSources() {
if (extraSourcesRequest) return extraSourcesRequest if (extraSourcesRequest) return extraSourcesRequest
@@ -201,6 +211,7 @@ let timer: ReturnType<typeof setTimeout>
onBeforeMount(async () => { onBeforeMount(async () => {
await loadConfig() await loadConfig()
enableMissingBuiltInSources()
initializeColors() initializeColors()
}) })
+5
View File
@@ -311,6 +311,11 @@ export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
tab: 'bangumi', tab: 'bangumi',
icon: 'mdi-calendar-star-outline', icon: 'mdi-calendar-star-outline',
}, },
{
title: t('discoverTabs.anilist'),
tab: 'anilist',
icon: 'mdi-alpha-a-circle-outline',
},
] ]
} }
+43 -1
View File
@@ -1,4 +1,5 @@
import type { RecommendSource } from '@/api/types' import type { RecommendSource } from '@/api/types'
import i18n from '@/plugins/i18n'
import { import {
createBuiltInRecommendSources, createBuiltInRecommendSources,
mergeExtraRecommendSources, mergeExtraRecommendSources,
@@ -9,10 +10,31 @@ import { describe, expect, it } from 'vitest'
const translate = (key: string) => `translated:${key}` const translate = (key: string) => `translated:${key}`
describe('recommendSources', () => { 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', () => { it('creates the complete built-in source contract', () => {
const sources = createBuiltInRecommendSources(translate) const sources = createBuiltInRecommendSources(translate)
expect(sources).toHaveLength(13) expect(sources).toHaveLength(15)
expect(sources[0]).toEqual({ expect(sources[0]).toEqual({
apipath: 'recommend/tmdb_trending', apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow', 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', '/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', () => { it('appends extra sources in order and skips duplicate API paths', () => {
+13 -2
View File
@@ -30,6 +30,18 @@ export function createBuiltInRecommendSources(t: Translate): RecommendViewSource
title: t('recommend.bangumiDaily'), title: t('recommend.bangumiDaily'),
type: t('recommend.categoryAnime'), 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', apipath: 'recommend/tmdb_movies',
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'), 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', apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
linkurl: linkurl: '/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
'/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
title: t('recommend.tmdbHotTVShows'), title: t('recommend.tmdbHotTVShows'),
type: t('recommend.categoryTV'), type: t('recommend.categoryTV'),
}, },
+124
View File
@@ -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>
+38 -1
View File
@@ -566,6 +566,11 @@ function getBangumiLink() {
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}` 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) { function getEpisodeImage(stillPath: string) {
if (!stillPath) return '' if (!stillPath) return ''
@@ -963,6 +968,19 @@ onUnmounted(() => {
<span class="ms-1">Bangumi</span> <span class="ms-1">Bangumi</span>
</div> </div>
</a> </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> </div>
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">{{ t('media.seasons') }}</h2> <h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">{{ t('media.seasons') }}</h2>
<div v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="flex w-full flex-col space-y-2"> <div v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="flex w-full flex-col space-y-2">
@@ -1233,7 +1251,7 @@ onUnmounted(() => {
</div> </div>
</div> </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 class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings"> <div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating v-model="mediaDetail.vote_average" density="compact" length="10" class="ma-2" readonly /> <VRating v-model="mediaDetail.vote_average" density="compact" length="10" class="ma-2" readonly />
@@ -1242,6 +1260,10 @@ onUnmounted(() => {
<span>ID</span> <span>ID</span>
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span> <span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
</div> </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"> <div v-if="mediaDetail.original_title" class="media-fact">
<span>{{ t('media.info.originalTitle') }}</span> <span>{{ t('media.info.originalTitle') }}</span>
<span class="media-fact-value">{{ mediaDetail.original_title }}</span> <span class="media-fact-value">{{ mediaDetail.original_title }}</span>
@@ -1283,6 +1305,14 @@ onUnmounted(() => {
type="bangumi" type="bangumi"
/> />
</div> </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"> <div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView <MediaCardSlideView
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`" :apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
@@ -1308,6 +1338,13 @@ onUnmounted(() => {
:title="t('media.recommendations')" :title="t('media.recommendations')"
/> />
</div> </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"> <div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView <MediaCardSlideView
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`" :apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
+79 -2
View File
@@ -7,6 +7,8 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils' import { getDisplayImageUrl } from '@/utils/imageUtils'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
// //
const { t } = useI18n() const { t } = useI18n()
@@ -23,6 +25,20 @@ const personProps = defineProps({
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings 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) const personDetail = ref<Person>({} as Person)
@@ -32,6 +48,12 @@ const isRefreshed = ref(false)
// //
const isImageLoaded = 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 // API
async function getPersonDetail() { async function getPersonDetail() {
if (personProps.personid) { if (personProps.personid) {
@@ -41,6 +63,8 @@ async function getPersonDetail() {
personDetail.value = await api.get(`douban/person/${personProps.personid}`) personDetail.value = await api.get(`douban/person/${personProps.personid}`)
} else if (personProps.source === 'bangumi') { } else if (personProps.source === 'bangumi') {
personDetail.value = await api.get(`bangumi/person/${personProps.personid}`) 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 isRefreshed.value = true
} }
@@ -62,6 +86,9 @@ function getPersonImage() {
} else if (personProps.source === 'bangumi') { } else if (personProps.source === 'bangumi') {
if (!personDetail.value?.images) return personIcon if (!personDetail.value?.images) return personIcon
url = personDetail.value?.images?.medium 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 { } else {
return personIcon return personIcon
} }
@@ -85,6 +112,8 @@ function getPersonCreditsPath() {
apipath = 'douban' apipath = 'douban'
} else if (personProps.source === 'bangumi') { } else if (personProps.source === 'bangumi') {
apipath = 'bangumi' apipath = 'bangumi'
} else if (personProps.source === 'anilist') {
apipath = 'anilist'
} }
return `/browse/${apipath}/person/credits/${personDetail.value.id}?title=${t('person.credits')}` return `/browse/${apipath}/person/credits/${personDetail.value.id}?title=${t('person.credits')}`
} }
@@ -96,6 +125,8 @@ function getPersonCreditsApiPath() {
apipath = 'douban' apipath = 'douban'
} else if (personProps.source === 'bangumi') { } else if (personProps.source === 'bangumi') {
apipath = 'bangumi' apipath = 'bangumi'
} else if (personProps.source === 'anilist') {
apipath = 'anilist'
} }
return `${apipath}/person/credits/${personDetail.value.id}` return `${apipath}/person/credits/${personDetail.value.id}`
} }
@@ -133,12 +164,17 @@ onBeforeMount(() => {
</div> </div>
<div class="relative text-left"> <div class="relative text-left">
<div class="group outline-none ring-0" role="button" tabindex="-1"> <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 }} {{ personDetail.biography }}
</p> </p>
</div> </div>
</div> </div>
<div> <div class="person-credits-section">
<div class="slider-header"> <div class="slider-header">
<RouterLink :to="getPersonCreditsPath()" class="slider-title"> <RouterLink :to="getPersonCreditsPath()" class="slider-title">
<span>{{ t('person.credits') }}</span> <span>{{ t('person.credits') }}</span>
@@ -155,3 +191,44 @@ onBeforeMount(() => {
:error-description="t('error.networkError')" :error-description="t('error.networkError')"
/> />
</template> </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() 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 () => { it('hides search and subscribe actions without permissions', async () => {
await renderDetail({ permissions: { discovery: true, manage: false, search: false, subscribe: false } }) 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()
})
})