- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
2162 lines
90 KiB
TypeScript
2162 lines
90 KiB
TypeScript
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||
import {
|
||
deleteRedisKey,
|
||
deleteRedisPattern,
|
||
downloadBlob,
|
||
dumpTable,
|
||
exportData,
|
||
execSql,
|
||
getRedisKey,
|
||
getDdl,
|
||
listColumns,
|
||
listRedisKeys,
|
||
listTables,
|
||
loadTree,
|
||
openSession,
|
||
searchObjects,
|
||
type DumpMode,
|
||
type RedisKeyInfo,
|
||
type SearchObjectHit,
|
||
} from "./api";
|
||
import { TableMetadataTree } from "./TableMetadataTree";
|
||
import { DdlModal } from "./DdlModal";
|
||
import { TableDesigner } from "./TableDesigner";
|
||
import type { ColumnInfo } from "./store";
|
||
import { isDriverReady, driverCaps, type TreeGroupKind } from "./catalog";
|
||
import { TreeChevron } from "./TreeChevron";
|
||
import {
|
||
buildRedisKeyTree,
|
||
createKeyCommand,
|
||
filterRedisTree,
|
||
formatRedisTtl,
|
||
redisHintForType,
|
||
redisLeafLabel,
|
||
redisTypeColor,
|
||
removeRedisKeyFromTree,
|
||
} from "./redis";
|
||
import { previewSelectSql, quoteIdent as quoteSqlIdent, relationParts } from "./sql";
|
||
import { connectionEndpoint } from "./contextActions";
|
||
import { askInput } from "./dialogs";
|
||
import { useWorkspaceStore, type SavedConnection, type TreeNode } from "./store";
|
||
import { editorDatabase, isRedisCluster } from "./redisContext";
|
||
|
||
export { formatRedisTtl };
|
||
|
||
const GROUPS: { id: string; label: string; kind: TreeNode["kind"] }[] = [
|
||
{ id: "tables", label: "表", kind: "group" },
|
||
{ id: "views", label: "视图", kind: "group" },
|
||
{ id: "procedures", label: "存储过程", kind: "group" },
|
||
{ id: "functions", label: "函数", kind: "group" },
|
||
];
|
||
|
||
/** 对象树分类色板:高饱和色,深浅主题下都够亮 */
|
||
const TREE_COLORS = {
|
||
database: "#10b981", // 库 · 祖母绿
|
||
tables: "#3b82f6", // 表 · 蓝
|
||
views: "#8b5cf6", // 视图 · 紫
|
||
procedures: "#f59e0b", // 存储过程 · 琥珀
|
||
functions: "#06b6d4", // 函数 · 青
|
||
keys: "#ef4444", // Redis 键 · 红
|
||
folder: "#f59e0b", // Redis 文件夹 · 琥珀
|
||
columns: "#22c55e", // 字段 · 绿
|
||
} as const;
|
||
|
||
type TreeColorKey = keyof typeof TREE_COLORS;
|
||
|
||
function groupIdColor(groupId?: string): TreeColorKey {
|
||
switch (groupId) {
|
||
case "tables":
|
||
return "tables";
|
||
case "views":
|
||
return "views";
|
||
case "procedures":
|
||
return "procedures";
|
||
case "functions":
|
||
return "functions";
|
||
case "keys":
|
||
return "keys";
|
||
case "folder":
|
||
return "folder";
|
||
case "columns":
|
||
return "columns";
|
||
default:
|
||
return "tables";
|
||
}
|
||
}
|
||
|
||
/** 分类图标:实底 16×16 线面结合,颜色即分类色 */
|
||
function TreeIcon({ kind, size = 13 }: { kind: TreeColorKey; size?: number }) {
|
||
const c = TREE_COLORS[kind];
|
||
const common = { stroke: c, fill: "none", strokeWidth: 1.7, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 16 16" className="shrink-0" aria-hidden="true">
|
||
{kind === "database" && (
|
||
<g {...common}>
|
||
<ellipse cx="8" cy="3.6" rx="5.2" ry="2.1" fill={c} fillOpacity="0.35" />
|
||
<path d="M2.8 3.6v8.8c0 1.16 2.33 2.1 5.2 2.1s5.2-.94 5.2-2.1V3.6" />
|
||
<path d="M2.8 8c0 1.16 2.33 2.1 5.2 2.1S13.2 9.16 13.2 8" />
|
||
</g>
|
||
)}
|
||
{kind === "tables" && (
|
||
<g {...common}>
|
||
<rect x="2" y="2" width="12" height="12" rx="2" fill={c} fillOpacity="0.18" />
|
||
<path d="M2 6h12M2 10h12M6 2v12M10 2v12" strokeOpacity="0.85" />
|
||
</g>
|
||
)}
|
||
{kind === "views" && (
|
||
<g {...common}>
|
||
<path d="M1.8 8s2.2-4 6.2-4 6.2 4 6.2 4-2.2 4-6.2 4-6.2-4-6.2-4Z" fill={c} fillOpacity="0.15" />
|
||
<circle cx="8" cy="8" r="1.9" fill={c} stroke="none" />
|
||
</g>
|
||
)}
|
||
{kind === "procedures" && (
|
||
<g {...common}>
|
||
<rect x="2" y="2.5" width="12" height="11" rx="2" fill={c} fillOpacity="0.15" />
|
||
<path d="M6.5 5.8v4.4L10.4 8Z" fill={c} stroke="none" />
|
||
</g>
|
||
)}
|
||
{kind === "functions" && (
|
||
<g {...common}>
|
||
<rect x="2" y="2" width="12" height="12" rx="6" fill={c} fillOpacity="0.15" />
|
||
<path d="M9.4 5.2c-1.2 0-1.2 1-1.2 1.8v2c0 .8 0 1.8-1.3 1.8 1.3 0 1.3 1 1.3 1.8" />
|
||
<path d="M6.6 5.2c1.2 0 1.2 1 1.2 1.8" strokeOpacity="0" />
|
||
</g>
|
||
)}
|
||
{kind === "keys" && (
|
||
<g {...common}>
|
||
<circle cx="5.4" cy="10.6" r="3" fill={c} fillOpacity="0.2" />
|
||
<path d="m7.6 8.4 5-5M10 6l1.8 1.8M11.6 4.4l1.8 1.8" />
|
||
</g>
|
||
)}
|
||
{kind === "folder" && (
|
||
<g {...common}>
|
||
<path d="M2.2 4.2h4.2l1.2 1.4H13.8a1 1 0 0 1 1 1v6.2a1 1 0 0 1-1 1H2.2a1 1 0 0 1-1-1V5.2a1 1 0 0 1 1-1Z" fill={c} fillOpacity="0.2" />
|
||
<path d="M1.2 7.2h13.6" />
|
||
</g>
|
||
)}
|
||
{kind === "columns" && (
|
||
<g {...common}>
|
||
<rect x="2" y="2.5" width="12" height="11" rx="2" fill={c} fillOpacity="0.14" />
|
||
<path d="M4.6 5.6h6.8M4.6 8h6.8M4.6 10.4h4.4" />
|
||
</g>
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/** 分类计数徽章:分类色底 12% 透明 + 分类色文字 */
|
||
function CountBadge({ kind, count }: { kind: TreeColorKey; count: number }) {
|
||
const c = TREE_COLORS[kind];
|
||
return (
|
||
<span
|
||
className="ml-1.5 rounded px-1 text-[10px] font-semibold leading-4 tabular-nums"
|
||
style={{ background: `${c}1f`, color: c }}
|
||
>
|
||
{count}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
|
||
function isRedisConn(driver?: string) {
|
||
return (driver || "").toLowerCase() === "redis";
|
||
}
|
||
|
||
/** 建连在途 promise:并发调用复用同一次 openSession(06-P1#8),避免重复建会话 */
|
||
const sessionInflight = new Map<string, Promise<string>>();
|
||
|
||
/** 可自动重连的错误:会话失效 / 连接类故障(Redis 重启、网络闪断等) */
|
||
export function isReconnectableError(e: unknown): boolean {
|
||
const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
|
||
return (
|
||
msg.includes("会话已失效") ||
|
||
msg.includes("session") ||
|
||
/connection refused|broken pipe|eof|i\/o timeout|reset by peer|connect: bad|closed|unreachable/.test(msg)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 会话自动重连包装:op 失败且属会话/连接类错误时,清本地会话缓存 →
|
||
* 重新 openSession → 重试一次。用户无感恢复(对标 ARDM/Navicat 的自动重连)。
|
||
*/
|
||
export async function withSessionRetry<T>(c: SavedConnection, op: (sid: string) => Promise<T>): Promise<T> {
|
||
try {
|
||
return await op(await ensureSession(c));
|
||
} catch (e) {
|
||
if (!isReconnectableError(e)) throw e;
|
||
useWorkspaceStore.getState().setSession(c.id, null);
|
||
return await op(await ensureSession(c));
|
||
}
|
||
}
|
||
|
||
export async function ensureSession(c: SavedConnection): Promise<string> {
|
||
const store = useWorkspaceStore.getState();
|
||
const existing = store.sessions[c.id];
|
||
if (existing) return existing;
|
||
const inflight = sessionInflight.get(c.id);
|
||
if (inflight) return inflight;
|
||
const p = (async () => {
|
||
const { sessionId } = await openSession(c);
|
||
useWorkspaceStore.getState().setSession(c.id, sessionId);
|
||
return sessionId;
|
||
})();
|
||
sessionInflight.set(c.id, p);
|
||
try {
|
||
return await p;
|
||
} finally {
|
||
sessionInflight.delete(c.id);
|
||
}
|
||
}
|
||
|
||
function emptyGroups(): TreeNode[] {
|
||
// 按驱动能力过滤分组:NoSQL 库不挂「视图/存储过程/函数」等名不副实的空组
|
||
const caps = driverCaps(useWorkspaceStore.getState().connections.find(
|
||
(c) => c.id === useWorkspaceStore.getState().activeConnectionId,
|
||
)?.driver ?? "");
|
||
return GROUPS.filter((g) => caps.treeGroups.includes(g.id as TreeGroupKind)).map((g) => ({
|
||
name: caps.groupLabel[g.id as TreeGroupKind] ?? g.label,
|
||
kind: "group" as const,
|
||
groupId: g.id,
|
||
count: 0,
|
||
children: [],
|
||
loaded: false,
|
||
}));
|
||
}
|
||
|
||
type CtxMenu = {
|
||
x: number;
|
||
y: number;
|
||
label: string;
|
||
/** 对象种类:决定 P0 菜单骨架 */
|
||
kind?: "database" | "table" | "view" | "key" | "group" | "folder";
|
||
/** Redis 文件夹:按此前缀过滤 */
|
||
onFilterPrefix?: () => void;
|
||
/** Redis 文件夹 / 键组:新建键 */
|
||
onNewKey?: () => void;
|
||
/** Redis 文件夹:删除整个目录(前缀批量删除,强确认) */
|
||
onDeleteFolder?: () => void;
|
||
/** Redis 键:批量删除多选的键 */
|
||
onDeleteSelected?: () => void;
|
||
onRefresh: () => void;
|
||
onSchema?: () => void;
|
||
onView?: () => void;
|
||
onDelete?: () => void;
|
||
/** R2-F3:表/视图 —— 查看 DDL(弹 DdlModal) */
|
||
onDdl?: () => void;
|
||
onCopyDdl?: () => void;
|
||
/** 表/视图:查看数据 */
|
||
onQueryData?: () => void;
|
||
onQueryLimit?: () => void;
|
||
qualifiedName?: string;
|
||
/** 表/视图:生成 SQL 到独立查询标签 */
|
||
onGen?: (kind: GenSqlKind) => void;
|
||
/** 表:设计表(设计器 v1:字段网格 + CREATE/ALTER 预览) */
|
||
onDesign?: () => void;
|
||
/** 表:转储 SQL 文件(仅结构/仅数据/结构+数据) */
|
||
onDump?: (mode: DumpMode) => void;
|
||
onExport?: (format: "csv" | "json") => void;
|
||
/** 库:新建表(设计器空表) */
|
||
onNewTable?: () => void;
|
||
/** 表:截断/清空 */
|
||
onTruncate?: () => void;
|
||
/** 表:DROP */
|
||
onDropTable?: () => void;
|
||
/** 库:新建查询 */
|
||
onNewQuery?: () => void;
|
||
/** 库:浏览对象(展开) */
|
||
onBrowse?: () => void;
|
||
/** 库:设为默认库 */
|
||
onSetDefaultDb?: () => void;
|
||
/** 库:删除库 */
|
||
onDropDatabase?: () => void;
|
||
/** 刷新按钮文案(Redis 键用「刷新 TTL」) */
|
||
refreshLabel?: string;
|
||
/** 复制名称按钮文案 */
|
||
copyLabel?: string;
|
||
};
|
||
|
||
type Ctx = CtxMenu | null;
|
||
|
||
export type GenSqlKind = "select" | "insert" | "update" | "delete" | "ddl";
|
||
|
||
/** 标识符按方言加引号(Oracle 裸名) */
|
||
function quoteIdent(driver: string | undefined, ident: string): string {
|
||
return quoteSqlIdent(driver, ident);
|
||
}
|
||
|
||
/** 库.表 全限定(SQLite 为单文件无库级) */
|
||
function qualTable(driver: string | undefined, db: string, table: string): string {
|
||
return relationParts(driver, db, table).map((part) => quoteIdent(driver, part)).join(".");
|
||
}
|
||
|
||
/** 生成 SQL 模板:? 占位符,主键自动进 WHERE;无列信息时退化为骨架 */
|
||
function buildGenSql(
|
||
kind: GenSqlKind,
|
||
driver: string | undefined,
|
||
db: string,
|
||
table: string,
|
||
cols: ColumnInfo[],
|
||
): string {
|
||
const qt = qualTable(driver, db, table);
|
||
const q = (s: string) => quoteIdent(driver, s);
|
||
const names = cols.map((c) => c.name).filter(Boolean);
|
||
const pk = cols.find((c) => c.key === "PRI" || c.key === "PK")?.name;
|
||
if (kind === "insert") {
|
||
if (!names.length) return `INSERT INTO ${qt} (...) VALUES (...);`;
|
||
return `INSERT INTO ${qt}\n (${names.map(q).join(", ")})\nVALUES\n (${names.map(() => "?").join(", ")});`;
|
||
}
|
||
if (kind === "update") {
|
||
const sets = (names.length ? names : ["col"]).map((c) => `${q(c)} = ?`);
|
||
return `UPDATE ${qt}\nSET\n ${sets.join(",\n ")}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
|
||
}
|
||
if (kind === "delete") {
|
||
return `DELETE FROM ${qt}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
|
||
}
|
||
const columns = names.length ? names.map(q).join(", ") : "*";
|
||
const d = (driver ?? "").toLowerCase();
|
||
if (d === "sqlserver" || d === "mssql") return `SELECT TOP 100 ${columns} FROM ${qt};`;
|
||
if (d === "oracle") return `SELECT ${columns} FROM ${qt} FETCH FIRST 100 ROWS ONLY;`;
|
||
return `SELECT ${columns} FROM ${qt} LIMIT 100;`;
|
||
}
|
||
|
||
export function ObjectTree() {
|
||
const connections = useWorkspaceStore((s) => s.connections);
|
||
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
|
||
const tree = useWorkspaceStore((s) => s.tree);
|
||
const treeLoading = useWorkspaceStore((s) => s.treeLoading);
|
||
const set = useWorkspaceStore((s) => s.set);
|
||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
||
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
|
||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||
const notify = useWorkspaceStore((s) => s.notify);
|
||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||
const pushHistory = useWorkspaceStore((s) => s.pushHistory);
|
||
const setResultSets = useWorkspaceStore((s) => s.setResultSets);
|
||
const tabs = useWorkspaceStore((s) => s.tabs);
|
||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||
const [ctx, setCtx] = useState<Ctx>(null);
|
||
const keyFilter = useWorkspaceStore((s) => s.sidebarFilter);
|
||
const [redisDbDraft, setRedisDbDraft] = useState<Record<string, string>>({});
|
||
const [redisDbSearch, setRedisDbSearch] = useState<Record<string, string>>({});
|
||
const [redisKeyCursors, setRedisKeyCursors] = useState<Record<string, string>>({});
|
||
const redisLoadedKeys = useRef<Record<string, RedisKeyInfo[]>>({});
|
||
const redisRequests = useRef<Record<string, number>>({});
|
||
const setKeyFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
|
||
/** Redis 类型过滤:全部 / string / hash / list / set / zset / stream(前端过滤) */
|
||
const redisTypeFilter = useWorkspaceStore((s) => s.sidebarTypeFilter);
|
||
const [newKey, setNewKey] = useState<{ name: string; type: string; value: string; db?: string } | null>(null);
|
||
/** 库/表过滤(非 Redis 连接);只匹配库名与已加载的对象名 */
|
||
const treeFilter = useWorkspaceStore((s) => s.sidebarFilter);
|
||
const setTreeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
|
||
/** R2-F4:本地过滤无命中时的服务端搜索状态 */
|
||
const [dbSearchKw, setDbSearchKw] = useState("");
|
||
const [dbSearching, setDbSearching] = useState(false);
|
||
const [dbSearchResults, setDbSearchResults] = useState<SearchObjectHit[] | null>(null);
|
||
/** R2-F3:DDL 弹窗目标(库.表) */
|
||
const [ddlTarget, setDdlTarget] = useState<{ db: string; table: string } | null>(null);
|
||
/** 表设计器目标(table 缺省 = 新建表) */
|
||
const [designerTarget, setDesignerTarget] = useState<{ db: string; table?: string } | null>(null);
|
||
const ddlRequest = useWorkspaceStore((s) => s.ddlRequest);
|
||
const viewDataRequest = useWorkspaceStore((s) => s.viewDataRequest);
|
||
/** 修 O1:单击选中的树节点 key(db 或 db/obj) */
|
||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||
/** 多选键(Cmd/Ctrl+Click 切换),用于批量删除 */
|
||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||
const toggleKeySelect = (dbName: string, key: string, additive: boolean) => {
|
||
const id = `${dbName}/${key}`;
|
||
setSelectedKeys((prev) => {
|
||
const next = additive ? new Set(prev) : new Set<string>();
|
||
if (additive && prev.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
const treeScrollRef = useRef<HTMLDivElement>(null);
|
||
|
||
const conn = connections.find((c) => c.id === activeId);
|
||
const selectedRedisDb = editorDatabase(conn, tabs.find(t => t.id === activeTabId));
|
||
|
||
useEffect(() => {
|
||
setRedisDbDraft({});
|
||
setRedisDbSearch({});
|
||
setRedisKeyCursors({});
|
||
redisLoadedKeys.current = {};
|
||
}, [activeId]);
|
||
|
||
useEffect(() => {
|
||
// 底部全局搜索发起新查询时,清除当前库独立搜索的覆盖。
|
||
setRedisDbSearch({});
|
||
setRedisDbDraft({});
|
||
}, [keyFilter]);
|
||
|
||
const refresh = async (id: string) => {
|
||
const c = connections.find((x) => x.id === id);
|
||
if (!c) return;
|
||
if (!isDriverReady(c.driver)) {
|
||
set("tree", []);
|
||
set("status", "驱动未就绪");
|
||
return;
|
||
}
|
||
set("treeLoading", true);
|
||
set("status", `加载 ${c.name}…`);
|
||
try {
|
||
const sid = await ensureSession(c);
|
||
const nodes = await loadTree(c, sid);
|
||
set("tree", nodes);
|
||
set("status", `${c.name} · ${nodes.length} 库`);
|
||
// C8/§3.10:后台预热当前库表清单,喂给 SqlEditor 补全与 AiPanel 上下文
|
||
if (c.database && !isRedisConn(c.driver)) {
|
||
void listTables(c, c.database, sid).catch(() => {});
|
||
}
|
||
} catch (e) {
|
||
set("tree", []);
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
set("status", msg);
|
||
if (msg.includes("会话") || msg.toLowerCase().includes("session") || msg.toLowerCase().includes("connect")) {
|
||
set("connState", "down");
|
||
}
|
||
notifyError(e, "modal");
|
||
} finally {
|
||
set("treeLoading", false);
|
||
}
|
||
};
|
||
|
||
const treeEpoch = useWorkspaceStore((s) => s.treeEpoch);
|
||
useEffect(() => {
|
||
if (activeId) void refresh(activeId);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeId, treeEpoch]);
|
||
|
||
useEffect(() => {
|
||
// 底部搜索/刷新可重复提交相同关键词;每次都以该关键词为准。
|
||
setRedisDbSearch({});
|
||
setRedisDbDraft({});
|
||
}, [treeEpoch]);
|
||
|
||
useEffect(() => {
|
||
if (!ctx) return;
|
||
const close = () => setCtx(null);
|
||
const onKey = (e: KeyboardEvent) => {
|
||
// 修 O2:Esc 关闭右键菜单
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setCtx(null);
|
||
}
|
||
};
|
||
window.addEventListener("click", close);
|
||
window.addEventListener("keydown", onKey, true);
|
||
return () => {
|
||
window.removeEventListener("click", close);
|
||
window.removeEventListener("keydown", onKey, true);
|
||
};
|
||
}, [ctx]);
|
||
|
||
// 修 O2:视口边界翻转 —— 菜单渲染后测量,贴右/下缘时自动收回
|
||
const ctxRef = useRef<HTMLDivElement>(null);
|
||
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
|
||
useEffect(() => {
|
||
if (!ctx) {
|
||
setCtxPos(null);
|
||
return;
|
||
}
|
||
const el = ctxRef.current;
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const x = ctx.x + r.width > window.innerWidth - 8 ? Math.max(8, window.innerWidth - r.width - 8) : ctx.x;
|
||
const y = ctx.y + r.height > window.innerHeight - 8 ? Math.max(8, window.innerHeight - r.height - 8) : ctx.y;
|
||
setCtxPos({ x, y });
|
||
}, [ctx]);
|
||
|
||
const patchDb = (dbName: string, patch: Partial<TreeNode> | ((n: TreeNode) => TreeNode)) => {
|
||
const cur = useWorkspaceStore.getState().tree;
|
||
set(
|
||
"tree",
|
||
cur.map((d) => {
|
||
if (d.name !== dbName || d.kind !== "database") return d;
|
||
return typeof patch === "function" ? patch(d) : { ...d, ...patch };
|
||
}),
|
||
);
|
||
};
|
||
|
||
const expandDb = async (dbName: string, requestedPattern?: string, nextCursor?: string) => {
|
||
if (!conn) return;
|
||
const node = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
||
if ((node?.loaded && !nextCursor) || node?.loading) return;
|
||
const redis = isRedisConn(conn.driver);
|
||
const request = (redisRequests.current[dbName] ?? 0) + 1;
|
||
redisRequests.current[dbName] = request;
|
||
patchDb(dbName, {
|
||
loading: true,
|
||
children: redis && nextCursor ? node?.children : redis ? [] : emptyGroups(),
|
||
});
|
||
try {
|
||
if (redis) {
|
||
// 无通配符由服务端按 *关键词* 扫描,精确键单独置顶;有通配符按 glob 扫。
|
||
const raw = (requestedPattern ?? redisDbSearch[dbName] ?? keyFilter).trim();
|
||
const pat = raw || undefined;
|
||
const res = await withSessionRetry(conn, (sid2) => listRedisKeys(conn, dbName, { sessionId: sid2, pattern: pat, keyCursor: nextCursor }));
|
||
if (useWorkspaceStore.getState().activeConnectionId !== conn.id || redisRequests.current[dbName] !== request) return;
|
||
if (res.error) throw new Error(res.error);
|
||
const page = (res.keys ?? []).map((k) => ({
|
||
name: k.name,
|
||
type: k.type,
|
||
ttl: k.ttl,
|
||
exact: k.exact || (res.exactHit && k.name === raw) || undefined,
|
||
}));
|
||
const merged = new Map<string, RedisKeyInfo>();
|
||
if (nextCursor) for (const key of redisLoadedKeys.current[dbName] ?? []) merged.set(key.name, key);
|
||
for (const key of page) merged.set(key.name, key);
|
||
const keys = [...merged.values()];
|
||
redisLoadedKeys.current[dbName] = keys;
|
||
setRedisKeyCursors(prev => ({ ...prev, [dbName]: res.nextCursor ?? "" }));
|
||
const sep = conn.keySeparator || ":";
|
||
patchDb(dbName, {
|
||
loading: false,
|
||
loaded: true,
|
||
children: buildRedisKeyTree(keys, sep),
|
||
});
|
||
return;
|
||
}
|
||
const sid = await ensureSession(conn);
|
||
const { tables } = await withSessionRetry(conn, (sid2) => listTables(conn, dbName, sid2));
|
||
// 仅当驱动声明对应分组时才发起元数据查询(ES/ZK/Influx/Mongo 等不查视图/过程/函数)
|
||
const caps = driverCaps(conn.driver);
|
||
let views: string[] = [];
|
||
let procedures: string[] = [];
|
||
let functions: string[] = [];
|
||
if (caps.treeGroups.includes("views")) {
|
||
try {
|
||
views = await listSchemaObjects(conn, sid, dbName, "views");
|
||
} catch {
|
||
/* optional */
|
||
}
|
||
}
|
||
if (caps.treeGroups.includes("procedures")) {
|
||
try {
|
||
procedures = await listSchemaObjects(conn, sid, dbName, "procedures");
|
||
} catch {
|
||
/* optional */
|
||
}
|
||
}
|
||
if (caps.treeGroups.includes("functions")) {
|
||
try {
|
||
functions = await listSchemaObjects(conn, sid, dbName, "functions");
|
||
} catch {
|
||
/* optional */
|
||
}
|
||
}
|
||
// flat 驱动(ES/Mongo/Influx/ZK):对象直挂库节点,不包分组
|
||
if (caps.treeGroups.length === 0) {
|
||
patchDb(dbName, {
|
||
loading: false,
|
||
loaded: true,
|
||
children: tables.map((t) => ({ name: t, kind: "table" as const })),
|
||
});
|
||
return;
|
||
}
|
||
const label = (k: TreeGroupKind, fb: string) => caps.groupLabel[k] ?? fb;
|
||
patchDb(dbName, {
|
||
loading: false,
|
||
loaded: true,
|
||
children: [
|
||
{
|
||
name: label("tables", "表"),
|
||
kind: "group",
|
||
groupId: "tables",
|
||
count: tables.length,
|
||
loaded: true,
|
||
children: tables.map((t) => ({ name: t, kind: "table" as const })),
|
||
},
|
||
...(caps.treeGroups.includes("views")
|
||
? [{
|
||
name: label("views", "视图"),
|
||
kind: "group" as const,
|
||
groupId: "views",
|
||
count: views.length,
|
||
loaded: true,
|
||
children: views.map((t) => ({ name: t, kind: "view" as const })),
|
||
}]
|
||
: []),
|
||
...(caps.treeGroups.includes("procedures")
|
||
? [{
|
||
name: label("procedures", "存储过程"),
|
||
kind: "group" as const,
|
||
groupId: "procedures",
|
||
count: procedures.length,
|
||
loaded: true,
|
||
children: procedures.map((t) => ({ name: t, kind: "procedure" as const })),
|
||
}]
|
||
: []),
|
||
...(caps.treeGroups.includes("functions")
|
||
? [{
|
||
name: label("functions", "函数"),
|
||
kind: "group" as const,
|
||
groupId: "functions",
|
||
count: functions.length,
|
||
loaded: true,
|
||
children: functions.map((t) => ({ name: t, kind: "function" as const })),
|
||
}]
|
||
: []),
|
||
],
|
||
});
|
||
} catch (e) {
|
||
if (redis && (redisRequests.current[dbName] !== request || useWorkspaceStore.getState().activeConnectionId !== conn.id)) return;
|
||
patchDb(dbName, {
|
||
loading: false,
|
||
loaded: !!nextCursor,
|
||
children: nextCursor ? node?.children : isRedisConn(conn.driver) ? [] : emptyGroups(),
|
||
});
|
||
notifyError(e, "toast");
|
||
}
|
||
};
|
||
|
||
const searchRedisDb = (dbName: string) => {
|
||
const raw = (redisDbDraft[dbName] ?? "").trim();
|
||
setRedisDbSearch(prev => ({ ...prev, [dbName]: raw }));
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName, raw);
|
||
};
|
||
|
||
// Redis:刷新后自动展开库以灌入 SCAN/精确结果(details open 属性不保证触发 onToggle)
|
||
useEffect(() => {
|
||
if (!conn || !isRedisConn(conn.driver) || treeLoading) return;
|
||
const shouldExpand = keyFilter.trim().length > 0 || tree.length === 1;
|
||
if (!shouldExpand) return;
|
||
const currentDb = selectedRedisDb;
|
||
for (const db of tree) {
|
||
if (db.name !== currentDb) continue;
|
||
if (db.kind === "database" && !db.loaded && !db.loading) void expandDb(db.name);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [conn?.id, tree, treeLoading, keyFilter, selectedRedisDb]);
|
||
|
||
const expandTable = async (dbName: string, table: string, groupId = "tables") => {
|
||
if (!conn) return;
|
||
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
||
const tablesGroup = dbNode?.children?.find((n) => n.groupId === groupId);
|
||
const tNode = tablesGroup?.children?.find((n) => n.name === table);
|
||
if (tNode?.loaded || tNode?.loading) return;
|
||
|
||
const mark = (patch: Partial<TreeNode>) => {
|
||
patchDb(dbName, (d) => ({
|
||
...d,
|
||
children: (d.children ?? []).map((g) =>
|
||
g.groupId !== groupId
|
||
? g
|
||
: {
|
||
...g,
|
||
children: (g.children ?? []).map((t) => (t.name === table ? { ...t, ...patch } : t)),
|
||
},
|
||
),
|
||
}));
|
||
};
|
||
mark({ loading: true });
|
||
try {
|
||
const { columns } = await withSessionRetry(conn, (sid) => listColumns(conn, dbName, table, sid));
|
||
mark({
|
||
loading: false,
|
||
loaded: true,
|
||
children: [
|
||
{
|
||
name: "字段",
|
||
kind: "group" as const,
|
||
groupId: "columns",
|
||
count: columns.length,
|
||
loaded: true,
|
||
children: columns.map((col) => ({
|
||
name: col.name,
|
||
kind: "column" as const,
|
||
type: col.type,
|
||
nullable: col.nullable,
|
||
key: col.key,
|
||
})),
|
||
},
|
||
],
|
||
});
|
||
} catch (e) {
|
||
mark({ loading: false });
|
||
notifyError(e, "toast");
|
||
}
|
||
};
|
||
|
||
|
||
const openSchema = async (dbName: string, table: string) => {
|
||
if (!conn) return;
|
||
set("resultTab", "schema");
|
||
set("schemaView", { database: dbName, table, columns: [], loading: true });
|
||
try {
|
||
const { columns } = await withSessionRetry(conn, (sid) => listColumns(conn, dbName, table, sid));
|
||
set("schemaView", { database: dbName, table, columns, loading: false });
|
||
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
|
||
set("resultMeta", { database: dbName, table, pkCols });
|
||
set("connState", "ok");
|
||
set("status", `结构 ${table} · ${columns.length} 列`);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
set("schemaView", { database: dbName, table, columns: [], loading: false, error: msg });
|
||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||
notifyError(e, "toast");
|
||
}
|
||
};
|
||
|
||
|
||
const openRedisKey = async (dbName: string, key: string) => {
|
||
if (!conn) return;
|
||
set("running", true);
|
||
set("status", `读取 ${key}…`);
|
||
try {
|
||
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, key, { sessionId: sid, limit: 500 }));
|
||
const qr = {
|
||
columns: res.columns ?? [],
|
||
rows: res.rows ?? [],
|
||
rowCount: res.rowCount ?? 0,
|
||
timeMs: res.timeMs ?? 0,
|
||
error: res.error,
|
||
truncated: res.truncated,
|
||
limit: res.limit,
|
||
nextCursor: res.nextCursor,
|
||
};
|
||
const hint = redisHintForType(key, res.type);
|
||
set("resultMeta", { database: dbName, table: key, pkCols: [], redisType: res.type, redisTtl: res.ttl });
|
||
setResultSets([
|
||
{ id: crypto.randomUUID(), label: key, sql: hint, result: qr, database: dbName },
|
||
]);
|
||
set("schemaView", {
|
||
database: dbName,
|
||
table: key,
|
||
columns: [
|
||
{
|
||
name: key,
|
||
type: res.type || "unknown",
|
||
nullable: true,
|
||
comment: `ttl=${formatRedisTtl(res.ttl)}`,
|
||
},
|
||
],
|
||
loading: false,
|
||
});
|
||
set("connState", "ok");
|
||
const ttlLabel = formatRedisTtl(res.ttl);
|
||
set(
|
||
"status",
|
||
res.error
|
||
? res.error
|
||
: `${key} · ${res.type || "?"} · TTL ${ttlLabel} · ${qr.rowCount} 行`,
|
||
);
|
||
const tab = tabs.find((t) => t.id === activeTabId);
|
||
if (!tab?.dirty) updateActiveSql(hint);
|
||
pushHistory({
|
||
sql: hint,
|
||
connectionId: conn.id,
|
||
rowCount: qr.rowCount,
|
||
timeMs: qr.timeMs,
|
||
error: qr.error,
|
||
});
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg });
|
||
set("status", msg);
|
||
notifyError(e, "modal");
|
||
} finally {
|
||
set("running", false);
|
||
}
|
||
};
|
||
|
||
const removeRedisKey = async (dbName: string, key: string) => {
|
||
if (!conn) return;
|
||
const ok = await askConfirm({
|
||
title: "删除键",
|
||
message: `确认删除 Redis 键「${key}」?此操作不可恢复。`,
|
||
okLabel: "删除",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const res = await withSessionRetry(conn, (sid) => deleteRedisKey(conn, dbName, key, { sessionId: sid }));
|
||
if (res.error) throw new Error(res.error);
|
||
notify({ kind: "success", title: "已删除", message: key, mode: "toast", durationMs: 1400 });
|
||
patchDb(dbName, (d) => ({
|
||
...d,
|
||
children: removeRedisKeyFromTree(d.children ?? [], key),
|
||
}));
|
||
const meta = useWorkspaceStore.getState().resultMeta;
|
||
if (meta?.table === key && meta?.database === dbName) {
|
||
set("result", null);
|
||
set("resultMeta", { database: dbName, pkCols: [] });
|
||
}
|
||
set("status", `已删除 ${key}`);
|
||
} catch (e) {
|
||
notifyError(e, "modal");
|
||
}
|
||
};
|
||
|
||
/** 删除整个目录(前缀批量删除):强确认 → SCAN+UNLINK → 刷新库 */
|
||
const deleteFolderPrefix = async (dbName: string, prefix: string, count: number) => {
|
||
if (!conn) return;
|
||
const label = prefix.replace(/:+$/, "") || prefix;
|
||
const pattern = prefix.endsWith(":") ? `${prefix}*` : `${prefix}:*`;
|
||
const ok = await askConfirm({
|
||
title: "删除目录",
|
||
message: `确认删除目录「${label}」下的全部键?\n模式:${pattern} · 当前已加载 ${count} 个键。此操作不可恢复。`,
|
||
okLabel: "删除",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const res = await withSessionRetry(conn, (sid) => deleteRedisPattern(conn, dbName, pattern, { sessionId: sid }));
|
||
if (res.error) throw new Error(res.error);
|
||
notify({
|
||
kind: "success",
|
||
title: "已删除目录",
|
||
message: `${label} · ${res.deleted} 个键${res.truncated ? "(扫描截断,可能仍有残留)" : ""}`,
|
||
mode: "toast",
|
||
durationMs: 2400,
|
||
});
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName);
|
||
} catch (e) {
|
||
notifyError(e, "modal");
|
||
}
|
||
};
|
||
|
||
/** 批量删除选中的键(Cmd+Click 多选) */
|
||
const deleteSelectedKeys = async (dbName: string) => {
|
||
if (!conn) return;
|
||
const ids = [...selectedKeys].filter((id) => id.startsWith(`${dbName}/`)).map((id) => id.slice(dbName.length + 1));
|
||
if (!ids.length) return;
|
||
const ok = await askConfirm({
|
||
title: "批量删除",
|
||
message: `确认删除选中的 ${ids.length} 个键?此操作不可恢复。`,
|
||
okLabel: "删除",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const sid = await ensureSession(conn);
|
||
let deleted = 0;
|
||
for (const key of ids) {
|
||
const res = await deleteRedisKey(conn, dbName, key, { sessionId: sid });
|
||
if (!res.error) deleted++;
|
||
}
|
||
notify({
|
||
kind: "success",
|
||
title: "已批量删除",
|
||
message: `${deleted}/${ids.length} 个键`,
|
||
mode: "toast",
|
||
durationMs: 2000,
|
||
});
|
||
setSelectedKeys(new Set());
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName);
|
||
} catch (e) {
|
||
notifyError(e, "modal");
|
||
}
|
||
};
|
||
|
||
const createRedisKey = async (dbName: string, spec: { name: string; type: string; value: string }) => { if (!conn) return;
|
||
const name = spec.name.trim();
|
||
if (!name) {
|
||
notify({ kind: "warning", title: "新建键", message: "键名必填", mode: "toast" });
|
||
return;
|
||
}
|
||
try {
|
||
const sql = createKeyCommand(spec.type, name, spec.value);
|
||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName }));
|
||
if (res.error) throw new Error(res.error);
|
||
notify({ kind: "success", title: "已新建", message: name, mode: "toast", durationMs: 1400 });
|
||
setNewKey(null);
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
await expandDb(dbName);
|
||
await openRedisKey(dbName, name);
|
||
} catch (e) {
|
||
notifyError(e, "modal");
|
||
}
|
||
};
|
||
|
||
const putSelectSql = async (dbName: string, table: string, limit = 100) => {
|
||
if (!conn) return;
|
||
set("focusedRelation", { database: dbName, table });
|
||
if (isRedisConn(conn.driver)) {
|
||
await openRedisKey(dbName, table);
|
||
return;
|
||
}
|
||
// 非 SQL 方言驱动:树点表 = 各驱动 DSL 预览(ZK 读节点 / Mongo find / Influx 按 measurement 过滤)
|
||
if (conn.driver === "zookeeper" || conn.driver === "mongodb" || conn.driver === "influxdb") {
|
||
const sql =
|
||
conn.driver === "zookeeper"
|
||
? `GET ${dbName ? `/${dbName}/${table}` : `/${table}`}`
|
||
: conn.driver === "mongodb"
|
||
? `db.${table}.find()`
|
||
: `from(bucket: "${dbName}") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "${table}") |> limit(n: 100)`;
|
||
updateActiveSql(sql);
|
||
set("running", true);
|
||
set("status", `打开 ${table}…`);
|
||
try {
|
||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit: 100 }));
|
||
setResultSets([{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: 100, database: dbName }]);
|
||
set("resultTab", "data");
|
||
set("connState", "ok");
|
||
set("status", res.error ? res.error : `${table} · ${res.rowCount} 行`);
|
||
pushHistory({ sql, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
|
||
} catch (e) {
|
||
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: e instanceof Error ? e.message : String(e) });
|
||
notifyError(e, "modal");
|
||
} finally {
|
||
set("running", false);
|
||
}
|
||
return;
|
||
}
|
||
// 预览 SQL 不烧 LIMIT:翻页由请求层 offset/limit 承担(带 LIMIT 时第 2 页起恒为空)
|
||
const sql = previewSelectSql(conn.driver, dbName, table);
|
||
const tab = tabs.find((t) => t.id === activeTabId);
|
||
const samePreview = tab?.connectionId === conn.id && tab.database === dbName && tab.sql === sql &&
|
||
(tab.title === table || tab.title === `${table}@${dbName}`);
|
||
if (!samePreview && (tab?.dirty || !!tab?.sql.trim())) {
|
||
// 当前标签有内容时新开,避免覆盖其他表预览或 SQL 草稿。
|
||
newQueryTab(dbName);
|
||
}
|
||
useWorkspaceStore.getState().updateActiveSql(sql);
|
||
useWorkspaceStore.getState().setActiveTabTitle(`${table}@${dbName}`);
|
||
useWorkspaceStore.getState().setTabDatabase(dbName);
|
||
set("running", true);
|
||
set("status", `打开 ${table}…`);
|
||
try {
|
||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit }));
|
||
// 挂为结果集(C5):树打开的表预览同样可翻页
|
||
setResultSets([
|
||
{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: limit, database: dbName,
|
||
source: { connectionId: conn.id, driver: conn.driver, database: dbName, table } },
|
||
]);
|
||
set("resultTab", "data");
|
||
try {
|
||
const { columns } = await withSessionRetry(conn, (sid2) => listColumns(conn, dbName, table, sid2));
|
||
const pkCols = columns.filter((c: ColumnInfo) => c.key === "PRI").map((c: ColumnInfo) => c.name);
|
||
set("resultMeta", { database: dbName, table, pkCols });
|
||
} catch {
|
||
set("resultMeta", { database: dbName, table, pkCols: [] });
|
||
}
|
||
set("connState", "ok");
|
||
set("status", res.error ? res.error : `${table} · ${res.rowCount} 行`);
|
||
pushHistory({ sql, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
|
||
void expandTable(dbName, table);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg });
|
||
set("status", msg);
|
||
notifyError(e, "modal");
|
||
} finally {
|
||
set("running", false);
|
||
}
|
||
};
|
||
|
||
const copyName = async (name: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(name);
|
||
} catch (e) { notifyError(e, "toast"); }
|
||
};
|
||
|
||
const onCtx = (
|
||
e: ReactMouseEvent,
|
||
label: string,
|
||
onRefresh: () => void,
|
||
onSchema?: () => void,
|
||
onView?: () => void,
|
||
onDelete?: () => void,
|
||
extra?: Partial<
|
||
Pick<
|
||
CtxMenu,
|
||
| "kind"
|
||
| "onQueryData"
|
||
| "onQueryLimit"
|
||
| "qualifiedName"
|
||
| "onGen"
|
||
| "onDesign"
|
||
| "onDump"
|
||
| "onExport"
|
||
| "onNewTable"
|
||
| "onTruncate"
|
||
| "onDropTable"
|
||
| "onNewQuery"
|
||
| "onDdl"
|
||
| "onCopyDdl"
|
||
| "onBrowse"
|
||
| "onSetDefaultDb"
|
||
| "onDropDatabase"
|
||
| "onFilterPrefix"
|
||
| "onNewKey"
|
||
| "onDeleteFolder"
|
||
| "onDeleteSelected"
|
||
| "refreshLabel"
|
||
| "copyLabel"
|
||
>
|
||
>,
|
||
) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete, ...extra });
|
||
};
|
||
|
||
const canDropDatabase = (driver?: string) => {
|
||
const d = (driver || "").toLowerCase();
|
||
if (!d || d === "sqlite" || d === "redis" || d === "mongodb" || d === "elasticsearch" || d === "zookeeper" || d === "influxdb") return false;
|
||
return true;
|
||
};
|
||
|
||
const canSetDefaultDb = (driver?: string) => {
|
||
const d = (driver || "").toLowerCase();
|
||
return !!d && d !== "sqlite" && d !== "redis";
|
||
};
|
||
|
||
const dropDatabase = async (dbName: string) => {
|
||
if (!conn) return;
|
||
if (!canDropDatabase(conn.driver)) {
|
||
notify({ kind: "warning", title: "删除库", message: "当前引擎不支持删除库", mode: "toast" });
|
||
return;
|
||
}
|
||
const ok = await askConfirm({
|
||
title: "删除数据库",
|
||
message: `确认删除数据库「${dbName}」?库内对象将一并删除,不可恢复。`,
|
||
okLabel: "删除库",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const d = conn.driver.toLowerCase();
|
||
let sql = `DROP DATABASE ${quoteIdent(conn.driver, dbName)}`;
|
||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss" || d === "opengauss") {
|
||
sql = `DROP DATABASE ${quoteIdent(conn.driver, dbName)}`;
|
||
}
|
||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, confirmDanger: true }));
|
||
if (res.error) throw new Error(res.error);
|
||
notify({ kind: "success", title: "已删除库", message: dbName, mode: "toast" });
|
||
if (activeId) void refresh(activeId);
|
||
} catch (e) {
|
||
notifyError(e, "modal");
|
||
}
|
||
};
|
||
|
||
const setDefaultDatabase = (dbName: string) => {
|
||
if (!conn) return;
|
||
const next = { ...conn, database: dbName };
|
||
useWorkspaceStore.getState().upsertConnection(next);
|
||
notify({ kind: "success", title: "已设为默认库", message: dbName, mode: "toast", durationMs: 1500 });
|
||
};
|
||
|
||
/** 生成表 SQL 模板:优先用树上已加载的列,取不到再查库 */
|
||
const genTableSql = async (dbName: string, table: string, kind: GenSqlKind) => {
|
||
if (!conn) return;
|
||
if (kind === "ddl") {
|
||
try {
|
||
const res = await withSessionRetry(conn, sid => getDdl(conn, table, { database: dbName, sessionId: sid }));
|
||
if (res.error || !res.ddl) throw new Error(res.error || "未返回 DDL");
|
||
if (useWorkspaceStore.getState().activeConnectionId !== conn.id) return;
|
||
const st = useWorkspaceStore.getState();
|
||
st.newQueryTab(dbName);
|
||
st.updateActiveSql(res.ddl);
|
||
st.setActiveTabTitle(`DDL · ${table}`);
|
||
} catch (error) { notifyError(error, "toast"); }
|
||
return;
|
||
}
|
||
const st = useWorkspaceStore.getState();
|
||
const tNode = st.tree
|
||
.find((n) => n.name === dbName)
|
||
?.children?.find((g) => g.groupId === "tables")
|
||
?.children?.find((c) => c.name === table);
|
||
let cols = ((tNode?.children ?? []).find((c) => c.groupId === "columns")?.children ?? []) as ColumnInfo[];
|
||
if (!cols.length) {
|
||
try {
|
||
cols = (await withSessionRetry(conn, (sid) => listColumns(conn, dbName, table, sid))).columns;
|
||
} catch {
|
||
/* 拿不到列也能生成骨架模板 */
|
||
}
|
||
}
|
||
// 模板进入独立标签,避免覆盖现有草稿或异步加载期间切换到的查询。
|
||
if (useWorkspaceStore.getState().activeConnectionId !== conn.id) return;
|
||
st.newQueryTab(dbName);
|
||
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
|
||
st.setActiveTabTitle(`${kind.toUpperCase()} · ${table}`);
|
||
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
|
||
};
|
||
|
||
/** 截断 / 删除表:二次确认后执行并刷新库 */
|
||
|
||
/** 表右键「转储 SQL 文件」:服务端流式生成 .sql 后浏览器下载 */
|
||
const dumpTableSql = async (dbName: string, table: string, mode: DumpMode) => {
|
||
if (!conn) return;
|
||
try {
|
||
const { blob, filename } = await withSessionRetry(conn, (sid) =>
|
||
dumpTable(conn, { table, database: dbName, mode, sessionId: sid }),
|
||
);
|
||
downloadBlob(blob, filename || `dump-${table}.sql`);
|
||
const label = mode === "structure" ? "仅结构" : mode === "data" ? "仅数据" : "结构和数据";
|
||
notify({ kind: "success", title: "转储已下载", message: `${label} · ${dbName}.${table}`, mode: "toast", durationMs: 1800, feedback: "visible" });
|
||
} catch (e) {
|
||
notifyError(e, "toast");
|
||
}
|
||
};
|
||
|
||
const exportTableData = async (dbName: string, table: string, format: "csv" | "json") => {
|
||
if (!conn) return;
|
||
const input = await askInput({
|
||
title: `导出 ${dbName}.${table} · ${format.toUpperCase()}`,
|
||
label: "最多导出行数(1–500000;达到上限时文件可能不完整)",
|
||
defaultValue: "100000",
|
||
placeholder: "100000",
|
||
});
|
||
if (input == null) return;
|
||
const limit = Number(input.trim());
|
||
if (!/^\d+$/.test(input.trim()) || limit < 1 || limit > 500000) {
|
||
notify({ kind: "warning", title: "行数无效", message: "请输入 1–500000 之间的整数", mode: "toast" });
|
||
return;
|
||
}
|
||
try {
|
||
const { blob, filename } = await withSessionRetry(conn, sid => exportData(conn, { database: dbName, table, format, limit, sessionId: sid }));
|
||
downloadBlob(blob, filename || `${table}.${format}`);
|
||
notify({ kind: "success", title: "导出已下载", message: `上限 ${limit} 行;若表超过上限,文件仅包含前 ${limit} 行`, mode: "toast", feedback: "visible" });
|
||
} catch (error) { notifyError(error, "modal"); }
|
||
};
|
||
|
||
/** 设计器应用后刷新该库对象(结构与表清单都可能变化) */
|
||
const refreshDbAfterDesign = (dbName: string) => {
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!ddlRequest) return;
|
||
setDdlTarget({ db: ddlRequest.database, table: ddlRequest.table });
|
||
set("focusedRelation", { database: ddlRequest.database, table: ddlRequest.table });
|
||
set("ddlRequest", null);
|
||
}, [ddlRequest, set]);
|
||
|
||
useEffect(() => {
|
||
if (!viewDataRequest) return;
|
||
const { database, table } = viewDataRequest;
|
||
set("viewDataRequest", null);
|
||
void putSelectSql(database, table);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [viewDataRequest]);
|
||
|
||
const runTableDanger = async (dbName: string, table: string, action: "truncate" | "drop") => {
|
||
if (!conn) return;
|
||
const ok = await askConfirm({
|
||
title: action === "truncate" ? "截断表" : "删除表",
|
||
message:
|
||
action === "truncate"
|
||
? `确认清空表「${dbName}.${table}」的全部数据?结构保留,数据不可恢复。`
|
||
: `确认删除表「${dbName}.${table}」?表结构与数据将一并删除,不可恢复。`,
|
||
okLabel: action === "truncate" ? "截断" : "删除",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const sqlText =
|
||
action === "truncate"
|
||
? `TRUNCATE TABLE ${qualTable(conn.driver, dbName, table)};`
|
||
: `DROP TABLE ${qualTable(conn.driver, dbName, table)};`;
|
||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sqlText, { sessionId: sid, database: dbName, confirmDanger: true }));
|
||
if (res.error) throw new Error(res.error);
|
||
notify({
|
||
kind: "success",
|
||
title: action === "truncate" ? "已截断" : "已删除",
|
||
message: `${dbName}.${table}`,
|
||
mode: "toast",
|
||
durationMs: 1600,
|
||
});
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName);
|
||
} catch (e) {
|
||
notifyError(e, "toast");
|
||
}
|
||
};
|
||
|
||
/** 字段行:绿色短横图标 + 类型淡显,主键加 🔑 */
|
||
const ColumnRow = ({ col, onRefresh }: { col: TreeNode; onRefresh: () => void }) => (
|
||
<div
|
||
className="ops-tree-item opacity-85"
|
||
title={col.type}
|
||
onContextMenu={(e) => onCtx(e, col.name, onRefresh)}
|
||
>
|
||
<span
|
||
className="ml-1 mr-0.5 h-[3px] w-2.5 shrink-0 self-center rounded-full"
|
||
style={{ background: TREE_COLORS.columns, opacity: 0.85 }}
|
||
/>
|
||
<span className="truncate">
|
||
{col.key === "PRI" ? "🔑 " : ""}
|
||
{col.name}
|
||
<span className="ml-1 opacity-50">{col.type}</span>
|
||
</span>
|
||
</div>
|
||
);
|
||
|
||
/** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */
|
||
const f = treeFilter.trim().toLowerCase();
|
||
const nameHit = (n: string) => !f || n.toLowerCase().includes(f);
|
||
// Redis:侧栏关键字由 listRedisKeys + filterRedisTree 负责;勿按库名/顶层 folder 名剪空(否则精确键永远不显示)
|
||
const visibleTree = f && !isRedisConn(conn?.driver)
|
||
? tree
|
||
.map((db) => {
|
||
if (nameHit(db.name)) return db;
|
||
// flat 驱动:children 即对象,直接按名过滤
|
||
if (driverCaps(conn?.driver ?? "").treeGroups.length === 0) {
|
||
const objs = (db.children ?? []).filter((o) => nameHit(o.name));
|
||
return objs.length ? ({ ...db, children: objs } as TreeNode) : null;
|
||
}
|
||
const groups = (db.children ?? [])
|
||
.map((g) => {
|
||
const children = (g.children ?? []).filter((o) => nameHit(o.name));
|
||
return children.length ? ({ ...g, children, count: children.length } as TreeNode) : null;
|
||
})
|
||
.filter((g): g is TreeNode => g !== null);
|
||
return groups.length ? ({ ...db, children: groups } as TreeNode) : null;
|
||
})
|
||
.filter((db): db is TreeNode => db !== null)
|
||
: tree;
|
||
|
||
/** 修 O4:过滤未命中时,一键展开全部库(懒加载)后重搜 */
|
||
const expandAllAndResearch = () => {
|
||
for (const db of tree) {
|
||
if (!db.loaded && !db.loading) void expandDb(db.name);
|
||
}
|
||
};
|
||
|
||
/** R2-F4:本地过滤无命中 → 服务端跨库搜索(E6) */
|
||
const runDbSearch = async () => {
|
||
const kw = treeFilter.trim();
|
||
if (!conn || kw.length < 2 || dbSearching) return;
|
||
setDbSearching(true);
|
||
setDbSearchResults(null);
|
||
setDbSearchKw(kw);
|
||
try {
|
||
const res = await withSessionRetry(conn, (sid) => searchObjects(conn, kw, { sessionId: sid }));
|
||
if (res.error) throw new Error(res.error);
|
||
setDbSearchResults(res.results ?? []);
|
||
} catch (e) {
|
||
notifyError(e, "toast");
|
||
} finally {
|
||
setDbSearching(false);
|
||
}
|
||
};
|
||
|
||
/** R2-F4:点击搜索结果 = 走现有「双击打开表」路径(库未加载先加载,再预览+拉列) */
|
||
const openSearchHit = async (hit: SearchObjectHit) => {
|
||
setDbSearchResults(null);
|
||
setTreeFilter("");
|
||
if (!conn) return;
|
||
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === hit.schema && n.kind === "database");
|
||
if (dbNode && !dbNode.loaded && !dbNode.loading) await expandDb(hit.schema);
|
||
await putSelectSql(hit.schema, hit.name);
|
||
};
|
||
|
||
/**
|
||
* 修 K2:树键盘导航 —— ↑↓ 移动、→ 展开 / ← 收起、Enter 打开。
|
||
* roving tabindex:所有行 tabIndex=-1,容器是唯一 Tab 停靠点,方向键在行间移动 DOM 焦点。
|
||
*/
|
||
const onTreeKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||
const root = treeScrollRef.current;
|
||
if (!root) return;
|
||
const rows = Array.from(root.querySelectorAll<HTMLElement>("[data-tree-row]")).filter(
|
||
(el) => el.offsetParent !== null,
|
||
);
|
||
if (!rows.length) return;
|
||
const idx = rows.indexOf(document.activeElement as HTMLElement);
|
||
const focusRow = (i: number) => {
|
||
const el = rows[Math.max(0, Math.min(rows.length - 1, i))];
|
||
if (!el) return;
|
||
el.focus();
|
||
el.scrollIntoView({ block: "nearest" });
|
||
};
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
focusRow(idx < 0 ? 0 : idx + 1);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
focusRow(idx <= 0 ? 0 : idx - 1);
|
||
return;
|
||
}
|
||
if (idx < 0) return;
|
||
const row = rows[idx];
|
||
const details = row.closest("details");
|
||
if (e.key === "ArrowRight") {
|
||
e.preventDefault();
|
||
if (details && !details.open) details.open = true; // 触发 onToggle → 懒加载
|
||
else focusRow(idx + 1);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowLeft") {
|
||
e.preventDefault();
|
||
if (details?.open && row.tagName === "SUMMARY") details.open = false;
|
||
else {
|
||
const parentSummary = details?.parentElement?.closest("details")?.querySelector(":scope > summary");
|
||
if (parentSummary instanceof HTMLElement) {
|
||
parentSummary.focus();
|
||
parentSummary.scrollIntoView({ block: "nearest" });
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
const { kind, db, name } = row.dataset;
|
||
if ((kind === "table" || kind === "view") && db && name) void putSelectSql(db, name);
|
||
else if ((kind === "key" || kind === "folder") && db && name) {
|
||
if (kind === "key") void openRedisKey(db, name);
|
||
else if (details) details.open = !details.open;
|
||
}
|
||
else if (row.tagName === "SUMMARY" && details) details.open = !details.open;
|
||
}
|
||
};
|
||
|
||
const nodeSelected = (key: string) => selectedNode === key;
|
||
const redisSep = conn?.keySeparator || ":";
|
||
|
||
const renderRedisNodes = (dbName: string, nodes: TreeNode[]): ReactNode =>
|
||
nodes.map((obj) =>
|
||
obj.kind === "folder" ? (
|
||
<details key={obj.fullName || obj.name} className="mb-px">
|
||
<summary
|
||
className={`ops-tree-item list-none [&::-webkit-details-marker]:hidden ${nodeSelected(`${dbName}/${obj.fullName}`) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind="folder"
|
||
data-db={dbName}
|
||
data-name={obj.fullName}
|
||
tabIndex={-1}
|
||
onClick={() => setSelectedNode(`${dbName}/${obj.fullName}`)}
|
||
onContextMenu={(e) =>
|
||
onCtx(e, obj.fullName || obj.name, () => {
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName);
|
||
}, undefined, undefined, undefined, {
|
||
kind: "folder",
|
||
copyLabel: "复制前缀",
|
||
onFilterPrefix: () => {
|
||
const pattern = `${obj.fullName || obj.name}*`;
|
||
setKeyFilter(pattern);
|
||
useWorkspaceStore.getState().requestTreeRefresh();
|
||
},
|
||
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "", db: dbName }),
|
||
onDeleteFolder: () => void deleteFolderPrefix(dbName, obj.fullName || obj.name, obj.count ?? 0),
|
||
})
|
||
}
|
||
>
|
||
<TreeChevron size={9} className="opacity-40" />
|
||
<TreeIcon kind="folder" />
|
||
<span className="min-w-0 flex-1 truncate">
|
||
{obj.name}
|
||
<CountBadge kind="folder" count={obj.count ?? 0} />
|
||
</span>
|
||
</summary>
|
||
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{renderRedisNodes(dbName, obj.children ?? [])}
|
||
</div>
|
||
</details>
|
||
) : (
|
||
<div
|
||
key={obj.name}
|
||
className={`ops-tree-item ${nodeSelected(`${dbName}/${obj.name}`) || selectedKeys.has(`${dbName}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind="key"
|
||
data-db={dbName}
|
||
data-name={obj.name}
|
||
tabIndex={-1}
|
||
onClick={(e) => {
|
||
if (e.metaKey || e.ctrlKey) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleKeySelect(dbName, obj.name, true);
|
||
return;
|
||
}
|
||
setSelectedNode(`${dbName}/${obj.name}`);
|
||
}}
|
||
onContextMenu={(e) =>
|
||
onCtx(
|
||
e,
|
||
obj.name,
|
||
() => {
|
||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(dbName);
|
||
},
|
||
undefined,
|
||
() => void openRedisKey(dbName, obj.name),
|
||
() => void removeRedisKey(dbName, obj.name),
|
||
{
|
||
kind: "key",
|
||
refreshLabel: "刷新 TTL",
|
||
copyLabel: "复制键名",
|
||
onDeleteSelected: selectedKeys.size > 1 ? () => void deleteSelectedKeys(dbName) : undefined,
|
||
},
|
||
)
|
||
}
|
||
>
|
||
<span
|
||
className="ml-1 mr-0.5 h-2 w-2 shrink-0 self-center rounded-full"
|
||
style={{ background: redisTypeColor(obj.type), opacity: 0.9 }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="min-w-0 flex-1 truncate text-left"
|
||
title={obj.name}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
void openRedisKey(dbName, obj.name);
|
||
}}
|
||
>
|
||
<span className="truncate">{redisLeafLabel(obj.name, redisSep)}</span>
|
||
{obj.exact && (
|
||
<span
|
||
className="ml-1 shrink-0 rounded px-1 text-[10px] font-medium"
|
||
style={{ color: "var(--ops-accent)", background: "color-mix(in srgb, var(--ops-accent) 18%, transparent)" }}
|
||
>
|
||
精确
|
||
</span>
|
||
)}
|
||
<span className="ml-1 font-normal opacity-45">
|
||
{obj.type || "?"}
|
||
{obj.ttl != null && obj.ttl >= 0 ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
),
|
||
);
|
||
|
||
/** 库下的对象行(表/视图/集合/索引/子节点…):分组内外共用;flat 驱动直挂库节点 */
|
||
const renderDbObject = (dbName: string, obj: TreeNode, groupId?: string) =>
|
||
obj.kind === "table" || obj.kind === "view" ? (
|
||
// 无列概念的对象(ZK 子节点等)渲染为叶子行:单击选中、双击打开,不可展开
|
||
!driverCaps(conn?.driver ?? "").columns && obj.kind === "table" ? (
|
||
<div
|
||
key={obj.name}
|
||
className={`ops-tree-item ${nodeSelected(`${dbName}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind="table"
|
||
data-db={dbName}
|
||
data-name={obj.name}
|
||
tabIndex={-1}
|
||
onClick={() => setSelectedNode(`${dbName}/${obj.name}`)}
|
||
onDoubleClick={() => void putSelectSql(dbName, obj.name)}
|
||
onContextMenu={(e) =>
|
||
onCtx(e, obj.name, () => {
|
||
patchDb(dbName, { loaded: false, children: undefined });
|
||
void expandDb(dbName);
|
||
}, undefined, undefined, undefined, {
|
||
kind: "table",
|
||
onQueryData: () => void putSelectSql(dbName, obj.name),
|
||
copyLabel: "复制名称",
|
||
})
|
||
}
|
||
>
|
||
<TreeIcon kind="tables" size={12} />
|
||
<span className="truncate">{obj.name}</span>
|
||
</div>
|
||
) : (
|
||
<details
|
||
key={obj.name}
|
||
onToggle={(e) => {
|
||
if (e.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) {
|
||
void expandTable(dbName, obj.name, obj.kind === "view" ? "views" : "tables");
|
||
}
|
||
}}
|
||
onContextMenu={(e) =>
|
||
onCtx(
|
||
e,
|
||
obj.name,
|
||
() => {
|
||
if (obj.kind === "table") {
|
||
patchDb(dbName, (d) => ({
|
||
...d,
|
||
children: (d.children ?? []).map((gg) =>
|
||
gg.groupId !== "tables"
|
||
? gg
|
||
: {
|
||
...gg,
|
||
children: (gg.children ?? []).map((t) =>
|
||
t.name === obj.name ? { ...t, loaded: false, children: undefined } : t,
|
||
),
|
||
},
|
||
),
|
||
}));
|
||
void expandTable(dbName, obj.name);
|
||
} else {
|
||
patchDb(dbName, { loaded: false, children: undefined });
|
||
void expandDb(dbName);
|
||
}
|
||
},
|
||
() => void openSchema(dbName, obj.name),
|
||
undefined,
|
||
undefined,
|
||
{
|
||
kind: obj.kind === "view" ? "view" : "table",
|
||
onQueryData: () => void putSelectSql(dbName, obj.name),
|
||
onQueryLimit: () => { void (async () => {
|
||
const value = await askInput({ title: "打开前 N 行", label: "行数(1–10000)", defaultValue: "100", placeholder: "100" });
|
||
if (value == null) return;
|
||
if (!/^\d+$/.test(value.trim()) || Number(value) < 1 || Number(value) > 10000) {
|
||
notify({ kind: "warning", title: "行数无效", message: "请输入 1–10000 之间的整数", mode: "toast" });
|
||
return;
|
||
}
|
||
if (useWorkspaceStore.getState().activeConnectionId === conn?.id) await putSelectSql(dbName, obj.name, Number(value));
|
||
})(); },
|
||
qualifiedName: qualTable(conn?.driver, dbName, obj.name),
|
||
onGen: (k) => void genTableSql(dbName, obj.name, k),
|
||
onDesign:
|
||
obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
|
||
? () => setDesignerTarget({ db: dbName, table: obj.name })
|
||
: undefined,
|
||
onDump:
|
||
obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
|
||
? (mode) => void dumpTableSql(dbName, obj.name, mode)
|
||
: undefined,
|
||
onExport: obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
|
||
? format => void exportTableData(dbName, obj.name, format)
|
||
: undefined,
|
||
// DDL/截断/删除按驱动能力开关:ES/Influx/Mongo/ZK 等只读树不提供
|
||
onDdl: driverCaps(conn?.driver ?? "").ddl
|
||
? () => {
|
||
set("focusedRelation", { database: dbName, table: obj.name });
|
||
setDdlTarget({ db: dbName, table: obj.name });
|
||
}
|
||
: undefined,
|
||
onCopyDdl: driverCaps(conn?.driver ?? "").ddl && conn ? () => {
|
||
void (async () => {
|
||
try {
|
||
const response = await withSessionRetry(conn, (sessionId) => getDdl(conn, obj.name, { database: dbName, sessionId }));
|
||
if (response.error) throw new Error(response.error);
|
||
if (!response.ddl) throw new Error("未返回表结构定义");
|
||
await copyName(response.ddl);
|
||
} catch (error) { notifyError(error, "toast"); }
|
||
})();
|
||
} : undefined,
|
||
onTruncate:
|
||
obj.kind === "table" && driverCaps(conn?.driver ?? "").truncate
|
||
? () => void runTableDanger(dbName, obj.name, "truncate")
|
||
: undefined,
|
||
onDropTable:
|
||
obj.kind === "table" && driverCaps(conn?.driver ?? "").dropObject
|
||
? () => void runTableDanger(dbName, obj.name, "drop")
|
||
: undefined,
|
||
copyLabel: "复制名称",
|
||
},
|
||
)
|
||
}
|
||
>
|
||
<summary
|
||
className={`ops-tree-item list-none [&::-webkit-details-marker]:hidden ${nodeSelected(`${dbName}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind={obj.kind}
|
||
data-db={dbName}
|
||
data-name={obj.name}
|
||
tabIndex={-1}
|
||
onClick={() => setSelectedNode(`${dbName}/${obj.name}`)}
|
||
>
|
||
<TreeChevron size={9} className="opacity-40" />
|
||
<TreeIcon kind={obj.kind === "view" ? "views" : "tables"} size={12} />
|
||
<button
|
||
type="button"
|
||
className="min-w-0 flex-1 truncate text-left"
|
||
title="单击选中 · 双击打开前 100 行"
|
||
tabIndex={-1}
|
||
onDoubleClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
void putSelectSql(dbName, obj.name);
|
||
}}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setSelectedNode(`${dbName}/${obj.name}`);
|
||
}}
|
||
>
|
||
{obj.name}
|
||
</button>
|
||
</summary>
|
||
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{(obj.children ?? []).map((sub) =>
|
||
sub.kind === "group" && sub.groupId === "columns" ? (
|
||
<details key={sub.groupId} className="mb-px">
|
||
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden">
|
||
<TreeChevron size={9} className="opacity-40" />
|
||
<TreeIcon kind="columns" size={11} />
|
||
<span className="ops-tree-group min-w-0 flex-1 truncate">
|
||
{sub.name}
|
||
<CountBadge kind="columns" count={sub.count ?? sub.children?.length ?? 0} />
|
||
</span>
|
||
</summary>
|
||
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{(sub.children ?? []).map((col) => (
|
||
<ColumnRow key={col.name} col={col} onRefresh={() => void expandTable(dbName, obj.name)} />
|
||
))}
|
||
</div>
|
||
</details>
|
||
) : (
|
||
<ColumnRow key={sub.name} col={sub} onRefresh={() => void expandTable(dbName, obj.name)} />
|
||
),
|
||
)}
|
||
{obj.loaded && conn && obj.kind === "table" && driverCaps(conn.driver).ddl && <TableMetadataTree conn={conn} database={dbName} table={obj.name} />}
|
||
{obj.loading && (
|
||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||
列…
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)
|
||
) : (
|
||
// 存储过程/函数等:叶子行
|
||
<div
|
||
key={obj.name}
|
||
className={`ops-tree-item ${nodeSelected(`${dbName}/${groupId ?? "fn"}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind={obj.kind}
|
||
data-db={dbName}
|
||
data-name={obj.name}
|
||
tabIndex={-1}
|
||
onClick={() => setSelectedNode(`${dbName}/${groupId ?? "fn"}/${obj.name}`)}
|
||
onContextMenu={(e) =>
|
||
onCtx(e, obj.name, () => {
|
||
patchDb(dbName, { loaded: false, children: undefined });
|
||
void expandDb(dbName);
|
||
})
|
||
}
|
||
onDoubleClick={() => void copyName(obj.name)}
|
||
>
|
||
<TreeIcon kind={groupIdColor(groupId)} size={11} />
|
||
<span className="truncate">{obj.name}</span>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col">
|
||
<div className="ops-section-label justify-between gap-1.5" style={{ display: "none" }}>
|
||
<button
|
||
type="button"
|
||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
||
style={{ color: "var(--ops-accent)" }}
|
||
title="刷新对象树"
|
||
aria-label="刷新对象树"
|
||
onClick={() => activeId && void refresh(activeId)}
|
||
>
|
||
↻
|
||
</button>
|
||
</div>
|
||
{/* 修 O4:过滤框独立一行(脱离 section-label) */}
|
||
{!isRedisConn(conn?.driver) && (
|
||
<div style={{ display: "none" }}>
|
||
<input
|
||
data-ops-tree-filter
|
||
className="ops-input w-full py-0.5 text-[11px]"
|
||
placeholder="过滤库 / 表…(⌘⇧F)"
|
||
aria-label="过滤库 / 表"
|
||
value={treeFilter}
|
||
onChange={(e) => {
|
||
setTreeFilter(e.target.value);
|
||
setDbSearchResults(null);
|
||
setDbSearchKw("");
|
||
}}
|
||
onKeyDown={(e) => {
|
||
// R2-F4:Escape 关闭服务端搜索并清空关键字
|
||
if (e.key === "Escape") {
|
||
setTreeFilter("");
|
||
setDbSearchResults(null);
|
||
setDbSearchKw("");
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
/>
|
||
{/* R2-F4:本地过滤无命中且关键字 ≥2 字符 → 提供服务端搜索(E6) */}
|
||
{f.length >= 2 && visibleTree.length === 0 && !treeLoading && (
|
||
<div className="mt-1 border-t pt-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
<button
|
||
type="button"
|
||
className="w-full truncate text-left hover:underline disabled:opacity-60"
|
||
style={{ color: "var(--ops-accent)", fontSize: "var(--ops-text-xs)" }}
|
||
disabled={dbSearching}
|
||
onClick={() => void runDbSearch()}
|
||
>
|
||
{dbSearching ? "搜索中…" : `在数据库中搜索「${treeFilter.trim()}」`}
|
||
</button>
|
||
{dbSearchResults !== null && !dbSearching &&
|
||
(dbSearchResults.length === 0 ? (
|
||
<div className="px-0.5 py-1" style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-muted)" }}>
|
||
未找到与「{dbSearchKw}」匹配的表/视图
|
||
</div>
|
||
) : (
|
||
<div className="mt-0.5 max-h-40 overflow-auto">
|
||
{dbSearchResults.map((r) => (
|
||
<button
|
||
key={`${r.schema}.${r.name}`}
|
||
type="button"
|
||
className="flex w-full items-center gap-1 truncate rounded px-1 py-0.5 text-left hover:underline"
|
||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||
title={`打开 ${r.schema}.${r.name}`}
|
||
onClick={() => void openSearchHit(r)}
|
||
>
|
||
<span className="truncate">
|
||
{r.schema}.{r.name}
|
||
</span>
|
||
<span
|
||
className="ml-auto shrink-0 rounded px-1 text-[10px]"
|
||
style={{
|
||
color: r.type === "view" ? TREE_COLORS.views : TREE_COLORS.tables,
|
||
background: `${r.type === "view" ? TREE_COLORS.views : TREE_COLORS.tables}1f`,
|
||
}}
|
||
>
|
||
{r.type === "view" ? "视图" : "表"}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* P1: Redis 顶栏已迁到 SidebarFilterBar(单搜索+↻);新建键表单由库/文件夹右键唤起 */}
|
||
{isRedisConn(conn?.driver) && newKey && (
|
||
<div className="mx-1 mb-1 space-y-1 rounded border p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||
<div className="text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||
新建键{newKey.db != null && newKey.db !== "" ? ` · DB${newKey.db}` : ""}
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<select
|
||
className="ops-input h-6 py-0 text-[11px]"
|
||
value={newKey.type}
|
||
onChange={(e) => setNewKey({ ...newKey, type: e.target.value })}
|
||
>
|
||
<option value="string">String</option>
|
||
<option value="hash">Hash</option>
|
||
<option value="list">List</option>
|
||
<option value="set">Set</option>
|
||
<option value="zset">ZSet</option>
|
||
</select>
|
||
<input
|
||
className="ops-input h-6 min-w-0 flex-1 py-0 font-mono text-[11px]"
|
||
placeholder="键名"
|
||
value={newKey.name}
|
||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<input
|
||
className="ops-input h-6 w-full py-0 font-mono text-[11px]"
|
||
placeholder={newKey.type === "hash" ? "初始 field 的值" : "初始值"}
|
||
value={newKey.value}
|
||
onChange={(e) => setNewKey({ ...newKey, value: e.target.value })}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
const dbName = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
|
||
void createRedisKey(dbName, newKey);
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button type="button" className="text-[11px] opacity-70" onClick={() => setNewKey(null)}>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="text-[11px] hover:underline"
|
||
style={{ color: "var(--ops-accent)" }}
|
||
onClick={() => {
|
||
const dbName = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
|
||
void createRedisKey(dbName, newKey);
|
||
}}
|
||
>
|
||
创建
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div
|
||
ref={treeScrollRef}
|
||
className="p-1 text-[12.5px] outline-none"
|
||
tabIndex={0}
|
||
role="tree"
|
||
onKeyDown={onTreeKeyDown}
|
||
>
|
||
{treeLoading && (
|
||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
加载中…
|
||
</div>
|
||
)}
|
||
{!treeLoading && tree.length === 0 && (
|
||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
|
||
</div>
|
||
)}
|
||
{visibleTree.map((db) => conn && isRedisConn(conn.driver) && isRedisCluster(conn) ? (
|
||
<div key={db.name}>
|
||
<div className="flex items-center gap-1 px-1 py-1">
|
||
<input className="ops-input h-6 min-w-0 flex-1 text-[11px]" aria-label="搜索集群 Key"
|
||
placeholder="搜索集群 Key(自动包含匹配)"
|
||
value={redisDbDraft[db.name] ?? ""}
|
||
onChange={e => setRedisDbDraft(prev => ({ ...prev, [db.name]: e.target.value }))}
|
||
onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) searchRedisDb(db.name); }} />
|
||
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => searchRedisDb(db.name)}>搜索</button>
|
||
</div>
|
||
{db.loading && <div className="ops-tree-meta-note">加载 Key…</div>}
|
||
{renderRedisNodes(db.name, filterRedisTree(db.children ?? [], /[*?[\]]/.test((redisDbSearch[db.name] ?? keyFilter).trim()) ? "" : redisDbSearch[db.name] ?? keyFilter, redisTypeFilter))}
|
||
{redisKeyCursors[db.name] && <button type="button" className="ops-btn-ghost mx-2 my-1 text-[11px]" disabled={db.loading}
|
||
title="继续从当前游标扫描,不会一次读取全部 Key"
|
||
onClick={() => void expandDb(db.name, redisDbSearch[db.name] ?? keyFilter, redisKeyCursors[db.name])}>
|
||
{db.loading ? "加载中…" : "加载更多"}
|
||
</button>}
|
||
{db.loaded && !db.children?.length && <div className="ops-tree-meta-note">未找到 Key;搜索有扫描上限,可输入更明确的名称</div>}
|
||
</div>
|
||
) : (
|
||
<details
|
||
key={db.name}
|
||
className="mb-px"
|
||
open={isRedisConn(conn?.driver) ? db.name === selectedRedisDb || !!redisDbSearch[db.name] : f ? true : undefined}
|
||
onToggle={(e) => {
|
||
if (e.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
|
||
}}
|
||
onContextMenu={(e) =>
|
||
onCtx(e, db.name, () => {
|
||
patchDb(db.name, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(db.name);
|
||
}, undefined, undefined, undefined, {
|
||
kind: "database",
|
||
onNewQuery: isRedisConn(conn?.driver)
|
||
? undefined
|
||
: () => useWorkspaceStore.getState().newQueryTab(db.name),
|
||
onNewTable: !isRedisConn(conn?.driver) && driverCaps(conn?.driver ?? "").ddl
|
||
? () => setDesignerTarget({ db: db.name })
|
||
: undefined,
|
||
onNewKey: isRedisConn(conn?.driver)
|
||
? () => setNewKey({ name: "", type: "string", value: "", db: db.name })
|
||
: undefined,
|
||
onBrowse: () => void expandDb(db.name),
|
||
onSetDefaultDb: canSetDefaultDb(conn?.driver) && driverCaps(conn?.driver ?? "").setDefaultDb
|
||
? () => setDefaultDatabase(db.name)
|
||
: undefined,
|
||
onDropDatabase: canDropDatabase(conn?.driver) && driverCaps(conn?.driver ?? "").dropObject
|
||
? () => void dropDatabase(db.name)
|
||
: undefined,
|
||
copyLabel: "复制名称",
|
||
})
|
||
}
|
||
>
|
||
<summary
|
||
className={`ops-tree-item cursor-pointer list-none [&::-webkit-details-marker]:hidden ${nodeSelected(db.name) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind="database"
|
||
data-name={db.name}
|
||
tabIndex={-1}
|
||
onClick={() => setSelectedNode(db.name)}
|
||
>
|
||
<TreeChevron size={9} className="opacity-50" />
|
||
<TreeIcon kind="database" />
|
||
<span className="truncate font-medium">{db.name}</span>
|
||
{db.loading && <span className="ml-auto text-[10px] opacity-40">…</span>}
|
||
</summary>
|
||
<div className="ml-1.5 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{isRedisConn(conn?.driver) && <div className="flex items-center gap-1 px-1 py-1">
|
||
<input className="ops-input h-6 min-w-0 flex-1 text-[11px]" aria-label={`搜索 DB${db.name} 的 Key`}
|
||
placeholder={`搜索 DB${db.name} Key(自动包含匹配)`}
|
||
value={redisDbDraft[db.name] ?? ""}
|
||
onChange={e => setRedisDbDraft(prev => ({ ...prev, [db.name]: e.target.value }))}
|
||
onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) searchRedisDb(db.name); }} />
|
||
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => searchRedisDb(db.name)}>搜索</button>
|
||
</div>}
|
||
{isRedisConn(conn?.driver) ? (<>
|
||
{/* 服务端已按 pattern SCAN/精确命中;通配符查询不再做字面子串过滤。 */}
|
||
{renderRedisNodes(
|
||
db.name,
|
||
filterRedisTree(
|
||
db.children ?? [],
|
||
/[*?[\]]/.test((redisDbSearch[db.name] ?? keyFilter).trim()) ? "" : redisDbSearch[db.name] ?? keyFilter,
|
||
redisTypeFilter,
|
||
),
|
||
)}
|
||
{redisKeyCursors[db.name] && <button type="button" className="ops-btn-ghost mx-1 my-1 text-[11px]" disabled={db.loading} title="继续从当前游标扫描,不会一次读取全部 Key"
|
||
onClick={() => void expandDb(db.name, redisDbSearch[db.name] ?? keyFilter, redisKeyCursors[db.name])}>
|
||
{db.loading ? "加载中…" : "加载更多"}
|
||
</button>}
|
||
</>
|
||
) : driverCaps(conn?.driver ?? "").treeGroups.length === 0 ? (
|
||
// flat 驱动(ES/Mongo/Influx/ZK):对象直挂库节点,无分组包装
|
||
<>
|
||
{(db.children ?? []).map((obj) => renderDbObject(db.name, obj))}
|
||
{db.loaded && (db.children ?? []).length === 0 && (
|
||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||
空
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (db.children ?? emptyGroups()).map((g) => (
|
||
<details key={g.groupId || g.name} className="mb-px" open={f ? true : undefined}>
|
||
<summary
|
||
className={`ops-tree-item list-none [&::-webkit-details-marker]:hidden ${nodeSelected(`${db.name}/${g.groupId}`) ? "ops-tree-item-active" : ""}`}
|
||
data-tree-row
|
||
data-kind="group"
|
||
data-db={db.name}
|
||
data-name={g.name}
|
||
tabIndex={-1}
|
||
onClick={() => setSelectedNode(`${db.name}/${g.groupId}`)}
|
||
onContextMenu={(e) =>
|
||
onCtx(e, g.name, () => {
|
||
patchDb(db.name, { loaded: false, loading: false, children: undefined });
|
||
void expandDb(db.name);
|
||
}, undefined, undefined, undefined, { kind: "group" })
|
||
}
|
||
>
|
||
<TreeChevron size={9} className="opacity-40" />
|
||
<TreeIcon kind={groupIdColor(g.groupId)} />
|
||
<span className="ops-tree-group min-w-0 flex-1 truncate">
|
||
{g.name}
|
||
<CountBadge kind={groupIdColor(g.groupId)} count={g.count ?? g.children?.length ?? 0} />
|
||
</span>
|
||
</summary>
|
||
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{(g.children ?? []).length === 0 && g.loaded && (
|
||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||
空
|
||
</div>
|
||
)}
|
||
{(g.children ?? []).map((obj) => renderDbObject(db.name, obj, g.groupId))}
|
||
</div>
|
||
</details>
|
||
))}
|
||
{db.loaded && (db.children ?? []).length === 0 && (
|
||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||
空库或无权限
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
))}
|
||
{f && !isRedisConn(conn?.driver) && visibleTree.length === 0 && !treeLoading && (
|
||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
<div>没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。</div>
|
||
{tree.some((db) => !db.loaded) && (
|
||
<button
|
||
type="button"
|
||
className="mt-1 hover:underline"
|
||
style={{ color: "var(--ops-accent)" }}
|
||
onClick={expandAllAndResearch}
|
||
>
|
||
展开全部库后重搜
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{f && isRedisConn(conn?.driver) && !treeLoading && tree.length > 0 &&
|
||
tree.every((db) => db.loaded && filterRedisTree(db.children ?? [], /[*?[\]]/.test(keyFilter.trim()) ? "" : keyFilter, redisTypeFilter).length === 0) && (
|
||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
没有匹配「{treeFilter.trim()}」的键
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{ctx && (
|
||
<div
|
||
ref={ctxRef}
|
||
className="ops-ctx"
|
||
style={
|
||
ctxPos
|
||
? { left: ctxPos.x, top: ctxPos.y }
|
||
: { left: ctx.x, top: ctx.y, visibility: "hidden" }
|
||
}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 库 P0 */}
|
||
{ctx.kind === "database" && (
|
||
<>
|
||
{ctx.onNewKey && (
|
||
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>新建键</button>
|
||
)}
|
||
{ctx.onNewQuery && (
|
||
<button type="button" onClick={() => { ctx.onNewQuery?.(); setCtx(null); }}>新建查询</button>
|
||
)}
|
||
{ctx.onNewTable && (
|
||
<button type="button" onClick={() => { ctx.onNewTable?.(); setCtx(null); }}>新建表…</button>
|
||
)}
|
||
{ctx.onBrowse && (
|
||
<button type="button" onClick={() => { ctx.onBrowse?.(); setCtx(null); }}>浏览对象</button>
|
||
)}
|
||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
|
||
{conn && <button type="button" onClick={() => { void copyName(connectionEndpoint(conn)); setCtx(null); }}>复制主机端口</button>}
|
||
{ctx.onSetDefaultDb ? (
|
||
<button type="button" onClick={() => { ctx.onSetDefaultDb?.(); setCtx(null); }}>设为默认库</button>
|
||
) : (
|
||
<button type="button" disabled title="当前引擎不支持">设为默认库</button>
|
||
)}
|
||
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
|
||
<div className="ops-ctx-sep" />
|
||
{ctx.onDropDatabase ? (
|
||
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onDropDatabase?.(); setCtx(null); }}>删除库…</button>
|
||
) : (
|
||
<button type="button" disabled title="当前引擎不支持删除库" style={{ color: "var(--ops-danger)" }}>删除库…</button>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 表/视图 P0 */}
|
||
{(ctx.kind === "table" || ctx.kind === "view") && (
|
||
<>
|
||
{ctx.onQueryData && (
|
||
<button type="button" onClick={() => { ctx.onQueryData?.(); setCtx(null); }}>查看数据</button>
|
||
)}
|
||
{ctx.onQueryLimit && <button type="button" onClick={() => { ctx.onQueryLimit?.(); setCtx(null); }}>打开前 N 行…</button>}
|
||
{ctx.onSchema && <button type="button" onClick={() => { ctx.onSchema?.(); setCtx(null); }}>查看结构</button>}
|
||
{ctx.onDesign && (
|
||
<button type="button" onClick={() => { ctx.onDesign?.(); setCtx(null); }}>设计表…</button>
|
||
)}
|
||
{ctx.onDdl && (
|
||
<button type="button" onClick={() => { ctx.onDdl?.(); setCtx(null); }}>查看 DDL</button>
|
||
)}
|
||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
|
||
{ctx.qualifiedName && <button type="button" onClick={() => { void copyName(ctx.qualifiedName!); setCtx(null); }}>复制限定表名(库 / Schema 前缀)</button>}
|
||
{ctx.onCopyDdl && <button type="button" onClick={() => { ctx.onCopyDdl?.(); setCtx(null); }}>复制建表 / 视图 SQL</button>}
|
||
{ctx.onGen && <>
|
||
<div className="ops-ctx-sep" />
|
||
<details className="ops-ctx-group"><summary>生成 SQL <span>›</span></summary>
|
||
<button type="button" onClick={() => { ctx.onGen?.("select"); setCtx(null); }}>SELECT</button>
|
||
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("insert"); setCtx(null); }}>INSERT</button>}
|
||
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("update"); setCtx(null); }}>UPDATE</button>}
|
||
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("delete"); setCtx(null); }}>DELETE</button>}
|
||
{ctx.onDdl && <button type="button" onClick={() => { ctx.onGen?.("ddl"); setCtx(null); }}>DDL</button>}
|
||
</details>
|
||
</>}
|
||
{ctx.onExport && <>
|
||
<details className="ops-ctx-group"><summary>导出数据 <span>›</span></summary>
|
||
<button type="button" onClick={() => { ctx.onExport?.("csv"); setCtx(null); }}>CSV…</button>
|
||
<button type="button" onClick={() => { ctx.onExport?.("json"); setCtx(null); }}>JSON…</button>
|
||
</details>
|
||
</>}
|
||
{ctx.onDump && <>
|
||
<details className="ops-ctx-group"><summary>转储 SQL <span>›</span></summary>
|
||
<button type="button" onClick={() => { ctx.onDump?.("structure"); setCtx(null); }}>仅结构…</button>
|
||
<button type="button" onClick={() => { ctx.onDump?.("data"); setCtx(null); }}>仅数据…</button>
|
||
<button type="button" onClick={() => { ctx.onDump?.("structure_data"); setCtx(null); }}>结构和数据…</button>
|
||
</details>
|
||
</>}
|
||
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
|
||
{(ctx.onTruncate || ctx.onDropTable) && <div className="ops-ctx-sep" />}
|
||
{ctx.onTruncate && (
|
||
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onTruncate?.(); setCtx(null); }}>清空/截断…</button>
|
||
)}
|
||
{ctx.onDropTable && (
|
||
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onDropTable?.(); setCtx(null); }}>删除表…</button>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Redis 键 P0 */}
|
||
{ctx.kind === "key" && (
|
||
<>
|
||
{ctx.onView && (
|
||
<button type="button" onClick={() => { ctx.onView?.(); setCtx(null); }}>查看值</button>
|
||
)}
|
||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制键名"}</button>
|
||
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>{ctx.refreshLabel ?? "刷新 TTL"}</button>
|
||
{ctx.onDelete && (
|
||
<>
|
||
<div className="ops-ctx-sep" />
|
||
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onDelete?.(); setCtx(null); }}>删除键…</button>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{ctx.kind === "folder" && (
|
||
<>
|
||
{ctx.onFilterPrefix && (
|
||
<button type="button" onClick={() => { ctx.onFilterPrefix?.(); setCtx(null); }}>只看此前缀</button>
|
||
)}
|
||
{ctx.onNewKey && (
|
||
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>在此新建键</button>
|
||
)}
|
||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制前缀"}</button>
|
||
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
|
||
{ctx.onDeleteFolder && (
|
||
<button
|
||
type="button"
|
||
style={{ color: "var(--ops-danger)" }}
|
||
onClick={() => { ctx.onDeleteFolder?.(); setCtx(null); }}
|
||
>
|
||
删除目录…
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 分组等兜底:刷新 + 复制 */}
|
||
{(!ctx.kind || ctx.kind === "group") && (
|
||
<>
|
||
{ctx.onNewKey && (
|
||
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>新建键</button>
|
||
)}
|
||
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
|
||
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>复制名称</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* R2-F3:DDL 查看弹窗 */}
|
||
{ddlTarget && conn && (
|
||
<DdlModal
|
||
conn={conn}
|
||
database={ddlTarget.db}
|
||
table={ddlTarget.table}
|
||
onClose={() => setDdlTarget(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* 表设计器 v1:新建表 / 设计既有表(字段网格 + DDL 预览) */}
|
||
{designerTarget && conn && (
|
||
<TableDesigner
|
||
conn={conn}
|
||
database={designerTarget.db}
|
||
table={designerTarget.table}
|
||
onClose={() => setDesignerTarget(null)}
|
||
onApplied={() => refreshDbAfterDesign(designerTarget.db)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
async function listSchemaObjects(
|
||
conn: SavedConnection,
|
||
sid: string,
|
||
db: string,
|
||
kind: "views" | "procedures" | "functions",
|
||
): Promise<string[]> {
|
||
const d = conn.driver;
|
||
let sql = "";
|
||
if (kind === "views") {
|
||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||
sql = `SELECT table_name AS n FROM information_schema.views WHERE table_schema = 'public' ORDER BY 1`;
|
||
} else if (d === "sqlite") {
|
||
sql = `SELECT name AS n FROM sqlite_master WHERE type='view' ORDER BY 1`;
|
||
} else {
|
||
sql = `SELECT TABLE_NAME AS n FROM information_schema.views WHERE TABLE_SCHEMA = '${db.replace(/'/g, "''")}' ORDER BY 1`;
|
||
}
|
||
} else if (kind === "procedures") {
|
||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||
sql = `SELECT routine_name AS n FROM information_schema.routines WHERE routine_schema='public' AND routine_type='PROCEDURE' ORDER BY 1`;
|
||
} else if (d === "sqlite") {
|
||
return [];
|
||
} else {
|
||
sql = `SELECT ROUTINE_NAME AS n FROM information_schema.routines WHERE ROUTINE_SCHEMA='${db.replace(/'/g, "''")}' AND ROUTINE_TYPE='PROCEDURE' ORDER BY 1`;
|
||
}
|
||
} else {
|
||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||
sql = `SELECT routine_name AS n FROM information_schema.routines WHERE routine_schema='public' AND routine_type='FUNCTION' ORDER BY 1`;
|
||
} else if (d === "sqlite") {
|
||
return [];
|
||
} else {
|
||
sql = `SELECT ROUTINE_NAME AS n FROM information_schema.routines WHERE ROUTINE_SCHEMA='${db.replace(/'/g, "''")}' AND ROUTINE_TYPE='FUNCTION' ORDER BY 1`;
|
||
}
|
||
}
|
||
const res = await execSql(conn, sql, { database: db, sessionId: sid, limit: 5000 });
|
||
if (res.error) return [];
|
||
const key = res.columns[0];
|
||
return res.rows.map((r) => String(r[key] ?? "")).filter(Boolean);
|
||
}
|