feat: 产品筛选器的模糊搜索 #70

Manually merged
remilia merged 2 commits from feat/fuse-filter into master 2025-11-10 16:05:35 +08:00
5 changed files with 116 additions and 16 deletions
Showing only changes of commit 4c8dfb5b56 - Show all commits

View File

@ -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,

View File

@ -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;
});
});

95
app/utils/fuzzyFilter.ts Normal file
View File

@ -0,0 +1,95 @@
import Fuse from 'fuse.js';
interface FuzzyFilterOptions<T> {
/** 匹配关键字 */
keyword: string;
/** 搜索字段 */
keys: Array<FuzzyKeyOf<T>>;
/** 模糊程度 (0~1越低越严格) */
threshold?: number;
/** 最小匹配字符数 */
minMatchCharLength?: number;
/** 当前语言 */
locale?: string;
}
/** 限定 keys 只能选择字符串字段 */
type FuzzyKeyOf<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
export function fuzzyMatch<T extends object>(
source: T[],
options: FuzzyFilterOptions<T>
): 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<T>(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;
})
);
}

View File

@ -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",

3
pnpm-lock.yaml generated
View File

@ -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