Compare commits

...

2 Commits

Author SHA1 Message Date
6f08701847 fix: 修复无法搜索问题
All checks were successful
deploy to server / build-and-deploy (push) Successful in 2m51s
- 将搜索服务移至Server端
2025-11-14 14:59:03 +08:00
17bb8adee3 fix: 修复Server搜索问题
All checks were successful
deploy to server / build-and-deploy (push) Successful in 3m7s
- 将meilisearch搜索服务移至Server端
2025-11-14 14:57:55 +08:00
6 changed files with 148 additions and 175 deletions

View File

@ -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,
};
};

View File

@ -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
View 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
View 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;
};

View File

@ -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',
}); });
}); });

View File

@ -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: (