feat(data-grid): 新增快速 WHERE 筛选输入与补全能力

- 新增表格筛选面板快速 WHERE 条件输入
- 支持字段、操作符和关键字自动补全
- 查询、分页统计与筛选导出合并快速 WHERE 条件
- 修复补全过程中的字段引号丢失和重复追加问题
Refs #354
This commit is contained in:
Syngnat
2026-04-26 20:06:15 +08:00
parent 55829bce86
commit 30301cd637
5 changed files with 544 additions and 14 deletions

View File

@@ -99,4 +99,28 @@ describe('DataGrid layout', () => {
expect(markup).toContain('复制行');
expect(markup).toContain('粘贴行');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
showFilter
quickWhereCondition="name like 'a%'"
onApplyQuickWhereCondition={() => {}}
/>,
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
});

View File

@@ -1,7 +1,7 @@
// cspell:ignore anticon sqls uuidv uuidv4 hscroll
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
@@ -61,6 +61,13 @@ import {
resolveTemporalEditorSaveValue,
type TemporalPickerType,
} from './dataGridTemporal';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
resolveWhereConditionSelectedValue,
resolveWhereConditionSuggestions,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -889,6 +896,8 @@ interface DataGridProps {
exportSqlWithFilter?: string;
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
appliedFilterConditions?: FilterCondition[];
quickWhereCondition?: string;
onApplyQuickWhereCondition?: (condition: string) => void;
scrollSnapshot?: { top: number; left: number };
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
}
@@ -914,7 +923,8 @@ const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
scrollSnapshot, onScrollSnapshotChange
}) => {
const connections = useStore(state => state.connections);
@@ -2199,6 +2209,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// Filter State
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition));
const filterPanelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -2208,6 +2219,29 @@ const DataGrid: React.FC<DataGridProps> = ({
setNextFilterId(Math.max(1, maxId + 1));
}, [appliedFilterConditions, normalizeGridFilterConditions]);
useEffect(() => {
setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition));
}, [quickWhereCondition]);
const quickWhereSuggestionOptions = useMemo(() => {
const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames;
return resolveWhereConditionSuggestions({
input: quickWhereDraft,
columnNames: columnSuggestionSource,
dbType,
}).map((item) => ({
value: item.value,
insertText: item.insertText,
suggestionKind: item.kind,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span>{item.label}</span>
<span style={{ color: darkMode ? 'rgba(255,255,255,0.46)' : 'rgba(0,0,0,0.42)', fontSize: 12 }}>{item.detail}</span>
</div>
),
}));
}, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]);
useEffect(() => {
if (!showFilter) {
return;
@@ -4032,9 +4066,10 @@ const DataGrid: React.FC<DataGridProps> = ({
return clauses.join(' OR ');
}, [pkColumns, tableName]);
const buildCurrentPageSql = useCallback((dbType: string) => {
const buildCurrentPageSql = useCallback((dbType: string) => {
if (!tableName || !pagination) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = String(dbType || '').trim().toLowerCase();
@@ -4045,7 +4080,7 @@ const DataGrid: React.FC<DataGridProps> = ({
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tableName, pagination, filterConditions, sortInfo, pkColumns]);
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
@@ -4277,7 +4312,25 @@ const DataGrid: React.FC<DataGridProps> = ({
const removeFilter = (id: number) => {
setFilterConditions(prev => prev.filter(c => c.id !== id));
};
const applyQuickWhereCondition = useCallback((condition: string = quickWhereDraft): boolean => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
void message.warning(validation.message);
return false;
}
setQuickWhereDraft(normalized);
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(normalized);
return true;
}, [quickWhereDraft, onApplyQuickWhereCondition]);
const clearQuickWhereCondition = useCallback(() => {
setQuickWhereDraft('');
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition('');
}, [onApplyQuickWhereCondition]);
const applyFilters = () => {
if (!applyQuickWhereCondition()) return;
if (onApplyFilter) onApplyFilter(filterConditions);
};
@@ -5149,6 +5202,73 @@ const DataGrid: React.FC<DataGridProps> = ({
display: 'flex',
flexDirection: 'column',
}}>
<div
data-grid-quick-where="true"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
marginBottom: 10,
borderRadius: Math.max(10, panelRadius - 2),
border: `1px solid ${panelFrameColor}`,
background: darkMode ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.72)',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<span
style={{
flex: '0 0 auto',
minWidth: 58,
height: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
background: darkMode ? 'rgba(24,144,255,0.18)' : 'rgba(24,144,255,0.10)',
border: `1px solid ${darkMode ? 'rgba(24,144,255,0.32)' : 'rgba(24,144,255,0.22)'}`,
color: selectionAccentHex,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.03em',
}}
>
WHERE
</span>
<AutoComplete
value={quickWhereDraft}
options={quickWhereSuggestionOptions}
onChange={setQuickWhereDraft}
onSelect={(value, option) => {
setQuickWhereDraft(resolveWhereConditionSelectedValue({
selectedValue: value,
currentInput: quickWhereDraft,
insertText: (option as any)?.insertText,
}));
}}
style={{ flex: '1 1 320px', minWidth: 220 }}
popupMatchSelectWidth={420}
>
<Input
{...noAutoCapInputProps}
allowClear
placeholder={dbType === 'mongodb' ? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}' : '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\''}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault();
applyQuickWhereCondition();
}
}}
/>
</AutoComplete>
<Button size="small" type="primary" onClick={() => applyQuickWhereCondition()}>
WHERE
</Button>
<Button size="small" onClick={clearQuickWhereCondition} disabled={!quickWhereDraft && !quickWhereCondition}>
</Button>
</div>
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
@@ -5316,6 +5436,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<Button type="primary" onClick={applyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={() => {
setFilterConditions([]);
clearQuickWhereCondition();
if (onApplyFilter) onApplyFilter([]);
if (onSort) onSort('', '');
}}></Button>

View File

@@ -10,6 +10,11 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
type ViewerPaginationState = {
current: number;
@@ -135,6 +140,7 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
type ViewerFilterSnapshot = {
showFilter: boolean;
conditions: FilterCondition[];
quickWhereCondition: string;
currentPage: number;
pageSize: number;
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
@@ -165,11 +171,12 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) {
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
}
return {
showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions),
quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition),
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
sortInfo: Array.isArray(cached.sortInfo)
@@ -226,6 +233,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
@@ -239,6 +247,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
@@ -246,12 +255,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
}, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setShowFilter(snapshot.showFilter);
setFilterConditions(snapshot.conditions);
setQuickWhereCondition(snapshot.quickWhereCondition);
setSortInfo(snapshot.sortInfo);
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
@@ -259,7 +269,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
useEffect(() => {
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
}, [persistViewerSnapshot]);
useEffect(() => {
return () => {
@@ -399,6 +409,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const dbType = resolveDataSourceType(config);
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
if (!quickWhereValidation.ok) {
message.error(quickWhereValidation.message);
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition);
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
@@ -406,7 +424,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let mongoFilter: Record<string, unknown> | undefined;
if (isMongoDB) {
try {
mongoFilter = buildMongoFilter(filterConditions);
mongoFilter = buildMongoFilter(effectiveFilterConditions);
} catch (e: any) {
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
if (fetchSeqRef.current === seq) setLoading(false);
@@ -416,7 +434,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const whereSQL = isMongoDB
? JSON.stringify(mongoFilter || {})
: buildWhereSQL(dbType, filterConditions);
: buildWhereSQL(dbType, effectiveFilterConditions);
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
@@ -824,7 +842,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -852,13 +870,23 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
message.error(validation.message);
return;
}
setQuickWhereCondition(normalized);
}, []);
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = resolveDataSourceType(currentConnConfig);
if (!tableName || !dbType) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
@@ -869,7 +897,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]);
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
useEffect(() => {
const action = resolveDataViewerAutoFetchAction({
@@ -886,7 +914,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
return;
}
fetchData(1, pagination.pageSize);
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
@@ -909,6 +937,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
appliedFilterConditions={filterConditions}
quickWhereCondition={quickWhereCondition}
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}