feat: 表筛选结果一键导出功能 (#161)

* 🔧 chore(gitignore): 忽略 AI 上下文文档避免版本控制污染

添加 CLAUDE.md 及其子目录变体到 .gitignore,防止 AI 辅助开发过程中生成的临时上下文文件被意外提交到仓库。

- 忽略根目录 CLAUDE.md
- 忽略所有子目录下的 CLAUDE.md 文件

* feat: 表筛选结果一键导出功能

- 新增表浏览模式下筛选结果的导出功能
- DataViewer 生成包含筛选条件的完整 SQL
- DataGrid 动态显示分组导出菜单(筛选结果 + 全表)
- 支持 CSV、Excel、JSON、Markdown 四种格式
- 添加未提交修改的警告提示
- 复用现有 ExportQuery 后端方法,无需后端修改

实现细节:
- 使用 buildWhereSQL 和 buildOrderBySQL 构建 SQL
- 支持 MySQL/MariaDB 的 sort buffer 优化
- 分组菜单设计避免用户误操作
- 导出文件名包含 _filtered 后缀

关闭 #issue
This commit is contained in:
Toskysun
2026-03-04 13:54:51 +08:00
committed by GitHub
parent 8c91d8929b
commit 4570516678
3 changed files with 60 additions and 4 deletions

3
.gitignore vendored
View File

@@ -19,3 +19,6 @@ GoNavi-Wails.exe
.ace-tool/
.claude/
tmpclaude-*
CLAUDE.md
**/CLAUDE.md

View File

@@ -577,6 +577,7 @@ interface DataGridProps {
// Filtering
showFilter?: boolean;
onToggleFilter?: () => void;
exportSqlWithFilter?: string;
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
}
@@ -595,9 +596,9 @@ type ColumnMeta = {
comment: string;
};
const DataGrid: React.FC<DataGridProps> = ({
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, onApplyFilter
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter
}) => {
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
@@ -620,6 +621,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const isQueryResultExport = exportScope === 'queryResult';
const canImport = exportScope === 'table' && !!tableName;
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]);
const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0;
// Background Helper
const getBg = (darkHex: string) => {
@@ -2481,6 +2484,23 @@ const DataGrid: React.FC<DataGridProps> = ({
});
};
const handleExportFilteredAll = async (format: string) => {
if (!connectionId || !tableName) return;
if (!filteredExportSql) {
message.warning('当前未应用筛选条件');
return;
}
if (!supportsSqlQueryExport) {
message.error('当前数据源不支持按筛选结果导出');
return;
}
if (hasChanges) {
message.warning("当前存在未提交修改,筛选结果导出基于数据库已提交数据。");
}
await exportByQuery(filteredExportSql, format, `${tableName || 'export'}_filtered`);
};
const handleImport = async () => {
if (!connectionId || !tableName) return;
const config = buildConnConfig();
@@ -2562,7 +2582,21 @@ const DataGrid: React.FC<DataGridProps> = ({
if (onApplyFilter) onApplyFilter(filterConditions);
};
const exportMenu: MenuProps['items'] = [
const exportMenu: MenuProps['items'] = hasFilteredExportSql ? [
{ type: 'group', label: '筛选结果', children: [
{ key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') },
{ key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') },
{ key: 'filtered-json', label: 'JSON', onClick: () => handleExportFilteredAll('json') },
{ key: 'filtered-md', label: 'Markdown', onClick: () => handleExportFilteredAll('md') },
]},
{ type: 'divider' },
{ type: 'group', label: '全表', children: [
{ key: 'table-csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'table-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'table-json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'table-md', label: 'Markdown', onClick: () => handleExport('md') },
]},
] : [
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
@@ -676,6 +676,24 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = String(currentConnConfig?.type || '').trim();
if (!tableName || !dbType) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = dbType.toLowerCase();
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
useEffect(() => {
fetchData(1, pagination.pageSize);
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
@@ -702,6 +720,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onApplyFilter={handleApplyFilter}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}
/>
</div>
);