1949 lines
76 KiB
TypeScript
1949 lines
76 KiB
TypeScript
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
|
import {
|
|
deleteRedisKey,
|
|
deleteRedisPattern,
|
|
execSql,
|
|
getRedisKey,
|
|
listColumns,
|
|
listRedisKeys,
|
|
listTables,
|
|
loadTree,
|
|
openSession,
|
|
searchObjects,
|
|
type SearchObjectHit,
|
|
} from "./api";
|
|
import { DdlModal } from "./DdlModal";
|
|
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, sampleSelectSql } from "./sql";
|
|
import { useWorkspaceStore, type SavedConnection, type TreeNode } from "./store";
|
|
|
|
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;
|
|
/** 表/视图:查看数据 */
|
|
onQueryData?: () => void;
|
|
/** 表/视图:生成 SQL(P1,菜单暂不展示) */
|
|
onGen?: (kind: GenSqlKind) => 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";
|
|
|
|
/** 标识符按方言加引号(Oracle 裸名) */
|
|
function quoteIdent(driver: string | undefined, ident: string): string {
|
|
const d = (driver || "mysql").toLowerCase();
|
|
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") return `"${ident}"`;
|
|
if (d === "sqlserver" || d === "mssql") return `[${ident}]`;
|
|
if (d === "sqlite") return `"${ident}"`;
|
|
if (d === "oracle") return ident;
|
|
return `\`${ident}\``;
|
|
}
|
|
|
|
/** 库.表 全限定(SQLite 为单文件无库级) */
|
|
function qualTable(driver: string | undefined, db: string, table: string): string {
|
|
const d = (driver || "mysql").toLowerCase();
|
|
if (d === "sqlite") return quoteIdent(d, table);
|
|
return `${quoteIdent(d, db)}.${quoteIdent(d, table)}`;
|
|
}
|
|
|
|
/** 生成 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 条件 */"};`;
|
|
}
|
|
return sampleSelectSql(driver, db, table);
|
|
}
|
|
|
|
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 setKeyFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
|
|
/** Redis 类型过滤:全部 / string / hash / list / set / zset / stream(前端过滤) */
|
|
const redisTypeFilter = useWorkspaceStore((s) => s.sidebarTypeFilter);
|
|
const setRedisTypeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarTypeFilter", v);
|
|
const [newKey, setNewKey] = useState<{ name: string; type: string; value: 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);
|
|
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 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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (activeId) void refresh(activeId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeId]);
|
|
|
|
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) => {
|
|
if (!conn) return;
|
|
const node = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
|
if (node?.loaded || node?.loading) return;
|
|
const redis = isRedisConn(conn.driver);
|
|
patchDb(dbName, {
|
|
loading: true,
|
|
children: redis ? [] : emptyGroups(),
|
|
});
|
|
try {
|
|
if (redis) {
|
|
// 模糊语义:无通配符的输入自动包 *q*(对标 ARDM),避免 "agent" 被当精确 glob 扫不到键
|
|
const raw = keyFilter.trim();
|
|
const pat = !raw ? undefined : /[*?[\]]/.test(raw) ? raw : `*${raw}*`;
|
|
const res = await withSessionRetry(conn, (sid2) => listRedisKeys(conn, dbName, { sessionId: sid2, pattern: pat }));
|
|
if (res.error) throw new Error(res.error);
|
|
const keys = res.keys ?? [];
|
|
const sep = conn.keySeparator || ":";
|
|
patchDb(dbName, {
|
|
loading: false,
|
|
loaded: true,
|
|
children: buildRedisKeyTree(keys, sep),
|
|
});
|
|
if (res.truncated) {
|
|
notify({
|
|
kind: "info",
|
|
title: "键列表已截断",
|
|
message: `最多展示 ${keys.length} 个键(SCAN)。可用过滤缩小范围。`,
|
|
mode: "toast",
|
|
durationMs: 2200,
|
|
});
|
|
}
|
|
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) {
|
|
patchDb(dbName, {
|
|
loading: false,
|
|
loaded: true,
|
|
children: isRedisConn(conn.driver) ? [] : emptyGroups(),
|
|
});
|
|
notifyError(e, "toast");
|
|
}
|
|
};
|
|
|
|
const expandTable = async (dbName: string, table: string) => {
|
|
if (!conn) return;
|
|
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
|
const tablesGroup = dbNode?.children?.find((n) => n.groupId === "tables");
|
|
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 !== "tables"
|
|
? 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,
|
|
};
|
|
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) => {
|
|
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);
|
|
if (tab?.dirty) {
|
|
const openNew = await askConfirm({
|
|
title: "当前查询未保存",
|
|
message: `「${tab.title}」有未保存改动。新开标签写入 SELECT?`,
|
|
okLabel: "新开标签",
|
|
});
|
|
if (!openNew) return;
|
|
newQueryTab();
|
|
await Promise.resolve();
|
|
useWorkspaceStore.getState().updateActiveSql(sql);
|
|
// 新 Tab 标题用表名,避免一排「查询 N」找不到哪页是哪张表
|
|
useWorkspaceStore.getState().setActiveTabTitle(table);
|
|
} else {
|
|
updateActiveSql(sql);
|
|
}
|
|
set("running", true);
|
|
set("status", `打开 ${table}…`);
|
|
try {
|
|
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit: 100 }));
|
|
// 挂为结果集(C5):树打开的表预览同样可翻页
|
|
setResultSets([
|
|
{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: 100, database: dbName },
|
|
]);
|
|
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) => {
|
|
await navigator.clipboard.writeText(name);
|
|
notify({ kind: "success", title: "已复制", message: name, mode: "toast", durationMs: 1200 });
|
|
};
|
|
|
|
const onCtx = (
|
|
e: ReactMouseEvent,
|
|
label: string,
|
|
onRefresh: () => void,
|
|
onSchema?: () => void,
|
|
onView?: () => void,
|
|
onDelete?: () => void,
|
|
extra?: Partial<
|
|
Pick<
|
|
CtxMenu,
|
|
| "kind"
|
|
| "onQueryData"
|
|
| "onGen"
|
|
| "onTruncate"
|
|
| "onDropTable"
|
|
| "onNewQuery"
|
|
| "onDdl"
|
|
| "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;
|
|
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 {
|
|
/* 拿不到列也能生成骨架模板 */
|
|
}
|
|
}
|
|
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
|
|
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
|
|
};
|
|
|
|
/** 截断 / 删除表:二次确认后执行并刷新库 */
|
|
|
|
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);
|
|
const visibleTree = f
|
|
? 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: () => {
|
|
setKeyFilter(`${obj.fullName || obj.name}*`);
|
|
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
|
void expandDb(dbName);
|
|
},
|
|
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "" }),
|
|
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>
|
|
<span className="ml-1 font-normal opacity-45">
|
|
{obj.type || "?"}
|
|
{obj.ttl != null ? ` · ${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.currentTarget as HTMLDetailsElement).open && obj.kind === "table") {
|
|
void expandTable(dbName, obj.name);
|
|
}
|
|
}}
|
|
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),
|
|
onGen: (k) => void genTableSql(dbName, obj.name, k),
|
|
// DDL/截断/删除按驱动能力开关:ES/Influx/Mongo/ZK 等只读树不提供
|
|
onDdl: driverCaps(conn?.driver ?? "").ddl
|
|
? () => {
|
|
set("focusedRelation", { database: dbName, table: obj.name });
|
|
setDdlTarget({ db: dbName, table: obj.name });
|
|
}
|
|
: 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.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>
|
|
)}
|
|
{isRedisConn(conn?.driver) && (
|
|
<div style={{ display: "none" }}>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
data-ops-tree-filter
|
|
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
|
placeholder="搜索键名,输入即过滤;回车服务端模糊扫描"
|
|
value={keyFilter}
|
|
onChange={(e) => setKeyFilter(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
|
|
for (const d of openDbs) {
|
|
patchDb(d.name, { loaded: false, loading: false, children: undefined });
|
|
void expandDb(d.name);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
<select
|
|
className="ops-input shrink-0 py-0.5 text-[11px]"
|
|
style={{ width: "auto" }}
|
|
value={redisTypeFilter}
|
|
onChange={(e) => setRedisTypeFilter(e.target.value)}
|
|
title="按类型筛选"
|
|
>
|
|
<option value="all">全部类型</option>
|
|
<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>
|
|
<option value="stream">stream</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 text-[11px] hover:underline"
|
|
style={{ color: "var(--ops-accent)" }}
|
|
onClick={() => {
|
|
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
|
|
for (const d of openDbs) {
|
|
patchDb(d.name, { loaded: false, loading: false, children: undefined });
|
|
void expandDb(d.name);
|
|
}
|
|
}}
|
|
>
|
|
应用
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 text-[11px] hover:underline"
|
|
style={{ color: "var(--ops-accent)" }}
|
|
title="新建键"
|
|
onClick={() =>
|
|
setNewKey({
|
|
name: keyFilter.endsWith("*") ? keyFilter.slice(0, -1) : keyFilter,
|
|
type: "string",
|
|
value: "",
|
|
})
|
|
}
|
|
>
|
|
新建
|
|
</button>
|
|
</div>
|
|
{newKey && (
|
|
<div className="mt-1 space-y-1 rounded border p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
|
<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 })}
|
|
/>
|
|
</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" && tree[0]) void createRedisKey(tree[0].name, 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 = tree.find((n) => n.kind === "database")?.name ?? "0";
|
|
void createRedisKey(dbName, newKey);
|
|
}}
|
|
>
|
|
创建
|
|
</button>
|
|
</div>
|
|
</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) => (
|
|
<details
|
|
key={db.name}
|
|
className="mb-px"
|
|
open={f ? true : undefined}
|
|
onToggle={(e) => {
|
|
if ((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(),
|
|
onNewKey: isRedisConn(conn?.driver)
|
|
? () => setNewKey({ name: "", type: "string", value: "" })
|
|
: 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) ? (
|
|
renderRedisNodes(db.name, filterRedisTree(db.children ?? [], keyFilter, redisTypeFilter))
|
|
) : 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 && 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>
|
|
)}
|
|
</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.onNewQuery && (
|
|
<button type="button" onClick={() => { ctx.onNewQuery?.(); 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>
|
|
{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.onDdl && (
|
|
<button type="button" onClick={() => { ctx.onDdl?.(); setCtx(null); }}>查看 DDL</button>
|
|
)}
|
|
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
|
|
<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)}
|
|
/>
|
|
)}
|
|
</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);
|
|
}
|