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

@@ -0,0 +1,67 @@
# OpsxSQL 查询体验与工单流程优化(借鉴 opsxdb / opsx-sec)
**用户已确认**:① 移除工单创建页死的「定时执行」字段 ② 全部四块都做。
**不做**(明确出界):多 tab 页签系统、草稿自动保存、批量审批、Redis Hash 值级 HSCAN 分页——后续迭代。
---
## Phase 1 — 后端 Redis 扫描服务化(借鉴 opsxdb server/internal/db/redis.go)
改造 `backend/internal/handler/redis.go`:
1. **SCAN 增量游标分页**(对标 opsxdb 的不透明游标设计):
- `keyScanPosition{Pattern, DB, Nodes, Index, Cursor}` JSON→base64 编码回传;下次请求带回,服务端校验 pattern/db/节点拓扑一致,不一致报「搜索位置已失效」
- 每页上限 200 键,单页最多 20 次 SCAN 调用,完整批次一起返回
- 响应:`{keys:[{name,type,ttl}], next_cursor, exact?}` — **无通配符输入时先 TYPE 精确探测,命中作为 exact 项置顶**
2. **pattern 规则**:`redisBrowsePattern` — 无 `*?[` 通配符自动包 `*关键词*`;有则按 glob 原样
3. **pipeline 批量 TYPE/TTL**:替换现逐 key 串行 N+1(大库必超时);type 筛选参数真正生效(pipeline 取到 type 后过滤)
4. **集群归一**:`ScanAllKeys` 改为 ForEachMaster 各分片**增量** SCAN 归并排序;cluster 模式只暴露 DB0;MOVED 错误追加「请将部署模式切为集群并填种子节点」提示
5. **CLI 修复**(现为必 403 的死功能):后端 `RedisExecuteCommand` 接受 `{db, command}` 整串——按空白拆 args(引号感知),白名单匹配首词;前端契约同步(api/datasource/redis.ts);危险命令清单二段确认在前端做
6. 更新 key 的 DEL+重写改为按类型的原子命令(Hash 用 HSET/HDEL 行级;保留整 key 重写兜底)
验证:go build/vet/test + curl 实测(单机 redis-server 本地起,若有;否则用实例 fixture 手测游标翻页)
## Phase 2 — 前端 Redis 键浏览重构
新建 `src/features/redis/keyBrowser/` 共享模块(详情页键管理 tab 与 QueriesPage 的 SchemaSidebar Redis 分支两处接入):
1. **hooks/useRedisKeyBrowser.ts**:游标加载状态机(首页/加载更多/重置)、请求序号防竞态(过期响应丢弃)、按键名 Map 增量合并、db/pattern/type 参数
2. **utils/keyTree.ts**(对标 opsxdb src/redis.ts):
- `buildRedisKeyTree` 按 `:` 分隔建 folder 树(文件夹计数徽标、numeric 排序、无分隔符留根层、exact 置顶)
- `formatRedisTtl`(-1 永久/-2 不存在/45s/5m30s/2d5h)、`redisTypeColor` 八类型色点、`removeRedisKeyFromTree` 本地移除(删键不重拉)
3. **搜索框交互**:回车/按钮触发 SCAN(不做防抖——SCAN 有成本,opsxdb 同款取舍);有关键字时显示「清除」;占位提示支持 `*` 通配
4. **树渲染**:叶子只显示末段(hover title 全名)、行尾淡显 `{type} · {ttl}`、底部「加载更多」按钮(title 提示游标扫描语义);DB 切换下拉 cluster 恒 DB0
5. **类型筛选**下拉接真实后端过滤(修死功能)
6. RedisDetailPage 键管理 tab 与 SchemaSidebar Redis 分支替换为新模块(保持各自布局壳)
## Phase 3 — MySQL/PG 查询便捷性(借鉴 opsxdb ObjectTree/SqlEditor)
1. **通用右键菜单组件** `src/components/ContextMenu.tsx`:视口边界翻转、Esc/外点关闭、菜单项分隔线
2. **SchemaSidebar 增强**:
- 表右键菜单:查看数据(生成方言化 SELECT 进编辑器并执行)/ 生成 SQL 子菜单(SELECT/INSERT/UPDATE/DELETE,全列展开+复合主键进 WHERE)/ 复制表名 / 复制建表 DDL(已有 DDL API 则接,无则隐藏)
- 双击表 = 生成 SELECT 预览;列右键:复制列名/复制 table.column
- 搜索框:本地已加载过滤保留
3. **SqlEditor 增强**:「执行选中优先」——Mod-Enter/执行按钮改为选区优先、否则光标所在语句(按分号切分,简单引号感知)
4. **查询历史持久化**(后端 API 已存在闲置):执行成功后自动 saveQueryHistory;历史抽屉改为后端数据源(分页拉取+关键字过滤),点击回填、▶ 回填并执行;收藏 id 集(localStorage)叠加在后端历史上
5. **自定义模板**:QueryTemplatePanel 支持用户自定义片段(localStorage,{name, sql}),与内置并列
## Phase 4 — 工单流程 step 优化(借鉴 opsx-sec)
1. **状态驱动步骤条**:`features/ticket/components/TicketStageSteps.tsx`——工单详情页(及详情弹窗)顶部步骤条:提交→初审→复审(按 workflow 实际 steps 动态生成)→执行→完成;active 由工单 status 推导(数据驱动,非可点击回跳);驳回/失败/回滚显示终态错误色;下方快捷入口按钮(查看 SQL/审批/执行日志)
2. **审批时间线修复**:DetailTimeline 的 ACTION_CONFIG 键改为后端实际 action 值(created/approve/reject/pending_review/executed/...),图标+颜色按事件类型分色(成功绿/警告琥珀/失败红)
3. **审批交互统一**(详情弹窗与详情页行为对齐):
- 通过/驳回:弹出行内评论输入(可选)→确认;列表页行内快捷通过保留但加轻量确认与评论入口;`alert()` 全部换 toast
- 执行:二次确认复述影响面「将对实例 X 库 Y 执行 N 条语句,确认执行?」(opsx-sec 带影响面摘要模式)
- 禁用按钮可解释:不可执行时 disabled+tooltip 原因(等待审批/非执行人/已执行等),`disabledReason()` 纯函数
4. **创建页修正**(TicketCreatePage):
- 移除死的「定时执行」字段;新增「回滚 SQL」采集(后端已支持 rollback_sql);priority 下拉真实可选
- **审批流预览**:选定实例后展示其 workflow 步骤与审批人名单(数据已有,创建前可见谁会审)
- SQL 检测通过才可提交的 gate 保留(已是两段式)
5. **空态引导**:暂无工单空态内嵌「创建变更工单」主按钮(EmptyState 引导动作)
## 实施与验证
- 每 Phase 独立 commit;前端每步过 `tsc --noEmit` + `npm run build` + `audit:pages`(页面不超 700 行),后端过 `go build/vet/test`
- Phase 1/2 完成后:本地起 Go 服务(独立端口+DB),curl 验证游标翻页/pattern/type 过滤/集群归一;浏览器实测 Redis 键浏览(搜索/分组树/加载更多/DB 切换)
- Phase 3/4 完成后:浏览器实测右键菜单/生成 SQL/历史持久化回填、工单步骤条/审批评论/禁用原因/创建页新字段
- 全部完成后跑全量门禁(vitest/tsc/build/go),收尾总结
+29 -3
View File
@@ -9,6 +9,7 @@ import (
"sql-platform/internal/ghost" "sql-platform/internal/ghost"
"sql-platform/internal/handler/resp" "sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -445,11 +446,36 @@ func ListAnnouncements(c *gin.Context) {
// GET /api/v1/query-histories // GET /api/v1/query-histories
func ListQueryHistories(c *gin.Context) { func ListQueryHistories(c *gin.Context) {
_, _, _ = c.GetInt("user_id"), c.GetString("username"), c.GetString("role") userID, _, _ := middleware.GetCurrentUser(c)
q := model.DB.Order("id DESC").Limit(100)
if c.Query("mine") == "1" {
q = q.Where("user_id = ?", userID)
}
var histories []model.QueryHistory var histories []model.QueryHistory
model.DB.Order("id DESC").Limit(100).Find(&histories) q.Find(&histories)
c.JSON(http.StatusOK, histories) resp.OK(c, histories)
}
// PUT /api/v1/query-histories/:id/favorite — 收藏/取消收藏
func ToggleQueryHistoryFavorite(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
id := c.Param("id")
var history model.QueryHistory
if err := model.DB.First(&history, id).Error; err != nil {
resp.NotFound(c, "History not found")
return
}
if history.UserID != userID {
resp.Forbidden(c, "只能收藏自己的查询历史")
return
}
if err := model.DB.Model(&history).Update("is_favorite", !history.IsFavorite).Error; err != nil {
resp.InternalErr(c, err)
return
}
resp.OK(c, gin.H{"id": history.ID, "is_favorite": !history.IsFavorite})
} }
// GET /api/v1/settings — returns settings grouped by `group` field // GET /api/v1/settings — returns settings grouped by `group` field
+1
View File
@@ -199,6 +199,7 @@ func Setup(r *gin.Engine) {
} }
auth.GET("/announcements", handler.ListAnnouncements) auth.GET("/announcements", handler.ListAnnouncements)
auth.GET("/query-histories", handler.ListQueryHistories) auth.GET("/query-histories", handler.ListQueryHistories)
auth.PUT("/query-histories/:id/favorite", handler.ToggleQueryHistoryFavorite)
// Notifications CRUD (replaces old channels/templates endpoints) // Notifications CRUD (replaces old channels/templates endpoints)
auth.GET("/notifications", handler.ListNotifications) auth.GET("/notifications", handler.ListNotifications)
auth.POST("/notifications", handler.CreateNotification) auth.POST("/notifications", handler.CreateNotification)
+9 -6
View File
@@ -4,12 +4,15 @@ export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) => execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) =>
request.post<any, any>('/query', data), request.post<any, any>('/query', data),
formatSQL: (sql: string) => formatSQL: (sql: string) => request.post<any, any>('/sql-assistant/format', { sql }),
request.post<any, any>('/sql-assistant/format', { sql }),
analyzeSQL: (sql: string, db_type: string) => analyzeSQL: (sql: string, db_type: string) => request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
getDBTypeDetail: (dbType: string) => getDBTypeDetail: (dbType: string) => request.get<any, any>('/db-types/' + dbType),
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 { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis'; import { redisApi } from '@/api/redis';
import { RedisKeyBrowser } from '@/features/redis/keyBrowser/RedisKeyBrowser'; 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'; import { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -27,6 +36,8 @@ interface Props {
/** Redis 集群模式为 true */ /** Redis 集群模式为 true */
clusterMode?: boolean; clusterMode?: boolean;
onInsert: (text: string) => void; onInsert: (text: string) => void;
/** 双击表/「查看数据」:生成 SELECT 并直接执行 */
onExecuteSql?: (sql: string) => void;
onDatabaseChange?: (db: string) => void; onDatabaseChange?: (db: string) => void;
/** 为 true 时只显示当前选中的库,不允许切换(数据变更场景防误操作) */ /** 为 true 时只显示当前选中的库,不允许切换(数据变更场景防误操作) */
singleDb?: boolean; singleDb?: boolean;
@@ -61,6 +72,7 @@ export default function SchemaSidebar({
dbType, dbType,
clusterMode, clusterMode,
onInsert, onInsert,
onExecuteSql,
onDatabaseChange, onDatabaseChange,
singleDb, singleDb,
}: Props) { }: Props) {
@@ -82,6 +94,27 @@ export default function SchemaSidebar({
// -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) -- // -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) --
const [redisDb, setRedisDb] = useState(0); const [redisDb, setRedisDb] = useState(0);
const redisDbs = useRedisDbs(Number(instanceId)); 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 [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyDetail, setKeyDetail] = useState<any>(null); const [keyDetail, setKeyDetail] = useState<any>(null);
const [loadingDetail, setLoadingDetail] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false);
@@ -380,8 +413,10 @@ export default function SchemaSidebar({
handleExpandTable(dbName, t.name); 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" 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 ? ( {isTableExpanded ? (
<ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" /> <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 <button
key={col.Field} key={col.Field}
onClick={() => onInsert(`${t.name}.${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" 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' : ''}`} title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
> >
@@ -594,6 +647,7 @@ export default function SchemaSidebar({
</div> </div>
)} )}
</div> </div>
{ctxMenu.menu}
</div> </div>
); );
} }
+512 -225
View File
@@ -6,11 +6,7 @@ import { sql, SQLDialect } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language';
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
} from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete'; import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
@@ -35,45 +31,206 @@ export interface SqlEditorProps {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const REDIS_KEYWORDS = [ const REDIS_KEYWORDS = [
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET', 'GET',
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD', 'SET',
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN', 'KEYS',
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER', 'HGET',
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL', 'HSET',
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL', 'HGETALL',
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT', 'HDEL',
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH', 'HMSET',
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE', 'ZADD',
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN', 'ZRANGE',
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT', 'ZREM',
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX', 'ZRANK',
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE', 'ZSCORE',
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF', '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 = [ const MONGODB_KEYWORDS = [
// CRUD // CRUD
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany', 'find',
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete', 'findOne',
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct', 'insertOne',
'insertMany',
'updateOne',
'updateMany',
'deleteOne',
'deleteMany',
'replaceOne',
'findOneAndUpdate',
'findOneAndDelete',
'findOneAndReplace',
'bulkWrite',
'aggregate',
'countDocuments',
'distinct',
// Aggregation stages // Aggregation stages
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup', 'match',
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount', 'group',
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count', 'project',
'sort',
'limit',
'skip',
'unwind',
'lookup',
'addFields',
'set',
'unset',
'facet',
'bucket',
'bucketAuto',
'sortByCount',
'graphLookup',
'out',
'merge',
'replaceRoot',
'sample',
'count',
// Operators // Operators
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not', 'eq',
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then', 'ne',
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays', 'gt',
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last', '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 // Index / admin
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint', 'createIndex',
'dropIndex',
'getIndexes',
'explain',
'hint',
// Collection / DB // Collection / DB
'use', 'show', 'db', 'collection', 'collections', 'databases', 'use',
'show',
'db',
'collection',
'collections',
'databases',
// JS keywords commonly used // JS keywords commonly used
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while', 'function',
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof', 'return',
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal', 'var',
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef', '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 { function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension {
@@ -102,9 +259,7 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
if (Array.isArray(sampleValue)) { if (Array.isArray(sampleValue)) {
// Direct: { table: [columns] } // Direct: { table: [columns] }
tables = Object.fromEntries( tables = Object.fromEntries(Object.entries(schema).map(([k, v]) => [k, v as string[]]));
Object.entries(schema).map(([k, v]) => [k, v as string[]])
);
} else { } else {
// Nested: { db: { table: [columns] } } // Nested: { db: { table: [columns] } }
// Register: db → table names (so "db." shows tables) // Register: db → table names (so "db." shows tables)
@@ -124,7 +279,8 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
} }
const dialect = SQLDialect.define({ 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 } : {}), ...(tables ? { schema: tables } : {}),
} as any); } as any);
return sql({ dialect }); return sql({ dialect });
@@ -222,20 +378,103 @@ const SQL_BREAK_KEYWORDS = [
]; ];
const SQL_KEYWORDS_UPPER = new Set([ const SQL_KEYWORDS_UPPER = new Set([
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET', 'SELECT',
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON', 'FROM',
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS', 'WHERE',
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'ORDER',
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT', 'BY',
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER', 'GROUP',
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION', 'HAVING',
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT', 'LIMIT',
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION', 'OFFSET',
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN', 'JOIN',
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'INNER',
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE', 'LEFT',
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON', 'RIGHT',
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE', '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 { export function formatSQL(input: string): string {
@@ -270,190 +509,238 @@ export function formatSQL(input: string): string {
export interface SqlEditorHandle { export interface SqlEditorHandle {
/** Insert text at cursor position and move cursor after inserted text */ /** Insert text at cursor position and move cursor after inserted text */
insertText: (text: string) => void; insertText: (text: string) => void;
/** 选区优先,否则光标所在语句;与全文相同时返回 null(调用方直接用全文) */
getSqlToRun: () => string | null;
} }
const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(({ /** 按分号切分语句(单/双引号与行注释感知),返回每段的 [start, end) 与文本 */
value, export function splitSqlStatements(text: string): Array<{ start: number; end: number; text: string }> {
onChange, const out: Array<{ start: number; end: number; text: string }> = [];
placeholder = 'Enter SQL query…', let start = 0;
language = 'sql', let quote: string | null = null;
height = '200px', let lineComment = false;
readOnly = false, for (let i = 0; i < text.length; i++) {
onExecute, const ch = text[i];
schema, const next = text[i + 1];
}, ref) => { if (lineComment) {
const containerRef = useRef<HTMLDivElement>(null); if (ch === '\n') lineComment = false;
const viewRef = useRef<EditorView | null>(null); continue;
const langCompartmentRef = useRef(new Compartment()); }
const readOnlyCompartmentRef = useRef(new Compartment()); if (quote) {
const placeholderCompartmentRef = useRef(new Compartment()); if (ch === '\\') {
const isExternalUpdateRef = useRef(false); 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 const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
useImperativeHandle(ref, () => ({ (
insertText: (text: string) => { {
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; const view = viewRef.current;
if (!view) return; 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 currentContent = view.state.doc.toString();
const onChangeRef = useRef(onChange); if (currentContent !== value) {
onChangeRef.current = onChange; isExternalUpdateRef.current = true;
view.dispatch({
// Ctrl+Enter handler changes: { from: 0, to: view.state.doc.length, insert: value },
const runKeymap = useCallback( });
(): Extension[] => [ isExternalUpdateRef.current = false;
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());
} }
}); }, [value]);
const state = EditorState.create({ // Update language/schema when they change
doc: value, useEffect(() => {
extensions: [ const view = viewRef.current;
lineNumbers(), if (!view) return;
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;
view.dispatch({ view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value }, effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
}); });
isExternalUpdateRef.current = false; }, [language, schema]);
}
}, [value]);
// Update language/schema when they change // Update readOnly when it changes
useEffect(() => { useEffect(() => {
const view = viewRef.current; const view = viewRef.current;
if (!view) return; if (!view) return;
view.dispatch({ view.dispatch({
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)), effects: readOnlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
}); });
}, [language, schema]); }, [readOnly]);
// Update readOnly when it changes // Update placeholder when it changes
useEffect(() => { useEffect(() => {
const view = viewRef.current; const view = viewRef.current;
if (!view) return; if (!view) return;
view.dispatch({ view.dispatch({
effects: readOnlyCompartmentRef.current.reconfigure( effects: placeholderCompartmentRef.current.reconfigure(cmPlaceholder(placeholder ?? '')),
EditorState.readOnly.of(readOnly), });
), }, [placeholder]);
});
}, [readOnly]);
// Update placeholder when it changes return <div ref={containerRef} style={{ height, minHeight: height }} className="sql-editor-wrapper" />;
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"
/>
);
});
export default SqlEditor; 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 { import {
CheckCircle, CheckCircle,
ClipboardPaste, ClipboardPaste,
History, History,
Loader2,
PanelRightClose, PanelRightClose,
Play,
RefreshCw,
Search, Search,
Star, Star,
StarOff, User,
X,
} from 'lucide-react'; } from 'lucide-react';
import type { QueryHistoryItem } from '../types'; import { queryApi } from '@/api/query';
import { dbTypeIcon } from '../constants';
import type { QueryHistoryRecord } from '../types';
interface QueryHistoryDrawerProps { interface QueryHistoryDrawerProps {
history: QueryHistoryItem[];
filteredHistory: QueryHistoryItem[];
favFilter: boolean;
searchHistory: string;
clearConfirm: boolean;
dbTypeIcon: Record<string, string>;
onClose: () => void; onClose: () => void;
onSearchChange: (value: string) => void; /** 回填编辑器;run=true 时同时执行 */
onToggleFavoriteFilter: () => void; onUseSql: (sql: string, run?: boolean) => void;
onClearHistory: () => void; /** 每次执行成功后 +1,触发重拉 */
onToggleFavorite: (id: number) => void; refreshKey: number;
onUseSql: (sql: string) => void;
} }
export function QueryHistoryDrawer({ export function QueryHistoryDrawer({ onClose, onUseSql, refreshKey }: QueryHistoryDrawerProps) {
history, const [items, setItems] = useState<QueryHistoryRecord[]>([]);
filteredHistory, const [loading, setLoading] = useState(false);
favFilter, const [keyword, setKeyword] = useState('');
searchHistory, const [mineOnly, setMineOnly] = useState(false);
clearConfirm, const [favOnly, setFavOnly] = useState(false);
dbTypeIcon,
onClose, const fetchList = useCallback((mine: boolean) => {
onSearchChange, setLoading(true);
onToggleFavoriteFilter, queryApi
onClearHistory, .getHistories(mine)
onToggleFavorite, .then((res) => {
onUseSql, setItems(Array.isArray(res) ? res : []);
}: QueryHistoryDrawerProps) { })
.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 ( return (
<div className="w-[300px] shrink-0 border-l border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col"> <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"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold flex items-center gap-2"> <span className="text-sm font-semibold flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" /> <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> </span>
<button onClick={onClose} className="dg-btn dg-btn-ghost dg-btn-sm p-1"> <div className="flex items-center gap-1">
<PanelRightClose className="w-4 h-4" /> <button
</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> </div>
{history.length > 0 && ( <div className="px-4 pb-2 space-y-2">
<div className="px-4 pb-2 space-y-2"> <div className="relative">
<div className="relative"> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" /> <input
<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"
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="搜索历史(语句/实例/库)..."
placeholder="搜索历史..." value={keyword}
value={searchHistory} onChange={(e) => setKeyword(e.target.value)}
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> </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"> <div className="flex-1 overflow-y-auto">
{history.length === 0 ? ( {loading && items.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div> <div className="flex items-center justify-center gap-2 p-6 text-xs dg-text-muted">
) : filteredHistory.length === 0 ? ( <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="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
) : ( ) : (
<div className="space-y-1.5 px-3 py-2"> <div className="space-y-1.5 px-3 py-2">
{filteredHistory.map(function (h) { {filtered.map((h) => (
return ( <div
<div key={h.id} className={'dg-card px-3 py-2.5 group ' + (h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : '')}> key={h.id}
<div className="flex items-center gap-2 mb-1"> className={'dg-card px-3 py-2.5 group ' + (h.is_favorite ? 'bg-yellow-500/5 border-yellow-500/20' : '')}
{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')}> <div className="flex items-center gap-2 mb-1">
{h.execution_time_ms}ms {h.db_type && dbTypeIcon[h.db_type] && (
</span> <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span> )}
<div className="flex-1" /> <span className="text-[10px] dg-text-muted truncate">
<button onClick={function () { onToggleFavorite(h.id); }} {h.instance_name}
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.database_name ? ` · ${h.database_name}` : ''}
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />} </span>
</button> <div className="flex-1" />
</div> <button
<div className="flex items-center gap-1"> onClick={() => toggleFavorite(h.id)}
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code> className={
<button onClick={function () { onUseSql(h.sql); }} 'p-0.5 rounded transition-colors ' +
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100" (h.is_favorite
title="粘贴到编辑器"> ? 'text-yellow-400'
<ClipboardPaste className="w-3 h-3" /> : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400')
</button> }
</div> title={h.is_favorite ? '取消收藏' : '收藏'}
>
<Star className={'w-3 h-3 ' + (h.is_favorite ? 'fill-current' : '')} />
</button>
</div> </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>
)} )}
</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'; 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 { interface QueryTemplatePanelProps {
categories: string[]; categories: string[];
activeCategory: string; activeCategory: string;
@@ -22,43 +48,166 @@ export function QueryTemplatePanel({
onSearchChange, onSearchChange,
onApplyTemplate, onApplyTemplate,
}: QueryTemplatePanelProps) { }: 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 ( return (
<div className="shrink-0 px-5 pb-2"> <div className="shrink-0 px-5 pb-2">
<div className="dg-card p-3"> <div className="dg-card p-3">
<div className="flex items-center gap-2 mb-3 flex-wrap"> <div className="flex items-center gap-2 mb-3 flex-wrap">
{categories.map(function (cat) { {categories.map(function (cat) {
return ( return (
<button key={cat} onClick={function () { onCategoryChange(cat); }} <button
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (activeCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : '')}> 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} {categoryLabels[cat] || cat}
</button> </button>
); );
})} })}
<div className="flex-1" /> <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"> <div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input <input
value={search} 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" 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="搜索模板..." placeholder="搜索模板..."
/> />
</div> </div>
</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 ? ( {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) { {templates.map(function (tpl) {
return ( return (
<button key={tpl.id} onClick={function () { onApplyTemplate(tpl); }} <button
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group" key={tpl.id}
title={tpl.description}> onClick={function () {
<div className="flex items-center gap-2 mb-0.5"> onApplyTemplate(tpl);
<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> 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> </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> </button>
); );
})} })}
@@ -1,6 +1,4 @@
import { import { ChevronDown, Database, Loader2, PanelRightClose, PanelRightOpen, Play, Sparkles } from 'lucide-react';
ChevronDown, Database, Loader2, PanelRightClose, PanelRightOpen, Play, Sparkles,
} from 'lucide-react';
import type { DBType } from '@/lib/queryTemplates'; import type { DBType } from '@/lib/queryTemplates';
import { dbTypeIcon, dbTypeLabel } from '../constants'; import { dbTypeIcon, dbTypeLabel } from '../constants';
import type { QueryInstanceOption } from '../types'; import type { QueryInstanceOption } from '../types';
@@ -31,7 +29,6 @@ interface QueryTopBarProps {
/** 模板总数 */ /** 模板总数 */
templateCount: number; templateCount: number;
/** 历史记录总数 */ /** 历史记录总数 */
historyCount: number;
onInstanceIdChange: (instanceId: string) => void; onInstanceIdChange: (instanceId: string) => void;
onDatabaseChange: (database: string) => void; onDatabaseChange: (database: string) => void;
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -55,7 +52,6 @@ export function QueryTopBar({
showHistory, showHistory,
executing, executing,
templateCount, templateCount,
historyCount,
onInstanceIdChange, onInstanceIdChange,
onDatabaseChange, onDatabaseChange,
onToggleSidebar, onToggleSidebar,
@@ -70,10 +66,13 @@ export function QueryTopBar({
{selectedInstance && dbTypeIcon[dbType] && ( {selectedInstance && dbTypeIcon[dbType] && (
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" /> <img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
)} )}
<select value={instanceId} onChange={e => onInstanceIdChange(e.target.value)} <select
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]"> 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> <option value="">选择实例</option>
{instances.map(inst => ( {instances.map((inst) => (
<option key={inst.id} value={inst.id}> <option key={inst.id} value={inst.id}>
{inst.name} ({dbTypeLabel[inst.db_type] || inst.db_type}) {inst.name} ({dbTypeLabel[inst.db_type] || inst.db_type})
</option> </option>
@@ -81,15 +80,29 @@ export function QueryTopBar({
</select> </select>
{dbType === 'redis' ? ( {dbType === 'redis' ? (
<select value={database || '0'} onChange={e => onDatabaseChange(e.target.value)} <select
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"> value={database || '0'}
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)} 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>
) : ( ) : (
<select value={database} onChange={e => onDatabaseChange(e.target.value)} <select
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]"> 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> <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> </select>
)} )}
</div> </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"> <div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
{instanceId && ( {instanceId && (
<button onClick={onToggleSidebar} <button
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}> 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" /> <Database className="w-3.5 h-3.5" />
{showSidebar ? '隐藏' : '导航'} {showSidebar ? '隐藏' : '导航'}
</button> </button>
)} )}
<button onClick={onToggleTemplates} <button onClick={onToggleTemplates} className="dg-btn dg-btn-ghost dg-btn-sm">
className="dg-btn dg-btn-ghost dg-btn-sm">
<Sparkles className="w-3.5 h-3.5 text-blue-400" /> <Sparkles className="w-3.5 h-3.5 text-blue-400" />
模板 ({templateCount}) 模板 ({templateCount})
<ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
</button> </button>
<button onClick={onToggleHistory} <button
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}> 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" />} {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>
<button onClick={onExecute} disabled={executing || !instanceId} <button
className="dg-btn dg-btn-primary ml-auto md:ml-0"> 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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{executing ? '执行中...' : '执行'} {executing ? '执行中...' : '执行'}
</button> </button>
@@ -1,5 +1,6 @@
import type { Ref } from 'react';
import { AlertTriangle } from 'lucide-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 type { DBType } from '@/lib/queryTemplates';
import { dbTypeIcon, dbTypeLabel, placeholderMap, queryPolicyText } from '../constants'; import { dbTypeIcon, dbTypeLabel, placeholderMap, queryPolicyText } from '../constants';
@@ -13,8 +14,10 @@ interface SqlEditorSectionProps {
/** 库表结构(仅 MySQL/PG 传入编辑器) */ /** 库表结构(仅 MySQL/PG 传入编辑器) */
schema: Record<string, Record<string, string[]>>; schema: Record<string, Record<string, string[]>>;
onSqlChange: (sql: string) => void; onSqlChange: (sql: string) => void;
/** 执行查询(Ctrl+Enter 触发) */ /** 执行查询(Ctrl+Enter 触发;执行范围为选区优先) */
onExecute: () => void; onExecute: () => void;
/** 透传编辑器句柄(选区/光标语句获取) */
editorRef?: Ref<SqlEditorHandle>;
} }
/** SQL 编辑器卡片:编辑器主体 + 模式/字数 footer + 查询策略提示 */ /** SQL 编辑器卡片:编辑器主体 + 模式/字数 footer + 查询策略提示 */
@@ -25,21 +28,33 @@ export function SqlEditorSection({
schema, schema,
onSqlChange, onSqlChange,
onExecute, onExecute,
editorRef,
}: SqlEditorSectionProps) { }: SqlEditorSectionProps) {
return ( return (
<div className="shrink-0 p-0 md:p-4 pb-2"> <div className="shrink-0 p-0 md:p-4 pb-2">
<div className="dg-card overflow-hidden flex flex-col"> <div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={onSqlChange} placeholder={placeholderMap[dbType]} <SqlEditor
language={editorLang} height="200px" onExecute={onExecute} ref={editorRef}
schema={(dbType === 'mysql' || dbType === 'postgresql') ? schema : undefined} /> 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"> <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"> <span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />} {dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符 {dbTypeLabel[dbType]} 模式 · {sql.length} 字符
</span> </span>
<div className="hidden sm:flex items-center gap-2"> <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)]">
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span> 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> </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"> <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 { export interface QueryHistoryItem {
id: number; id: number;
sql: string; sql: string;
+1
View File
@@ -36,6 +36,7 @@ export const STORAGE_KEYS = {
theme: 'opsxsql.theme.mode', theme: 'opsxsql.theme.mode',
loginStyle: 'opsxsql.ui.loginStyle', loginStyle: 'opsxsql.ui.loginStyle',
queryFavorites: 'opsxsql.query.favorites', queryFavorites: 'opsxsql.query.favorites',
queryCustomTemplates: 'opsxsql.query.customTemplates',
querySideWidth: 'opsxsql.query.sideWidth', querySideWidth: 'opsxsql.query.sideWidth',
chunkReloaded: 'opsxsql.app.chunkReloaded', chunkReloaded: 'opsxsql.app.chunkReloaded',
} as const; } 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 { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import { QueryTopBar } from '@/features/query/components/QueryTopBar'; import { QueryTopBar } from '@/features/query/components/QueryTopBar';
import { SqlEditorSection } from '@/features/query/components/SqlEditorSection'; import { SqlEditorSection } from '@/features/query/components/SqlEditorSection';
import type { SqlEditorHandle } from '@/components/SqlEditor';
import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel'; import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel';
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel'; import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants'; import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
import type { import type {
QueryHistoryItem,
QueryInstanceOption, QueryInstanceOption,
QueryResult, QueryResult,
RedisKeyDetail, RedisKeyDetail,
@@ -71,19 +71,10 @@ export default function QueriesPage() {
const sidebarDragRef = useRef<HTMLDivElement>(null); const sidebarDragRef = useRef<HTMLDivElement>(null);
// -- 历史 state -- // -- 历史 state --
const [history, setHistory] = useState<QueryHistoryItem[]>(() => { const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
try { const sqlEditorRef = useRef<SqlEditorHandle>(null);
const saved = readStorage('queryFavorites'); /** 最近一次执行的语句(选中片段或全文),分页重放以此为准 */
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>(); const lastExecutedSqlRef = useRef('');
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);
/* ---- Data loading ---- */ /* ---- Data loading ---- */
useEffect(() => { useEffect(() => {
@@ -263,16 +254,20 @@ export default function QueriesPage() {
/* ---- Execute ---- */ /* ---- Execute ---- */
const handleExecute = useCallback( 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; 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('请选择实例并输入查询语句'); setError('请选择实例并输入查询语句');
return; return;
} }
if (/\{(?:table|db|collection)\}/.test(sql)) { if (/\{(?:table|db|collection)\}/.test(effectiveSql)) {
setError('SQL 中含有未替换的占位符,请先替换或删除'); setError('SQL 中含有未替换的占位符,请先替换或删除');
return; return;
} }
lastExecutedSqlRef.current = effectiveSql;
setExecuting(true); setExecuting(true);
setError(''); setError('');
setCurrentPage(page); setCurrentPage(page);
@@ -282,7 +277,7 @@ export default function QueriesPage() {
const res = (await queryApi.execute({ const res = (await queryApi.execute({
instance_id: Number(instanceId), instance_id: Number(instanceId),
database: database || undefined, database: database || undefined,
sql, sql: effectiveSql,
limit: pageSize, limit: pageSize,
offset, offset,
})) as unknown as QueryResult; })) as unknown as QueryResult;
@@ -294,21 +289,8 @@ export default function QueriesPage() {
setResult(normalized); setResult(normalized);
} }
if (page === 1) { if (page === 1) {
setHistory((prev) => [ // 历史由后端执行时自动落库,这里只触发抽屉刷新
{ setHistoryRefreshKey((k) => k + 1);
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,
]);
} }
} catch (e: unknown) { } catch (e: unknown) {
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data
@@ -321,23 +303,6 @@ export default function QueriesPage() {
? JSON.stringify(raw) ? JSON.stringify(raw)
: '查询失败'; : '查询失败';
setError(msg); 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 { } finally {
setExecuting(false); setExecuting(false);
} }
@@ -405,29 +370,6 @@ export default function QueriesPage() {
[handleExecute], [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 拖拽 -- // -- Sidebar 拖拽 --
const handleDragStart = useCallback( const handleDragStart = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -453,16 +395,6 @@ export default function QueriesPage() {
[sideWidth], [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 handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []);
const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []); const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []);
const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []); const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []);
@@ -486,7 +418,6 @@ export default function QueriesPage() {
showHistory={showHistory} showHistory={showHistory}
executing={executing} executing={executing}
templateCount={templates.length} templateCount={templates.length}
historyCount={history.length}
onInstanceIdChange={setInstanceId} onInstanceIdChange={setInstanceId}
onDatabaseChange={setDatabase} onDatabaseChange={setDatabase}
onToggleSidebar={handleToggleSidebar} onToggleSidebar={handleToggleSidebar}
@@ -529,6 +460,10 @@ export default function QueriesPage() {
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' '); setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
} }
}} }}
onExecuteSql={(sqlText) => {
setSql(sqlText);
handleExecute(1, sqlText);
}}
onDatabaseChange={setDatabase} onDatabaseChange={setDatabase}
singleDb={!!database} singleDb={!!database}
/> />
@@ -573,6 +508,7 @@ export default function QueriesPage() {
schema={editorSchema} schema={editorSchema}
onSqlChange={setSql} onSqlChange={setSql}
onExecute={handleExecute} onExecute={handleExecute}
editorRef={sqlEditorRef}
/> />
{/* -- 错误提示 -- */} {/* -- 错误提示 -- */}
@@ -621,18 +557,12 @@ export default function QueriesPage() {
{showHistory && ( {showHistory && (
<QueryHistoryDrawer <QueryHistoryDrawer
history={history}
filteredHistory={filteredHistory}
favFilter={favFilter}
searchHistory={searchHistory}
clearConfirm={clearConfirm}
dbTypeIcon={dbTypeIcon}
onClose={() => setShowHistory(false)} onClose={() => setShowHistory(false)}
onSearchChange={setSearchHistory} onUseSql={(sqlText, run) => {
onToggleFavoriteFilter={() => setFavFilter(!favFilter)} setSql(sqlText);
onClearHistory={handleClearHistory} if (run) handleExecute(1, sqlText);
onToggleFavorite={toggleFavorite} }}
onUseSql={setSql} refreshKey={historyRefreshKey}
/> />
)} )}
<RedisValueViewerDialog <RedisValueViewerDialog