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:
1 parent
ff539b47e0
commit
f6d636fa44
16 files changed
+1392
-457
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user