mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 08:11:06 +00:00
perf(sidebar): 优化侧边栏搜索匹配性能
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { matchSidebarLabel } from "@/lib/sidebarSearch";
|
||||
import TreeItem from "./TreeItem.vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import {
|
||||
@@ -18,7 +19,27 @@ import {
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const searchQuery = ref("");
|
||||
const deferredSearchQuery = ref("");
|
||||
const selectedTypes = ref<string[]>([]);
|
||||
let searchTimer: number | undefined;
|
||||
|
||||
watch(
|
||||
searchQuery,
|
||||
(value) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
window.clearTimeout(searchTimer);
|
||||
|
||||
if (!normalized) {
|
||||
deferredSearchQuery.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimer = window.setTimeout(() => {
|
||||
deferredSearchQuery.value = normalized;
|
||||
}, 120);
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
const isFiltering = computed(() => !!searchQuery.value.trim() || hasTypeFilter.value);
|
||||
|
||||
@@ -55,28 +76,36 @@ function clearTypeFilter() {
|
||||
selectedTypes.value = [];
|
||||
}
|
||||
|
||||
function fuzzyMatch(text: string, query: string): boolean {
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length && j < query.length; i++) {
|
||||
if (text[i] === query[j]) j++;
|
||||
}
|
||||
return j === query.length;
|
||||
}
|
||||
const normalizedLabelCache = new WeakMap<TreeNode, { label: string; normalized: string }>();
|
||||
|
||||
function matchesQuery(node: TreeNode, q: string): boolean {
|
||||
const label = node.label.toLowerCase();
|
||||
if (label.includes(q) || fuzzyMatch(label, q)) return true;
|
||||
return node.children?.some((child) => matchesQuery(child, q)) ?? false;
|
||||
function normalizedLabel(node: TreeNode): string {
|
||||
const cached = normalizedLabelCache.get(node);
|
||||
if (cached?.label === node.label) return cached.normalized;
|
||||
|
||||
const normalized = node.label.toLowerCase();
|
||||
normalizedLabelCache.set(node, { label: node.label, normalized });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
|
||||
return nodes
|
||||
.filter((node) => matchesQuery(node, q))
|
||||
.map((node) => {
|
||||
if (!node.children) return node;
|
||||
const filtered = filterTree(node.children, q);
|
||||
return { ...node, children: filtered, isExpanded: filtered.length > 0 };
|
||||
});
|
||||
const filteredNodes: TreeNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const label = normalizedLabel(node);
|
||||
const selfMatches = !!matchSidebarLabel(label, q);
|
||||
const filteredChildren = node.children ? filterTree(node.children, q) : undefined;
|
||||
|
||||
if (selfMatches || (filteredChildren && filteredChildren.length > 0)) {
|
||||
if (!node.children) {
|
||||
filteredNodes.push(node);
|
||||
} else {
|
||||
const children = filteredChildren ?? [];
|
||||
filteredNodes.push({ ...node, children, isExpanded: children.length > 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
function matchesType(node: TreeNode): boolean {
|
||||
@@ -102,7 +131,7 @@ const filteredNodes = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
const q = deferredSearchQuery.value;
|
||||
if (q) {
|
||||
nodes = filterTree(nodes, q);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export type SidebarSearchMatchKind = "exact" | "prefix" | "word-prefix" | "substring" | "abbreviation" | "fuzzy";
|
||||
|
||||
export interface SidebarSearchMatch {
|
||||
kind: SidebarSearchMatchKind;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function isWordBoundary(text: string, index: number): boolean {
|
||||
if (index === 0) return true;
|
||||
const prev = text[index - 1];
|
||||
return prev === "_" || prev === "-" || prev === "." || prev === " " || prev === "/" || prev === "\\";
|
||||
}
|
||||
|
||||
function matchesWordPrefix(text: string, query: string): boolean {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (isWordBoundary(text, i) && text.startsWith(query, i)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchesAbbreviation(text: string, query: string): boolean {
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length && j < query.length; i++) {
|
||||
if (isWordBoundary(text, i) && text[i] === query[j]) j++;
|
||||
}
|
||||
return j === query.length;
|
||||
}
|
||||
|
||||
function matchesSubsequence(text: string, query: string): boolean {
|
||||
if (query.length < 2 || query.length > text.length) return false;
|
||||
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length && j < query.length; i++) {
|
||||
if (text[i] === query[j]) j++;
|
||||
}
|
||||
return j === query.length;
|
||||
}
|
||||
|
||||
export function matchSidebarLabel(label: string, query: string): SidebarSearchMatch | null {
|
||||
if (!query) return null;
|
||||
|
||||
if (label === query) return { kind: "exact", score: 100 };
|
||||
if (label.startsWith(query)) return { kind: "prefix", score: 90 };
|
||||
if (matchesWordPrefix(label, query)) return { kind: "word-prefix", score: 80 };
|
||||
if (label.includes(query)) return { kind: "substring", score: 70 };
|
||||
if (query.length >= 2 && matchesAbbreviation(label, query)) return { kind: "abbreviation", score: 60 };
|
||||
if (matchesSubsequence(label, query)) return { kind: "fuzzy", score: 40 };
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user