mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-26 00:11:43 +08:00
@@ -1 +1 @@
|
||||
d0f9366af59a6367ad3c7e2d4185ead4
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
@@ -705,7 +705,7 @@ function App() {
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}>关于</Button>
|
||||
</div>
|
||||
<Layout style={{ flex: 1, minHeight: 0 }}>
|
||||
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
|
||||
<Sider
|
||||
width={sidebarWidth}
|
||||
style={{
|
||||
@@ -756,8 +756,8 @@ function App() {
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
|
||||
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
|
||||
@@ -43,6 +43,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
|
||||
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
|
||||
const [discoveringMembers, setDiscoveringMembers] = useState(false);
|
||||
const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -393,9 +394,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const values = form.getFieldsValue(true);
|
||||
const uri = buildUriFromValues(values);
|
||||
form.setFieldValue('uri', uri);
|
||||
message.success('URI 已生成');
|
||||
setUriFeedback({ type: 'success', message: 'URI 已生成' });
|
||||
} catch {
|
||||
message.error('生成 URI 失败');
|
||||
setUriFeedback({ type: 'error', message: '生成 URI 失败' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -404,21 +405,21 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const uriText = String(form.getFieldValue('uri') || '').trim();
|
||||
const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase();
|
||||
if (!uriText) {
|
||||
message.warning('请先输入 URI');
|
||||
setUriFeedback({ type: 'warning', message: '请先输入 URI' });
|
||||
return;
|
||||
}
|
||||
const parsedValues = parseUriToValues(uriText, type);
|
||||
if (!parsedValues) {
|
||||
message.error('当前 URI 与数据源类型不匹配,或 URI 格式不支持');
|
||||
setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' });
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue({ ...parsedValues, uri: uriText });
|
||||
if (testResult) {
|
||||
setTestResult(null);
|
||||
}
|
||||
message.success('已根据 URI 回填连接参数');
|
||||
setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' });
|
||||
} catch {
|
||||
message.error('URI 解析失败,请检查格式后重试');
|
||||
setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -430,14 +431,14 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
form.setFieldValue('uri', uriText);
|
||||
}
|
||||
if (!uriText) {
|
||||
message.warning('没有可复制的 URI');
|
||||
setUriFeedback({ type: 'warning', message: '没有可复制的 URI' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(uriText);
|
||||
message.success('URI 已复制');
|
||||
setUriFeedback({ type: 'success', message: 'URI 已复制' });
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
setUriFeedback({ type: 'error', message: '复制失败' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -448,6 +449,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
setDbList([]);
|
||||
setRedisDbList([]);
|
||||
setMongoMembers([]);
|
||||
setUriFeedback(null);
|
||||
if (initialValues) {
|
||||
// Edit mode: Go directly to step 2
|
||||
setStep(2);
|
||||
@@ -925,6 +927,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
setTestResult(null); // Clear result on change
|
||||
setTestErrorLogOpen(false);
|
||||
}
|
||||
if (changed.uri !== undefined || changed.type !== undefined) {
|
||||
setUriFeedback(null);
|
||||
}
|
||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
@@ -958,6 +963,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<Button onClick={handleParseURI}>从 URI 解析</Button>
|
||||
<Button onClick={handleCopyURI}>复制 URI</Button>
|
||||
</Space>
|
||||
{uriFeedback && (
|
||||
<Alert
|
||||
showIcon
|
||||
closable
|
||||
type={uriFeedback.type}
|
||||
message={uriFeedback.message}
|
||||
onClose={() => setUriFeedback(null)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
|
||||
@@ -9,7 +9,7 @@ import ImportPreviewModal from './ImportPreviewModal';
|
||||
import { useStore } from '../store';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
|
||||
// --- Error Boundary ---
|
||||
@@ -496,6 +496,7 @@ interface DataGridProps {
|
||||
onSort?: (field: string, order: string) => void;
|
||||
onPageChange?: (page: number, size: number) => void;
|
||||
pagination?: { current: number, pageSize: number, total: number, totalKnown?: boolean };
|
||||
sortInfoExternal?: { columnKey: string, order: string } | null;
|
||||
// Filtering
|
||||
showFilter?: boolean;
|
||||
onToggleFilter?: () => void;
|
||||
@@ -514,7 +515,7 @@ type GridViewMode = 'table' | 'json' | 'text';
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
||||
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
|
||||
}) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
@@ -661,6 +662,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend'
|
||||
? sortInfoExternal.order
|
||||
: '';
|
||||
const nextColumn = nextOrder ? String(sortInfoExternal?.columnKey || '') : '';
|
||||
const currColumn = String(sortInfo?.columnKey || '');
|
||||
const currOrder = sortInfo?.order === 'ascend' || sortInfo?.order === 'descend' ? sortInfo.order : '';
|
||||
if (nextColumn === currColumn && nextOrder === currOrder) return;
|
||||
if (!nextColumn || !nextOrder) {
|
||||
setSortInfo(null);
|
||||
} else {
|
||||
setSortInfo({ columnKey: nextColumn, order: nextOrder });
|
||||
}
|
||||
}, [sortInfoExternal, sortInfo]);
|
||||
|
||||
const closeCellEditor = useCallback(() => {
|
||||
setCellEditorOpen(false);
|
||||
setCellEditorMeta(null);
|
||||
@@ -1113,9 +1129,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
||||
if (isResizingRef.current) return; // Block sort if resizing
|
||||
if (sorter.field) {
|
||||
const field = String(sorter.field);
|
||||
const order = sorter.order as string;
|
||||
setSortInfo({ columnKey: sorter.field as string, order });
|
||||
if (onSort) onSort(sorter.field, order);
|
||||
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
|
||||
if (!normalizedOrder) {
|
||||
setSortInfo(null);
|
||||
if (onSort) onSort('', '');
|
||||
return;
|
||||
}
|
||||
setSortInfo({ columnKey: field, order: normalizedOrder });
|
||||
if (onSort) onSort(field, normalizedOrder);
|
||||
} else {
|
||||
setSortInfo(null);
|
||||
if (onSort) onSort('', '');
|
||||
@@ -1820,6 +1843,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
const offset = (pagination.current - 1) * pagination.pageSize;
|
||||
sql += ` LIMIT ${pagination.pageSize} OFFSET ${offset}`;
|
||||
return sql;
|
||||
@@ -2034,9 +2062,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
|
||||
|
||||
return (
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: bgContent }}>
|
||||
{/* Toolbar */}
|
||||
<div className="data-grid-toolbar-scroll" style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch' }}>
|
||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
@@ -2121,36 +2149,38 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
setViewMode(nextMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
setViewMode(nextMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilter && (
|
||||
@@ -2448,8 +2478,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
) : (
|
||||
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Button size="small" onClick={() => setTextRecordIndex(i => Math.max(0, i - 1))} disabled={textViewRows.length === 0 || textRecordIndex <= 0}>
|
||||
上一条
|
||||
@@ -2466,7 +2496,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="custom-scrollbar" style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
|
||||
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
|
||||
{currentTextRow ? columnNames.map((col) => (
|
||||
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
|
||||
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
|
||||
@@ -2672,8 +2702,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.${gridId} .ant-table { background: transparent !important; }
|
||||
<style>{`
|
||||
.${gridId} .data-grid-toolbar-scroll > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar {
|
||||
height: 7px;
|
||||
}
|
||||
.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.22)'};
|
||||
border-radius: 999px;
|
||||
}
|
||||
.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.${gridId} .ant-table { background: transparent !important; }
|
||||
.${gridId} .ant-table-container { background: transparent !important; border: none !important; }
|
||||
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -60,6 +60,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const dbType = config.type || '';
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb';
|
||||
|
||||
const dbName = tab.dbName || '';
|
||||
const tableName = tab.tableName || '';
|
||||
@@ -74,24 +76,46 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const requestStartTime = Date.now();
|
||||
let executedSql = sql;
|
||||
try {
|
||||
const pData = DBQuery(config as any, dbName, sql);
|
||||
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
||||
const startTime = Date.now();
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: result.success ? 'success' : 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
||||
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
||||
dbName
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const resData = await pData;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Log Execution
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: sql,
|
||||
status: resData.success ? 'success' : 'error',
|
||||
duration: duration,
|
||||
message: resData.success ? '' : resData.message,
|
||||
affectedRows: Array.isArray(resData.data) ? resData.data.length : undefined,
|
||||
dbName
|
||||
});
|
||||
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
||||
let resData = await executeDataQuery(sql, '主查询');
|
||||
|
||||
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
|
||||
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
|
||||
if (retrySql32MB !== sql) {
|
||||
executedSql = retrySql32MB;
|
||||
resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)');
|
||||
}
|
||||
if (!resData.success && isSortMemoryErr(resData.message)) {
|
||||
const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024);
|
||||
if (retrySql128MB !== executedSql) {
|
||||
executedSql = retrySql128MB;
|
||||
resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)');
|
||||
}
|
||||
}
|
||||
if (resData.success) {
|
||||
message.warning('已自动提升排序缓冲并重试成功。');
|
||||
}
|
||||
}
|
||||
|
||||
if (pkColumns.length === 0) {
|
||||
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||
@@ -187,7 +211,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(resData.message);
|
||||
message.error(String(resData.message || '查询失败'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
@@ -195,9 +219,9 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-error`,
|
||||
timestamp: Date.now(),
|
||||
sql: sql,
|
||||
sql: executedSql,
|
||||
status: 'error',
|
||||
duration: Date.now() - startTime,
|
||||
duration: Date.now() - requestStartTime,
|
||||
message: e.message,
|
||||
dbName
|
||||
});
|
||||
@@ -211,7 +235,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const handleReload = useCallback(() => {
|
||||
fetchData(pagination.current, pagination.pageSize);
|
||||
}, [fetchData, pagination.current, pagination.pageSize]);
|
||||
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
|
||||
const handleSort = useCallback((field: string, order: string) => {
|
||||
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
|
||||
const normalizedField = String(field || '').trim();
|
||||
if (!normalizedField || !normalizedOrder) {
|
||||
setSortInfo(null);
|
||||
return;
|
||||
}
|
||||
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
|
||||
}, []);
|
||||
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
|
||||
@@ -221,7 +253,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||
|
||||
return (
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<DataGrid
|
||||
data={data}
|
||||
columnNames={columnNames}
|
||||
@@ -238,6 +270,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
onToggleFilter={handleToggleFilter}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
readOnly={forceReadOnly}
|
||||
sortInfoExternal={sortInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue } from '../types';
|
||||
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
|
||||
@@ -625,6 +625,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
case 'list': return 'orange';
|
||||
case 'set': return 'purple';
|
||||
case 'zset': return 'magenta';
|
||||
case 'stream': return 'cyan';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
@@ -1468,6 +1469,212 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderStreamValue = () => {
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
|
||||
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
|
||||
return {
|
||||
index,
|
||||
id: item.id,
|
||||
rawFieldsText,
|
||||
displayFields: displayValue,
|
||||
isBinary,
|
||||
isJson,
|
||||
encoding,
|
||||
};
|
||||
});
|
||||
|
||||
const handleAddStreamEntry = async (fieldsText: string, id: string) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(fieldsText);
|
||||
} catch (e) {
|
||||
message.error('字段 JSON 格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
message.error('字段必须是 JSON 对象');
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldMap: Record<string, string> = {};
|
||||
Object.entries(parsed as Record<string, unknown>).forEach(([field, value]) => {
|
||||
fieldMap[field] = value == null ? '' : String(value);
|
||||
});
|
||||
|
||||
if (Object.keys(fieldMap).length === 0) {
|
||||
message.error('至少提供一个字段');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisStreamAdd(config, selectedKey, fieldMap, id || '*');
|
||||
if (res.success) {
|
||||
const newID = res.data?.id ? ` (${res.data.id})` : '';
|
||||
message.success(`添加成功${newID}`);
|
||||
loadKeyValue(selectedKey);
|
||||
} else {
|
||||
message.error('添加失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('添加失败: ' + (e?.message || String(e)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteStreamEntry = async (id: string) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisStreamDelete(config, selectedKey, [id]);
|
||||
if (res.success) {
|
||||
const deleted = Number(res.data?.deleted ?? 0);
|
||||
if (deleted > 0) {
|
||||
message.success('删除成功');
|
||||
} else {
|
||||
message.warning('未删除任何消息,可能已不存在');
|
||||
}
|
||||
loadKeyValue(selectedKey);
|
||||
} else {
|
||||
message.error('删除失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('删除失败: ' + (e?.message || String(e)));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加 Stream 消息',
|
||||
width: 680,
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label>ID(可选,默认 *):</label>
|
||||
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
|
||||
</div>
|
||||
<div>
|
||||
<label>字段 JSON:</label>
|
||||
<Input.TextArea id="new-stream-fields" rows={8} defaultValue={'{\n "field": "value"\n}'} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
const id = (document.getElementById('new-stream-id') as HTMLInputElement)?.value?.trim() || '*';
|
||||
const fieldsText = (document.getElementById('new-stream-fields') as HTMLTextAreaElement)?.value || '{}';
|
||||
await handleAddStreamEntry(fieldsText, id);
|
||||
}
|
||||
});
|
||||
}}>添加消息</Button>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
<Radio.Button value="text">原始文本</Radio.Button>
|
||||
<Radio.Button value="utf8">UTF-8</Radio.Button>
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={[
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '字段',
|
||||
dataIndex: 'displayFields',
|
||||
key: 'fields',
|
||||
ellipsis: true,
|
||||
render: (text: string, record: any) => {
|
||||
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
|
||||
? `[${record.encoding}]\n${text}`
|
||||
: text;
|
||||
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_: any, record: any) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="复制 ID">
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
|
||||
navigator.clipboard.writeText(record.id).then(() => {
|
||||
message.success('已复制');
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
}} />
|
||||
</Tooltip>
|
||||
<Tooltip title="复制字段 JSON">
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
|
||||
navigator.clipboard.writeText(record.rawFieldsText).then(() => {
|
||||
message.success('已复制');
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
}} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确定删除此消息?" onConfirm={() => handleDeleteStreamEntry(record.id)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 50 }}
|
||||
scroll={{ y: 'calc(100vh - 350px)' }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||
@@ -1511,6 +1718,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
{keyValue.type === 'list' && renderListValue()}
|
||||
{keyValue.type === 'set' && renderSetValue()}
|
||||
{keyValue.type === 'zset' && renderZSetValue()}
|
||||
{keyValue.type === 'stream' && renderStreamValue()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const removeConnection = useStore(state => state.removeConnection);
|
||||
const closeTabsByConnection = useStore(state => state.closeTabsByConnection);
|
||||
const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const tableAccessCount = useStore(state => state.tableAccessCount);
|
||||
@@ -1592,6 +1594,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const res = await DropDatabase(config as any, dbName);
|
||||
if (res.success) {
|
||||
message.success("数据库删除成功");
|
||||
closeTabsByDatabase(conn.id, dbName);
|
||||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||||
await loadDatabases(getConnectionNodeRef(conn));
|
||||
@@ -2095,6 +2098,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
closeTabsByConnection(String(node.key));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
},
|
||||
@@ -2107,7 +2111,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||||
onOk: () => removeConnection(node.key)
|
||||
onOk: () => {
|
||||
closeTabsByConnection(String(node.key));
|
||||
removeConnection(node.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2177,6 +2184,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
// Clear children (undefined to trigger reload)
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
closeTabsByConnection(String(node.key));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
},
|
||||
@@ -2189,7 +2197,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||||
onOk: () => removeConnection(node.key)
|
||||
onOk: () => {
|
||||
closeTabsByConnection(String(node.key));
|
||||
removeConnection(node.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2276,6 +2287,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
label: '关闭数据库',
|
||||
icon: <DisconnectOutlined />,
|
||||
onClick: () => {
|
||||
const dbConnId = String(node.dataRef?.id || '');
|
||||
const dbName = String(node.dataRef?.dbName || node.title || '').trim();
|
||||
setConnectionStates(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[node.key];
|
||||
@@ -2284,6 +2297,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
if (dbConnId && dbName) {
|
||||
closeTabsByDatabase(dbConnId, dbName);
|
||||
}
|
||||
message.success("已关闭数据库");
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,6 +116,7 @@ const TabManager: React.FC = () => {
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -126,6 +127,7 @@ const TabManager: React.FC = () => {
|
||||
.main-tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -133,12 +135,14 @@ const TabManager: React.FC = () => {
|
||||
.main-tabs .ant-tabs-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main-tabs .ant-tabs-tabpane {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -146,6 +150,7 @@ const TabManager: React.FC = () => {
|
||||
.main-tabs .ant-tabs-tabpane > div {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.main-tabs .ant-tabs-tabpane-hidden {
|
||||
display: none !important;
|
||||
|
||||
@@ -269,6 +269,8 @@ interface AppState {
|
||||
closeOtherTabs: (id: string) => void;
|
||||
closeTabsToLeft: (id: string) => void;
|
||||
closeTabsToRight: (id: string) => void;
|
||||
closeTabsByConnection: (connectionId: string) => void;
|
||||
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
||||
@@ -428,6 +430,45 @@ export const useStore = create<AppState>()(
|
||||
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
|
||||
}),
|
||||
|
||||
closeTabsByConnection: (connectionId) => set((state) => {
|
||||
const targetConnectionId = String(connectionId || '').trim();
|
||||
if (!targetConnectionId) return state;
|
||||
const newTabs = state.tabs.filter(t => String(t.connectionId || '').trim() !== targetConnectionId);
|
||||
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
|
||||
const nextActiveTabId = activeStillExists
|
||||
? state.activeTabId
|
||||
: (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
|
||||
const nextActiveContext = state.activeContext?.connectionId === targetConnectionId ? null : state.activeContext;
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: nextActiveTabId,
|
||||
activeContext: nextActiveContext,
|
||||
};
|
||||
}),
|
||||
|
||||
closeTabsByDatabase: (connectionId, dbName) => set((state) => {
|
||||
const targetConnectionId = String(connectionId || '').trim();
|
||||
const targetDbName = String(dbName || '').trim();
|
||||
if (!targetConnectionId || !targetDbName) return state;
|
||||
const newTabs = state.tabs.filter((tab) => {
|
||||
const sameConnection = String(tab.connectionId || '').trim() === targetConnectionId;
|
||||
const sameDb = String(tab.dbName || '').trim() === targetDbName;
|
||||
return !(sameConnection && sameDb);
|
||||
});
|
||||
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
|
||||
const nextActiveTabId = activeStillExists
|
||||
? state.activeTabId
|
||||
: (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
|
||||
const sameActiveContext = state.activeContext
|
||||
&& state.activeContext.connectionId === targetConnectionId
|
||||
&& state.activeContext.dbName === targetDbName;
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: nextActiveTabId,
|
||||
activeContext: sameActiveContext ? null : state.activeContext,
|
||||
};
|
||||
}),
|
||||
|
||||
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
|
||||
|
||||
setActiveTab: (id) => set({ activeTabId: id }),
|
||||
|
||||
@@ -131,7 +131,7 @@ export interface RedisScanResult {
|
||||
}
|
||||
|
||||
export interface RedisValue {
|
||||
type: 'string' | 'hash' | 'list' | 'set' | 'zset';
|
||||
type: 'string' | 'hash' | 'list' | 'set' | 'zset' | 'stream';
|
||||
ttl: number;
|
||||
value: any;
|
||||
length: number;
|
||||
@@ -146,3 +146,8 @@ export interface ZSetMember {
|
||||
member: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface StreamEntry {
|
||||
id: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -68,11 +68,39 @@ type SortInfo = {
|
||||
order?: string;
|
||||
} | null | undefined;
|
||||
|
||||
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
|
||||
// MySQL: 使用 Optimizer Hint `SET_VAR`。
|
||||
// MariaDB: 使用 `SET STATEMENT ... FOR` 包装当前查询。
|
||||
export const withSortBufferTuningSQL = (
|
||||
dbType: string,
|
||||
sql: string,
|
||||
sortBufferBytes: number,
|
||||
) => {
|
||||
const rawSql = String(sql || '');
|
||||
const trimmed = rawSql.trim();
|
||||
if (!trimmed) return rawSql;
|
||||
if (!/^select\b/i.test(trimmed)) return rawSql;
|
||||
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const bytes = Math.max(256 * 1024, Math.floor(Number(sortBufferBytes) || 0));
|
||||
if (normalizedType === 'mysql') {
|
||||
return rawSql.replace(
|
||||
/^\s*select\b/i,
|
||||
(matched) => `${matched} /*+ SET_VAR(sort_buffer_size=${bytes}) */`,
|
||||
);
|
||||
}
|
||||
if (normalizedType === 'mariadb') {
|
||||
return `SET STATEMENT sort_buffer_size=${bytes} FOR ${rawSql}`;
|
||||
}
|
||||
return rawSql;
|
||||
};
|
||||
|
||||
export const buildOrderBySQL = (
|
||||
dbType: string,
|
||||
sortInfo: SortInfo,
|
||||
fallbackColumns: string[] = [],
|
||||
) => {
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || ''));
|
||||
const sortOrder = String(sortInfo?.order || '');
|
||||
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
|
||||
@@ -80,6 +108,13 @@ export const buildOrderBySQL = (
|
||||
return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`;
|
||||
}
|
||||
|
||||
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort,
|
||||
// 导致 `Error 1038 (HY001): Out of sort memory`。
|
||||
// 因此仅在用户主动点击排序时下发 ORDER BY,默认分页查询不加兜底排序。
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stableColumns = (fallbackColumns || [])
|
||||
.map((col) => normalizeIdentPart(String(col || '')))
|
||||
|
||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -120,6 +120,10 @@ export function RedisSetString(arg1:connection.ConnectionConfig,arg2:string,arg3
|
||||
|
||||
export function RedisSetTTL(arg1:connection.ConnectionConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisStreamAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Record<string, string>,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisStreamDelete(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisTestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<redis.ZSetMember>):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -234,6 +234,14 @@ export function RedisSetTTL(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisSetTTL'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RedisStreamAdd(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['RedisStreamAdd'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function RedisStreamDelete(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisStreamDelete'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RedisTestConnection(arg1) {
|
||||
return window['go']['app']['App']['RedisTestConnection'](arg1);
|
||||
}
|
||||
|
||||
@@ -450,6 +450,40 @@ func (a *App) RedisZSetRemove(config connection.ConnectionConfig, key string, me
|
||||
return connection.QueryResult{Success: true, Message: "删除成功"}
|
||||
}
|
||||
|
||||
// RedisStreamAdd adds an entry to a stream
|
||||
func (a *App) RedisStreamAdd(config connection.ConnectionConfig, key string, fields map[string]string, id string) connection.QueryResult {
|
||||
config.Type = "redis"
|
||||
client, err := a.getRedisClient(config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
newID, err := client.StreamAdd(key, fields, id)
|
||||
if err != nil {
|
||||
logger.Error(err, "RedisStreamAdd 添加失败:key=%s id=%s", key, id)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "添加成功", Data: map[string]string{"id": newID}}
|
||||
}
|
||||
|
||||
// RedisStreamDelete deletes stream entries by IDs
|
||||
func (a *App) RedisStreamDelete(config connection.ConnectionConfig, key string, ids []string) connection.QueryResult {
|
||||
config.Type = "redis"
|
||||
client, err := a.getRedisClient(config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
deleted, err := client.StreamDelete(key, ids...)
|
||||
if err != nil {
|
||||
logger.Error(err, "RedisStreamDelete 删除失败:key=%s ids=%v", key, ids)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "删除成功", Data: map[string]int64{"deleted": deleted}}
|
||||
}
|
||||
|
||||
// RedisFlushDB flushes the current database
|
||||
func (a *App) RedisFlushDB(config connection.ConnectionConfig) connection.QueryResult {
|
||||
config.Type = "redis"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -67,7 +68,39 @@ func (s *SphinxDB) GetDatabases() ([]string, error) {
|
||||
}
|
||||
|
||||
func (s *SphinxDB) GetTables(dbName string) ([]string, error) {
|
||||
return s.MySQLDB.GetTables(s.resolveDatabaseName(dbName))
|
||||
tables, err := s.MySQLDB.GetTables(s.resolveDatabaseName(dbName))
|
||||
if err == nil {
|
||||
return tables, nil
|
||||
}
|
||||
if !isSphinxUnsupportedFeatureError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sphinx/Manticore 常见返回列名为 `Index`,并且不支持 `SHOW TABLES FROM <db>` 语法。
|
||||
data, fields, fallbackErr := s.MySQLDB.Query("SHOW TABLES")
|
||||
if fallbackErr != nil {
|
||||
return nil, fallbackErr
|
||||
}
|
||||
|
||||
fallbackTables := make([]string, 0, len(data))
|
||||
for _, row := range data {
|
||||
if val, ok := row["Index"]; ok {
|
||||
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
|
||||
continue
|
||||
}
|
||||
if val, ok := row["index"]; ok {
|
||||
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
|
||||
continue
|
||||
}
|
||||
for _, field := range fields {
|
||||
if val, ok := row[field]; ok {
|
||||
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackTables, nil
|
||||
}
|
||||
|
||||
func (s *SphinxDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import "GoNavi-Wails/internal/connection"
|
||||
|
||||
// RedisValue represents a Redis value with its type and metadata
|
||||
type RedisValue struct {
|
||||
Type string `json:"type"` // string, hash, list, set, zset
|
||||
Type string `json:"type"` // string, hash, list, set, zset, stream
|
||||
TTL int64 `json:"ttl"` // TTL in seconds, -1 means no expiry, -2 means key doesn't exist
|
||||
Value interface{} `json:"value"` // The actual value
|
||||
Length int64 `json:"length"` // Length/size of the value
|
||||
@@ -72,6 +72,11 @@ type RedisClient interface {
|
||||
ZSetAdd(key string, members ...ZSetMember) error
|
||||
ZSetRemove(key string, members ...string) error
|
||||
|
||||
// Stream operations
|
||||
GetStream(key, start, stop string, count int64) ([]StreamEntry, error)
|
||||
StreamAdd(key string, fields map[string]string, id string) (string, error)
|
||||
StreamDelete(key string, ids ...string) (int64, error)
|
||||
|
||||
// Command execution
|
||||
ExecuteCommand(args []string) (interface{}, error)
|
||||
|
||||
@@ -88,3 +93,9 @@ type ZSetMember struct {
|
||||
Member string `json:"member"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// StreamEntry represents a single stream message
|
||||
type StreamEntry struct {
|
||||
ID string `json:"id"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
|
||||
@@ -334,6 +334,26 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
result.Value = members
|
||||
result.Length = length
|
||||
|
||||
case "stream":
|
||||
length, err := r.client.XLen(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Length = length
|
||||
if length == 0 {
|
||||
result.Value = []StreamEntry{}
|
||||
break
|
||||
}
|
||||
limit := int64(1000)
|
||||
if length < limit {
|
||||
limit = length
|
||||
}
|
||||
val, err := r.client.XRangeN(ctx, key, "-", "+", limit).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Value = toStreamEntries(val)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 Redis 数据类型: %s", keyType)
|
||||
}
|
||||
@@ -523,6 +543,91 @@ func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error {
|
||||
return r.client.ZRem(ctx, key, args...).Err()
|
||||
}
|
||||
|
||||
// GetStream gets stream entries in a range
|
||||
func (r *RedisClientImpl) GetStream(key, start, stop string, count int64) ([]StreamEntry, error) {
|
||||
if r.client == nil {
|
||||
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||
}
|
||||
if start == "" {
|
||||
start = "-"
|
||||
}
|
||||
if stop == "" {
|
||||
stop = "+"
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 1000
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
val, err := r.client.XRangeN(ctx, key, start, stop, count).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toStreamEntries(val), nil
|
||||
}
|
||||
|
||||
// StreamAdd adds an entry to a stream
|
||||
func (r *RedisClientImpl) StreamAdd(key string, fields map[string]string, id string) (string, error) {
|
||||
if r.client == nil {
|
||||
return "", fmt.Errorf("Redis 客户端未连接")
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("Stream 字段不能为空")
|
||||
}
|
||||
if id == "" {
|
||||
id = "*"
|
||||
}
|
||||
|
||||
values := make(map[string]interface{}, len(fields))
|
||||
for field, value := range fields {
|
||||
values[field] = value
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
newID, err := r.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: key,
|
||||
ID: id,
|
||||
Values: values,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
// StreamDelete deletes entries from a stream by IDs
|
||||
func (r *RedisClientImpl) StreamDelete(key string, ids ...string) (int64, error) {
|
||||
if r.client == nil {
|
||||
return 0, fmt.Errorf("Redis 客户端未连接")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, fmt.Errorf("Stream ID 不能为空")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.XDel(ctx, key, ids...).Result()
|
||||
}
|
||||
|
||||
func toStreamEntries(messages []redis.XMessage) []StreamEntry {
|
||||
entries := make([]StreamEntry, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
fields := make(map[string]string, len(msg.Values))
|
||||
for field, value := range msg.Values {
|
||||
fields[field] = fmt.Sprint(value)
|
||||
}
|
||||
entries = append(entries, StreamEntry{
|
||||
ID: msg.ID,
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ExecuteCommand executes a raw Redis command
|
||||
func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
|
||||
if r.client == nil {
|
||||
|
||||
Reference in New Issue
Block a user