feat: 将搜索页面由Strapi迁移至Direcuts

- 路由页面相关源码修改
- 类型标注与组合式API
- 相关工具函数
This commit is contained in:
2025-10-24 16:18:26 +08:00
parent 05938550e6
commit f62c4a3987
11 changed files with 309 additions and 151 deletions

View File

@ -2,14 +2,14 @@
<div v-if="hasResults"> <div v-if="hasResults">
<div class="search-results"> <div class="search-results">
<NuxtLink <NuxtLink
v-for="(hit, hitIndex) in paginatedHits" v-for="hit in paginatedHits"
:key="`${getHitIdentifier(hit.content, hitIndex)}`" :key="`${hit.type}-${hit.id}`"
:to="localePath(resolveHitLink(hit.content))" :to="localePath(resolveHitLink(hit))"
> >
<el-card class="result-card"> <el-card class="result-card">
<h3 class="result-title">{{ getHitTitle(hit.content) }}</h3> <h3 class="result-title">{{ hit.title }}</h3>
<p v-if="getHitSummary(hit.content)" class="result-summary"> <p v-if="hit.summary" class="result-summary">
{{ getHitSummary(hit.content) }} {{ hit.summary }}
</p> </p>
<p v-if="hit.type" class="result-type"> <p v-if="hit.type" class="result-type">
<span>内容类型: </span> <span>内容类型: </span>
@ -44,13 +44,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface HitItem {
content: SearchHit;
type: string;
}
const props = defineProps<{ const props = defineProps<{
hitItems: HitItem[]; searchItems: SearchItemView[];
currentPage: number; currentPage: number;
category?: string; category?: string;
}>(); }>();
@ -74,12 +69,12 @@
const pageSize = ref(5); const pageSize = ref(5);
// 搜索相关 // 搜索相关
const hits = props.hitItems; const items = props.searchItems;
const filteredHits = computed(() => { const filteredHits = computed(() => {
if (props.category) { if (props.category) {
return hits.filter((hit) => hit.type === props.category); return items.filter((item) => item.type === props.category);
} else { } else {
return hits; return items;
} }
}); });
const paginatedHits = computed(() => { const paginatedHits = computed(() => {
@ -106,64 +101,13 @@
return filteredHits.value.length > 0; return filteredHits.value.length > 0;
}); });
/**
* 获取搜索条目的唯一标识符
* 尝试根据搜索条目的相关词条获取唯一标识符
* 若未找到则fallback至给定的index
* @param hit 搜索条目
* @param index 条目索引
*/
const getHitIdentifier = (hit: SearchHit, index: number) => {
const candidate = [hit.objectID, hit.documentId, hit.id, hit.slug].find(
(value) =>
['string', 'number'].includes(typeof value) && String(value).length > 0
);
return candidate != null ? String(candidate) : String(index);
};
/**
* 获取搜索条目的标题
* @param hit 搜索条目
*/
const getHitTitle = (hit: SearchHit) => {
const candidate = [
hit.title,
hit.name,
hit.heading,
hit.documentTitle,
].find((value) => typeof value === 'string' && value.trim().length > 0);
return candidate ? String(candidate) : t('search.untitled');
};
/**
* 获取搜索条目的摘要
* @param hit 搜索条目
*/
const getHitSummary = (hit: SearchHit) => {
const candidate = [
hit.summary,
hit.description,
hit.snippet,
hit.content,
hit.text,
].find((value) => typeof value === 'string' && value.trim().length > 0);
return candidate ? String(candidate) : '';
};
/** /**
* 解析条目链接 * 解析条目链接
* 根据条目类型返回正确的跳转链接 * 根据条目类型返回正确的跳转链接
* @param hit 搜索条目 * @param item 搜索条目
*/ */
const resolveHitLink = (hit: SearchHit) => { const resolveHitLink = (item: SearchItemView) => {
if (typeof hit.route === 'string' && hit.route.trim().length > 0) { const slugCandidate = item.id;
return localePath(hit.route);
}
const slugCandidate = [hit.slug, hit.documentId, hit.id, hit.objectID].find(
(value) =>
['string', 'number'].includes(typeof value) && String(value).length > 0
);
if (!slugCandidate) { if (!slugCandidate) {
return null; return null;
@ -171,11 +115,11 @@
const slug = String(slugCandidate); const slug = String(slugCandidate);
if (hit.indexUid === 'production') { if (item.type === 'product') {
return localePath({ path: `/productions/${slug}` }); return localePath({ path: `/productions/${slug}` });
} }
if (hit.indexUid === 'solution') { if (item.type === 'solution') {
return localePath({ path: `/solutions/${slug}` }); return localePath({ path: `/solutions/${slug}` });
} }

View File

@ -87,5 +87,10 @@ export const useLocalizations = () => {
* @returns 语言映射对象 * @returns 语言映射对象
*/ */
getLocaleMapping: getMapping, getLocaleMapping: getMapping,
/** 所有可用的Directus语言代码列表(只读) **/
availableDirectusLocales: readonly(
Object.values(localeMap).map((item) => item.directus)
),
}; };
}; };

View File

@ -1,33 +1,25 @@
import { MeiliSearch } from 'meilisearch'; import { MeiliSearch } from 'meilisearch';
import type { SearchParams, SearchResponse } from 'meilisearch'; import type { SearchParams } from 'meilisearch';
interface RawSearchSection { const parseIndexes = (
indexUid: string; indexes: string | string[] | undefined,
response: SearchResponse<Record<string, unknown>>; locale?: string
} ): string[] => {
export interface SearchHit extends Record<string, unknown> {
indexUid: string;
objectID?: string | number;
}
export interface SearchSection {
indexUid: string;
hits: SearchHit[];
estimatedTotalHits: number;
processingTimeMs: number;
}
const parseIndexes = (indexes: string | string[] | undefined): string[] => {
if (!indexes) { if (!indexes) {
return []; return [];
} }
let suffix = '';
if (locale) {
suffix = `_${locale}`;
}
if (Array.isArray(indexes)) { if (Array.isArray(indexes)) {
return indexes.map((item) => item.trim()).filter(Boolean); return indexes.map((item) => `${item.trim()}${suffix}`).filter(Boolean);
} }
return indexes return indexes
.split(',') .split(',')
.map((item) => item.trim()) .map((item) => `${item.trim()}${suffix}`)
.filter(Boolean); .filter(Boolean);
}; };
@ -56,10 +48,22 @@ export const useMeilisearch = () => {
return meiliClient; return meiliClient;
}; };
const search = async ( /**
* 泛型搜索函数
* @template T 文档类型, 如 MeiliProductIndex
* ---
* @param query 搜索关键词
* @param params 其他搜索参数
* @returns 搜索结果数组
*/
async function search<
K extends MeiliSearchItemType = MeiliSearchItemType,
T extends MeiliIndexMap[K] = MeiliIndexMap[K],
>(
query: string, query: string,
params: SearchParams = {} params: SearchParams = {},
): Promise<SearchSection[]> => { searchLocale?: string
): Promise<SearchSection<T>[]> {
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
if (!trimmedQuery) { if (!trimmedQuery) {
return []; return [];
@ -70,34 +74,35 @@ export const useMeilisearch = () => {
return []; return [];
} }
const activeIndexes = indexes.value; const activeIndexes = indexes.value as K[];
if (!activeIndexes.length) { if (!activeIndexes.length) {
console.warn('No Meilisearch indexes configured.'); console.warn('No Meilisearch indexes configured.');
return []; return [];
} }
const rawIndexMap = Object.fromEntries(
activeIndexes.map((index) => [`${index}_${searchLocale}`, index])
);
const indexesWithLocale = activeIndexes.map(
(index) => index + (searchLocale ? `_${searchLocale}` : '')
);
const requests = activeIndexes.map(async (indexUid) => { console.log(indexesWithLocale);
const response = await client.index(indexUid).search(trimmedQuery, {
const requests = indexesWithLocale.map(async (indexUid) => {
const response = await client.index(indexUid).search<T>(trimmedQuery, {
limit: params.limit ?? 10, limit: params.limit ?? 10,
...params, ...params,
}); });
const safeResponse = JSON.parse(JSON.stringify(response));
return { return {
indexUid, indexUid,
response: { response,
hits: safeResponse.hits, } satisfies RawSearchSection<T>;
estimatedTotalHits:
safeResponse.estimatedTotalHits ?? safeResponse.hits.length,
processingTimeMs: safeResponse.processingTimeMs ?? 0,
query: safeResponse.query,
},
} satisfies RawSearchSection;
}); });
console.log((await requests[0])?.response.hits[0]?.locale);
const settled = await Promise.allSettled(requests); const settled = await Promise.allSettled(requests);
console.log('Meilisearch settled results:', settled);
settled settled
.filter( .filter(
(result): result is PromiseRejectedResult => (result): result is PromiseRejectedResult =>
@ -108,22 +113,22 @@ export const useMeilisearch = () => {
}); });
return settled return settled
.filter((result) => result.status === 'fulfilled') .filter(
(result): result is PromiseFulfilledResult<RawSearchSection<T>> =>
result.status === 'fulfilled'
)
.map((result) => { .map((result) => {
const fulfilled = result as PromiseFulfilledResult<RawSearchSection>; const { indexUid, response } = result.value;
return { return {
indexUid: fulfilled.value.indexUid, indexUid: indexUid,
hits: fulfilled.value.response.hits.map((hit) => ({ rawIndex: rawIndexMap[indexUid],
...hit, hits: response.hits,
indexUid: fulfilled.value.indexUid,
})),
estimatedTotalHits: estimatedTotalHits:
fulfilled.value.response.estimatedTotalHits ?? response.estimatedTotalHits ?? response.hits.length,
fulfilled.value.response.hits.length, processingTimeMs: response.processingTimeMs ?? 0,
processingTimeMs: fulfilled.value.response.processingTimeMs ?? 0,
}; };
}); });
}; }
return { return {
search, search,

View File

@ -0,0 +1,16 @@
/**
* 搜索索引转换器
* @param hit 搜索条目
* @returns 转换后的搜索条目视图模型
*
* ---
* @example
* const view = toSearchItemView(item, 'products');
*/
export function toSearchItemView<T extends MeiliSearchItemType>(
item: MeiliIndexMap[T],
type: T
): SearchItemView {
const converter = converters[type];
return converter ? converter(item) : null;
}

View File

@ -0,0 +1,35 @@
/**
* 各索引对应的转换函数表
*/
export const converters: {
[K in keyof MeiliIndexMap]: (item: MeiliIndexMap[K]) => SearchItemView;
} = {
products: (item: MeiliIndexMap['products']): SearchItemView => ({
id: item.id,
type: 'product',
title: item.name,
summary: item.summary,
}),
solutions: (item: MeiliIndexMap['solutions']): SearchItemView => ({
id: item.id,
type: 'solution',
title: item.title,
summary: item.summary,
}),
questions: (item: MeiliIndexMap['questions']): SearchItemView => ({
id: item.id,
type: 'question',
title: item.title,
summary: item.content.slice(0, 100) + '...',
}),
product_documents: (
item: MeiliIndexMap['product_documents']
): SearchItemView => ({
id: item.id,
type: 'document',
title: item.title,
}),
};

View File

@ -0,0 +1,13 @@
export interface SearchItemView {
/** 唯一标识符 **/
id: number;
/** 条目类型 **/
type: 'product' | 'solution' | 'question' | 'document';
/** 条目标题 **/
title: string;
/** 条目摘要 **/
summary?: string;
}

View File

@ -30,16 +30,16 @@
<el-tab-pane :label="`全部(${resultCount['all']})`" name="all"> <el-tab-pane :label="`全部(${resultCount['all']})`" name="all">
<search-results <search-results
v-model:current-page="currentPage" v-model:current-page="currentPage"
:hit-items="hits" :search-items="searchItems"
/> />
</el-tab-pane> </el-tab-pane>
<el-tab-pane <el-tab-pane
:label="`产品(${resultCount['production'] || 0})`" :label="`产品(${resultCount['product'] || 0})`"
name="production" name="production"
> >
<search-results <search-results
v-model:current-page="currentPage" v-model:current-page="currentPage"
:hit-items="hits" :search-items="searchItems"
category="production" category="production"
/> />
</el-tab-pane> </el-tab-pane>
@ -49,7 +49,7 @@
> >
<search-results <search-results
v-model:current-page="currentPage" v-model:current-page="currentPage"
:hit-items="hits" :search-items="searchItems"
category="solution" category="solution"
/> />
</el-tab-pane> </el-tab-pane>
@ -59,7 +59,7 @@
> >
<search-results <search-results
v-model:current-page="currentPage" v-model:current-page="currentPage"
:hit-items="hits" :search-items="searchItems"
category="question" category="question"
/> />
</el-tab-pane> </el-tab-pane>
@ -69,7 +69,7 @@
> >
<search-results <search-results
v-model:current-page="currentPage" v-model:current-page="currentPage"
:hit-items="hits" :search-items="searchItems"
category="document" category="document"
/> />
</el-tab-pane> </el-tab-pane>
@ -92,8 +92,8 @@
// i18n相关 // i18n相关
const { t } = useI18n(); const { t } = useI18n();
const { getStrapiLocale } = useLocalizations(); const { getDirectusLocale } = useLocalizations();
const strapiLocale = getStrapiLocale(); const directusLocale = getDirectusLocale();
// 路由相关 // 路由相关
const route = useRoute(); const route = useRoute();
@ -110,40 +110,48 @@
pending: loading, pending: loading,
error, error,
} = await useAsyncData( } = await useAsyncData(
() => `search-${route.query.query ?? ''}`, () => `search-${directusLocale}-${route.query.query ?? ''}`,
async () => { async () => {
const q = String(route.query.query ?? '').trim(); const q = String(route.query.query ?? '').trim();
if (!q) return []; if (!q) return [];
return await search(q, { limit: 12 }); return await search(q, { limit: 12 }, directusLocale);
} }
); );
// 本地化+空Section过滤 // 空Section过滤
const filteredSections = computed(() => const filteredSections = computed(() =>
sections.value sections.value.filter((section) => section.hits.length > 0)
.map((section) => ({
...section,
hits: section.hits.filter(
(hit) =>
!hit.locale ||
String(hit.locale).toLowerCase() === strapiLocale.toLowerCase()
),
}))
.filter((section) => section.hits.length > 0)
); );
const typeMap = {
products: 'products',
solutions: 'solutions',
questions: 'questions',
product_documents: 'product_documents',
} as const;
// 展平hits // 展平hits
const hits = computed(() => const hits = computed(() =>
filteredSections.value.flatMap((item) => filteredSections.value.flatMap((section) => {
item.hits.map((content) => ({ content, type: item.indexUid })) 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);
})
);
console.log(searchItems.value);
// 分类控制 // 分类控制
const activeTab = ref('all'); const activeTab = ref('all');
const resultCount = computed(() => { const resultCount = computed(() => {
const map: Record<string, number> = { all: hits.value.length }; const map: Record<string, number> = { all: searchItems.value.length };
for (const hit of hits.value) { for (const item of searchItems.value) {
map[hit.type] = (map[hit.type] ?? 0) + 1; map[item.type] = (map[item.type] ?? 0) + 1;
} }
return map; return map;
}); });
@ -177,7 +185,7 @@
} }
try { try {
const results = await search(trimmed, { limit: 12 }); const results = await search(trimmed, { limit: 12 }, directusLocale);
if (requestId === activeRequestId.value) { if (requestId === activeRequestId.value) {
sections.value = results; sections.value = results;
} }
@ -199,10 +207,10 @@
watch( watch(
() => route.query.query, () => route.query.query,
(newQuery) => { async (newQuery) => {
if (typeof newQuery === 'string' && newQuery.trim()) { if (typeof newQuery === 'string' && newQuery.trim()) {
keyword.value = newQuery; keyword.value = newQuery;
performSearch(newQuery); await performSearch(newQuery);
} else { } else {
loading.value = false; loading.value = false;
} }

View File

@ -0,0 +1,2 @@
export * from './meili-index';
export * from './search-result';

View File

@ -0,0 +1,88 @@
/**
* 产品索引文档结构
*/
export interface MeiliProductIndex {
/** 唯一标识符 **/
id: number;
/** 产品名称 **/
name: string;
/** 产品简介 **/
summary: string;
/** 产品详情 **/
description: string;
/** 产品类型 **/
type: string;
}
/**
* 解决方案索引文档结构
*/
export interface MeiliSolutionIndex {
/** 唯一标识符 **/
id: number;
/** 解决方案标题 **/
title: string;
/** 解决方案摘要 **/
summary: string;
/** 解决方案内容 **/
content: string;
/** 解决方案类型 **/
type: string;
}
/**
* 相关问题索引文档结构
*/
export interface MeiliQuestionIndex {
/** 唯一标识符 **/
id: number;
/** 问题标题 **/
title: string;
/** 问题内容 **/
content: string;
/** 相关产品 **/
products: string[];
/** 相关产品类型 **/
product_types: string[];
}
/**
* 相关文档索引文档结构
*/
export interface MeiliProductDocumentIndex {
/** 唯一标识符 **/
id: number;
/** 文档标题 **/
title: string;
/** 相关产品 **/
products: string[];
/** 相关产品类型 **/
product_types: string[];
}
/**
* 索引名与类型映射
*/
export interface MeiliIndexMap {
products: MeiliProductIndex;
solutions: MeiliSolutionIndex;
questions: MeiliQuestionIndex;
product_documents: MeiliProductDocumentIndex;
}
export type MeiliSearchItemType = keyof MeiliIndexMap;

View File

@ -0,0 +1,42 @@
import type { SearchResponse } from 'meilisearch';
/**
* 原始搜索分段结果
* @template T 索引类型
*/
export interface RawSearchSection<T> {
/** 索引名 **/
indexUid: string;
/** 响应数据 **/
response: SearchResponse<T>;
}
/**
* 搜索结果
*/
export interface SearchHit extends Record<string, unknown> {
objectID?: string | number;
}
/**
* 搜索分段结果
* @template T 索引类型
*/
export interface SearchSection<T> {
/** 索引名 **/
indexUid: string;
/** 原始索引名 **/
rawIndex: MeiliSearchItemType;
/** 命中条目 **/
hits: T[];
// hits: SearchHit[];
/** 条目总数 **/
estimatedTotalHits: number;
/** 处理时间 **/
processingTimeMs: number;
}

View File

@ -27,7 +27,7 @@ export default defineNuxtConfig({
? typeof process.env.MEILI_SEARCH_INDEXES === 'string' ? typeof process.env.MEILI_SEARCH_INDEXES === 'string'
? process.env.MEILI_SEARCH_INDEXES.split(',').map((i) => i.trim()) ? process.env.MEILI_SEARCH_INDEXES.split(',').map((i) => i.trim())
: process.env.MEILI_SEARCH_INDEXES : process.env.MEILI_SEARCH_INDEXES
: ['production', 'solution'], : ['products', 'solutions', 'questions', 'product_documents'],
}, },
strapi: { strapi: {
url: process.env.STRAPI_URL || 'http://localhost:1337', url: process.env.STRAPI_URL || 'http://localhost:1337',