feat(query): sql workspace conveniences per opsxdb patterns

- generic ContextMenu component (viewport flip, esc/outside close)
- SchemaSidebar table right-click menu: preview data, generate
  SELECT/INSERT/UPDATE/DELETE templates with pk-aware WHERE, copy table
  name; double-click table runs preview SELECT; column right-click
  copy/insert; dialect-aware quoting; preview SELECT carries no LIMIT
- SqlEditor selection-first execution (Mod-Enter runs selection else
  statement at cursor via quote/comment-aware splitter); pagination
  replays last executed statement
- query history persisted server-side: drawer fetches query-histories
  with keyword/mine/favorites filters, star toggle (new PUT favorite +
  mine filter endpoints), insert and insert-and-run
- custom templates in localStorage alongside built-ins
- unit tests: genSql + statement splitter (28 tests green)
This commit is contained in:
Logan.Li committed 2026-10-26 18:53:02 +08:00
1 parent ff539b47e0
commit f6d636fa44
16 files changed
+1392 -457

No files matched your search

+9 -6
View File
@@ -4,12 +4,15 @@ export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) =>
request.post<any, any>('/query', data),
formatSQL: (sql: string) =>
request.post<any, any>('/sql-assistant/format', { sql }),
formatSQL: (sql: string) => request.post<any, any>('/sql-assistant/format', { sql }),
analyzeSQL: (sql: string, db_type: string) =>
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
analyzeSQL: (sql: string, db_type: string) => request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
getDBTypeDetail: (dbType: string) =>
request.get<any, any>('/db-types/' + dbType),
getDBTypeDetail: (dbType: string) => request.get<any, any>('/db-types/' + dbType),
/** 查询历史(后端每次执行自动落库;mine=true 只看自己) */
getHistories: (mine?: boolean) => request.get<any, any>('/query-histories', { params: mine ? { mine: 1 } : {} }),
/** 收藏/取消收藏 */
toggleHistoryFavorite: (id: number) => request.put<any, any>(`/query-histories/${id}/favorite`),
};
+99
View File
@@ -0,0 +1,99 @@
/**
* 通用右键菜单:视口边界翻转、Esc/外点关闭、分隔线与危险项。
* 用途:SchemaSidebar 表/列右键等;必需 props:items;已知限制:不支持子菜单(用扁平分组替代)。
*/
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
export interface ContextMenuItem {
label: string;
onClick?: () => void;
danger?: boolean;
disabled?: boolean;
separatorBefore?: boolean;
}
interface Props {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let left = x;
let top = y;
if (left + rect.width > window.innerWidth - 8) left = Math.max(8, x - rect.width);
if (top + rect.height > window.innerHeight - 8) top = Math.max(8, y - rect.height);
setPos({ left, top });
}, [x, y]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
window.addEventListener('keydown', onKey);
// 用 capture 阶段监听下一次 mousedown,确保菜单内点击前的外点也能关掉
window.addEventListener('mousedown', onDown, true);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown, true);
};
}, [onClose]);
return (
<div
ref={ref}
className="fixed z-50 min-w-[160px] rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] py-1 shadow-lg"
style={{ left: pos.left, top: pos.top }}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item) => (
<div key={item.label}>
{item.separatorBefore && <div className="my-1 h-px bg-[var(--dg-border-light)]" />}
<button
type="button"
disabled={item.disabled}
onClick={() => {
item.onClick?.();
onClose();
}}
className={`block w-full px-3 py-1.5 text-left text-xs transition-colors disabled:opacity-40 ${
item.danger
? 'text-red-400 hover:bg-red-500/10'
: 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{item.label}
</button>
</div>
))}
</div>
);
}
/** 便捷挂载:返回 {open, menu} — open(e, items) 阻止默认菜单,menu 渲染 */
export function useContextMenu(): {
open: (e: React.MouseEvent, items: ContextMenuItem[]) => void;
menu: ReactNode;
} {
const [state, setState] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
const open = (e: React.MouseEvent, items: ContextMenuItem[]) => {
e.preventDefault();
e.stopPropagation();
setState({ x: e.clientX, y: e.clientY, items });
};
const menu = state ? (
<ContextMenu x={state.x} y={state.y} items={state.items} onClose={() => setState(null)} />
) : null;
return { open, menu };
}
+55 -1
View File
@@ -14,6 +14,15 @@ import {
import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis';
import { RedisKeyBrowser } from '@/features/redis/keyBrowser/RedisKeyBrowser';
import { useContextMenu } from '@/components/ContextMenu';
import {
previewSelectSql,
generateSelectSql,
generateInsertSql,
generateUpdateSql,
generateDeleteSql,
type SqlDialect,
} from '@/features/query/genSql';
import { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser';
/* ------------------------------------------------------------------ */
@@ -27,6 +36,8 @@ interface Props {
/** Redis 集群模式为 true */
clusterMode?: boolean;
onInsert: (text: string) => void;
/** 双击表/「查看数据」:生成 SELECT 并直接执行 */
onExecuteSql?: (sql: string) => void;
onDatabaseChange?: (db: string) => void;
/** 为 true 时只显示当前选中的库,不允许切换(数据变更场景防误操作) */
singleDb?: boolean;
@@ -61,6 +72,7 @@ export default function SchemaSidebar({
dbType,
clusterMode,
onInsert,
onExecuteSql,
onDatabaseChange,
singleDb,
}: Props) {
@@ -82,6 +94,27 @@ export default function SchemaSidebar({
// -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) --
const [redisDb, setRedisDb] = useState(0);
const redisDbs = useRedisDbs(Number(instanceId));
const ctxMenu = useContextMenu();
const dialect: SqlDialect = dbType === 'postgresql' ? 'postgresql' : 'mysql';
/** 表右键菜单项(已展开过的表可用全列模板,否则降级为无列模板) */
const tableMenuItems = (dbName: string, tableName: string) => {
const cols = expandedTable === `${dbName}.${tableName}` ? tableColumns : [];
const gen = (fn: () => string) => () => onInsert(fn());
return [
{ label: '查看数据(前 100 行)', onClick: () => onExecuteSql?.(previewSelectSql(tableName, dialect, dbName)) },
{
label: '生成 SELECT',
onClick: gen(() => generateSelectSql(tableName, cols, dialect, dbName)),
separatorBefore: true,
},
{ label: '生成 INSERT', onClick: gen(() => generateInsertSql(tableName, cols, dialect, dbName)) },
{ label: '生成 UPDATE', onClick: gen(() => generateUpdateSql(tableName, cols, dialect, dbName)) },
{ label: '生成 DELETE', onClick: gen(() => generateDeleteSql(tableName, cols, dialect, dbName)), danger: true },
{ label: '复制表名', onClick: () => navigator.clipboard?.writeText(tableName), separatorBefore: true },
{ label: '复制限定表名', onClick: () => navigator.clipboard?.writeText(`${dbName}.${tableName}`) },
];
};
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyDetail, setKeyDetail] = useState<any>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
@@ -380,8 +413,10 @@ export default function SchemaSidebar({
handleExpandTable(dbName, t.name);
}
}}
onDoubleClick={() => onExecuteSql?.(previewSelectSql(t.name, dialect, dbName))}
onContextMenu={(e) => ctxMenu.open(e, tableMenuItems(dbName, t.name))}
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
title={`单击展开/折叠 · Alt+点击插入表名`}
title={`单击展开/折叠 · 双击查看数据 · Alt+点击插入表名 · 右键更多`}
>
{isTableExpanded ? (
<ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
@@ -440,6 +475,24 @@ export default function SchemaSidebar({
<button
key={col.Field}
onClick={() => onInsert(`${t.name}.${col.Field}`)}
onContextMenu={(e) =>
ctxMenu.open(e, [
{
label: '复制列名',
onClick: () => navigator.clipboard?.writeText(col.Field),
},
{
label: `复制 ${t.name}.${col.Field}`,
onClick: () =>
navigator.clipboard?.writeText(`${t.name}.${col.Field}`),
},
{
label: '插入列名',
onClick: () => onInsert(col.Field),
separatorBefore: true,
},
])
}
className="flex items-center gap-1.5 w-full px-2 py-0.5 text-[10px] hover:bg-[var(--dg-bg-hover)] transition-colors"
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
>
@@ -594,6 +647,7 @@ export default function SchemaSidebar({
</div>
)}
</div>
{ctxMenu.menu}
</div>
);
}
+512 -225
View File
@@ -6,11 +6,7 @@ import { sql, SQLDialect } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import {
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
} from '@codemirror/language';
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
@@ -35,45 +31,206 @@ export interface SqlEditorProps {
// ---------------------------------------------------------------------------
const REDIS_KEYWORDS = [
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET',
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD',
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN',
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER',
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL',
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL',
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT',
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH',
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE',
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN',
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX',
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE',
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF',
'GET',
'SET',
'KEYS',
'HGET',
'HSET',
'HGETALL',
'HDEL',
'HMSET',
'ZADD',
'ZRANGE',
'ZREM',
'ZRANK',
'ZSCORE',
'ZCARD',
'LPUSH',
'RPUSH',
'LPOP',
'RPOP',
'LRANGE',
'LLEN',
'SADD',
'SMEMBERS',
'SREM',
'SCARD',
'SISMEMBER',
'INFO',
'CLIENT',
'CONFIG',
'DEL',
'EXISTS',
'EXPIRE',
'TTL',
'TYPE',
'RENAME',
'DBSIZE',
'FLUSHDB',
'FLUSHALL',
'PING',
'ECHO',
'SELECT',
'MOVE',
'OBJECT',
'SORT',
'MULTI',
'EXEC',
'DISCARD',
'WATCH',
'UNWATCH',
'PUBLISH',
'SUBSCRIBE',
'UNSUBSCRIBE',
'PSUBSCRIBE',
'SCAN',
'HSCAN',
'SSCAN',
'ZSCAN',
'INCR',
'DECR',
'INCRBY',
'DECRBY',
'INCRBYFLOAT',
'APPEND',
'STRLEN',
'MGET',
'MSET',
'SETNX',
'SETEX',
'AUTH',
'QUIT',
'BGSAVE',
'SAVE',
'LASTSAVE',
'SLOWLOG',
'MONITOR',
'DEBUG',
'SHUTDOWN',
'SLAVEOF',
'REPLICAOF',
];
const MONGODB_KEYWORDS = [
// CRUD
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany',
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete',
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct',
'find',
'findOne',
'insertOne',
'insertMany',
'updateOne',
'updateMany',
'deleteOne',
'deleteMany',
'replaceOne',
'findOneAndUpdate',
'findOneAndDelete',
'findOneAndReplace',
'bulkWrite',
'aggregate',
'countDocuments',
'distinct',
// Aggregation stages
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup',
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount',
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count',
'match',
'group',
'project',
'sort',
'limit',
'skip',
'unwind',
'lookup',
'addFields',
'set',
'unset',
'facet',
'bucket',
'bucketAuto',
'sortByCount',
'graphLookup',
'out',
'merge',
'replaceRoot',
'sample',
'count',
// Operators
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not',
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then',
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays',
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last',
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'in',
'nin',
'and',
'or',
'not',
'nor',
'exists',
'type',
'regex',
'expr',
'cond',
'switch',
'if',
'then',
'else',
'let',
'map',
'filter',
'reduce',
'range',
'concatArrays',
'avg',
'sum',
'min',
'max',
'push',
'addToSet',
'first',
'last',
// Index / admin
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint',
'createIndex',
'dropIndex',
'getIndexes',
'explain',
'hint',
// Collection / DB
'use', 'show', 'db', 'collection', 'collections', 'databases',
'use',
'show',
'db',
'collection',
'collections',
'databases',
// JS keywords commonly used
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while',
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof',
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal',
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
'function',
'return',
'var',
'let',
'const',
'if',
'else',
'for',
'while',
'true',
'false',
'null',
'undefined',
'new',
'this',
'typeof',
'instanceof',
'ObjectId',
'Date',
'ISODate',
'NumberLong',
'NumberInt',
'NumberDecimal',
'Regex',
'BinData',
'Timestamp',
'MinKey',
'MaxKey',
'Symbol',
'DBRef',
];
function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension {
@@ -102,9 +259,7 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
if (Array.isArray(sampleValue)) {
// Direct: { table: [columns] }
tables = Object.fromEntries(
Object.entries(schema).map(([k, v]) => [k, v as string[]])
);
tables = Object.fromEntries(Object.entries(schema).map(([k, v]) => [k, v as string[]]));
} else {
// Nested: { db: { table: [columns] } }
// Register: db → table names (so "db." shows tables)
@@ -124,7 +279,8 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
}
const dialect = SQLDialect.define({
keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
keywords:
'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
...(tables ? { schema: tables } : {}),
} as any);
return sql({ dialect });
@@ -222,20 +378,103 @@ const SQL_BREAK_KEYWORDS = [
];
const SQL_KEYWORDS_UPPER = new Set([
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON',
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS',
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT',
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER',
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION',
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT',
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN',
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE',
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON',
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE',
'SELECT',
'FROM',
'WHERE',
'ORDER',
'BY',
'GROUP',
'HAVING',
'LIMIT',
'OFFSET',
'JOIN',
'INNER',
'LEFT',
'RIGHT',
'FULL',
'OUTER',
'CROSS',
'ON',
'AND',
'OR',
'NOT',
'IN',
'IS',
'NULL',
'LIKE',
'BETWEEN',
'AS',
'ASC',
'DESC',
'DISTINCT',
'ALL',
'CASE',
'WHEN',
'THEN',
'ELSE',
'END',
'EXISTS',
'UNION',
'INTERSECT',
'EXCEPT',
'INSERT',
'INTO',
'VALUES',
'UPDATE',
'SET',
'DELETE',
'CREATE',
'ALTER',
'DROP',
'TABLE',
'INDEX',
'VIEW',
'TRIGGER',
'PROCEDURE',
'FUNCTION',
'PRIMARY',
'KEY',
'FOREIGN',
'REFERENCES',
'UNIQUE',
'CHECK',
'DEFAULT',
'GRANT',
'REVOKE',
'COMMIT',
'ROLLBACK',
'BEGIN',
'TRANSACTION',
'IF',
'ELSE',
'WHILE',
'FOR',
'RETURN',
'COUNT',
'SUM',
'AVG',
'MIN',
'MAX',
'TRUE',
'FALSE',
'BOOLEAN',
'INTEGER',
'BIGINT',
'FLOAT',
'DOUBLE',
'VARCHAR',
'TEXT',
'DATE',
'TIMESTAMP',
'BLOB',
'JSON',
'TRUNCATE',
'EXPLAIN',
'ANALYZE',
'SHOW',
'DESCRIBE',
'USE',
'DATABASE',
]);
export function formatSQL(input: string): string {
@@ -270,190 +509,238 @@ export function formatSQL(input: string): string {
export interface SqlEditorHandle {
/** Insert text at cursor position and move cursor after inserted text */
insertText: (text: string) => void;
/** 选区优先,否则光标所在语句;与全文相同时返回 null(调用方直接用全文) */
getSqlToRun: () => string | null;
}
const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(({
value,
onChange,
placeholder = 'Enter SQL query…',
language = 'sql',
height = '200px',
readOnly = false,
onExecute,
schema,
}, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const langCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const placeholderCompartmentRef = useRef(new Compartment());
const isExternalUpdateRef = useRef(false);
/** 按分号切分语句(单/双引号与行注释感知),返回每段的 [start, end) 与文本 */
export function splitSqlStatements(text: string): Array<{ start: number; end: number; text: string }> {
const out: Array<{ start: number; end: number; text: string }> = [];
let start = 0;
let quote: string | null = null;
let lineComment = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (lineComment) {
if (ch === '\n') lineComment = false;
continue;
}
if (quote) {
if (ch === '\\') {
i++;
continue;
}
if (ch === quote) quote = null;
continue;
}
if (ch === '-' && next === '-') {
lineComment = true;
i++;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
quote = ch;
continue;
}
if (ch === ';') {
out.push({ start, end: i, text: text.slice(start, i) });
start = i + 1;
}
}
if (start < text.length) out.push({ start, end: text.length, text: text.slice(start) });
return out;
}
// Expose insertText to parent via ref
useImperativeHandle(ref, () => ({
insertText: (text: string) => {
const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
(
{
value,
onChange,
placeholder = 'Enter SQL query…',
language = 'sql',
height = '200px',
readOnly = false,
onExecute,
schema,
},
ref,
) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const langCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const placeholderCompartmentRef = useRef(new Compartment());
const isExternalUpdateRef = useRef(false);
// Expose insertText to parent via ref
useImperativeHandle(
ref,
() => ({
insertText: (text: string) => {
const view = viewRef.current;
if (!view) return;
const pos = view.state.selection.main.head;
view.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.length },
});
view.focus();
},
getSqlToRun: (): string | null => {
const view = viewRef.current;
if (!view) return null;
const sel = view.state.selection.main;
const selected = view.state.sliceDoc(sel.from, sel.to).trim();
if (selected) return selected;
const doc = view.state.doc.toString();
const statements = splitSqlStatements(doc).filter((st) => st.text.trim());
if (statements.length <= 1) return null; // 单语句/空:用全文
const cursor = sel.head;
const stmt = statements.find((st) => cursor > st.start && cursor <= st.end + 1);
if (!stmt) return null;
const fragment = stmt.text.trim();
return fragment && fragment !== doc.trim() ? fragment : null;
},
}),
[],
);
// Keep onChange in a stable ref so callbacks never go stale
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Ctrl+Enter handler
const runKeymap = useCallback(
(): Extension[] => [
keymap.of([
{
key: 'Mod-Enter',
run: () => {
if (onExecute) {
onExecute();
} else {
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
}
return true;
},
},
{
key: 'Mod-Shift-f',
run: (view: EditorView) => {
const current = view.state.doc.toString();
const formatted = formatSQL(current);
if (formatted !== current) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
});
}
return true;
},
},
]),
],
[onExecute],
);
// Create editor once
useEffect(() => {
if (!containerRef.current) return;
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdateRef.current) {
onChangeRef.current(update.state.doc.toString());
}
});
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
autocompletion({
activateOnTyping: true,
icons: false,
}),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
langCompartmentRef.current.of(getSQLExtension(language, schema)),
readOnlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
updateListener,
...runKeymap(),
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap]),
EditorView.lineWrapping,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// We intentionally only run this once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes into the editor (avoid loops)
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const pos = view.state.selection.main.head;
view.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.length },
});
view.focus();
},
}), []);
// Keep onChange in a stable ref so callbacks never go stale
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Ctrl+Enter handler
const runKeymap = useCallback(
(): Extension[] => [
keymap.of([
{
key: 'Mod-Enter',
run: () => {
if (onExecute) {
onExecute();
} else {
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
}
return true;
},
},
{
key: 'Mod-Shift-f',
run: (view: EditorView) => {
const current = view.state.doc.toString();
const formatted = formatSQL(current);
if (formatted !== current) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
});
}
return true;
},
},
]),
],
[onExecute],
);
// Create editor once
useEffect(() => {
if (!containerRef.current) return;
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdateRef.current) {
onChangeRef.current(update.state.doc.toString());
const currentContent = view.state.doc.toString();
if (currentContent !== value) {
isExternalUpdateRef.current = true;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
isExternalUpdateRef.current = false;
}
});
}, [value]);
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
autocompletion({
activateOnTyping: true,
icons: false,
}),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
langCompartmentRef.current.of(getSQLExtension(language, schema)),
readOnlyCompartmentRef.current.of(
EditorState.readOnly.of(readOnly),
),
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
updateListener,
...runKeymap(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// We intentionally only run this once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes into the editor (avoid loops)
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentContent = view.state.doc.toString();
if (currentContent !== value) {
isExternalUpdateRef.current = true;
// Update language/schema when they change
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
});
isExternalUpdateRef.current = false;
}
}, [value]);
}, [language, schema]);
// Update language/schema when they change
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
});
}, [language, schema]);
// Update readOnly when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: readOnlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
});
}, [readOnly]);
// Update readOnly when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: readOnlyCompartmentRef.current.reconfigure(
EditorState.readOnly.of(readOnly),
),
});
}, [readOnly]);
// Update placeholder when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: placeholderCompartmentRef.current.reconfigure(cmPlaceholder(placeholder ?? '')),
});
}, [placeholder]);
// Update placeholder when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: placeholderCompartmentRef.current.reconfigure(
cmPlaceholder(placeholder ?? ''),
),
});
}, [placeholder]);
return (
<div
ref={containerRef}
style={{ height, minHeight: height }}
className="sql-editor-wrapper"
/>
);
});
return <div ref={containerRef} style={{ height, minHeight: height }} className="sql-editor-wrapper" />;
},
);
export default SqlEditor;
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import {
primaryColumns,
previewSelectSql,
generateSelectSql,
generateInsertSql,
generateUpdateSql,
generateDeleteSql,
} from '@/features/query/genSql';
import { splitSqlStatements } from '@/components/SqlEditor';
const cols = [
{ Field: 'id', Type: 'int', Key: 'PRI' },
{ Field: 'name', Type: 'varchar(50)' },
{ Field: 'org_id', Type: 'int', Key: 'PRI' },
];
const idx = [
{ Key_name: 'PRIMARY', Column_name: 'id' },
{ Key_name: 'PRIMARY', Column_name: 'org_id' },
];
describe('primaryColumns', () => {
it('索引推导复合主键', () => {
expect(primaryColumns(cols, idx)).toEqual(['id', 'org_id']);
});
it('无索引时退化为 Key=PRI', () => {
expect(primaryColumns(cols, undefined)).toEqual(['id', 'org_id']);
});
it('全无主键信息返回空', () => {
expect(primaryColumns([{ Field: 'a' }], [])).toEqual([]);
});
});
describe('方言引号', () => {
it('MySQL 反引号 / PG 双引号', () => {
expect(previewSelectSql('t1', 'mysql')).toBe('SELECT * FROM `t1`');
expect(previewSelectSql('t1', 'postgresql')).toContain('"t1"');
});
it('预览 SELECT 不带 LIMIT(翻页由请求层承担)', () => {
const sql = previewSelectSql('t1', 'mysql', 'db1');
expect(sql).toBe('SELECT * FROM `db1`.`t1`');
expect(sql.toLowerCase()).not.toContain('limit');
});
});
describe('生成 SQL 模板', () => {
it('SELECT 全列展开 + 复合主键 WHERE', () => {
const sql = generateSelectSql('users', cols, 'mysql', 'app');
expect(sql).toContain('`id`, `name`, `org_id`');
expect(sql).toContain('`id` = ? AND `org_id` = ?');
});
it('INSERT 占位符对齐', () => {
const sql = generateInsertSql('users', cols, 'mysql');
expect(sql).toContain('(`id`, `name`, `org_id`)');
expect(sql).toContain('(?, ?, ?)');
});
it('UPDATE SET 全列 + 主键 WHERE;无主键退化为首列', () => {
const sql = generateUpdateSql('users', cols, 'postgresql');
expect(sql).toContain('"id" = ?, "name" = ?, "org_id" = ?');
expect(sql).toContain('WHERE "id" = ? AND "org_id" = ?');
const noPk = generateUpdateSql('t', [{ Field: 'x' }, { Field: 'y' }], 'mysql');
expect(noPk).toContain('WHERE `x` = ?');
});
it('DELETE 主键 WHERE', () => {
expect(generateDeleteSql('users', cols, 'mysql')).toContain('WHERE `id` = ? AND `org_id` = ?');
});
});
describe('splitSqlStatements(选区执行用)', () => {
it('按分号切分并保留位置', () => {
const parts = splitSqlStatements('SELECT 1; SELECT 2;');
expect(parts.filter((p) => p.text.trim())).toHaveLength(2);
expect(parts[0].text).toBe('SELECT 1');
});
it('引号内分号不切分', () => {
const parts = splitSqlStatements(`SELECT 'a;b' AS x; SELECT 2`);
expect(parts.filter((p) => p.text.trim())).toHaveLength(2);
expect(parts[0].text).toContain("'a;b'");
});
it('行注释中的分号不切分', () => {
const parts = splitSqlStatements('SELECT 1 -- ; note\n; SELECT 2');
expect(parts.filter((p) => p.text.trim())).toHaveLength(2);
});
it('转义引号不终止字符串', () => {
const parts = splitSqlStatements(`SELECT 'it\\'s; fine'; SELECT 2`);
expect(parts.filter((p) => p.text.trim())).toHaveLength(2);
});
});
@@ -1,116 +1,201 @@
/**
* 查询历史抽屉:数据源为后端 query_histories(每次执行自动落库)。
* 必需 props:onClose/onUseSql/refreshKey;已知限制:后端保留最近 100 条。
*/
import { useCallback, useEffect, useState } from 'react';
import {
CheckCircle,
ClipboardPaste,
History,
Loader2,
PanelRightClose,
Play,
RefreshCw,
Search,
Star,
StarOff,
X,
User,
} from 'lucide-react';
import type { QueryHistoryItem } from '../types';
import { queryApi } from '@/api/query';
import { dbTypeIcon } from '../constants';
import type { QueryHistoryRecord } from '../types';
interface QueryHistoryDrawerProps {
history: QueryHistoryItem[];
filteredHistory: QueryHistoryItem[];
favFilter: boolean;
searchHistory: string;
clearConfirm: boolean;
dbTypeIcon: Record<string, string>;
onClose: () => void;
onSearchChange: (value: string) => void;
onToggleFavoriteFilter: () => void;
onClearHistory: () => void;
onToggleFavorite: (id: number) => void;
onUseSql: (sql: string) => void;
/** 回填编辑器;run=true 时同时执行 */
onUseSql: (sql: string, run?: boolean) => void;
/** 每次执行成功后 +1,触发重拉 */
refreshKey: number;
}
export function QueryHistoryDrawer({
history,
filteredHistory,
favFilter,
searchHistory,
clearConfirm,
dbTypeIcon,
onClose,
onSearchChange,
onToggleFavoriteFilter,
onClearHistory,
onToggleFavorite,
onUseSql,
}: QueryHistoryDrawerProps) {
export function QueryHistoryDrawer({ onClose, onUseSql, refreshKey }: QueryHistoryDrawerProps) {
const [items, setItems] = useState<QueryHistoryRecord[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [mineOnly, setMineOnly] = useState(false);
const [favOnly, setFavOnly] = useState(false);
const fetchList = useCallback((mine: boolean) => {
setLoading(true);
queryApi
.getHistories(mine)
.then((res) => {
setItems(Array.isArray(res) ? res : []);
})
.catch(() => {
/* 拦截器已提示 */
})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchList(mineOnly);
}, [fetchList, mineOnly, refreshKey]);
const toggleFavorite = (id: number) => {
setItems((prev) => prev.map((h) => (h.id === id ? { ...h, is_favorite: !h.is_favorite } : h)));
queryApi.toggleHistoryFavorite(id).catch(() => fetchList(mineOnly));
};
const kw = keyword.trim().toLowerCase();
const filtered = items.filter((h) => {
if (favOnly && !h.is_favorite) return false;
if (!kw) return true;
return (
h.query_content.toLowerCase().includes(kw) ||
(h.instance_name || '').toLowerCase().includes(kw) ||
(h.database_name || '').toLowerCase().includes(kw)
);
});
return (
<div className="w-[300px] shrink-0 border-l border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
查询历史
{history.length > 0 && <span className="text-xs dg-text-muted font-normal">({history.length})</span>}
{items.length > 0 && <span className="text-xs dg-text-muted font-normal">({items.length})</span>}
</span>
<button onClick={onClose} className="dg-btn dg-btn-ghost dg-btn-sm p-1">
<PanelRightClose className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => fetchList(mineOnly)}
disabled={loading}
className="dg-btn dg-btn-ghost dg-btn-sm p-1"
title="刷新"
>
<RefreshCw className={'w-4 h-4 ' + (loading ? 'animate-spin' : '')} />
</button>
<button onClick={onClose} className="dg-btn dg-btn-ghost dg-btn-sm p-1">
<PanelRightClose className="w-4 h-4" />
</button>
</div>
</div>
{history.length > 0 && (
<div className="px-4 pb-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史..."
value={searchHistory}
onChange={function (e) { onSearchChange(e.target.value); }}
/>
</div>
<div className="flex items-center gap-2">
<button onClick={onToggleFavoriteFilter}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (favFilter ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : '')}>
<Star className="w-3 h-3" />仅收藏
</button>
<div className="flex-1" />
<button onClick={onClearHistory}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (clearConfirm ? 'text-red-400 bg-red-500/10' : '')}>
{clearConfirm ? <CheckCircle className="w-3 h-3" /> : <X className="w-3 h-3" />}
{clearConfirm ? '确认清除' : '清除'}
</button>
</div>
<div className="px-4 pb-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史(语句/实例/库)..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={() => setMineOnly((v) => !v)}
className={
'dg-btn dg-btn-ghost dg-btn-sm ' + (mineOnly ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : '')
}
title="只看自己的历史"
>
<User className="w-3 h-3" />
仅我的
</button>
<button
onClick={() => setFavOnly((v) => !v)}
className={
'dg-btn dg-btn-ghost dg-btn-sm ' +
(favOnly ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : '')
}
>
<Star className="w-3 h-3" />
仅收藏
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div>
) : filteredHistory.length === 0 ? (
{loading && items.length === 0 ? (
<div className="flex items-center justify-center gap-2 p-6 text-xs dg-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
加载中...
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史,执行查询后自动记录</div>
) : filtered.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
) : (
<div className="space-y-1.5 px-3 py-2">
{filteredHistory.map(function (h) {
return (
<div key={h.id} className={'dg-card px-3 py-2.5 group ' + (h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : '')}>
<div className="flex items-center gap-2 mb-1">
{h.db_type && dbTypeIcon[h.db_type] && <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />}
<span className={'text-[10px] px-1.5 py-0.5 rounded-full ' + (h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400')}>
{h.execution_time_ms}ms
</span>
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span>
<div className="flex-1" />
<button onClick={function () { onToggleFavorite(h.id); }}
className={'p-0.5 rounded transition-colors ' + (h.favorite ? 'text-yellow-400' : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400')}>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center gap-1">
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<button onClick={function () { onUseSql(h.sql); }}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
title="粘贴到编辑器">
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
{filtered.map((h) => (
<div
key={h.id}
className={'dg-card px-3 py-2.5 group ' + (h.is_favorite ? 'bg-yellow-500/5 border-yellow-500/20' : '')}
>
<div className="flex items-center gap-2 mb-1">
{h.db_type && dbTypeIcon[h.db_type] && (
<img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />
)}
<span className="text-[10px] dg-text-muted truncate">
{h.instance_name}
{h.database_name ? ` · ${h.database_name}` : ''}
</span>
<div className="flex-1" />
<button
onClick={() => toggleFavorite(h.id)}
className={
'p-0.5 rounded transition-colors ' +
(h.is_favorite
? 'text-yellow-400'
: 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400')
}
title={h.is_favorite ? '取消收藏' : '收藏'}
>
<Star className={'w-3 h-3 ' + (h.is_favorite ? 'fill-current' : '')} />
</button>
</div>
);
})}
<div className="flex items-center gap-1">
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate" title={h.query_content}>
{h.query_content.substring(0, 80)}
</code>
<button
onClick={() => onUseSql(h.query_content)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
title="粘贴到编辑器"
>
<ClipboardPaste className="w-3 h-3" />
</button>
<button
onClick={() => onUseSql(h.query_content, true)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 text-green-400 opacity-0 group-hover:opacity-100"
title="回填并执行"
>
<Play className="w-3 h-3" />
</button>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] dg-text-muted">
<span
className={
'px-1.5 py-0.5 rounded-full ' +
(h.is_success ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400')
}
>
{h.is_success ? <CheckCircle className="w-2.5 h-2.5 inline mr-0.5" /> : null}
{h.execution_time_ms}ms · {h.result_rows} 行
</span>
<span>{new Date(h.created_at).toLocaleString()}</span>
</div>
</div>
))}
</div>
)}
</div>
@@ -1,6 +1,32 @@
import { Search } from 'lucide-react';
import { useState } from 'react';
import { Plus, Search, Star, Trash2, X } from 'lucide-react';
import { readStorage, writeStorage } from '@/lib/storage';
import type { QueryTemplate } from '@/lib/queryTemplates';
/** 自定义模板(localStorage 持久化,opsxsql.query.customTemplates) */
export interface CustomTemplate {
name: string;
sql: string;
}
export function loadCustomTemplates(): CustomTemplate[] {
try {
const raw = readStorage('queryCustomTemplates');
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((t) => t && t.name && t.sql) : [];
} catch {
return [];
}
}
function saveCustomTemplates(list: CustomTemplate[]): void {
try {
writeStorage('queryCustomTemplates', JSON.stringify(list));
} catch {
/* noop */
}
}
interface QueryTemplatePanelProps {
categories: string[];
activeCategory: string;
@@ -22,43 +48,166 @@ export function QueryTemplatePanel({
onSearchChange,
onApplyTemplate,
}: QueryTemplatePanelProps) {
const [customList, setCustomList] = useState<CustomTemplate[]>(loadCustomTemplates);
const [showEditor, setShowEditor] = useState(false);
const [draft, setDraft] = useState<CustomTemplate>({ name: '', sql: '' });
const addCustom = () => {
const name = draft.name.trim();
const sql = draft.sql.trim();
if (!name || !sql) return;
const next = [{ name, sql }, ...customList.filter((t) => t.name !== name)];
setCustomList(next);
saveCustomTemplates(next);
setDraft({ name: '', sql: '' });
setShowEditor(false);
};
const removeCustom = (name: string) => {
const next = customList.filter((t) => t.name !== name);
setCustomList(next);
saveCustomTemplates(next);
};
const applyCustom = (tpl: CustomTemplate) => {
onApplyTemplate({
id: `custom-${tpl.name}`,
name: tpl.name,
description: '自定义模板',
category: '自定义',
sql: tpl.sql,
supportedTypes: [],
} as unknown as QueryTemplate);
};
const kw = search.trim().toLowerCase();
const filteredCustom = kw
? customList.filter((t) => t.name.toLowerCase().includes(kw) || t.sql.toLowerCase().includes(kw))
: customList;
const showCustom = activeCategory === 'all' || activeCategory === '自定义';
return (
<div className="shrink-0 px-5 pb-2">
<div className="dg-card p-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
{categories.map(function (cat) {
return (
<button key={cat} onClick={function () { onCategoryChange(cat); }}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (activeCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : '')}>
<button
key={cat}
onClick={function () {
onCategoryChange(cat);
}}
className={
'dg-btn dg-btn-ghost dg-btn-sm ' +
(activeCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : '')
}
>
{categoryLabels[cat] || cat}
</button>
);
})}
<div className="flex-1" />
<button
onClick={() => setShowEditor((v) => !v)}
className="dg-btn dg-btn-ghost dg-btn-sm"
title="把常用语句存为模板"
>
{showEditor ? <X className="w-3 h-3" /> : <Plus className="w-3 h-3" />}自定义
</button>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
value={search}
onChange={function (e) { onSearchChange(e.target.value); }}
onChange={function (e) {
onSearchChange(e.target.value);
}}
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
placeholder="搜索模板..."
/>
</div>
</div>
{showEditor && (
<div className="mb-3 space-y-2 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-2.5">
<div className="flex gap-2">
<input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="模板名称(如:查订单按日)"
className="h-8 flex-1 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-2 text-xs"
/>
<button
onClick={addCustom}
disabled={!draft.name.trim() || !draft.sql.trim()}
className="dg-btn dg-btn-primary dg-btn-sm disabled:opacity-50"
>
保存
</button>
</div>
<textarea
value={draft.sql}
onChange={(e) => setDraft((d) => ({ ...d, sql: e.target.value }))}
placeholder="SQL / 命令内容(支持 {table} {db} 占位符)"
rows={3}
className="w-full rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-2 py-1.5 font-mono text-xs"
/>
</div>
)}
{showCustom && filteredCustom.length > 0 && (
<div className="mb-2 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{filteredCustom.map((tpl) => (
<div key={tpl.name} className="dg-card group flex items-center gap-2 p-2.5">
<button onClick={() => applyCustom(tpl)} className="min-w-0 flex-1 text-left" title={tpl.sql}>
<div className="flex items-center gap-2">
<span className="text-[10px] rounded bg-yellow-500/10 px-1.5 py-0.5 font-medium text-yellow-400">
自定义
</span>
<span className="truncate text-sm font-medium">{tpl.name}</span>
</div>
<p className="line-clamp-1 font-mono text-xs dg-text-muted">{tpl.sql}</p>
</button>
<button
onClick={() => removeCustom(tpl.name)}
className="dg-text-muted p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{showCustom && customList.length === 0 && !showEditor && (
<div className="mb-2 flex items-center gap-1.5 text-xs dg-text-muted">
<Star className="w-3 h-3" />
常用语句可存为自定义模板:点右上「自定义」添加
</div>
)}
{templates.length === 0 ? (
<div className="text-center py-4 text-xs dg-text-muted">无匹配模板</div>
<div className="py-4 text-center text-xs dg-text-muted">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
<div className="grid max-h-48 grid-cols-1 gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
{templates.map(function (tpl) {
return (
<button key={tpl.id} onClick={function () { onApplyTemplate(tpl); }}
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group"
title={tpl.description}>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
<button
key={tpl.id}
onClick={function () {
onApplyTemplate(tpl);
}}
className="dg-card group p-2.5 text-left hover:bg-[var(--dg-bg-hover)]"
title={tpl.description}
>
<div className="mb-0.5 flex items-center gap-2">
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
{tpl.category}
</span>
<span className="truncate text-sm font-medium transition-colors group-hover:text-blue-400">
{tpl.name}
</span>
</div>
<p className="text-xs dg-text-muted line-clamp-1">{tpl.description}</p>
<p className="line-clamp-1 text-xs dg-text-muted">{tpl.description}</p>
</button>
);
})}
@@ -1,6 +1,4 @@
import {
ChevronDown, Database, Loader2, PanelRightClose, PanelRightOpen, Play, Sparkles,
} from 'lucide-react';
import { ChevronDown, Database, Loader2, PanelRightClose, PanelRightOpen, Play, Sparkles } from 'lucide-react';
import type { DBType } from '@/lib/queryTemplates';
import { dbTypeIcon, dbTypeLabel } from '../constants';
import type { QueryInstanceOption } from '../types';
@@ -31,7 +29,6 @@ interface QueryTopBarProps {
/** 模板总数 */
templateCount: number;
/** 历史记录总数 */
historyCount: number;
onInstanceIdChange: (instanceId: string) => void;
onDatabaseChange: (database: string) => void;
onToggleSidebar: () => void;
@@ -55,7 +52,6 @@ export function QueryTopBar({
showHistory,
executing,
templateCount,
historyCount,
onInstanceIdChange,
onDatabaseChange,
onToggleSidebar,
@@ -70,10 +66,13 @@ export function QueryTopBar({
{selectedInstance && dbTypeIcon[dbType] && (
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
)}
<select value={instanceId} onChange={e => onInstanceIdChange(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
<select
value={instanceId}
onChange={(e) => onInstanceIdChange(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]"
>
<option value="">选择实例</option>
{instances.map(inst => (
{instances.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({dbTypeLabel[inst.db_type] || inst.db_type})
</option>
@@ -81,15 +80,29 @@ export function QueryTopBar({
</select>
{dbType === 'redis' ? (
<select value={database || '0'} onChange={e => onDatabaseChange(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
<select
value={database || '0'}
onChange={(e) => onDatabaseChange(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24"
>
{Array.from({ length: 16 }, (_, i) => (
<option key={i} value={String(i)}>
DB{i}
</option>
))}
</select>
) : (
<select value={database} onChange={e => onDatabaseChange(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
<select
value={database}
onChange={(e) => onDatabaseChange(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]"
>
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
{databases.map((db) => (
<option key={db} value={db}>
{db}
</option>
))}
</select>
)}
</div>
@@ -97,25 +110,31 @@ export function QueryTopBar({
{/* 右侧工具按钮组 */}
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
{instanceId && (
<button onClick={onToggleSidebar}
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
<button
onClick={onToggleSidebar}
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}
>
<Database className="w-3.5 h-3.5" />
{showSidebar ? '隐藏' : '导航'}
</button>
)}
<button onClick={onToggleTemplates}
className="dg-btn dg-btn-ghost dg-btn-sm">
<button onClick={onToggleTemplates} className="dg-btn dg-btn-ghost dg-btn-sm">
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
模板 ({templateCount})
<ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
</button>
<button onClick={onToggleHistory}
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
<button
onClick={onToggleHistory}
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}
>
{showHistory ? <PanelRightClose className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
历史 {historyCount > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{historyCount}</span>}
历史
</button>
<button onClick={onExecute} disabled={executing || !instanceId}
className="dg-btn dg-btn-primary ml-auto md:ml-0">
<button
onClick={onExecute}
disabled={executing || !instanceId}
className="dg-btn dg-btn-primary ml-auto md:ml-0"
>
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{executing ? '执行中...' : '执行'}
</button>
@@ -1,5 +1,6 @@
import type { Ref } from 'react';
import { AlertTriangle } from 'lucide-react';
import SqlEditor from '@/components/SqlEditor';
import SqlEditor, { type SqlEditorHandle } from '@/components/SqlEditor';
import type { DBType } from '@/lib/queryTemplates';
import { dbTypeIcon, dbTypeLabel, placeholderMap, queryPolicyText } from '../constants';
@@ -13,8 +14,10 @@ interface SqlEditorSectionProps {
/** 库表结构(仅 MySQL/PG 传入编辑器) */
schema: Record<string, Record<string, string[]>>;
onSqlChange: (sql: string) => void;
/** 执行查询(Ctrl+Enter 触发) */
/** 执行查询(Ctrl+Enter 触发;执行范围为选区优先) */
onExecute: () => void;
/** 透传编辑器句柄(选区/光标语句获取) */
editorRef?: Ref<SqlEditorHandle>;
}
/** SQL 编辑器卡片:编辑器主体 + 模式/字数 footer + 查询策略提示 */
@@ -25,21 +28,33 @@ export function SqlEditorSection({
schema,
onSqlChange,
onExecute,
editorRef,
}: SqlEditorSectionProps) {
return (
<div className="shrink-0 p-0 md:p-4 pb-2">
<div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={onSqlChange} placeholder={placeholderMap[dbType]}
language={editorLang} height="200px" onExecute={onExecute}
schema={(dbType === 'mysql' || dbType === 'postgresql') ? schema : undefined} />
<SqlEditor
ref={editorRef}
value={sql}
onChange={onSqlChange}
placeholder={placeholderMap[dbType]}
language={editorLang}
height="200px"
onExecute={onExecute}
schema={dbType === 'mysql' || dbType === 'postgresql' ? schema : undefined}
/>
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
<span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
</span>
<div className="hidden sm:flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
Ctrl+Enter 执行
</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
Ctrl+Shift+F 格式化
</span>
</div>
</div>
<div className="px-3 py-2 border-t border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[11px] text-[var(--dg-text-muted)] flex items-start gap-2">
+93
View File
@@ -0,0 +1,93 @@
/**
* 方言化 SQL 生成器(对标 opsxdb buildGenSql):表/列信息 → SELECT/INSERT/UPDATE/DELETE 模板。
* 复合主键自动进 WHERE;无主键时退化为首列等值条件。
*/
export interface ColumnMeta {
Field: string;
Type?: string;
Key?: string;
}
/** 从索引信息推导主键列(MySQL PRIMARY / PG *_pkey) */
export function primaryColumns(
columns: ColumnMeta[],
indexes?: Array<{ Key_name: string; Column_name: string }>,
): string[] {
const byIndex = (indexes || [])
.filter((idx) => idx.Key_name === 'PRIMARY' || idx.Key_name.endsWith('_pkey'))
.map((idx) => idx.Column_name);
if (byIndex.length > 0) return byIndex;
return columns.filter((c) => c.Key === 'PRI').map((c) => c.Field);
}
function quote(name: string, dialect: 'mysql' | 'postgresql'): string {
return dialect === 'mysql' ? `\`${name}\`` : `"${name}"`;
}
export type SqlDialect = 'mysql' | 'postgresql' | 'mongodb' | 'redis';
export function previewSelectSql(table: string, dialect: SqlDialect, database?: string): string {
const q = (n: string) => quote(n, dialect === 'postgresql' ? 'postgresql' : 'mysql');
const target = database ? `${q(database)}.${q(table)}` : q(table);
// 不烧 LIMIT:翻页由请求层 offset/limit 承担(backend 自动加 LIMIT)
return `SELECT * FROM ${target}`;
}
export function generateSelectSql(
table: string,
columns: ColumnMeta[],
dialect: SqlDialect,
database?: string,
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const cols = columns.length > 0 ? columns.map((c) => q(c.Field)).join(', ') : '*';
const pks = primaryColumns(columns);
const where = pks.length > 0 ? `\nWHERE ${pks.map((p) => `${q(p)} = ?`).join(' AND ')}` : '';
return `SELECT ${cols}\nFROM ${target}${where};`;
}
export function generateInsertSql(
table: string,
columns: ColumnMeta[],
dialect: SqlDialect,
database?: string,
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const cols = columns.map((c) => q(c.Field));
const placeholders = columns.map(() => '?');
if (cols.length === 0) return `INSERT INTO ${target} ...;`;
return `INSERT INTO ${target} (${cols.join(', ')})\nVALUES (${placeholders.join(', ')});`;
}
export function generateUpdateSql(
table: string,
columns: ColumnMeta[],
dialect: SqlDialect,
database?: string,
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const setCols = columns.length > 0 ? columns : [{ Field: 'column1' } as ColumnMeta];
const pks = primaryColumns(columns);
const whereCols = pks.length > 0 ? pks : [setCols[0].Field];
return `UPDATE ${target}\nSET ${setCols.map((c) => `${q(c.Field)} = ?`).join(', ')}\nWHERE ${whereCols.map((p) => `${q(p)} = ?`).join(' AND ')};`;
}
export function generateDeleteSql(
table: string,
columns: ColumnMeta[],
dialect: SqlDialect,
database?: string,
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const pks = primaryColumns(columns);
const whereCols = pks.length > 0 ? pks : columns[0] ? [columns[0].Field] : ['id'];
return `DELETE FROM ${target}\nWHERE ${whereCols.map((p) => `${q(p)} = ?`).join(' AND ')};`;
}
+18
View File
@@ -1,3 +1,21 @@
/** 查询历史(后端 query_histories 表字段) */
export interface QueryHistoryRecord {
id: number;
user_id: number;
username: string;
instance_id: number;
instance_name: string;
db_type: string;
database_name: string;
query_content: string;
query_type: string;
execution_time_ms: number;
result_rows: number;
is_success: boolean;
is_favorite: boolean;
created_at: string;
}
export interface QueryHistoryItem {
id: number;
sql: string;
+1
View File
@@ -36,6 +36,7 @@ export const STORAGE_KEYS = {
theme: 'opsxsql.theme.mode',
loginStyle: 'opsxsql.ui.loginStyle',
queryFavorites: 'opsxsql.query.favorites',
queryCustomTemplates: 'opsxsql.query.customTemplates',
querySideWidth: 'opsxsql.query.sideWidth',
chunkReloaded: 'opsxsql.app.chunkReloaded',
} as const;
+25 -95
View File
@@ -13,11 +13,11 @@ import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDraw
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import { QueryTopBar } from '@/features/query/components/QueryTopBar';
import { SqlEditorSection } from '@/features/query/components/SqlEditorSection';
import type { SqlEditorHandle } from '@/components/SqlEditor';
import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel';
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
import type {
QueryHistoryItem,
QueryInstanceOption,
QueryResult,
RedisKeyDetail,
@@ -71,19 +71,10 @@ export default function QueriesPage() {
const sidebarDragRef = useRef<HTMLDivElement>(null);
// -- 历史 state --
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
try {
const saved = readStorage('queryFavorites');
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
const initialHistory: QueryHistoryItem[] = [];
return initialHistory.map((h) => ({ ...h, favorite: favIds.has(h.id) }));
} catch {
return [];
}
});
const [favFilter, setFavFilter] = useState(false);
const [searchHistory, setSearchHistory] = useState('');
const [clearConfirm, setClearConfirm] = useState(false);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const sqlEditorRef = useRef<SqlEditorHandle>(null);
/** 最近一次执行的语句(选中片段或全文),分页重放以此为准 */
const lastExecutedSqlRef = useRef('');
/* ---- Data loading ---- */
useEffect(() => {
@@ -263,16 +254,20 @@ export default function QueriesPage() {
/* ---- Execute ---- */
const handleExecute = useCallback(
async (pageArg: number | unknown = 1) => {
async (pageArg: number | unknown = 1, sqlOverride?: string) => {
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
if (!sql || !instanceId) {
// 执行范围:显式覆盖 > 选区/光标语句(仅首页) > 上次执行语句(分页) > 编辑器全文
const effectiveSql =
sqlOverride ?? (page === 1 ? (sqlEditorRef.current?.getSqlToRun() ?? sql) : lastExecutedSqlRef.current || sql);
if (!effectiveSql || !instanceId) {
setError('请选择实例并输入查询语句');
return;
}
if (/\{(?:table|db|collection)\}/.test(sql)) {
if (/\{(?:table|db|collection)\}/.test(effectiveSql)) {
setError('SQL 中含有未替换的占位符,请先替换或删除');
return;
}
lastExecutedSqlRef.current = effectiveSql;
setExecuting(true);
setError('');
setCurrentPage(page);
@@ -282,7 +277,7 @@ export default function QueriesPage() {
const res = (await queryApi.execute({
instance_id: Number(instanceId),
database: database || undefined,
sql,
sql: effectiveSql,
limit: pageSize,
offset,
})) as unknown as QueryResult;
@@ -294,21 +289,8 @@ export default function QueriesPage() {
setResult(normalized);
}
if (page === 1) {
setHistory((prev) => [
{
id: Date.now(),
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'success',
execution_time_ms: normalized.execution_time_ms,
result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100),
created_at: new Date().toISOString(),
favorite: false,
},
...prev,
]);
// 历史由后端执行时自动落库,这里只触发抽屉刷新
setHistoryRefreshKey((k) => k + 1);
}
} catch (e: unknown) {
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data
@@ -321,23 +303,6 @@ export default function QueriesPage() {
? JSON.stringify(raw)
: '查询失败';
setError(msg);
if (page === 1) {
setHistory((prev) => [
{
id: Date.now(),
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'failed',
execution_time_ms: 0,
result_preview: msg,
created_at: new Date().toISOString(),
favorite: false,
},
...prev,
]);
}
} finally {
setExecuting(false);
}
@@ -405,29 +370,6 @@ export default function QueriesPage() {
[handleExecute],
);
/* ---- History ---- */
const toggleFavorite = useCallback((id: number) => {
setHistory((prev) => {
const next = prev.map((h) => (h.id === id ? { ...h, favorite: !h.favorite } : h));
try {
writeStorage('queryFavorites', JSON.stringify(next.filter((h) => h.favorite).map((h) => h.id)));
} catch {
/* noop */
}
return next;
});
}, []);
const handleClearHistory = useCallback(() => {
if (!clearConfirm) {
setClearConfirm(true);
setTimeout(() => setClearConfirm(false), 3000);
return;
}
setHistory([]);
setClearConfirm(false);
}, [clearConfirm]);
// -- Sidebar 拖拽 --
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
@@ -453,16 +395,6 @@ export default function QueriesPage() {
[sideWidth],
);
const filteredHistory = useMemo(() => {
let r = history;
if (favFilter) r = r.filter((h) => h.favorite);
if (searchHistory) {
const kw = searchHistory.toLowerCase();
r = r.filter((h) => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw));
}
return r;
}, [history, favFilter, searchHistory]);
const handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []);
const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []);
const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []);
@@ -486,7 +418,6 @@ export default function QueriesPage() {
showHistory={showHistory}
executing={executing}
templateCount={templates.length}
historyCount={history.length}
onInstanceIdChange={setInstanceId}
onDatabaseChange={setDatabase}
onToggleSidebar={handleToggleSidebar}
@@ -529,6 +460,10 @@ export default function QueriesPage() {
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
}
}}
onExecuteSql={(sqlText) => {
setSql(sqlText);
handleExecute(1, sqlText);
}}
onDatabaseChange={setDatabase}
singleDb={!!database}
/>
@@ -573,6 +508,7 @@ export default function QueriesPage() {
schema={editorSchema}
onSqlChange={setSql}
onExecute={handleExecute}
editorRef={sqlEditorRef}
/>
{/* -- 错误提示 -- */}
@@ -621,18 +557,12 @@ export default function QueriesPage() {
{showHistory && (
<QueryHistoryDrawer
history={history}
filteredHistory={filteredHistory}
favFilter={favFilter}
searchHistory={searchHistory}
clearConfirm={clearConfirm}
dbTypeIcon={dbTypeIcon}
onClose={() => setShowHistory(false)}
onSearchChange={setSearchHistory}
onToggleFavoriteFilter={() => setFavFilter(!favFilter)}
onClearHistory={handleClearHistory}
onToggleFavorite={toggleFavorite}
onUseSql={setSql}
onUseSql={(sqlText, run) => {
setSql(sqlText);
if (run) handleExecute(1, sqlText);
}}
refreshKey={historyRefreshKey}
/>
)}
<RedisValueViewerDialog