feat: 产品筛选器的模糊搜索 #70
@ -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,
|
||||
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
136
app/utils/fuzzyFilter.ts
Normal file
136
app/utils/fuzzyFilter.ts
Normal file
@ -0,0 +1,136 @@
|
||||
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 => {
|
||||
const normalizedText = text.normalize('NFKC').toLowerCase().trim();
|
||||
const translit = transliterateText(normalizedText);
|
||||
return `${normalizedText} ${translit}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 类型安全的对象取值函数
|
||||
*/
|
||||
function getPropertyByPath<T>(
|
||||
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<string, unknown>)[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<T>(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;
|
||||
})
|
||||
);
|
||||
}
|
||||
17
app/utils/transliterateText.ts
Normal file
17
app/utils/transliterateText.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Reference in New Issue
Block a user