feat: 将搜索页面由Strapi迁移至Direcuts
- 路由页面相关源码修改 - 类型标注与组合式API - 相关工具函数
This commit is contained in:
@ -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}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -87,5 +87,10 @@ export const useLocalizations = () => {
|
|||||||
* @returns 语言映射对象
|
* @returns 语言映射对象
|
||||||
*/
|
*/
|
||||||
getLocaleMapping: getMapping,
|
getLocaleMapping: getMapping,
|
||||||
|
|
||||||
|
/** 所有可用的Directus语言代码列表(只读) **/
|
||||||
|
availableDirectusLocales: readonly(
|
||||||
|
Object.values(localeMap).map((item) => item.directus)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
16
app/models/mappers/searchItemMapper.ts
Normal file
16
app/models/mappers/searchItemMapper.ts
Normal 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;
|
||||||
|
}
|
||||||
35
app/models/utils/search-converters.ts
Normal file
35
app/models/utils/search-converters.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
13
app/models/views/SearchItemView.ts
Normal file
13
app/models/views/SearchItemView.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export interface SearchItemView {
|
||||||
|
/** 唯一标识符 **/
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** 条目类型 **/
|
||||||
|
type: 'product' | 'solution' | 'question' | 'document';
|
||||||
|
|
||||||
|
/** 条目标题 **/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 条目摘要 **/
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
2
app/types/meilisearch/index.ts
Normal file
2
app/types/meilisearch/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './meili-index';
|
||||||
|
export * from './search-result';
|
||||||
88
app/types/meilisearch/meili-index.ts
Normal file
88
app/types/meilisearch/meili-index.ts
Normal 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;
|
||||||
42
app/types/meilisearch/search-result.ts
Normal file
42
app/types/meilisearch/search-result.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user