diff --git a/app/composables/useMeilisearch.ts b/app/composables/useMeilisearch.ts deleted file mode 100644 index ebf62fe..0000000 --- a/app/composables/useMeilisearch.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { MeiliSearch } from 'meilisearch'; -import type { SearchParams } from 'meilisearch'; - -const parseIndexes = ( - indexes: string | string[] | undefined, - locale?: string -): string[] => { - if (!indexes) { - return []; - } - - let suffix = ''; - if (locale) { - suffix = `_${locale}`; - } - - if (Array.isArray(indexes)) { - return indexes.map((item) => `${item.trim()}${suffix}`).filter(Boolean); - } - return indexes - .split(',') - .map((item) => `${item.trim()}${suffix}`) - .filter(Boolean); -}; - -export const useMeilisearch = () => { - const runtimeConfig = useRuntimeConfig(); - - const indexes = computed(() => - parseIndexes(runtimeConfig.public?.meili?.indexes) - ); - - let meiliClient: MeiliSearch | null = null; - - const ensureClient = () => { - if (meiliClient) return meiliClient; - - const host = runtimeConfig.public?.meili?.host; - if (!host) { - logger.warn('Meilisearch host is not configured.'); - return null; - } - const apiKey = runtimeConfig.public?.meili?.searchKey; - meiliClient = new MeiliSearch({ - host, - apiKey: apiKey || undefined, - }); - return meiliClient; - }; - - /** - * 泛型搜索函数 - * @template T 文档类型, 如 MeiliProductIndex - * --- - * @param query 搜索关键词 - * @param params 其他搜索参数 - * @returns 搜索结果数组 - */ - async function search< - K extends MeiliSearchItemType = MeiliSearchItemType, - T extends MeiliIndexMap[K] = MeiliIndexMap[K], - >( - query: string, - params: SearchParams = {}, - searchLocale?: string - ): Promise[]> { - const trimmedQuery = query.trim(); - if (!trimmedQuery) { - return []; - } - - const client = ensureClient(); - if (!client) { - return []; - } - - const activeIndexes = indexes.value as K[]; - if (!activeIndexes.length) { - logger.warn('No Meilisearch indexes configured.'); - return []; - } - const rawIndexMap = Object.fromEntries( - activeIndexes.map((index) => [`${index}_${searchLocale}`, index]) - ); - const indexesWithLocale = activeIndexes.map( - (index) => index + (searchLocale ? `_${searchLocale}` : '') - ); - - const requests = indexesWithLocale.map(async (indexUid) => { - const response = await client.index(indexUid).search(trimmedQuery, { - limit: params.limit ?? 10, - ...params, - }); - return { - indexUid, - response, - } satisfies RawSearchSection; - }); - - const settled = await Promise.allSettled(requests); - - settled - .filter( - (result): result is PromiseRejectedResult => - result.status === 'rejected' - ) - .forEach((result) => { - logger.error('Meilisearch query failed', result.reason); - }); - - return settled - .filter( - (result): result is PromiseFulfilledResult> => - result.status === 'fulfilled' - ) - .map((result) => { - const { indexUid, response } = result.value; - return { - indexUid: indexUid, - rawIndex: rawIndexMap[indexUid], - hits: response.hits, - estimatedTotalHits: - response.estimatedTotalHits ?? response.hits.length, - processingTimeMs: response.processingTimeMs ?? 0, - }; - }); - } - - return { - search, - indexes, - }; -}; diff --git a/app/pages/search.vue b/app/pages/search.vue index c29ac62..d555bb5 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -21,58 +21,36 @@ // i18n相关 const { t } = useI18n(); const { getDirectusLocale } = useLocalizations(); - const directusLocale = getDirectusLocale(); + const locale = getDirectusLocale(); // 路由相关 const route = useRoute(); // 搜索相关 - const { search } = useMeilisearch(); const keyword = ref(''); + if (typeof route.query.query === 'string' && route.query.query.trim()) { + keyword.value = route.query.query; + } + const { - data: sections, + data: searchItems, pending: loading, error, refresh, - } = await useAsyncData( - () => `search-${directusLocale}-${route.query.query ?? ''}`, - async () => { - const q = String(route.query.query ?? '').trim(); - if (!q) return []; - return await search(q, { limit: 12 }, directusLocale); + } = useAsyncData(`meilisearch-${keyword.value}-${locale}`, async () => { + try { + const data = await $fetch(`/api/search?query=${keyword.value}`, { + headers: { 'x-locale': locale }, + }); + return data; + } catch (error) { + logger.error('Error fetching search results: ', error); + throw error; } - ); + }); - // 空Section过滤 - const filteredSections = computed(() => - sections.value.filter((section) => section.hits.length > 0) - ); - - const typeMap = { - products: 'products', - solutions: 'solutions', - questions: 'questions', - product_documents: 'product_documents', - } as const; - // 展平hits - const hits = computed(() => - filteredSections.value.flatMap((section) => { - const type = typeMap[section.rawIndex as keyof typeof typeMap]; - if (!type) return []; - return section.hits.map((hit) => ({ type, content: hit })); - }) - ); - - const searchItems = computed(() => - hits.value.map((hit) => { - return toSearchItemView(hit.content, hit.type); - }) - ); - - const hasResults = computed(() => - filteredSections.value.some((section) => section.hits.length > 0) - ); + const hasResults = computed(() => searchItems.value.length > 0); watch( () => route.query.query, @@ -84,6 +62,13 @@ } ); + watch( + () => locale, + async () => { + await refresh(); + } + ); + watch(error, (value) => { if (value) { logger.error('数据获取失败: ', value); diff --git a/server/api/search.get.ts b/server/api/search.get.ts new file mode 100644 index 0000000..eea7a3c --- /dev/null +++ b/server/api/search.get.ts @@ -0,0 +1,37 @@ +import { toSearchItemView } from '../mappers/searchItemMapper'; +import { getMeiliService } from '../services/search'; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const q = String(query.query || ''); + const locale = getHeader(event, 'x-locale') || 'zh-CN'; + + logger.info(`Search query: "${q}" for locale: "${locale}"`); + + if (!q) return []; + + const meili = getMeiliService(); + const sections = await meili.search(q, { limit: 12 }, locale); + + // 空Section过滤 + const filteredSections = sections.filter( + (section) => section.hits.length > 0 + ); + + const typeMap = { + products: 'products', + solutions: 'solutions', + questions: 'questions', + product_documents: 'product_documents', + } as const; + + const hits = filteredSections.flatMap((section) => { + const type = typeMap[section.rawIndex as keyof typeof typeMap]; + if (!type) return []; + return section.hits.map((hit) => ({ type, content: hit })); + }); + + return hits.map((hit) => { + return toSearchItemView(hit.content, hit.type); + }); +}); diff --git a/server/services/search.ts b/server/services/search.ts new file mode 100644 index 0000000..a9cf498 --- /dev/null +++ b/server/services/search.ts @@ -0,0 +1,85 @@ +import { MeiliSearch } from 'meilisearch'; +import type { SearchParams } from 'meilisearch'; + +export class MeiliService { + private client: MeiliSearch; + private indexes: string[]; + + constructor() { + const runtimeConfig = useRuntimeConfig(); + + const host = runtimeConfig.public?.meili?.host; + const apiKey = runtimeConfig.public?.meili?.searchKey; + + if (!host) throw new Error('Meilisearch host not configured'); + if (!apiKey) throw new Error('Meilisearch server key missing'); + + this.client = new MeiliSearch({ host, apiKey }); + this.indexes = runtimeConfig.public.meili.indexes || []; + } + + /** 构建索引名: products_zh-CN */ + private buildIndexNames(locale?: string) { + if (!locale) return this.indexes; + return this.indexes.map((i) => `${i.trim()}_${locale}`); + } + + /** Server 封装的搜索方法 */ + async search< + K extends keyof MeiliIndexMap = keyof MeiliIndexMap, + T extends MeiliIndexMap[K] = MeiliIndexMap[K], + >(query: string, params: SearchParams = {}, locale?: string) { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + const activeIndexes = this.buildIndexNames(locale); + if (!activeIndexes.length) { + logger.warn('No Meilisearch indexes configured.'); + return []; + } + + const requests = activeIndexes.map(async (indexUID) => { + const response = await this.client + .index(indexUID) + .search(trimmedQuery, { + limit: params.limit ?? 10, + ...params, + }); + + return { + indexUid: indexUID, + rawIndex: indexUID.replace(`_${locale}`, ''), + hits: response.hits, + estimatedTotalHits: response.estimatedTotalHits ?? response.hits.length, + processingTimeMs: response.processingTimeMs ?? 0, + } as SearchSection; + }); + + // 并行安全执行 + const results = await Promise.allSettled(requests); + + results + .filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected' + ) + .forEach((result) => { + logger.error('Meilisearch query failed', result.reason); + }); + + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult> => + r.status === 'fulfilled' + ); + + return fulfilled.map((r) => r.value).filter((s) => s.hits.length > 0); + } +} + +/** 单例 */ +let instance: MeiliService | null = null; + +export const getMeiliService = () => { + if (!instance) instance = new MeiliService(); + return instance; +}; diff --git a/server/utils/search-converters.test.ts b/server/utils/search-converters.test.ts index 07d0ee5..95ad2e2 100644 --- a/server/utils/search-converters.test.ts +++ b/server/utils/search-converters.test.ts @@ -52,8 +52,7 @@ describe('converters', () => { expect(result).toEqual({ id: 1, title: 'How to use product?', - summary: - 'This is a detailed explanation of how to use the product effectively....', + summary: '', type: 'question', }); }); diff --git a/server/utils/search-converters.ts b/server/utils/search-converters.ts index b7d8217..2cc6f97 100644 --- a/server/utils/search-converters.ts +++ b/server/utils/search-converters.ts @@ -22,7 +22,7 @@ export const converters: { id: item.id, type: 'question', title: item.title, - summary: item.content.slice(0, 100) + '...', + summary: '', }), product_documents: (