fix: 修复结果集内的数据过滤功能 (#145)

This commit is contained in:
rarnu authored and GitHub committed 2026-05-07 22:05:27 +08:00
1 parent 75c7e11f55
commit 53c5c28542
5 files changed
+32 -262

No files matched your search

+12 -225
View File
@@ -63,7 +63,7 @@ import {
type CellPosition,
type CellSelectionRange,
} from "@/lib/gridSelection";
import { buildTableSelectSql, normalizeWhereInput, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
import { formatMarkdownTable } from "@/lib/markdownTable";
import {
@@ -97,8 +97,8 @@ const props = defineProps<{
const emit = defineEmits<{
reload: [];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
paginate: [offset: number, limit: number, orderBy?: string];
sort: [column: string, direction: "asc" | "desc" | null];
}>();
const hasData = computed(() => props.result.columns.length > 0);
@@ -205,154 +205,14 @@ const showTranspose = ref(false);
const sortCol = ref<string | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
const searchSuggestions = ref<string[]>([]);
const suggestionIndex = ref(-1);
const searchInputRef = ref<HTMLInputElement>();
const measureRef = ref<HTMLSpanElement>();
const suggestionLeft = ref(0);
function updateSuggestionPosition() {
nextTick(() => {
const input = searchInputRef.value;
const measure = measureRef.value;
if (!input || !measure) return;
const cursorPos = input.selectionStart ?? 0;
measure.textContent = searchText.value.slice(0, cursorPos);
suggestionLeft.value = measure.getBoundingClientRect().width;
});
}
watch(searchText, (val) => {
searchSuggestions.value = [];
if (!canUseWhereSearch.value || !props.tableMeta?.columns?.length) return;
const trimmed = val.trimStart();
const lower = trimmed.toLowerCase();
if (trimmed.length > 0 && lower !== "where" && "where".startsWith(lower)) {
searchSuggestions.value = ["WHERE "];
suggestionIndex.value = 0;
updateSuggestionPosition();
return;
}
const m = val.match(/^\s*where\s+(.+)$/i);
if (m) {
const lastToken = m[1].split(/[\s,()><=!]+/).pop() || "";
if (lastToken.length > 0) {
const tl = lastToken.toLowerCase();
searchSuggestions.value = props.tableMeta.columns
.map((c) => c.name)
.filter((n) => n.toLowerCase().startsWith(tl) && n.toLowerCase() !== tl)
.slice(0, 8);
suggestionIndex.value = 0;
updateSuggestionPosition();
}
}
});
function acceptSuggestion() {
const idx = suggestionIndex.value;
if (idx < 0 || idx >= searchSuggestions.value.length) return;
const sug = searchSuggestions.value[idx];
if (sug === "WHERE ") {
const trimmed = searchText.value.trimStart();
const leading = searchText.value.slice(0, searchText.value.length - trimmed.length);
searchText.value = leading + "WHERE ";
} else {
const lastWordMatch = searchText.value.match(/([^\s,()><=!]+)$/);
if (lastWordMatch) {
const lastWord = lastWordMatch[1];
const prefix = searchText.value.slice(0, -lastWord.length);
searchText.value = prefix + sug;
}
}
searchSuggestions.value = [];
suggestionIndex.value = -1;
searchInputRef.value?.focus();
}
function dismissSuggestions() {
searchSuggestions.value = [];
suggestionIndex.value = -1;
}
function navigateSuggestion(delta: number) {
if (searchSuggestions.value.length === 0) return;
suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), searchSuggestions.value.length - 1);
}
const PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
function onSearchKeydown(e: KeyboardEvent) {
if (e.key in PAIRS && !e.ctrlKey && !e.metaKey) {
const input = e.target as HTMLInputElement;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const close = PAIRS[e.key];
if (start !== end) {
// Wrap selection: 'text' → 'text'
e.preventDefault();
const selected = searchText.value.slice(start, end);
searchText.value = searchText.value.slice(0, start) + e.key + selected + close + searchText.value.slice(end);
nextTick(() => {
input.setSelectionRange(start + 1 + selected.length, start + 1 + selected.length);
});
suggestionIndex.value = -1;
return;
}
if (searchText.value[start] === close) {
// Cursor before closing char → skip over it
e.preventDefault();
input.setSelectionRange(start + 1, start + 1);
return;
}
e.preventDefault();
searchText.value = searchText.value.slice(0, start) + e.key + close + searchText.value.slice(end);
nextTick(() => {
input.setSelectionRange(start + 1, start + 1);
});
suggestionIndex.value = -1;
return;
}
if (searchSuggestions.value.length > 0) {
if (e.key === "Tab") {
e.preventDefault();
acceptSuggestion();
return;
}
if (e.key === "Escape") {
e.preventDefault();
dismissSuggestions();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
navigateSuggestion(1);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
navigateSuggestion(-1);
return;
}
}
if (e.key === "Enter") {
onSearchEnter(e);
return;
}
if (e.key === "Escape") {
searchText.value = "";
}
}
const saveError = ref("");
const isApplyingWhere = ref(false);
const rowStatusFilter = ref<RowStatusFilter>("all");
const columnWidths = ref<number[]>([]);
const gridRef = ref<HTMLDivElement>();
@@ -423,11 +283,7 @@ watch(() => props.result.columns.length, initColumnWidths);
const pageSize = ref(100);
const currentPage = ref(1);
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql);
const isWhereSearch = computed(() => canUseWhereSearch.value && /^\s*where\b/i.test(searchText.value));
const wherePredicate = computed(() => normalizeWhereInput(searchText.value));
const activeWhereInput = computed(() => (isWhereSearch.value && wherePredicate.value ? searchText.value : undefined));
const clientSearchText = computed(() => (isWhereSearch.value ? "" : searchText.value));
const clientSearchText = computed(() => searchText.value);
function currentOrderBy(): string | undefined {
return sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined;
@@ -436,17 +292,17 @@ function currentOrderBy(): string | undefined {
function prevPage() {
if (currentPage.value <= 1) return;
currentPage.value--;
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, activeWhereInput.value, currentOrderBy());
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentOrderBy());
}
function nextPage() {
if (!isFullPage.value) return;
currentPage.value++;
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, activeWhereInput.value, currentOrderBy());
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentOrderBy());
}
function changePageSize(size: number) {
pageSize.value = size;
currentPage.value = 1;
emit("paginate", 0, size, activeWhereInput.value, currentOrderBy());
emit("paginate", 0, size, currentOrderBy());
}
// --- Editing ---
@@ -604,45 +460,16 @@ function toggleSort(colName: string) {
if (sortCol.value === colName) {
if (sortDir.value === "asc") {
sortDir.value = "desc";
emit("sort", colName, "desc", activeWhereInput.value);
emit("sort", colName, "desc");
} else {
sortCol.value = null;
sortDir.value = "asc";
emit("sort", colName, null, activeWhereInput.value);
emit("sort", colName, null);
}
} else {
sortCol.value = colName;
sortDir.value = "asc";
emit("sort", colName, "asc", activeWhereInput.value);
}
}
function onSearchEnter(event: KeyboardEvent) {
if (!isWhereSearch.value) return;
event.preventDefault();
void applyWhereSearch();
}
async function applyWhereSearch() {
if (!props.tableMeta || !props.onExecuteSql || !wherePredicate.value) return;
isApplyingWhere.value = true;
saveError.value = "";
currentPage.value = 1;
try {
const sql = buildTableSelectSql({
databaseType: props.databaseType,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
primaryKeys: props.tableMeta.primaryKeys,
orderBy: sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined,
limit: pageSize.value,
whereInput: searchText.value,
});
await props.onExecuteSql(sql);
} catch (e: any) {
saveError.value = String(e?.message || e);
} finally {
isApplyingWhere.value = false;
emit("sort", colName, "asc");
}
}
@@ -1318,45 +1145,17 @@ function escapeAndHighlightKeywords(s: string): string {
</Button>
</div>
<!-- Search bar -->
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20 relative">
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref="searchInputRef"
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="canUseWhereSearch ? t('grid.searchOrWhere') : t('grid.search')"
:placeholder="t('grid.search')"
@keydown="onSearchKeydown"
@click="updateSuggestionPosition"
/>
<span
ref="measureRef"
class="invisible absolute left-0 top-0 text-xs whitespace-pre pointer-events-none"
aria-hidden="true"
/>
<!-- Suggestion dropdown -->
<div
v-if="searchSuggestions.length > 0"
class="absolute top-full mt-0.5 z-50 min-w-[180px] rounded-md border bg-popover text-popover-foreground shadow-md"
:style="{ left: suggestionLeft + 24 + 'px' }"
>
<div
v-for="(sug, idx) in searchSuggestions"
:key="sug"
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
:class="idx === suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'"
@mousedown.prevent="
suggestionIndex = idx;
acceptSuggestion();
"
@mouseenter="suggestionIndex = idx"
>
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
<span>{{ sug }}</span>
</div>
</div>
<Select
v-if="editable && tableMeta"
:model-value="rowStatusFilter"
@@ -1376,18 +1175,6 @@ function escapeAndHighlightKeywords(s: string): string {
<span v-if="hasActiveFilter" class="text-xs text-muted-foreground">
{{ displayItems.length }}/{{ totalFilterableRowCount }}
</span>
<Button
v-if="isWhereSearch"
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:disabled="isApplyingWhere || !wherePredicate"
@click="applyWhereSearch"
>
<Loader2 v-if="isApplyingWhere" class="w-3 h-3 mr-1 animate-spin" />
<Search v-else class="w-3 h-3 mr-1" />
{{ t("grid.applyWhere") }}
</Button>
<Button
v-if="editable && tableMeta"
variant="ghost"
+6 -16
View File
@@ -40,8 +40,8 @@ const emit = defineEmits<{
editorCursorChange: [pos: number];
formatError: [];
reload: [];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
paginate: [offset: number, limit: number, orderBy?: string];
sort: [column: string, direction: "asc" | "desc" | null];
executeSql: [sql: string];
clickTable: [tableName: string];
}>();
@@ -275,13 +275,9 @@ function onHandleCloseColumnPanel() {
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
@reload="emit('reload')"
@paginate="
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
emit('paginate', offset, limit, whereInput, orderBy)
"
@sort="
(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, direction, whereInput)
(offset: number, limit: number, orderBy?: string) => emit('paginate', offset, limit, orderBy)
"
@sort="(column: string, direction: 'asc' | 'desc' | null) => emit('sort', column, direction)"
/>
<div
v-if="activeTab.result?.columns.includes('Error')"
@@ -355,14 +351,8 @@ function onHandleCloseColumnPanel() {
:table-meta="activeTab.tableMeta"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
@reload="emit('reload')"
@paginate="
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
emit('paginate', offset, limit, whereInput, orderBy)
"
@sort="
(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, direction, whereInput)
"
@paginate="(offset: number, limit: number, orderBy?: string) => emit('paginate', offset, limit, orderBy)"
@sort="(column: string, direction: 'asc' | 'desc' | null) => emit('sort', column, direction)"
/>
<div
v-else-if="activeTab.isExecuting"
+5 -8
View File
@@ -13,10 +13,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return quoteTableIdentifier(config?.db_type, name);
}
function buildTableSql(
tab: QueryTab,
options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {},
): string {
function buildTableSql(tab: QueryTab, options: { orderBy?: string; limit?: number; offset?: number } = {}): string {
const config = connectionStore.getConfig(tab.connectionId);
const fallbackOrderColumns =
config?.db_type === "sqlserver" && !tab.tableMeta?.primaryKeys?.length
@@ -48,19 +45,19 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
queryStore.executeCurrentTab();
}
async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
async function onPaginate(offset: number, limit: number, orderBy?: string) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const sql = buildTableSql(tab, { limit, offset, whereInput, orderBy });
const sql = buildTableSql(tab, { limit, offset, orderBy });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
}
async function onSort(column: string, direction: "asc" | "desc" | null, whereInput?: string) {
async function onSort(column: string, direction: "asc" | "desc" | null) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined;
const sql = buildTableSql(tab, { orderBy, whereInput });
const sql = buildTableSql(tab, { orderBy });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
}
+4 -6
View File
@@ -182,9 +182,7 @@ export default {
exportJson: "Export JSON",
exportMarkdown: "Export Markdown",
copied: "Copied!",
search: "Search...",
searchOrWhere: "Search, or enter a WHERE clause...",
applyWhere: "Apply WHERE",
search: "Data Filter...",
filterRows: "Filter rows",
filterAllRows: "All rows",
filterChangedRows: "Changed",
@@ -197,9 +195,9 @@ export default {
deleteRow: "Delete Row",
restoreRow: "Restore Row",
statusClean: "Clean",
statusNew: "New",
statusEdited: "Edited",
statusDeleted: "Deleted",
statusNew: "New Items",
statusEdited: "Updated Items",
statusDeleted: "Deleted Items",
pendingChanges: "{count} pending",
selectedCells: "{count} selected",
cellDetails: "Cell Details",
+5 -7
View File
@@ -181,12 +181,10 @@ export default {
exportJson: "导出 JSON",
exportMarkdown: "导出 Markdown",
copied: "已复制!",
search: "搜索...",
searchOrWhere: "搜索,或输入 WHERE 条件...",
applyWhere: "应用 WHERE",
search: "数据过滤...",
filterRows: "筛选行",
filterAllRows: "全部行",
filterChangedRows: "已变更",
filterChangedRows: "有变更内容",
page: "第 {page} 页",
rowsPerPage: "每页行数",
save: "保存",
@@ -196,9 +194,9 @@ export default {
deleteRow: "删除行",
restoreRow: "恢复行",
statusClean: "未改",
statusNew: "新增",
statusEdited: "已改",
statusDeleted: "删除",
statusNew: "新增项",
statusEdited: "更新项",
statusDeleted: "删除项",
pendingChanges: "{count} 项待保存",
selectedCells: "已选 {count} 个单元格",
cellDetails: "单元格详情",