mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix(search): 搜索框媒体数据源支持多选并默认跟随全局配置 (#6270)
- 搜索来源由单选改为多选,多个来源以逗号分隔传给后端合并展示 - 默认勾选跟随基础设置-媒体搜索数据源,用户手动调整后不再被覆盖 - 更新来源选择相关测试
This commit is contained in:
@@ -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 } from '@/stores'
|
import { useUserStore, useGlobalSettingsStore } 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,6 +33,9 @@ const router = useRouter()
|
|||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 全局设置 Store
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 当前用户名
|
// 当前用户名
|
||||||
const userName = userStore.userName
|
const userName = userStore.userName
|
||||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
@@ -112,14 +115,61 @@ interface MediaSearchAction {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB。
|
// 三类搜索的媒体数据源可多选,为空数组时由后端按“基础设置-媒体搜索数据源”全局配置执行。
|
||||||
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
|
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource[]>>({
|
||||||
media: 'themoviedb',
|
media: [],
|
||||||
music: 'musicbrainz',
|
music: ['musicbrainz'],
|
||||||
collection: 'themoviedb',
|
collection: [],
|
||||||
person: 'themoviedb',
|
person: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 全局“媒体搜索数据源”配置(SEARCH_SOURCE),用于搜索框默认勾选,保证基础设置生效。
|
||||||
|
const configuredMediaSearchSources = computed((): MediaSearchSource[] => {
|
||||||
|
const raw = globalSettingsStore.get('SEARCH_SOURCE')
|
||||||
|
if (!raw) return []
|
||||||
|
return String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map(item => item.trim() as MediaSearchSource)
|
||||||
|
.filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标记用户是否已手动调整过来源,手动调整后不再被全局配置覆盖。
|
||||||
|
let sourcesTouched = false
|
||||||
|
// 标记是否为默认勾选初始化引起的变更,避免误判为用户操作。
|
||||||
|
let isInitializingSources = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依据全局媒体搜索数据源配置初始化各类搜索的来源勾选;
|
||||||
|
* 配置项不包含该类型可用来源时回退到 TheMovieDB,保证至少一个来源被选中。
|
||||||
|
*/
|
||||||
|
function initializeMediaSearchSources() {
|
||||||
|
isInitializingSources = true
|
||||||
|
try {
|
||||||
|
;(['media', 'collection', 'person'] as const).forEach(searchType => {
|
||||||
|
const configured = configuredMediaSearchSources.value.filter(source =>
|
||||||
|
mediaSearchSourceOptions.value[searchType].some(option => option.value === source),
|
||||||
|
)
|
||||||
|
selectedMediaSearchSources[searchType] = configured.length > 0 ? configured : ['themoviedb']
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isInitializingSources = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局配置异步加载完成后,若用户尚未手动调整,则按配置补充一次默认勾选。
|
||||||
|
watch(configuredMediaSearchSources, () => {
|
||||||
|
if (!sourcesTouched) initializeMediaSearchSources()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用户手动改动来源后记录标记,后续全局配置再变化也不覆盖用户选择。
|
||||||
|
watch(
|
||||||
|
selectedMediaSearchSources,
|
||||||
|
() => {
|
||||||
|
if (!isInitializingSources) sourcesTouched = true
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
// 按后端实际能力限定每类搜索可选的数据源。
|
// 按后端实际能力限定每类搜索可选的数据源。
|
||||||
const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSourceOption[]>>(() => {
|
const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSourceOption[]>>(() => {
|
||||||
const themoviedb = {
|
const themoviedb = {
|
||||||
@@ -398,7 +448,7 @@ function searchSubtitle() {
|
|||||||
closeSearch()
|
closeSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
/** 使用当前关键词搜索指定类型的媒体信息。 */
|
||||||
function searchMedia(searchType: MediaSearchType) {
|
function searchMedia(searchType: MediaSearchType) {
|
||||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||||
saveRecentSearches(searchWord.value)
|
saveRecentSearches(searchWord.value)
|
||||||
@@ -410,13 +460,16 @@ function searchMedia(searchType: MediaSearchType) {
|
|||||||
closeSearch()
|
closeSearch()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
title: searchWord.value,
|
||||||
|
type: searchType,
|
||||||
|
}
|
||||||
|
// 多个来源以逗号分隔传给后端,未勾选任何来源时不传,由后端按全局配置搜索。
|
||||||
|
const sources = selectedMediaSearchSources[searchType]
|
||||||
|
if (sources.length > 0) query.source = sources.join(',')
|
||||||
router.push({
|
router.push({
|
||||||
path: '/browse/media/search',
|
path: '/browse/media/search',
|
||||||
query: {
|
query,
|
||||||
title: searchWord.value,
|
|
||||||
type: searchType,
|
|
||||||
source: selectedMediaSearchSources[searchType],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
closeSearch()
|
closeSearch()
|
||||||
}
|
}
|
||||||
@@ -512,6 +565,8 @@ watch(dialog, async isOpen => {
|
|||||||
defineExpose({ focusSearchInput })
|
defineExpose({ focusSearchInput })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 搜索来源默认跟随全局媒体搜索数据源配置,保证基础设置生效
|
||||||
|
initializeMediaSearchSources()
|
||||||
// 根据权限加载不同的数据
|
// 根据权限加载不同的数据
|
||||||
if (hasAdminPermission.value) {
|
if (hasAdminPermission.value) {
|
||||||
fetchInstalledPlugins()
|
fetchInstalledPlugins()
|
||||||
@@ -618,6 +673,7 @@ onMounted(() => {
|
|||||||
v-model="selectedMediaSearchSources[action.type]"
|
v-model="selectedMediaSearchSources[action.type]"
|
||||||
class="search-item-source-toggle"
|
class="search-item-source-toggle"
|
||||||
density="compact"
|
density="compact"
|
||||||
|
multiple
|
||||||
mandatory
|
mandatory
|
||||||
role="group"
|
role="group"
|
||||||
selected-class="media-source-button--active"
|
selected-class="media-source-button--active"
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ const IconStub = defineComponent({
|
|||||||
template: '<i :data-icon="icon" />',
|
template: '<i :data-icon="icon" />',
|
||||||
})
|
})
|
||||||
|
|
||||||
async function renderSearchBar() {
|
async function renderSearchBar(
|
||||||
|
overrides: {
|
||||||
|
searchSource?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
return renderWithProviders(SearchBarDialog, {
|
return renderWithProviders(SearchBarDialog, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
@@ -32,7 +36,13 @@ async function renderSearchBar() {
|
|||||||
},
|
},
|
||||||
superUser: false,
|
superUser: false,
|
||||||
},
|
},
|
||||||
|
// 全局设置走真实 action,便于断言“媒体搜索数据源”配置对搜索框默认勾选的影响。
|
||||||
|
globalSettings: {
|
||||||
|
data: overrides.searchSource ? { SEARCH_SOURCE: overrides.searchSource } : {},
|
||||||
|
initialized: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
stubActions: false,
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
VIcon: IconStub,
|
VIcon: IconStub,
|
||||||
@@ -52,7 +62,7 @@ describe('SearchBarDialog media source selection', () => {
|
|||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults media searches to TheMovieDB', async () => {
|
it('defaults media searches to TheMovieDB when no global search source is configured', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { router } = await renderSearchBar()
|
const { router } = await renderSearchBar()
|
||||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||||
@@ -72,7 +82,38 @@ describe('SearchBarDialog media source selection', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('places supported sources inside each search item and uses the selected source', async () => {
|
it('follows the global media search source configuration and passes multiple sources', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { router } = await renderSearchBar({ searchSource: 'themoviedb,douban' })
|
||||||
|
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||||
|
|
||||||
|
await user.type(input, '芙莉莲')
|
||||||
|
const mediaItem = getSearchItem('电影、电视剧')
|
||||||
|
|
||||||
|
const mediaGroup = within(mediaItem).getByRole('group', { name: '电影、电视剧搜索数据源' })
|
||||||
|
expect(within(mediaGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' })).toHaveClass(
|
||||||
|
'media-source-button--active',
|
||||||
|
)
|
||||||
|
expect(within(mediaGroup).getByRole('button', { name: '使用 豆瓣 搜索' })).toHaveClass(
|
||||||
|
'media-source-button--active',
|
||||||
|
)
|
||||||
|
expect(within(mediaGroup).getByRole('button', { name: '使用 Bangumi 搜索' })).not.toHaveClass(
|
||||||
|
'media-source-button--active',
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(input)
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.currentRoute.value.query).toEqual({
|
||||||
|
source: 'themoviedb,douban',
|
||||||
|
title: '芙莉莲',
|
||||||
|
type: 'media',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places supported sources inside each search item and supports multi-source selection', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { router } = await renderSearchBar()
|
const { router } = await renderSearchBar()
|
||||||
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
|
||||||
@@ -93,7 +134,19 @@ describe('SearchBarDialog media source selection', () => {
|
|||||||
'media-source-button--active',
|
'media-source-button--active',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 追加选择 AniList 后再取消 TheMovieDb,来源应以逗号分隔传给后端。
|
||||||
await user.click(within(mediaGroup).getByRole('button', { name: '使用 AniList 搜索' }))
|
await user.click(within(mediaGroup).getByRole('button', { name: '使用 AniList 搜索' }))
|
||||||
|
expect(within(mediaGroup).getByRole('button', { name: '使用 AniList 搜索' })).toHaveClass(
|
||||||
|
'media-source-button--active',
|
||||||
|
)
|
||||||
|
await user.click(within(mediaGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' }))
|
||||||
|
expect(within(mediaGroup).getByRole('button', { name: '使用 AniList 搜索' })).toHaveClass(
|
||||||
|
'media-source-button--active',
|
||||||
|
)
|
||||||
|
expect(within(mediaGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' })).not.toHaveClass(
|
||||||
|
'media-source-button--active',
|
||||||
|
)
|
||||||
|
|
||||||
await user.click(input)
|
await user.click(input)
|
||||||
await user.keyboard('{Enter}')
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
@@ -127,7 +180,9 @@ describe('SearchBarDialog media source selection', () => {
|
|||||||
const personItem = getSearchItem('演员')
|
const personItem = getSearchItem('演员')
|
||||||
|
|
||||||
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
|
||||||
|
// 追加选择豆瓣并取消默认的 TheMovieDb,仅保留豆瓣来源。
|
||||||
await user.click(within(personGroup).getByRole('button', { name: '使用 豆瓣 搜索' }))
|
await user.click(within(personGroup).getByRole('button', { name: '使用 豆瓣 搜索' }))
|
||||||
|
await user.click(within(personGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' }))
|
||||||
await user.click(personItem)
|
await user.click(personItem)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -138,4 +193,4 @@ describe('SearchBarDialog media source selection', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user