feat: 添加搜索功能

- 使用meilisearch进行搜索
- 增添搜索界面
This commit is contained in:
2025-09-16 16:02:00 +08:00
parent 92c5a3baab
commit dba2cdf366
6 changed files with 550 additions and 14 deletions

View File

@ -63,9 +63,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search } from "@element-plus/icons-vue"; import { Search } from "@element-plus/icons-vue";
import { MeiliSearch } from "meilisearch";
const router = useRouter(); const router = useRouter();
const localePath = useLocalePath();
const { setLocale } = useI18n(); const { setLocale } = useI18n();
@ -73,19 +73,16 @@ const searchQuery = ref("");
const activeName = ref<string | undefined>(undefined); const activeName = ref<string | undefined>(undefined);
const handleSearch = async () => { const handleSearch = () => {
if (searchQuery.value.trim()) { const trimmed = searchQuery.value.trim();
// 这里可以添加搜索逻辑,例如导航到搜索结果页面 if (!trimmed) return;
console.log("Searching for:", searchQuery.value); navigateTo({
const meiliClient = new MeiliSearch({ path: localePath('/search'),
host: process.env.MEILI_HOST || "http://192.168.86.5:7700", query: {
}); query: trimmed
const index = meiliClient.index("production"); }
const search = await index.search(searchQuery.value, { })
limit: 20, searchQuery.value = "";
});
console.log("Search results:", search.hits);
}
}; };
const refreshMenu = () => { const refreshMenu = () => {

View File

@ -0,0 +1,128 @@
import { MeiliSearch } from "meilisearch";
import type { SearchParams, SearchResponse } from "meilisearch";
interface RawSearchSection {
indexUid: string;
response: SearchResponse<Record<string, unknown>>;
}
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) {
return [];
}
if (Array.isArray(indexes)) {
return indexes.map((item) => item.trim()).filter(Boolean);
}
return indexes
.split(",")
.map((item) => item.trim())
.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) {
console.warn("Meilisearch host is not configured.");
return null;
}
const apiKey = runtimeConfig.public?.meili?.searchKey;
meiliClient = new MeiliSearch({
host,
apiKey: apiKey || undefined,
});
return meiliClient;
};
const search = async (
query: string,
params: SearchParams = {}
): Promise<SearchSection[]> => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
return [];
}
const client = ensureClient();
if (!client) {
return [];
}
const activeIndexes = indexes.value;
if (!activeIndexes.length) {
console.warn("No Meilisearch indexes configured.");
return [];
}
const requests = activeIndexes.map(async (indexUid) => {
const response = await client.index(indexUid).search(trimmedQuery, {
limit: params.limit ?? 10,
...params,
});
const safeResponse = JSON.parse(JSON.stringify(response));
return {
indexUid,
response: {
hits: safeResponse.hits,
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);
settled
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.forEach((result) => {
console.error("Meilisearch query failed", result.reason);
});
return settled
.filter((result) => result.status === "fulfilled")
.map((result) => {
const fulfilled = result as PromiseFulfilledResult<RawSearchSection>;
return {
indexUid: fulfilled.value.indexUid,
hits: fulfilled.value.response.hits.map((hit) => ({
...hit,
indexUid: fulfilled.value.indexUid,
})),
estimatedTotalHits:
fulfilled.value.response.estimatedTotalHits ??
fulfilled.value.response.hits.length,
processingTimeMs: fulfilled.value.response.processingTimeMs ?? 0,
};
});
};
return {
search,
indexes,
};
};

365
app/pages/search.vue Normal file
View File

@ -0,0 +1,365 @@
<template>
<div class="search-page">
<div class="search-header">
<h1 class="page-title">{{ $t("search.title") }}</h1>
<div class="search-bar">
<el-input
v-model="keyword"
class="search-input"
:placeholder="$t('search-placeholder')"
:prefix-icon="Search"
clearable
@keyup.enter="navigateToQuery(keyword)"
@input="handleInput(keyword)"
@clear="handleClear"
/>
<el-button type="primary" @click="navigateToQuery(keyword)">
{{ $t("search.search-button") }}
</el-button>
</div>
<p v-if="keyword && hasResults" class="search-meta">
{{ $t("search.results-for", { query: keyword }) }}
</p>
</div>
<div v-if="loading" class="search-state">
<el-skeleton :rows="4" animated />
</div>
<div v-else-if="hasResults" class="search-results">
<section
v-for="section in filteredSections"
:key="section.indexUid"
class="search-section"
>
<header class="section-header">
<h2 class="section-title">
{{ getIndexLabel(section.indexUid) }}
<span class="section-count">{{
$t("search.result-count", { count: section.estimatedTotalHits })
}}</span>
</h2>
</header>
<div class="section-results">
<el-card
v-for="(hit, hitIndex) in section.hits"
:key="`${section.indexUid}-${getHitIdentifier(hit, hitIndex)}`"
class="result-card"
>
<NuxtLink
v-if="resolveHitLink(hit)"
:to="resolveHitLink(hit) || ''"
class="result-title"
>
{{ getHitTitle(hit) }}
</NuxtLink>
<h3 v-else class="result-title">{{ getHitTitle(hit) }}</h3>
<p v-if="getHitSummary(hit)" class="result-summary">
{{ getHitSummary(hit) }}
</p>
</el-card>
</div>
</section>
</div>
<div v-else class="search-state">
<el-empty
:description="
keyword
? $t('search.no-results', { query: keyword })
: $t('search.no-query')
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Search } from "@element-plus/icons-vue";
import type { SearchHit, SearchSection } from "~/composables/useMeilisearch";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const localePath = useLocalePath();
const { getStrapiLocale } = useLocalizations();
const strapiLocale = getStrapiLocale();
const { search } = useMeilisearch();
const loading = ref(true);
const keyword = ref("");
const sections = ref<SearchSection[]>([]);
const localeSections = computed(() =>
sections.value.map((section) => ({
...section,
hits: section.hits.filter(
(hit) =>
!hit.locale ||
String(hit.locale).toLowerCase() === strapiLocale.toLowerCase()
),
}))
);
const filteredSections = computed(() =>
localeSections.value.filter((section) => section.hits.length > 0)
);
const activeRequestId = ref(0);
const indexLabels = computed<Record<string, string>>(() => ({
production: t("search.sections.production"),
solution: t("search.sections.solution"),
support: t("search.sections.support"),
default: t("search.sections.default"),
}));
const getIndexLabel = (indexUid: string) =>
indexLabels.value[indexUid] || indexLabels.value.default;
const hasResults = computed(() =>
localeSections.value.some((section) => section.hits.length > 0)
);
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);
};
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");
};
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) : "";
};
const resolveHitLink = (hit: SearchHit) => {
if (typeof hit.route === "string" && hit.route.trim().length > 0) {
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) {
return null;
}
const slug = String(slugCandidate);
if (hit.indexUid === "production") {
return localePath({ path: `/productions/${slug}` });
}
if (hit.indexUid === "solution") {
return localePath({ path: `/solutions/${slug}` });
}
return null;
};
const navigateToQuery = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
navigateTo({
path: localePath("/search"),
query: { query: trimmed },
});
};
const performSearch = async (value: string) => {
activeRequestId.value += 1;
const requestId = activeRequestId.value;
const trimmed = value.trim();
if (!trimmed) {
if (requestId === activeRequestId.value) {
sections.value = [];
loading.value = false;
}
return;
}
loading.value = true;
try {
const results = await search(trimmed, { limit: 12 });
if (requestId === activeRequestId.value) {
sections.value = results;
}
console.log(results);
console.log(localeSections.value);
} catch (error) {
console.error("Failed to perform search", error);
if (requestId === activeRequestId.value) {
sections.value = [];
}
} finally {
if (requestId === activeRequestId.value) {
loading.value = false;
}
}
};
function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: never, ...args: Parameters<T>) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const handleInput = debounce((value: string) => {
performSearch(value);
}, 300);
const handleClear = () => {
keyword.value = "";
sections.value = [];
router.replace(localePath({ path: "/search" }));
};
onMounted(() => {
if (typeof route.query.query === "string" && route.query.query.trim()) {
keyword.value = route.query.query;
performSearch(route.query.query);
} else {
loading.value = false;
}
})
useHead(() => ({
title: t("search.head-title"),
}));
</script>
<style scoped>
.search-page {
margin: 0 auto;
max-width: 960px;
padding: 2.5rem 1.5rem 3rem;
min-height: 70vh;
}
.search-header {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.page-title {
font-size: 2.25rem;
font-weight: 600;
color: var(--el-text-color-primary);
}
.search-bar {
display: flex;
gap: 1rem;
align-items: center;
}
.search-input {
flex: 1;
}
.search-meta {
font-size: 0.9rem;
color: var(--el-text-color-secondary);
}
.search-state {
display: flex;
justify-content: center;
padding: 3rem 0;
}
.search-results {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.search-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--el-text-color-primary);
}
.section-count {
font-size: 0.9rem;
color: var(--el-text-color-secondary);
}
.section-results {
display: grid;
gap: 1.5rem;
}
.result-card {
border-radius: 12px;
transition: box-shadow 0.2s ease;
}
.result-card:hover {
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
}
.result-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--el-color-primary);
margin-bottom: 0.5rem;
display: inline-block;
}
.result-summary {
font-size: 0.95rem;
color: var(--el-text-color-regular);
line-height: 1.6;
}
@media (max-width: 640px) {
.search-page {
padding: 2rem 1rem;
}
.search-bar {
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
}
}
</style>

View File

@ -2,6 +2,22 @@
"back": "Back", "back": "Back",
"not-found": "Page Not Found", "not-found": "Page Not Found",
"search-placeholder": "Search...", "search-placeholder": "Search...",
"search": {
"title": "Search",
"head-title": "Search",
"search-button": "Search",
"results-for": "Results for \"{query}\"",
"result-count": "{count} results",
"no-results": "No results found for \"{query}\".",
"no-query": "Enter a keyword to start searching.",
"untitled": "Untitled",
"sections": {
"production": "Products",
"solution": "Solutions",
"support": "Support",
"default": "Other"
}
},
"company-name": "Jinshen Machinary Manufacturing Co., Ltd.", "company-name": "Jinshen Machinary Manufacturing Co., Ltd.",
"company-description": "We specialize in manufacturing a range of paper tube and can equipment, integrating design, manufacturing, sales, and service.", "company-description": "We specialize in manufacturing a range of paper tube and can equipment, integrating design, manufacturing, sales, and service.",
"learn-more": "Learn More", "learn-more": "Learn More",

View File

@ -2,6 +2,22 @@
"back": "返回", "back": "返回",
"not-found": "页面不存在", "not-found": "页面不存在",
"search-placeholder": "搜索...", "search-placeholder": "搜索...",
"search": {
"title": "站内搜索",
"head-title": "搜索",
"search-button": "搜索",
"results-for": "“{query}” 的搜索结果",
"result-count": "共 {count} 条结果",
"no-results": "没有找到与 “{query}” 匹配的内容。",
"no-query": "请输入关键字开始搜索。",
"untitled": "未命名条目",
"sections": {
"production": "产品",
"solution": "解决方案",
"support": "服务支持",
"default": "其他内容"
}
},
"company-name": "金申机械制造有限公司", "company-name": "金申机械制造有限公司",
"company-description": "专业生产一系列纸管、纸罐设备,集设计、制造、销售、服务于一体。", "company-description": "专业生产一系列纸管、纸罐设备,集设计、制造、销售、服务于一体。",
"learn-more": "了解更多", "learn-more": "了解更多",

View File

@ -18,6 +18,20 @@ export default defineNuxtConfig({
}, },
}, },
runtimeConfig: {
public: {
meili: {
host: process.env.MEILI_HOST || "http://localhost:7700",
searchKey: process.env.MEILI_SEARCH_KEY || "",
indexes: process.env.MEILI_SEARCH_INDEXES
? (typeof process.env.MEILI_SEARCH_INDEXES === "string"
? process.env.MEILI_SEARCH_INDEXES.split(",").map(i => i.trim())
: process.env.MEILI_SEARCH_INDEXES)
: ["production", "solution"],
},
},
},
fonts: { fonts: {
provider: 'local', provider: 'local',
}, },