mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat: add AniList browsing and source-aware search
This commit is contained in:
+1
-1
@@ -452,7 +452,7 @@ export interface TmdbEpisode {
|
|||||||
|
|
||||||
// TMDB人物信息
|
// TMDB人物信息
|
||||||
export interface Person {
|
export interface Person {
|
||||||
// 来源:themoviedb、douban、bangumi
|
// 来源:themoviedb、douban、bangumi、anilist
|
||||||
source?: string
|
source?: string
|
||||||
// ID
|
// ID
|
||||||
id?: number
|
id?: number
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '排序',
|
||||||
|
|||||||
@@ -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: '排序',
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const currentKey = ref(0)
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const filterParams = reactive({
|
||||||
|
sort: 'POPULARITY_DESC' as string | null,
|
||||||
|
genre: null as string | null,
|
||||||
|
format: null as string | null,
|
||||||
|
season: null as string | null,
|
||||||
|
season_year: null as number | null,
|
||||||
|
status: null as string | null,
|
||||||
|
country: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortItems = [
|
||||||
|
{ title: t('anilist.sortType.popularity'), value: 'POPULARITY_DESC' },
|
||||||
|
{ title: t('anilist.sortType.trending'), value: 'TRENDING_DESC' },
|
||||||
|
{ title: t('anilist.sortType.score'), value: 'SCORE_DESC' },
|
||||||
|
{ title: t('anilist.sortType.newest'), value: 'START_DATE_DESC' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const genreItems = [
|
||||||
|
'Action',
|
||||||
|
'Adventure',
|
||||||
|
'Comedy',
|
||||||
|
'Drama',
|
||||||
|
'Fantasy',
|
||||||
|
'Horror',
|
||||||
|
'Mahou Shoujo',
|
||||||
|
'Mecha',
|
||||||
|
'Music',
|
||||||
|
'Mystery',
|
||||||
|
'Psychological',
|
||||||
|
'Romance',
|
||||||
|
'Sci-Fi',
|
||||||
|
'Slice of Life',
|
||||||
|
'Sports',
|
||||||
|
'Supernatural',
|
||||||
|
'Thriller',
|
||||||
|
].map(value => ({ title: t(`anilist.genreType.${value.replaceAll(' ', '').replace('-', '').toLowerCase()}`), value }))
|
||||||
|
|
||||||
|
const formatItems = ['TV', 'TV_SHORT', 'MOVIE', 'OVA', 'ONA', 'SPECIAL', 'MUSIC'].map(value => ({
|
||||||
|
title: t(`anilist.formatType.${value.toLowerCase()}`),
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const seasonItems = ['WINTER', 'SPRING', 'SUMMER', 'FALL'].map(value => ({
|
||||||
|
title: t(`anilist.seasonType.${value.toLowerCase()}`),
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const statusItems = ['RELEASING', 'FINISHED', 'NOT_YET_RELEASED'].map(value => ({
|
||||||
|
title: t(`anilist.statusType.${value.toLowerCase()}`),
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const countryItems = ['JP', 'CN', 'KR', 'TW'].map(value => ({
|
||||||
|
title: t(`anilist.countryType.${value.toLowerCase()}`),
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const yearItems = Array.from({ length: 15 }, (_, index) => currentYear - index)
|
||||||
|
|
||||||
|
type AniListFilterKey = Exclude<keyof typeof filterParams, 'season_year'>
|
||||||
|
|
||||||
|
const filterGroups = [
|
||||||
|
{ key: 'sort', label: t('anilist.sort'), items: sortItems },
|
||||||
|
{ key: 'format', label: t('anilist.format'), items: formatItems },
|
||||||
|
{ key: 'genre', label: t('anilist.genre'), items: genreItems },
|
||||||
|
{ key: 'season', label: t('anilist.season'), items: seasonItems },
|
||||||
|
{ key: 'season_year', label: t('anilist.year'), items: yearItems.map(value => ({ title: value, value })) },
|
||||||
|
{ key: 'status', label: t('anilist.status'), items: statusItems },
|
||||||
|
{ key: 'country', label: t('anilist.country'), items: countryItems },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** 将筛选 Chip 的选择值写入对应 AniList 查询参数。 */
|
||||||
|
function updateFilter(key: (typeof filterGroups)[number]['key'], value: unknown) {
|
||||||
|
if (key === 'season_year') {
|
||||||
|
filterParams.season_year = typeof value === 'number' ? value : null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filterParams[key as AniListFilterKey] = typeof value === 'string' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterParams, () => {
|
||||||
|
if (!filterParams.sort) {
|
||||||
|
filterParams.sort = 'POPULARITY_DESC'
|
||||||
|
}
|
||||||
|
currentKey.value++
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="px-3">
|
||||||
|
<div
|
||||||
|
v-for="group in filterGroups"
|
||||||
|
:key="group.key"
|
||||||
|
class="flex justify-start align-center"
|
||||||
|
>
|
||||||
|
<div class="mr-5">
|
||||||
|
<VLabel>{{ group.label }}</VLabel>
|
||||||
|
</div>
|
||||||
|
<VChipGroup :model-value="filterParams[group.key]" @update:model-value="updateFilter(group.key, $event)">
|
||||||
|
<VChip
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.value"
|
||||||
|
:color="filterParams[group.key as keyof typeof filterParams] == item.value ? 'primary' : ''"
|
||||||
|
filter
|
||||||
|
tile
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</VChip>
|
||||||
|
</VChipGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MediaCardListView :key="currentKey" apipath="anilist/discover" :params="filterParams" />
|
||||||
|
</template>
|
||||||
@@ -566,6 +566,11 @@ function getBangumiLink() {
|
|||||||
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
|
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}`"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user