fix(search): 搜索框媒体数据源支持多选并默认跟随全局配置 (#6270)

- 搜索来源由单选改为多选,多个来源以逗号分隔传给后端合并展示
- 默认勾选跟随基础设置-媒体搜索数据源,用户手动调整后不再被覆盖
- 更新来源选择相关测试
This commit is contained in:
jxxghp
2026-08-11 21:07:01 +08:00
parent 247fb80e3c
commit 6c384bf1ee
2 changed files with 128 additions and 17 deletions
+69 -13
View File
@@ -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 } from '@/stores'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -33,6 +33,9 @@ const router = useRouter()
// 用户 Store
const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
// 当前用户名
const userName = userStore.userName
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -112,14 +115,61 @@ interface MediaSearchAction {
description: string
}
// 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
media: 'themoviedb',
music: 'musicbrainz',
collection: 'themoviedb',
person: 'themoviedb',
// 三类搜索的媒体数据源可多选,为空数组时由后端按“基础设置-媒体搜索数据源”全局配置执行
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource[]>>({
media: [],
music: ['musicbrainz'],
collection: [],
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 themoviedb = {
@@ -398,7 +448,7 @@ function searchSubtitle() {
closeSearch()
}
/** 跳转到指定类型的媒体搜索结果页。 */
/** 使用当前关键词搜索指定类型的媒体信息。 */
function searchMedia(searchType: MediaSearchType) {
if (!searchWord.value || !hasDiscoveryPermission.value) return
saveRecentSearches(searchWord.value)
@@ -410,13 +460,16 @@ function searchMedia(searchType: MediaSearchType) {
closeSearch()
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({
path: '/browse/media/search',
query: {
title: searchWord.value,
type: searchType,
source: selectedMediaSearchSources[searchType],
},
query,
})
closeSearch()
}
@@ -512,6 +565,8 @@ watch(dialog, async isOpen => {
defineExpose({ focusSearchInput })
onMounted(() => {
// 搜索来源默认跟随全局媒体搜索数据源配置,保证基础设置生效
initializeMediaSearchSources()
// 根据权限加载不同的数据
if (hasAdminPermission.value) {
fetchInstalledPlugins()
@@ -618,6 +673,7 @@ onMounted(() => {
v-model="selectedMediaSearchSources[action.type]"
class="search-item-source-toggle"
density="compact"
multiple
mandatory
role="group"
selected-class="media-source-button--active"
@@ -14,7 +14,11 @@ const IconStub = defineComponent({
template: '<i :data-icon="icon" />',
})
async function renderSearchBar() {
async function renderSearchBar(
overrides: {
searchSource?: string
} = {},
) {
return renderWithProviders(SearchBarDialog, {
props: {
modelValue: true,
@@ -32,7 +36,13 @@ async function renderSearchBar() {
},
superUser: false,
},
// 全局设置走真实 action,便于断言“媒体搜索数据源”配置对搜索框默认勾选的影响。
globalSettings: {
data: overrides.searchSource ? { SEARCH_SOURCE: overrides.searchSource } : {},
initialized: true,
},
},
stubActions: false,
global: {
stubs: {
VIcon: IconStub,
@@ -52,7 +62,7 @@ describe('SearchBarDialog media source selection', () => {
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 { router } = await renderSearchBar()
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 { router } = await renderSearchBar()
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
@@ -93,7 +134,19 @@ describe('SearchBarDialog media source selection', () => {
'media-source-button--active',
)
// 追加选择 AniList 后再取消 TheMovieDb,来源应以逗号分隔传给后端。
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.keyboard('{Enter}')
@@ -127,7 +180,9 @@ describe('SearchBarDialog media source selection', () => {
const personItem = getSearchItem('演员')
const personGroup = within(personItem).getByRole('group', { name: '演员搜索数据源' })
// 追加选择豆瓣并取消默认的 TheMovieDb,仅保留豆瓣来源。
await user.click(within(personGroup).getByRole('button', { name: '使用 豆瓣 搜索' }))
await user.click(within(personGroup).getByRole('button', { name: '使用 TheMovieDb 搜索' }))
await user.click(personItem)
await waitFor(() => {
@@ -138,4 +193,4 @@ describe('SearchBarDialog media source selection', () => {
})
})
})
})
})