diff --git a/app/pages/support/documents.vue b/app/pages/support/documents.vue index e67f386..c838dc8 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.3, + }); + 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..89ed0e9 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.3, + }); + 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..3a88391 --- /dev/null +++ b/app/utils/fuzzyFilter.ts @@ -0,0 +1,136 @@ +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 => { + const normalizedText = text.normalize('NFKC').toLowerCase().trim(); + const translit = transliterateText(normalizedText); + return `${normalizedText} ${translit}`; + }; + + /** + * 类型安全的对象取值函数 + */ + function getPropertyByPath( + obj: T, + path: string | string[] + ): string | undefined { + const keys = Array.isArray(path) ? path : path.split('.'); + let value: unknown = obj; + for (const key of keys) { + if (value && typeof value === 'object' && key in value) { + value = (value as Record)[key]; + } else { + return undefined; + } + } + return typeof value === 'string' ? value : undefined; + } + + // --- 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, + findAllMatches: true, + isCaseSensitive: false, + getFn: (obj, path) => { + const value = getPropertyByPath(obj, path); + if (typeof value === 'string') { + const normalized = value + .normalize('NFKC') + .replace(/\s+/g, '') + .toLowerCase() + .trim(); + const translit = transliterateText(normalized); + logger.debug(`${normalized} => ${translit}`); + // 拼接原文 + 转写,提升中外文混合匹配效果 + return `${normalized} ${translit}`; + } + return value; + }, + 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; + }, + }); + + logger.debug('Fuzzy search options:', options); + logger.debug('Fuzzy search keyword:', k); + 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/app/utils/transliterateText.ts b/app/utils/transliterateText.ts new file mode 100644 index 0000000..56d69ea --- /dev/null +++ b/app/utils/transliterateText.ts @@ -0,0 +1,17 @@ +import { pinyin } from 'pinyin-pro'; + +/** + * 将汉语文本转换为拼音形式 + */ +export function transliterateText(input: string): string { + if (!input) return ''; + const text = input.normalize('NFKC').trim(); + + // 检测是否包含中文字符 + if (/[\u4e00-\u9fa5]/.test(text)) { + return pinyin(text, { toneType: 'none', type: 'array' }).join(''); + } + + // 否则返回原文本 + return text; +} diff --git a/package.json b/package.json index 5aef693..9440f07 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,12 @@ "@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", "nuxt-directus": "5.7.0", + "pinyin-pro": "^3.27.0", "sass": "^1.90.0", "sharp": "^0.34.3", "vue": "^3.5.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85ef2e9..19778d8 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 @@ -59,6 +62,9 @@ importers: nuxt-directus: specifier: 5.7.0 version: 5.7.0(magicast@0.3.5) + pinyin-pro: + specifier: ^3.27.0 + version: 3.27.0 sass: specifier: ^1.90.0 version: 1.92.1 @@ -4396,6 +4402,9 @@ packages: typescript: optional: true + pinyin-pro@3.27.0: + resolution: {integrity: sha512-Osdgjwe7Rm17N2paDMM47yW+jUIUH3+0RGo8QP39ZTLpTaJVDK0T58hOLaMQJbcMmAebVuK2ePunTEVEx1clNQ==} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -10615,6 +10624,8 @@ snapshots: optionalDependencies: typescript: 5.9.2 + pinyin-pro@3.27.0: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8