同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库: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)与签名私钥仍按规则排除
This commit is contained in:
1 parent
5c0be49217
commit
2c0bfafd8d
269 files changed
+10372
-16435
No files matched your search
+387
-174
@@ -2,17 +2,25 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent,
|
||||
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";
|
||||
@@ -26,8 +34,11 @@ import {
|
||||
redisTypeColor,
|
||||
removeRedisKeyFromTree,
|
||||
} from "./redis";
|
||||
import { previewSelectSql, sampleSelectSql } from "./sql";
|
||||
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 };
|
||||
|
||||
@@ -232,10 +243,20 @@ type CtxMenu = {
|
||||
onDelete?: () => void;
|
||||
/** R2-F3:表/视图 —— 查看 DDL(弹 DdlModal) */
|
||||
onDdl?: () => void;
|
||||
onCopyDdl?: () => void;
|
||||
/** 表/视图:查看数据 */
|
||||
onQueryData?: () => void;
|
||||
/** 表/视图:生成 SQL(P1,菜单暂不展示) */
|
||||
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 */
|
||||
@@ -256,23 +277,16 @@ type CtxMenu = {
|
||||
|
||||
type Ctx = CtxMenu | null;
|
||||
|
||||
export type GenSqlKind = "select" | "insert" | "update" | "delete";
|
||||
export type GenSqlKind = "select" | "insert" | "update" | "delete" | "ddl";
|
||||
|
||||
/** 标识符按方言加引号(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}\``;
|
||||
return quoteSqlIdent(driver, 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)}`;
|
||||
return relationParts(driver, db, table).map((part) => quoteIdent(driver, part)).join(".");
|
||||
}
|
||||
|
||||
/** 生成 SQL 模板:? 占位符,主键自动进 WHERE;无列信息时退化为骨架 */
|
||||
@@ -298,7 +312,11 @@ function buildGenSql(
|
||||
if (kind === "delete") {
|
||||
return `DELETE FROM ${qt}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
|
||||
}
|
||||
return sampleSelectSql(driver, db, table);
|
||||
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() {
|
||||
@@ -318,11 +336,15 @@ export function ObjectTree() {
|
||||
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 setRedisTypeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarTypeFilter", v);
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: string; value: string } | null>(null);
|
||||
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);
|
||||
@@ -332,6 +354,8 @@ export function ObjectTree() {
|
||||
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) */
|
||||
@@ -350,6 +374,20 @@ export function ObjectTree() {
|
||||
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);
|
||||
@@ -383,10 +421,17 @@ export function ObjectTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const treeEpoch = useWorkspaceStore((s) => s.treeEpoch);
|
||||
useEffect(() => {
|
||||
if (activeId) void refresh(activeId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId]);
|
||||
}, [activeId, treeEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
// 底部搜索/刷新可重复提交相同关键词;每次都以该关键词为准。
|
||||
setRedisDbSearch({});
|
||||
setRedisDbDraft({});
|
||||
}, [treeEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
@@ -434,38 +479,43 @@ export function ObjectTree() {
|
||||
);
|
||||
};
|
||||
|
||||
const expandDb = async (dbName: string) => {
|
||||
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 || node?.loading) return;
|
||||
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 ? [] : emptyGroups(),
|
||||
children: redis && nextCursor ? node?.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 }));
|
||||
// 无通配符由服务端按 *关键词* 扫描,精确键单独置顶;有通配符按 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 keys = res.keys ?? [];
|
||||
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),
|
||||
});
|
||||
if (res.truncated) {
|
||||
notify({
|
||||
kind: "info",
|
||||
title: "键列表已截断",
|
||||
message: `最多展示 ${keys.length} 个键(SCAN)。可用过滤缩小范围。`,
|
||||
mode: "toast",
|
||||
durationMs: 2200,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sid = await ensureSession(conn);
|
||||
@@ -551,19 +601,40 @@ export function ObjectTree() {
|
||||
],
|
||||
});
|
||||
} catch (e) {
|
||||
if (redis && (redisRequests.current[dbName] !== request || useWorkspaceStore.getState().activeConnectionId !== conn.id)) return;
|
||||
patchDb(dbName, {
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: isRedisConn(conn.driver) ? [] : emptyGroups(),
|
||||
loaded: !!nextCursor,
|
||||
children: nextCursor ? node?.children : isRedisConn(conn.driver) ? [] : emptyGroups(),
|
||||
});
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
const expandTable = async (dbName: string, table: string) => {
|
||||
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 === "tables");
|
||||
const tablesGroup = dbNode?.children?.find((n) => n.groupId === groupId);
|
||||
const tNode = tablesGroup?.children?.find((n) => n.name === table);
|
||||
if (tNode?.loaded || tNode?.loading) return;
|
||||
|
||||
@@ -571,7 +642,7 @@ export function ObjectTree() {
|
||||
patchDb(dbName, (d) => ({
|
||||
...d,
|
||||
children: (d.children ?? []).map((g) =>
|
||||
g.groupId !== "tables"
|
||||
g.groupId !== groupId
|
||||
? g
|
||||
: {
|
||||
...g,
|
||||
@@ -644,6 +715,7 @@ export function ObjectTree() {
|
||||
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 });
|
||||
@@ -801,7 +873,7 @@ export function ObjectTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const putSelectSql = async (dbName: string, table: string) => {
|
||||
const putSelectSql = async (dbName: string, table: string, limit = 100) => {
|
||||
if (!conn) return;
|
||||
set("focusedRelation", { database: dbName, table });
|
||||
if (isRedisConn(conn.driver)) {
|
||||
@@ -837,28 +909,23 @@ export function ObjectTree() {
|
||||
// 预览 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);
|
||||
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: 100 }));
|
||||
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: 100, database: dbName },
|
||||
{ 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 {
|
||||
@@ -883,8 +950,9 @@ export function ObjectTree() {
|
||||
};
|
||||
|
||||
const copyName = async (name: string) => {
|
||||
await navigator.clipboard.writeText(name);
|
||||
notify({ kind: "success", title: "已复制", message: name, mode: "toast", durationMs: 1200 });
|
||||
try {
|
||||
await navigator.clipboard.writeText(name);
|
||||
} catch (e) { notifyError(e, "toast"); }
|
||||
};
|
||||
|
||||
const onCtx = (
|
||||
@@ -899,11 +967,18 @@ export function ObjectTree() {
|
||||
CtxMenu,
|
||||
| "kind"
|
||||
| "onQueryData"
|
||||
| "onQueryLimit"
|
||||
| "qualifiedName"
|
||||
| "onGen"
|
||||
| "onDesign"
|
||||
| "onDump"
|
||||
| "onExport"
|
||||
| "onNewTable"
|
||||
| "onTruncate"
|
||||
| "onDropTable"
|
||||
| "onNewQuery"
|
||||
| "onDdl"
|
||||
| "onCopyDdl"
|
||||
| "onBrowse"
|
||||
| "onSetDefaultDb"
|
||||
| "onDropDatabase"
|
||||
@@ -970,6 +1045,18 @@ export function ObjectTree() {
|
||||
/** 生成表 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)
|
||||
@@ -983,12 +1070,58 @@ export function ObjectTree() {
|
||||
/* 拿不到列也能生成骨架模板 */
|
||||
}
|
||||
}
|
||||
// 模板进入独立标签,避免覆盖现有草稿或异步加载期间切换到的查询。
|
||||
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 });
|
||||
@@ -1059,7 +1192,8 @@ export function ObjectTree() {
|
||||
/** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */
|
||||
const f = treeFilter.trim().toLowerCase();
|
||||
const nameHit = (n: string) => !f || n.toLowerCase().includes(f);
|
||||
const visibleTree = f
|
||||
// Redis:侧栏关键字由 listRedisKeys + filterRedisTree 负责;勿按库名/顶层 folder 名剪空(否则精确键永远不显示)
|
||||
const visibleTree = f && !isRedisConn(conn?.driver)
|
||||
? tree
|
||||
.map((db) => {
|
||||
if (nameHit(db.name)) return db;
|
||||
@@ -1198,11 +1332,11 @@ export function ObjectTree() {
|
||||
kind: "folder",
|
||||
copyLabel: "复制前缀",
|
||||
onFilterPrefix: () => {
|
||||
setKeyFilter(`${obj.fullName || obj.name}*`);
|
||||
patchDb(dbName, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(dbName);
|
||||
const pattern = `${obj.fullName || obj.name}*`;
|
||||
setKeyFilter(pattern);
|
||||
useWorkspaceStore.getState().requestTreeRefresh();
|
||||
},
|
||||
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "" }),
|
||||
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "", db: dbName }),
|
||||
onDeleteFolder: () => void deleteFolderPrefix(dbName, obj.fullName || obj.name, obj.count ?? 0),
|
||||
})
|
||||
}
|
||||
@@ -1271,9 +1405,17 @@ export function ObjectTree() {
|
||||
}}
|
||||
>
|
||||
<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 ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
|
||||
{obj.ttl != null && obj.ttl >= 0 ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1313,8 +1455,8 @@ export function ObjectTree() {
|
||||
<details
|
||||
key={obj.name}
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open && obj.kind === "table") {
|
||||
void expandTable(dbName, obj.name);
|
||||
if (e.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) {
|
||||
void expandTable(dbName, obj.name, obj.kind === "view" ? "views" : "tables");
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
@@ -1348,7 +1490,28 @@ export function ObjectTree() {
|
||||
{
|
||||
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
|
||||
? () => {
|
||||
@@ -1356,6 +1519,16 @@ export function ObjectTree() {
|
||||
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")
|
||||
@@ -1421,6 +1594,7 @@ export function ObjectTree() {
|
||||
<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)" }}>
|
||||
列…
|
||||
@@ -1539,118 +1713,60 @@ export function ObjectTree() {
|
||||
)}
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 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 shrink-0 py-0.5 text-[11px]"
|
||||
style={{ width: "auto" }}
|
||||
value={redisTypeFilter}
|
||||
onChange={(e) => setRedisTypeFilter(e.target.value)}
|
||||
title="按类型筛选"
|
||||
className="ops-input h-6 py-0 text-[11px]"
|
||||
value={newKey.type}
|
||||
onChange={(e) => setNewKey({ ...newKey, type: e.target.value })}
|
||||
>
|
||||
<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>
|
||||
<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="shrink-0 text-[11px] hover:underline"
|
||||
className="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);
|
||||
}
|
||||
const dbName = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
|
||||
void createRedisKey(dbName, newKey);
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</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
|
||||
@@ -1670,13 +1786,32 @@ export function ObjectTree() {
|
||||
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
|
||||
</div>
|
||||
)}
|
||||
{visibleTree.map((db) => (
|
||||
{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={f ? true : undefined}
|
||||
open={isRedisConn(conn?.driver) ? db.name === selectedRedisDb || !!redisDbSearch[db.name] : f ? true : undefined}
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
|
||||
if (e.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
onCtx(e, db.name, () => {
|
||||
@@ -1686,9 +1821,12 @@ export function ObjectTree() {
|
||||
kind: "database",
|
||||
onNewQuery: isRedisConn(conn?.driver)
|
||||
? undefined
|
||||
: () => useWorkspaceStore.getState().newQueryTab(),
|
||||
: () => 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: "" })
|
||||
? () => setNewKey({ name: "", type: "string", value: "", db: db.name })
|
||||
: undefined,
|
||||
onBrowse: () => void expandDb(db.name),
|
||||
onSetDefaultDb: canSetDefaultDb(conn?.driver) && driverCaps(conn?.driver ?? "").setDefaultDb
|
||||
@@ -1715,8 +1853,29 @@ export function ObjectTree() {
|
||||
{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))
|
||||
{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):对象直挂库节点,无分组包装
|
||||
<>
|
||||
@@ -1769,7 +1928,7 @@ export function ObjectTree() {
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{f && visibleTree.length === 0 && !treeLoading && (
|
||||
{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) && (
|
||||
@@ -1784,6 +1943,12 @@ export function ObjectTree() {
|
||||
)}
|
||||
</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 && (
|
||||
@@ -1800,13 +1965,20 @@ export function ObjectTree() {
|
||||
{/* 库 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>
|
||||
) : (
|
||||
@@ -1828,10 +2000,40 @@ export function ObjectTree() {
|
||||
{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 && (
|
||||
@@ -1904,6 +2106,17 @@ export function ObjectTree() {
|
||||
onClose={() => setDdlTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 表设计器 v1:新建表 / 设计既有表(字段网格 + DDL 预览) */}
|
||||
{designerTarget && conn && (
|
||||
<TableDesigner
|
||||
conn={conn}
|
||||
database={designerTarget.db}
|
||||
table={designerTarget.table}
|
||||
onClose={() => setDesignerTarget(null)}
|
||||
onApplied={() => refreshDbAfterDesign(designerTarget.db)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user