fix: 修复无法搜索问题
All checks were successful
deploy to server / build-and-deploy (push) Successful in 2m51s
All checks were successful
deploy to server / build-and-deploy (push) Successful in 2m51s
- 将搜索服务移至Server端
This commit is contained in:
@ -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<SearchSection<T>[]> {
|
||||
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<T>(trimmedQuery, {
|
||||
limit: params.limit ?? 10,
|
||||
...params,
|
||||
});
|
||||
return {
|
||||
indexUid,
|
||||
response,
|
||||
} satisfies RawSearchSection<T>;
|
||||
});
|
||||
|
||||
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<RawSearchSection<T>> =>
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
|
||||
37
server/api/search.get.ts
Normal file
37
server/api/search.get.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
85
server/services/search.ts
Normal file
85
server/services/search.ts
Normal file
@ -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<T>(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<T>;
|
||||
});
|
||||
|
||||
// 并行安全执行
|
||||
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<SearchSection<T>> =>
|
||||
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;
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,7 +22,7 @@ export const converters: {
|
||||
id: item.id,
|
||||
type: 'question',
|
||||
title: item.title,
|
||||
summary: item.content.slice(0, 100) + '...',
|
||||
summary: '',
|
||||
}),
|
||||
|
||||
product_documents: (
|
||||
|
||||
Reference in New Issue
Block a user