From 5cf0dc116af61726a301fe9ca900e931a085167d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 22 Jul 2026 12:53:58 +0800 Subject: [PATCH] feat: add AniList browsing and source-aware search --- src/api/types.ts | 2 +- src/components/cards/MediaCard.vue | 3 +- src/components/cards/PersonCard.vue | 3 + src/components/dialog/SearchBarDialog.vue | 239 ++++++++++++++---- .../dialog/__tests__/SearchBarDialog.spec.ts | 112 ++++++++ src/locales/en-US.ts | 65 +++++ src/locales/zh-CN.ts | 65 +++++ src/locales/zh-TW.ts | 65 +++++ src/pages/__tests__/discover.spec.ts | 29 ++- src/pages/__tests__/recommend.spec.ts | 4 +- src/pages/discover.vue | 8 + src/pages/recommend.vue | 11 + src/router/i18n-menu.ts | 5 + src/utils/__tests__/recommendSources.spec.ts | 44 +++- src/utils/recommendSources.ts | 15 +- src/views/discover/AniListView.vue | 124 +++++++++ src/views/discover/MediaDetailView.vue | 39 ++- src/views/discover/PersonDetailView.vue | 81 +++++- .../discover/__tests__/AniListView.spec.ts | 74 ++++++ .../__tests__/MediaDetailView.spec.ts | 20 ++ .../__tests__/PersonDetailView.spec.ts | 95 +++++++ 21 files changed, 1038 insertions(+), 65 deletions(-) create mode 100644 src/components/dialog/__tests__/SearchBarDialog.spec.ts create mode 100644 src/views/discover/AniListView.vue create mode 100644 src/views/discover/__tests__/AniListView.spec.ts create mode 100644 src/views/discover/__tests__/PersonDetailView.spec.ts diff --git a/src/api/types.ts b/src/api/types.ts index 4cf35750..9d2cabe5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -452,7 +452,7 @@ export interface TmdbEpisode { // TMDB人物信息 export interface Person { - // 来源:themoviedb、douban、bangumi + // 来源:themoviedb、douban、bangumi、anilist source?: string // ID id?: number diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index 1240a772..f5d132ce 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -540,7 +540,8 @@ onBeforeUnmount(() => { tile v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError" > - + + diff --git a/src/components/cards/PersonCard.vue b/src/components/cards/PersonCard.vue index d49103b1..323090ab 100644 --- a/src/components/cards/PersonCard.vue +++ b/src/components/cards/PersonCard.vue @@ -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 } diff --git a/src/components/dialog/SearchBarDialog.vue b/src/components/dialog/SearchBarDialog.vue index 0c6c349c..15568269 100644 --- a/src/components/dialog/SearchBarDialog.vue +++ b/src/components/dialog/SearchBarDialog.vue @@ -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([]) @@ -105,6 +96,83 @@ const searchOverlayProps = computed(() => // 搜索词 const searchWord = ref(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>({ + media: 'themoviedb', + collection: 'themoviedb', + person: 'themoviedb', +}) + +// 按后端实际能力限定每类搜索可选的数据源。 +const mediaSearchSourceOptions = computed>(() => { + 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(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') }} - - - - {{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }} - - - {{ t('common.search') }} {{ searchWord }} - {{ t('resource.title') }} - - - - {{ - t('dialog.searchBar.collections') - }} + + {{ action.title }} + {{ t('common.search') }} {{ searchWord }} - {{ t('dialog.searchBar.collectionSearch') }} - - - - - - {{ t('browse.actor') }} - - {{ t('common.search') }} {{ searchWord }} - {{ t('dialog.searchBar.actorSearch') }} + {{ action.description }} +
+ + + {{ source.label }} + + +
@@ -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; diff --git a/src/components/dialog/__tests__/SearchBarDialog.spec.ts b/src/components/dialog/__tests__/SearchBarDialog.spec.ts new file mode 100644 index 00000000..dac5a94e --- /dev/null +++ b/src/components/dialog/__tests__/SearchBarDialog.spec.ts @@ -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', + }) + }) + }) +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 63dd2a8e..58e12fd7 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 2a30fe60..481800e7 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -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: '排序', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 70855340..d89c1c1b 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -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: '排序', diff --git a/src/pages/__tests__/discover.spec.ts b/src/pages/__tests__/discover.spec.ts index ef80d091..2ff4c5c8 100644 --- a/src/pages/__tests__/discover.spec.ts +++ b/src/pages/__tests__/discover.spec.ts @@ -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 })) diff --git a/src/pages/__tests__/recommend.spec.ts b/src/pages/__tests__/recommend.spec.ts index 69fa7b5e..9dd5ff20 100644 --- a/src/pages/__tests__/recommend.spec.ts +++ b/src/pages/__tests__/recommend.spec.ts @@ -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) }) diff --git a/src/pages/discover.vue b/src/pages/discover.vue index ef36e547..1d29eace 100644 --- a/src/pages/discover.vue +++ b/src/pages/discover.vue @@ -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 () => { + + +
+ +
+
+
diff --git a/src/pages/recommend.vue b/src/pages/recommend.vue index 40879d9c..5d059e97 100644 --- a/src/pages/recommend.vue +++ b/src/pages/recommend.vue @@ -70,6 +70,7 @@ function openRecommendSettings() { const builtInRecommendSources = createBuiltInRecommendSources(t) const viewList = reactive([...builtInRecommendSources]) +const newlyAddedBuiltInPaths = new Set(['anilist/trending', 'anilist/popular-this-season']) // 计算当前分类下显示的视图 const filteredViews = computed(() => { @@ -109,6 +110,15 @@ function normalizeEnableConfig(value: unknown): Record | 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 onBeforeMount(async () => { await loadConfig() + enableMissingBuiltInSources() initializeColors() }) diff --git a/src/router/i18n-menu.ts b/src/router/i18n-menu.ts index d48dfc2e..fce6f2fa 100644 --- a/src/router/i18n-menu.ts +++ b/src/router/i18n-menu.ts @@ -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', + }, ] } diff --git a/src/utils/__tests__/recommendSources.spec.ts b/src/utils/__tests__/recommendSources.spec.ts index cefc9327..d8a97a87 100644 --- a/src/utils/__tests__/recommendSources.spec.ts +++ b/src/utils/__tests__/recommendSources.spec.ts @@ -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', () => { diff --git a/src/utils/recommendSources.ts b/src/utils/recommendSources.ts index 055e46f2..fbea851d 100644 --- a/src/utils/recommendSources.ts +++ b/src/utils/recommendSources.ts @@ -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'), }, diff --git a/src/views/discover/AniListView.vue b/src/views/discover/AniListView.vue new file mode 100644 index 00000000..573c1fe5 --- /dev/null +++ b/src/views/discover/AniListView.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/views/discover/MediaDetailView.vue b/src/views/discover/MediaDetailView.vue index 8e5af697..b7ec740d 100644 --- a/src/views/discover/MediaDetailView.vue +++ b/src/views/discover/MediaDetailView.vue @@ -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(() => { Bangumi
+ +
+ + AniList +
+

{{ t('media.seasons') }}

@@ -1233,7 +1251,7 @@ onUnmounted(() => {
-
+
@@ -1242,6 +1260,10 @@ onUnmounted(() => { ID {{ mediaDetail.bangumi_id }}
+
+ ID + {{ mediaDetail.anilist_id }} +
{{ t('media.info.originalTitle') }} {{ mediaDetail.original_title }} @@ -1283,6 +1305,14 @@ onUnmounted(() => { type="bangumi" />
+
+ +
{ :title="t('media.recommendations')" />
+
+ +
({} 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(() => {
-

+

+

{{ personDetail.biography }}

-
+
{{ t('person.credits') }} @@ -155,3 +191,44 @@ onBeforeMount(() => { :error-description="t('error.networkError')" /> + + diff --git a/src/views/discover/__tests__/AniListView.spec.ts b/src/views/discover/__tests__/AniListView.spec.ts new file mode 100644 index 00000000..16e157b8 --- /dev/null +++ b/src/views/discover/__tests__/AniListView.spec.ts @@ -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 + + 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', + }, + }) + }) + }) +}) diff --git a/src/views/discover/__tests__/MediaDetailView.spec.ts b/src/views/discover/__tests__/MediaDetailView.spec.ts index b62e424b..3582357f 100644 --- a/src/views/discover/__tests__/MediaDetailView.spec.ts +++ b/src/views/discover/__tests__/MediaDetailView.spec.ts @@ -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 } }) diff --git a/src/views/discover/__tests__/PersonDetailView.spec.ts b/src/views/discover/__tests__/PersonDetailView.spec.ts new file mode 100644 index 00000000..c317d087 --- /dev/null +++ b/src/views/discover/__tests__/PersonDetailView.spec.ts @@ -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', + }) + + 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() + }) +})