mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
feat: add AniList browsing and source-aware search
This commit is contained in:
@@ -540,7 +540,8 @@ onBeforeUnmount(() => {
|
||||
tile
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,9 @@ function getPersonImage() {
|
||||
} else if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.medium
|
||||
} else if (personProps.person?.source === 'anilist') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.large || personInfo.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -33,10 +33,6 @@ const router = useRouter()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
@@ -62,11 +58,6 @@ const hasAdminPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'admin')
|
||||
})
|
||||
|
||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||
const showCollectionSearch = computed(() => {
|
||||
return globalSettings.SEARCH_SOURCE?.includes('themoviedb') || false
|
||||
})
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
@@ -105,6 +96,83 @@ const searchOverlayProps = computed(() =>
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
||||
type MediaSearchType = 'media' | 'collection' | 'person'
|
||||
|
||||
interface MediaSearchSourceOption {
|
||||
label: string
|
||||
name: string
|
||||
value: MediaSearchSource
|
||||
}
|
||||
|
||||
interface MediaSearchAction {
|
||||
type: MediaSearchType
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB。
|
||||
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
|
||||
media: 'themoviedb',
|
||||
collection: 'themoviedb',
|
||||
person: 'themoviedb',
|
||||
})
|
||||
|
||||
// 按后端实际能力限定每类搜索可选的数据源。
|
||||
const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSourceOption[]>>(() => {
|
||||
const themoviedb = {
|
||||
label: 'TMDB',
|
||||
name: t('discoverTabs.themoviedb'),
|
||||
value: 'themoviedb' as const,
|
||||
}
|
||||
const douban = {
|
||||
label: t('discoverTabs.douban'),
|
||||
name: t('discoverTabs.douban'),
|
||||
value: 'douban' as const,
|
||||
}
|
||||
const bangumi = {
|
||||
label: 'Bangumi',
|
||||
name: t('discoverTabs.bangumi'),
|
||||
value: 'bangumi' as const,
|
||||
}
|
||||
const anilist = {
|
||||
label: 'AniList',
|
||||
name: t('discoverTabs.anilist'),
|
||||
value: 'anilist' as const,
|
||||
}
|
||||
|
||||
return {
|
||||
media: [themoviedb, douban, bangumi, anilist],
|
||||
collection: [themoviedb],
|
||||
person: [themoviedb, douban],
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索项及其来源组共用同一份声明,避免显示能力与请求类型不一致。
|
||||
const mediaSearchActions = computed(() => {
|
||||
return [
|
||||
{
|
||||
type: 'media',
|
||||
icon: 'mdi-movie-search',
|
||||
title: `${t('recommend.categoryMovie')}、${t('recommend.categoryTV')}`,
|
||||
description: t('resource.title'),
|
||||
},
|
||||
{
|
||||
type: 'collection',
|
||||
icon: 'mdi-movie-filter',
|
||||
title: t('dialog.searchBar.collections'),
|
||||
description: t('dialog.searchBar.collectionSearch'),
|
||||
},
|
||||
{
|
||||
type: 'person',
|
||||
icon: 'mdi-account-search',
|
||||
title: t('browse.actor'),
|
||||
description: t('dialog.searchBar.actorSearch'),
|
||||
},
|
||||
] satisfies MediaSearchAction[]
|
||||
})
|
||||
|
||||
// 当前尺寸下可见的搜索输入框。
|
||||
const searchWordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -318,8 +386,7 @@ function searchSubtitle() {
|
||||
}
|
||||
|
||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
function searchMedia(searchType: MediaSearchType) {
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
@@ -327,6 +394,7 @@ function searchMedia(searchType: string) {
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType,
|
||||
source: selectedMediaSearchSources[searchType],
|
||||
},
|
||||
})
|
||||
closeSearch()
|
||||
@@ -493,53 +561,52 @@ onMounted(() => {
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
v-for="action in mediaSearchActions"
|
||||
:key="action.type"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
class="search-result-item search-source-result-item mx-2 my-1"
|
||||
@click="searchMedia(action.type)"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
<VIcon :icon="action.icon" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ action.title }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
{{ action.description }}
|
||||
</VListItemSubtitle>
|
||||
<div class="search-item-source-row">
|
||||
<VBtnToggle
|
||||
v-model="selectedMediaSearchSources[action.type]"
|
||||
class="search-item-source-toggle"
|
||||
density="compact"
|
||||
mandatory
|
||||
role="group"
|
||||
selected-class="media-source-button--active"
|
||||
variant="text"
|
||||
:aria-label="t('dialog.searchBar.mediaSourceFor', { type: action.title })"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<VBtn
|
||||
v-for="source in mediaSearchSourceOptions[action.type]"
|
||||
:key="source.value"
|
||||
class="media-source-button"
|
||||
size="x-small"
|
||||
:value="source.value"
|
||||
:aria-label="t('dialog.searchBar.searchWithSource', { source: source.name })"
|
||||
:title="t('dialog.searchBar.searchWithSource', { source: source.name })"
|
||||
>
|
||||
{{ source.label }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
@@ -908,6 +975,69 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.search-item-source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: 0;
|
||||
inline-size: 100%;
|
||||
margin-block-start: 0;
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
block-size 0.15s ease,
|
||||
margin-block-start 0.15s ease;
|
||||
}
|
||||
|
||||
.search-source-result-item:hover .search-item-source-row,
|
||||
.search-source-result-item:focus-within .search-item-source-row {
|
||||
block-size: 28px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-control-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
block-size: 28px;
|
||||
max-inline-size: 100%;
|
||||
opacity: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
pointer-events: none;
|
||||
transform: scale(0.98);
|
||||
transform-origin: center left;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.search-source-result-item:hover .search-item-source-toggle,
|
||||
.search-source-result-item:focus-within .search-item-source-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.media-source-button {
|
||||
block-size: 100% !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72) !important;
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0;
|
||||
min-inline-size: 0 !important;
|
||||
padding-inline: 7px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-source-button:hover {
|
||||
background: var(--app-grouped-list-hover-background) !important;
|
||||
}
|
||||
|
||||
.media-source-button--active {
|
||||
background: var(--app-grouped-list-active-background) !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
margin-block-end: 2px;
|
||||
transition: background-color 0.15s ease;
|
||||
@@ -917,6 +1047,19 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.search-item-source-row {
|
||||
block-size: 28px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
|
||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||
import { screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
async function renderSearchBar() {
|
||||
return renderWithProviders(SearchBarDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
showActivator: true,
|
||||
},
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: {
|
||||
...DEFAULT_PERMISSIONS,
|
||||
admin: false,
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: false,
|
||||
subscribe: false,
|
||||
},
|
||||
superUser: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getSearchItem(title: string): HTMLElement {
|
||||
const item = screen.getByText(title).closest('.v-list-item')
|
||||
if (!(item instanceof HTMLElement)) throw new Error(`Search item not found: ${title}`)
|
||||
return item
|
||||
}
|
||||
|
||||
describe('SearchBarDialog media source selection', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults media searches to TheMovieDB', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '流浪地球{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.path).toBe('/browse/media/search')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'themoviedb',
|
||||
title: '流浪地球',
|
||||
type: 'media',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('places supported sources inside each search item and uses the selected source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '芙莉莲')
|
||||
const mediaItem = getSearchItem('电影、电视剧')
|
||||
const collectionItem = getSearchItem('系列合集')
|
||||
const personItem = getSearchItem('演员')
|
||||
|
||||
const mediaGroup = within(mediaItem).getByRole('group', { name: '电影、电视剧搜索数据源' })
|
||||
const collectionGroup = within(collectionItem).getByRole('group', { name: '系列合集搜索数据源' })
|
||||
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
||||
|
||||
expect(within(mediaGroup).getAllByRole('button')).toHaveLength(4)
|
||||
expect(within(collectionGroup).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(personGroup).getAllByRole('button')).toHaveLength(2)
|
||||
expect(within(mediaGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' })).toHaveClass(
|
||||
'media-source-button--active',
|
||||
)
|
||||
|
||||
await user.click(within(mediaGroup).getByRole('button', { name: '使用 AniList 搜索' }))
|
||||
await user.click(input)
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'anilist',
|
||||
title: '芙莉莲',
|
||||
type: 'media',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('searches actors with the selected supported source', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { router } = await renderSearchBar()
|
||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||
|
||||
await user.type(input, '刘德华')
|
||||
const personItem = getSearchItem('演员')
|
||||
|
||||
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
||||
await user.click(within(personGroup).getByRole('button', { name: '使用 豆瓣 搜索' }))
|
||||
await user.click(personItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'douban',
|
||||
title: '刘德华',
|
||||
type: 'person',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user