feat(table): 支持截断表与清空表操作

Fixes #351
This commit is contained in:
Syngnat
2026-04-11 22:53:04 +08:00
parent 33b21cc5ee
commit 2410aad849
9 changed files with 396 additions and 41 deletions

View File

@@ -39,6 +39,7 @@ import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import FindInDatabaseModal from './FindInDatabaseModal';
@@ -1761,13 +1762,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const startTime = Date.now();
try {
const app = (window as any).go.app.App;
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames);
hide();
const duration = Date.now() - startTime;
if (res.success) {
message.success('清空成功');
// 构造 SQL 日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
@@ -1786,7 +1787,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
} else if (res.message !== '已取消') {
message.error('清空失败: ' + res.message);
// 记录失败的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
@@ -1808,7 +1809,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const errMsg = e?.message || String(e);
message.error('清空失败: ' + errMsg);
// 记录异常的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`;
logSql += objectNames.map(name => name).join('; ');
addSqlLog({
id: Date.now().toString(),
@@ -2269,6 +2270,84 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => {
const conn = node.dataRef;
const tableName = String(conn.tableName || '').trim();
if (!tableName) return;
const { label, progressLabel } = getTableDataDangerActionMeta(action);
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) return;
const config = buildRuntimeConfig(conn, conn.dbName);
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
const startTime = Date.now();
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
hide();
const duration = Date.now() - startTime;
const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : [];
const logSql = executedSQLs.length > 0
? executedSQLs.join(';\n') + ';'
: `/* ${label} ${tableName} */`;
if (res.success) {
message.success(`${progressLabel}成功`);
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'success',
duration,
message: res.message,
dbName: conn.dbName,
affectedRows: res.data?.count || 0,
});
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
return;
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: res.message,
dbName: conn.dbName,
});
if (res.message !== '已取消') {
message.error(`${progressLabel}失败: ${res.message}`);
}
} catch (e: any) {
const duration = Date.now() - startTime;
const errMsg = e?.message || String(e);
hide();
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: `/* ${label} ${tableName} - ERROR */`,
status: 'error',
duration,
message: errMsg,
dbName: conn.dbName,
});
message.error(`${progressLabel}失败: ${errMsg}`);
}
};
// --- 视图操作 ---
const openViewDefinition = (node: any) => {
const { viewName, dbName, id } = node.dataRef;
@@ -3519,6 +3598,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
label: '危险操作',
icon: <WarningOutlined />,
children: [
...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{
key: 'truncate-table',
label: '截断表',
danger: true,
onClick: () => handleTableDataDangerAction(node, 'truncate')
}] : []),
{
key: 'clear-table',
label: '清空表',
danger: true,
onClick: () => handleTableDataDangerAction(node, 'clear')
},
{
key: 'drop-table',
label: '删除表',

View File

@@ -5,6 +5,7 @@ import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
interface TableOverviewProps {
tab: TabData;
@@ -281,6 +282,40 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
});
}, [buildConfig, tab.dbName, loadData]);
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
const config = buildConfig();
if (!config) return;
const { label, progressLabel } = getTableDataDangerActionMeta(action);
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
hide();
if (res.success) {
message.success(`${progressLabel}成功`);
loadData();
} else {
message.error(`${progressLabel}失败: ${res.message}`);
return Promise.reject();
}
} catch (e: any) {
hide();
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
return Promise.reject();
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
@@ -341,6 +376,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
return Math.max(max, table.dataSize + table.indexSize);
}, 0);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
if (loading) {
return (
@@ -437,6 +473,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
@@ -521,6 +559,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { supportsTableTruncateAction } from './tableDataDangerActions';
describe('tableDataDangerActions', () => {
it('supports native truncate for known relational dialects', () => {
expect(supportsTableTruncateAction('mysql')).toBe(true);
expect(supportsTableTruncateAction('postgres')).toBe(true);
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
});
it('rejects truncate for unsupported or document-style backends', () => {
expect(supportsTableTruncateAction('sqlite')).toBe(false);
expect(supportsTableTruncateAction('mongodb')).toBe(false);
expect(supportsTableTruncateAction('custom', 'sqlite3')).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
export type TableDataDangerActionKind = 'truncate' | 'clear';
const resolveCustomDriverDialect = (driver: string): string => {
const normalized = String(driver || '').trim().toLowerCase();
switch (normalized) {
case 'postgresql':
case 'postgres':
case 'pg':
case 'pq':
case 'pgx':
return 'postgres';
case 'dm':
case 'dameng':
case 'dm8':
return 'dameng';
case 'sqlite3':
case 'sqlite':
return 'sqlite';
case 'sphinxql':
return 'sphinx';
case 'diros':
case 'doris':
return 'diros';
case 'kingbase':
case 'kingbase8':
case 'kingbasees':
case 'kingbasev8':
return 'kingbase';
case 'highgo':
return 'highgo';
case 'vastbase':
return 'vastbase';
default:
break;
}
if (normalized.includes('postgres')) return 'postgres';
if (normalized.includes('kingbase')) return 'kingbase';
if (normalized.includes('highgo')) return 'highgo';
if (normalized.includes('vastbase')) return 'vastbase';
if (normalized.includes('sqlite')) return 'sqlite';
if (normalized.includes('sphinx')) return 'sphinx';
if (normalized.includes('diros') || normalized.includes('doris')) return 'diros';
return normalized;
};
export const resolveTableDataActionDBType = (type: string, driver?: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
if (normalizedType !== 'custom') {
return normalizedType;
}
return resolveCustomDriverDialect(driver || '');
};
export const supportsTableTruncateAction = (type: string, driver?: string): boolean => {
switch (resolveTableDataActionDBType(type, driver)) {
case 'mysql':
case 'mariadb':
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'sqlserver':
case 'oracle':
case 'dameng':
case 'clickhouse':
case 'duckdb':
return true;
default:
return false;
}
};
export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind): {
label: string;
progressLabel: string;
} => {
if (action === 'truncate') {
return { label: '截断表', progressLabel: '截断' };
}
return { label: '清空表', progressLabel: '清空' };
};