From 4c8dfb5b5649d3511957951612a9cbe76fd0fc3a Mon Sep 17 00:00:00 2001 From: R2m1liA <15258427350@163.com> Date: Mon, 10 Nov 2025 15:08:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=A7=E5=93=81=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E5=99=A8=E7=9A=84=E6=A8=A1=E7=B3=8A=E6=90=9C=E7=B4=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入纯前端依赖Fuse.js用于模糊匹配 - 新增Utils-fuzzyFilter封装Fuse的初始化与匹配 - 在前端将字符串匹配改为模糊匹配 --- app/pages/support/documents.vue | 19 +++---- app/pages/support/faq.vue | 14 ++--- app/utils/fuzzyFilter.ts | 95 +++++++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 ++ 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 app/utils/fuzzyFilter.ts diff --git a/app/pages/support/documents.vue b/app/pages/support/documents.vue index e67f386..949302b 100644 --- a/app/pages/support/documents.vue +++ b/app/pages/support/documents.vue @@ -73,8 +73,13 @@ return products; }); - const filteredDocuments = computed(() => - documents.value.filter((doc: DocumentListView) => { + const filteredDocuments = computed(() => { + const fuzzyMatchedDocuments = fuzzyMatch(documents.value, { + keyword: filters.keyword, + keys: ['title'], + threshold: 0.2, + }); + return fuzzyMatchedDocuments.filter((doc: DocumentListView) => { const matchProduct = filters.selectedProduct ? doc.products?.some( (product: DocumentListProduct) => @@ -87,13 +92,9 @@ ) : true; - const matchKeyword = filters.keyword - ? doc.title && doc.title.includes(filters.keyword) - : true; - - return matchProduct && matchKeyword; - }) - ); + return matchProduct; + }); + }); watch( () => filters.selectedType, diff --git a/app/pages/support/faq.vue b/app/pages/support/faq.vue index b47e058..7a1c9fa 100644 --- a/app/pages/support/faq.vue +++ b/app/pages/support/faq.vue @@ -74,7 +74,12 @@ }); const filteredQuestions = computed(() => { - return questions.value.filter((question: QuestionListView) => { + const fuzzyMatchedQuestions = fuzzyMatch(questions.value, { + keyword: filters.keyword, + keys: ['title', 'content'], + threshold: 0.2, + }); + return fuzzyMatchedQuestions.filter((question: QuestionListView) => { const matchProduct = filters.selectedProduct ? question.products?.some( (product: QuestionListProduct) => @@ -87,12 +92,7 @@ ) : true; - const matchKeyword = filters.keyword - ? (question.title && question.title.includes(filters.keyword)) || - (question.content && question.content.includes(filters.keyword)) - : true; - - return matchProduct && matchKeyword; + return matchProduct; }); }); diff --git a/app/utils/fuzzyFilter.ts b/app/utils/fuzzyFilter.ts new file mode 100644 index 0000000..41e8225 --- /dev/null +++ b/app/utils/fuzzyFilter.ts @@ -0,0 +1,95 @@ +import Fuse from 'fuse.js'; + +interface FuzzyFilterOptions { + /** 匹配关键字 */ + keyword: string; + + /** 搜索字段 */ + keys: Array>; + + /** 模糊程度 (0~1,越低越严格) */ + threshold?: number; + + /** 最小匹配字符数 */ + minMatchCharLength?: number; + + /** 当前语言 */ + locale?: string; +} + +/** 限定 keys 只能选择字符串字段 */ +type FuzzyKeyOf = { + [K in keyof T]: T[K] extends string ? K : never; +}[keyof T]; + +export function fuzzyMatch( + source: T[], + options: FuzzyFilterOptions +): T[] { + const { + keyword, + keys, + threshold = 0.35, + minMatchCharLength = 1, + locale = 'auto', + } = options; + + // --- 多语言比较器 --- + const collator = new Intl.Collator(locale === 'auto' ? undefined : locale, { + sensitivity: 'base', + ignorePunctuation: true, + }); + + // --- 文本标准化函数 --- + const normalizeText = (text: string): string => + text.normalize('NFKC').toLowerCase().trim(); + + // --- fallback 模糊匹配算法 --- + const fallbackFuzzyMatch = (text: string, pattern: string): boolean => { + const normalizedText = normalizeText(text); + const normalizedPattern = normalizeText(pattern); + let i = 0; + for (const char of normalizedPattern) { + i = normalizedText.indexOf(char, i); + if (i === -1) return false; + i++; + } + return true; + }; + + const k = keyword.trim(); + + if (!k) { + return source; + } + + if (import.meta.client) { + // 客户端使用Fuze.js进行模糊匹配 + const fuse = new Fuse(source, { + keys: keys as string[], + threshold, + minMatchCharLength, + ignoreLocation: true, + sortFn: (a, b) => { + const itemA = a.item as T; + const itemB = b.item as T; + const key = keys[0]; + const aValue = itemA[key]; + const bValue = itemB[key]; + if (typeof aValue === 'string' && typeof bValue === 'string') { + return collator.compare(aValue, bValue); + } + return 0; + }, + }); + + return fuse.search(k).map((result) => result.item); + } + + return source.filter((item) => + keys.some((key) => { + const value = item[key]; + return typeof value === 'string' ? fallbackFuzzyMatch(value, k) : false; + }) + ); +} diff --git a/package.json b/package.json index 5aef693..00f7eb4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@vueuse/nuxt": "^13.6.0", "dompurify": "^3.2.6", "element-plus": "^2.10.7", + "fuse.js": "^7.1.0", "markdown-it": "^14.1.0", "meilisearch": "^0.53.0", "nuxt": "^4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85ef2e9..eae14c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: element-plus: specifier: ^2.10.7 version: 2.11.2(vue@3.5.21(typescript@5.9.2)) + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 markdown-it: specifier: ^14.1.0 version: 14.1.0