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

+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