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相关
|
// i18n相关
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { getDirectusLocale } = useLocalizations();
|
const { getDirectusLocale } = useLocalizations();
|
||||||
const directusLocale = getDirectusLocale();
|
const locale = getDirectusLocale();
|
||||||
|
|
||||||
// 路由相关
|
// 路由相关
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// 搜索相关
|
// 搜索相关
|
||||||
const { search } = useMeilisearch();
|
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
|
|
||||||
|
if (typeof route.query.query === 'string' && route.query.query.trim()) {
|
||||||
|
keyword.value = route.query.query;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: sections,
|
data: searchItems,
|
||||||
pending: loading,
|
pending: loading,
|
||||||
error,
|
error,
|
||||||
refresh,
|
refresh,
|
||||||
} = await useAsyncData(
|
} = useAsyncData(`meilisearch-${keyword.value}-${locale}`, async () => {
|
||||||
() => `search-${directusLocale}-${route.query.query ?? ''}`,
|
try {
|
||||||
async () => {
|
const data = await $fetch(`/api/search?query=${keyword.value}`, {
|
||||||
const q = String(route.query.query ?? '').trim();
|
headers: { 'x-locale': locale },
|
||||||
if (!q) return [];
|
});
|
||||||
return await search(q, { limit: 12 }, directusLocale);
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching search results: ', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
// 空Section过滤
|
const hasResults = computed(() => searchItems.value.length > 0);
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.query,
|
() => route.query.query,
|
||||||
@ -84,6 +62,13 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => locale,
|
||||||
|
async () => {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
watch(error, (value) => {
|
watch(error, (value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
logger.error('数据获取失败: ', 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({
|
expect(result).toEqual({
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'How to use product?',
|
title: 'How to use product?',
|
||||||
summary:
|
summary: '',
|
||||||
'This is a detailed explanation of how to use the product effectively....',
|
|
||||||
type: 'question',
|
type: 'question',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const converters: {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
type: 'question',
|
type: 'question',
|
||||||
title: item.title,
|
title: item.title,
|
||||||
summary: item.content.slice(0, 100) + '...',
|
summary: '',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
product_documents: (
|
product_documents: (
|
||||||
|
|||||||
Reference in New Issue
Block a user