fix: 顶栏文案去重与文档/连库体验更新

顶栏定为:连接 · 新建连接 · 新建查询 · 查询历史 · 快捷键;
同步 docs/落地页、对象树与 connection-types 注册表整理。
This commit is contained in:
logan committed 2026-09-20 16:32:29 +08:00
1 parent f8740d27f7
commit 26f459d1b8
49 files changed
+2682 -492

No files matched your search

+412 -39
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import {
deleteRedisKey,
execSql,
@@ -21,6 +21,105 @@ const GROUPS: { id: string; label: string; kind: TreeNode["kind"] }[] = [
{ id: "functions", label: "函数", kind: "group" },
];
/** 对象树分类色板:高饱和色,深浅主题下都够亮 */
const TREE_COLORS = {
database: "#10b981", // 库 · 祖母绿
tables: "#3b82f6", // 表 · 蓝
views: "#8b5cf6", // 视图 · 紫
procedures: "#f59e0b", // 存储过程 · 琥珀
functions: "#06b6d4", // 函数 · 青
keys: "#ef4444", // 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 "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 === "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";
@@ -64,7 +163,7 @@ function emptyGroups(): TreeNode[] {
}));
}
type Ctx = {
type CtxMenu = {
x: number;
y: number;
label: string;
@@ -72,7 +171,64 @@ type Ctx = {
onSchema?: () => void;
onView?: () => void;
onDelete?: () => void;
} | null;
/** 表/视图:查询数据(回填 SELECT 到编辑器) */
onQueryData?: () => void;
/** 表/视图:生成 SQL 模板回填编辑器 */
onGen?: (kind: GenSqlKind) => void;
/** 表:截断(清数据保结构,二次确认) */
onTruncate?: () => void;
/** 表:DROP(二次确认) */
onDropTable?: () => void;
/** 库:新建查询 Tab */
onNewQuery?: () => void;
};
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);
@@ -90,6 +246,10 @@ export function ObjectTree() {
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
const [ctx, setCtx] = useState<Ctx>(null);
const [keyFilter, setKeyFilter] = useState("");
/** 库/表过滤(非 Redis 连接);只匹配库名与已加载的对象名 */
const [treeFilter, setTreeFilter] = useState("");
/** 单击/双击消歧:单击延迟打开表,双击取消定时器立即打开 */
const clickTimerRef = useRef<number | undefined>(undefined);
const conn = connections.find((c) => c.id === activeId);
@@ -289,13 +449,22 @@ export function ObjectTree() {
mark({
loading: false,
loaded: true,
children: columns.map((col) => ({
name: col.name,
kind: "column" as const,
type: col.type,
nullable: col.nullable,
key: col.key,
})),
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 });
@@ -451,6 +620,8 @@ export function ObjectTree() {
newQueryTab();
await Promise.resolve();
useWorkspaceStore.getState().updateActiveSql(sql);
// 新 Tab 标题用表名,避免一排「查询 N」找不到哪页是哪张表
useWorkspaceStore.getState().setActiveTabTitle(table);
} else {
updateActiveSql(sql);
}
@@ -494,16 +665,118 @@ export function ObjectTree() {
onSchema?: () => void,
onView?: () => void,
onDelete?: () => void,
extra?: Partial<Pick<CtxMenu, "onQueryData" | "onGen" | "onTruncate" | "onDropTable" | "onNewQuery">>,
) => {
e.preventDefault();
e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete });
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete, ...extra });
};
/** 生成表 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 {
const sid = await ensureSession(conn);
cols = (await listColumns(conn, dbName, table, sid)).columns;
} catch {
/* 拿不到列也能生成骨架模板 */
}
}
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
};
/** 截断 / 删除表:二次确认后执行并刷新库 */
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 sid = await ensureSession(conn);
const sqlText =
action === "truncate"
? `TRUNCATE TABLE ${qualTable(conn.driver, dbName, table)};`
: `DROP TABLE ${qualTable(conn.driver, dbName, table)};`;
const res = await execSql(conn, sqlText, { sessionId: sid, database: dbName });
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;
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;
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="ops-section-label justify-between border-b">
<div className="ops-section-label justify-between gap-1.5 border-b">
<span>对象</span>
{!isRedisConn(conn?.driver) && (
<input
className="ops-input min-w-0 max-w-40 flex-1 py-0.5 text-[11px]"
placeholder="过滤库 / 表…"
value={treeFilter}
onChange={(e) => setTreeFilter(e.target.value)}
/>
)}
<button
type="button"
className="normal-case tracking-normal hover:underline"
@@ -557,10 +830,11 @@ export function ObjectTree() {
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
</div>
)}
{tree.map((db) => (
{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);
}}
@@ -568,11 +842,14 @@ export function ObjectTree() {
onCtx(e, db.name, () => {
patchDb(db.name, { loaded: false, loading: false, children: undefined });
void expandDb(db.name);
}, undefined, undefined, undefined, {
onNewQuery: () => useWorkspaceStore.getState().newQueryTab(),
})
}
>
<summary className="ops-tree-item cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">▸</span>
<TreeIcon kind="database" />
<span className="truncate font-medium">{db.name}</span>
{db.loading && <span className="ml-auto text-[10px] opacity-40">…</span>}
</summary>
@@ -580,7 +857,7 @@ export function ObjectTree() {
{(db.children ?? (isRedisConn(conn?.driver)
? [{ name: "键", kind: "group" as const, groupId: "keys", count: 0, children: [], loaded: false }]
: emptyGroups())).map((g) => (
<details key={g.groupId || g.name} className="mb-px">
<details key={g.groupId || g.name} className="mb-px" open={f ? true : undefined}>
<summary
className="ops-tree-item list-none [&::-webkit-details-marker]:hidden"
onContextMenu={(e) =>
@@ -591,9 +868,10 @@ export function ObjectTree() {
}
>
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
<TreeIcon kind={groupIdColor(g.groupId)} />
<span className="ops-tree-group min-w-0 flex-1 truncate">
{g.name}
<span className="ml-1 font-normal opacity-70">{g.count ?? g.children?.length ?? 0}</span>
<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)" }}>
@@ -621,7 +899,10 @@ export function ObjectTree() {
)
}
>
<span className="w-3 shrink-0 text-center text-[10px] opacity-30">·</span>
<span
className="ml-1 mr-0.5 h-2 w-2 shrink-0 self-center rounded-full"
style={{ background: TREE_COLORS.keys, opacity: 0.8 }}
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
@@ -673,44 +954,64 @@ export function ObjectTree() {
}
},
() => void openSchema(db.name, obj.name),
undefined,
undefined,
{
onQueryData: () => void putSelectSql(db.name, obj.name),
onGen: (k) => void genTableSql(db.name, obj.name, k),
onTruncate: obj.kind === "table" ? () => void runTableDanger(db.name, obj.name, "truncate") : undefined,
onDropTable: obj.kind === "table" ? () => void runTableDanger(db.name, obj.name, "drop") : undefined,
},
)
}
>
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden">
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
<TreeIcon kind={obj.kind === "view" ? "views" : "tables"} size={12} />
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
title="双击生成 SELECT"
title="单击打开前 100 行 · 双击立即打开"
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.clearTimeout(clickTimerRef.current);
void putSelectSql(db.name, obj.name);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.clearTimeout(clickTimerRef.current);
clickTimerRef.current = window.setTimeout(() => {
void putSelectSql(db.name, obj.name);
}, 240);
}}
>
{obj.name}
</button>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{(obj.children ?? []).map((col) => (
<div
key={col.name}
className="ops-tree-item opacity-80"
title={col.type}
onContextMenu={(e) => onCtx(e, col.name, () => void expandTable(db.name, obj.name))}
>
<span className="w-3 shrink-0 text-center text-[10px] opacity-30">·</span>
<span className="truncate">
{col.key === "PRI" ? "🔑 " : ""}
{col.name}
<span className="ml-1 opacity-50">{col.type}</span>
</span>
</div>
))}
{(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">
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
<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(db.name, obj.name)} />
))}
</div>
</details>
) : (
<ColumnRow key={sub.name} col={sub} onRefresh={() => void expandTable(db.name, obj.name)} />
),
)}
{obj.loading && (
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
列…
@@ -730,7 +1031,7 @@ export function ObjectTree() {
}
onDoubleClick={() => void copyName(obj.name)}
>
<span className="w-3 shrink-0" />
<TreeIcon kind={groupIdColor(g.groupId)} size={11} />
<span className="truncate">{obj.name}</span>
</div>
),
@@ -746,10 +1047,77 @@ export function ObjectTree() {
</div>
</details>
))}
{f && visibleTree.length === 0 && !treeLoading && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。
</div>
)}
</div>
{ctx && (
<div className="ops-ctx" style={{ left: ctx.x, top: ctx.y }} onClick={(e) => e.stopPropagation()}>
{ctx.onQueryData && (
<button
type="button"
onClick={() => {
ctx.onQueryData?.();
setCtx(null);
}}
>
查询数据
</button>
)}
{ctx.onSchema && (
<button
type="button"
onClick={() => {
ctx.onSchema?.();
setCtx(null);
}}
>
查看结构
</button>
)}
{ctx.onNewQuery && (
<button
type="button"
onClick={() => {
ctx.onNewQuery?.();
setCtx(null);
}}
>
新建查询
</button>
)}
{ctx.onView && (
<button
type="button"
onClick={() => {
ctx.onView?.();
setCtx(null);
}}
>
查看值
</button>
)}
{ctx.onGen && (
<>
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
{(["select", "insert", "update", "delete"] as GenSqlKind[]).map((k) => (
<button
key={k}
type="button"
onClick={() => {
ctx.onGen?.(k);
setCtx(null);
}}
>
生成 {k.toUpperCase()}
</button>
))}
</>
)}
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
<button
type="button"
onClick={() => {
@@ -768,26 +1136,31 @@ export function ObjectTree() {
>
复制名
</button>
{ctx.onSchema && (
{(ctx.onTruncate || ctx.onDropTable || ctx.onDelete) && (
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
)}
{ctx.onTruncate && (
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => {
ctx.onSchema?.();
ctx.onTruncate?.();
setCtx(null);
}}
>
查看结构
截断表…
</button>
)}
{ctx.onView && (
{ctx.onDropTable && (
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => {
ctx.onView?.();
ctx.onDropTable?.();
setCtx(null);
}}
>
查看值
删除表…
</button>
)}
{ctx.onDelete && (
+147
View File
@@ -1,8 +1,63 @@
import { useEffect, useState } from "react";
import { execSql } from "./api";
import { ensureSession } from "./ObjectTree";
import { useWorkspaceStore } from "./store";
type MetaRow = Record<string, unknown>;
type TableMeta = {
indexes: MetaRow[];
fks: MetaRow[];
triggers: MetaRow[];
};
const EMPTY_META: TableMeta = { indexes: [], fks: [], triggers: [] };
/** MySQL 协议族才走 information_schema 元数据(PG/Oracle 的系统目录另行适配) */
function isMysqlFamily(driver?: string) {
return ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"].includes((driver || "").toLowerCase());
}
/** 标识符转义进 information_schema 字符串比较 */
const esc = (s: string) => s.replace(/'/g, "''");
export function SchemaPane() {
const schema = useWorkspaceStore((s) => s.schemaView);
const set = useWorkspaceStore((s) => s.set);
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
const mysql = isMysqlFamily(conn?.driver);
useEffect(() => {
let alive = true;
setMeta(EMPTY_META);
if (!conn || !mysql || !schema || schema.loading || schema.error || !schema.table) return;
(async () => {
try {
const sid = await ensureSession(conn);
const db = esc(schema.database);
const t = esc(schema.table);
const run = (sqlText: string) => execSql(conn, sqlText, { database: schema.database, sessionId: sid, limit: 200 });
const [ix, fk, tg] = await Promise.all([
run(
`SELECT INDEX_NAME AS name, IF(NON_UNIQUE=0,'UNIQUE','') AS uniq, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS cols FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' GROUP BY INDEX_NAME, NON_UNIQUE ORDER BY INDEX_NAME`,
),
run(
`SELECT CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS refTable, REFERENCED_COLUMN_NAME AS refCol FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' AND REFERENCED_TABLE_NAME IS NOT NULL`,
),
run(
`SELECT TRIGGER_NAME AS name, ACTION_TIMING AS timing, EVENT_MANIPULATION AS event, EVENT_OBJECT_TABLE AS onTable FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA='${db}' AND EVENT_OBJECT_TABLE='${t}'`,
),
]);
if (alive) setMeta({ indexes: ix.rows, fks: fk.rows, triggers: tg.rows });
} catch {
/* 元数据不可用时静默降级:结构页至少保底显示列 */
}
})();
return () => {
alive = false;
};
}, [conn, mysql, schema?.database, schema?.table, schema?.loading, schema?.error]);
if (!schema) {
return (
@@ -36,6 +91,8 @@ export function SchemaPane() {
);
}
const str = (v: unknown) => (v == null ? "" : String(v));
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="ops-section-label justify-between border-b">
@@ -86,6 +143,96 @@ export function SchemaPane() {
</tbody>
</table>
)}
{mysql && (
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
<details open className="px-3 py-1.5">
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
索引({meta.indexes.length})
</summary>
{meta.indexes.length === 0 ? (
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无索引</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th>索引名</th>
<th style={{ width: 70 }}>唯一</th>
<th>列</th>
</tr>
</thead>
<tbody>
{meta.indexes.map((r, i) => (
<tr key={i}>
<td className="font-mono text-[12px]">{str(r.name)}</td>
<td>{r.uniq ? "✓" : ""}</td>
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>{str(r.cols)}</td>
</tr>
))}
</tbody>
</table>
)}
</details>
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
外键({meta.fks.length})
</summary>
{meta.fks.length === 0 ? (
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无外键</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th>约束名</th>
<th>列</th>
<th>引用</th>
</tr>
</thead>
<tbody>
{meta.fks.map((r, i) => (
<tr key={i}>
<td className="font-mono text-[12px]">{str(r.name)}</td>
<td className="font-mono text-[12px]">{str(r.col)}</td>
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
{str(r.refTable)}.{str(r.refCol)}
</td>
</tr>
))}
</tbody>
</table>
)}
</details>
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
触发器({meta.triggers.length})
</summary>
{meta.triggers.length === 0 ? (
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无触发器</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th>触发器</th>
<th style={{ width: 80 }}>时机</th>
<th style={{ width: 80 }}>事件</th>
</tr>
</thead>
<tbody>
{meta.triggers.map((r, i) => (
<tr key={i}>
<td className="font-mono text-[12px]">{str(r.name)}</td>
<td>{str(r.timing)}</td>
<td>{str(r.event)}</td>
</tr>
))}
</tbody>
</table>
)}
</details>
</div>
)}
</div>
</div>
);
+27
View File
@@ -2,10 +2,36 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { sql } from "@codemirror/lang-sql";
import { tags as t } from "@lezer/highlight";
import { oneDark } from "@codemirror/theme-one-dark";
import { formatSqlText, statementAt } from "./sql";
/**
* SQL 高亮走克制的四色方案(VS Code Dark+ 观感):
* 关键字+数字 = 蓝,字符串 = 橙,注释 = 绿,标识符/运算符 = 前景浅白。
* 注册在 oneDark 之后,覆盖其默认的五彩配色。
*/
const sqlDarkHighlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#569CD6" },
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#569CD6" },
{ tag: [t.string, t.special(t.string)], color: "#CE9178" },
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#6A9955" },
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#D4D4D4" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#D4D4D4" },
]);
/** 浅色主题四色方案:关键字蓝、字符串枣红、注释绿、标识符近黑 */
const sqlLightHighlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#0000FF" },
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#0000FF" },
{ tag: [t.string, t.special(t.string)], color: "#A31515" },
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#008000" },
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#24292F" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#24292F" },
]);
export type SqlEditorHandle = {
/** Selection trim if nonempty; else statement at cursor (statementAt). */
getRunSql: () => string;
@@ -96,6 +122,7 @@ export const SqlEditor = forwardRef<
".cm-gutters": { border: "none" },
}),
...(theme === "dark" ? [oneDark] : []),
syntaxHighlighting(theme === "dark" ? sqlDarkHighlight : sqlLightHighlight),
],
});
const view = new EditorView({ state, parent: host.current });
+77 -17
View File
@@ -55,7 +55,7 @@ function TopNav() {
className={cx("ops-btn-ghost", showHistory && "ops-tree-item-active")}
onClick={() => set("showHistory", !showHistory)}
>
历史
查询历史
</button>
<button type="button" className="ops-btn-ghost" onClick={() => set("showHelp", !showHelp)}>
快捷键
@@ -135,6 +135,7 @@ function EditorPane() {
const notifyError = useWorkspaceStore((s) => s.notifyError);
const showHistory = useWorkspaceStore((s) => s.showHistory);
const history = useWorkspaceStore((s) => s.queryHistory);
const [histFilter, setHistFilter] = useState("");
const showBookmarks = useWorkspaceStore((s) => s.showBookmarks);
const bookmarks = useWorkspaceStore((s) => s.bookmarks);
const editorRef = useRef<SqlEditorHandle>(null);
@@ -583,9 +584,22 @@ function EditorPane() {
</div>
)}
{showHistory && (
<div className="max-h-36 shrink-0 overflow-auto border-b px-2 py-1 text-[11px]" style={{ borderColor: "var(--ops-border)" }}>
<div className="mb-1 flex items-center justify-between">
<span style={{ color: "var(--ops-muted)" }}>最近 {history.length} 条</span>
<div className="flex max-h-56 shrink-0 flex-col border-b text-[11px]" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex items-center gap-2 px-2 py-1">
<span className="shrink-0" style={{ color: "var(--ops-muted)" }}>
最近 {history.length} 条
</span>
<input
value={histFilter}
onChange={(e) => setHistFilter(e.target.value)}
placeholder="过滤历史…"
className="min-w-0 flex-1 rounded px-1.5 py-0.5 text-[11px]"
style={{
background: "var(--ops-input-bg, transparent)",
border: "1px solid var(--ops-border)",
color: "inherit",
}}
/>
<button
type="button"
className="hover:underline"
@@ -595,19 +609,65 @@ function EditorPane() {
清空
</button>
</div>
{history.length === 0 && <div style={{ color: "var(--ops-muted)" }}>暂无历史</div>}
{history.map((h) => (
<button
key={h.id}
type="button"
className="ops-tree-item w-full"
onClick={() => updateActiveSql(h.sql)}
title={h.sql}
>
<span className="w-16 shrink-0 opacity-50">{new Date(h.at).toLocaleTimeString()}</span>
<span className="truncate font-mono">{h.sql.replace(/\s+/g, " ")}</span>
</button>
))}
<div className="min-h-0 flex-1 overflow-auto px-1 pb-1">
{history.length === 0 && (
<div className="px-1.5 py-2 leading-5" style={{ color: "var(--ops-muted)" }}>
暂无历史。执行过的语句会留在这里:点击行回填编辑器,▶ 直接重跑;重复语句自动去重置顶。
</div>
)}
{history
.filter((h) => {
const q = histFilter.trim().toLowerCase();
if (!q) return true;
return (
h.sql.replace(/\s+/g, " ").toLowerCase().includes(q) ||
connections.find((c) => c.id === h.connectionId)?.name.toLowerCase().includes(q)
);
})
.map((h) => {
const connName = connections.find((c) => c.id === h.connectionId)?.name;
return (
<div key={h.id} className="ops-tree-item group w-full" title={h.sql}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
onClick={() => updateActiveSql(h.sql)}
>
<span className="w-14 shrink-0 opacity-50">{new Date(h.at).toLocaleTimeString()}</span>
{connName && (
<span
className="max-w-20 shrink-0 truncate rounded px-1 text-[10px]"
style={{ background: "var(--ops-accent-soft, rgba(127,127,127,.15))", color: "var(--ops-accent)" }}
>
{connName}
</span>
)}
<span
className={`truncate font-mono ${h.error ? "opacity-60" : ""}`}
style={h.error ? { color: "var(--ops-danger, #e11d48)" } : undefined}
>
{h.sql.replace(/\s+/g, " ")}
</span>
<span className="ml-auto shrink-0 tabular-nums opacity-45">
{h.error ? "失败" : `${h.rowCount ?? 0} 行 · ${h.timeMs ?? 0}ms`}
</span>
</button>
<button
type="button"
className="ml-1 shrink-0 rounded px-1 opacity-0 transition-opacity group-hover:opacity-90"
style={{ color: "var(--ops-accent)" }}
title="回填并执行"
onClick={() => {
updateActiveSql(h.sql);
void run(h.sql);
}}
>
▶
</button>
</div>
);
})}
</div>
</div>
)}
+29 -4
View File
@@ -231,6 +231,8 @@ type State = {
openConnectionList: () => void;
editConnection: (c: SavedConnection) => void;
newQueryTab: () => void;
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
setActiveTabTitle: (title: string) => void;
closeTab: (id: string) => void;
updateActiveSql: (sql: string) => void;
pushAi: (role: AiMessage["role"], content: string) => void;
@@ -488,6 +490,13 @@ export const useWorkspaceStore = create<State>((set, get) => ({
sshPassword: c.savePassword === false ? "" : c.sshPassword,
};
let list = [...get().connections];
// 同名防护:不同 id 同名时自动加序号后缀,避免侧栏出现重名条目
const nameTaken = (n: string) => list.some((x) => x.id !== next.id && x.name === n);
if (nameTaken(next.name)) {
let k = 2;
while (nameTaken(`${next.name} ${k}`)) k++;
next.name = `${next.name} ${k}`;
}
if (next.isDefault) {
list = list.map((x) => ({ ...x, isDefault: x.id === next.id ? true : false }));
}
@@ -643,6 +652,13 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
},
setActiveTabTitle: (title) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, title } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
closeTab: (id) => {
const tabs = get().tabs.filter((t) => t.id !== id);
const next = tabs.length ? tabs : [defaultTab()];
@@ -678,10 +694,19 @@ export const useWorkspaceStore = create<State>((set, get) => ({
},
pushHistory: (item) => {
const next: QueryHistoryItem[] = [
{ id: crypto.randomUUID(), at: item.at ?? Date.now(), sql: item.sql, connectionId: item.connectionId, rowCount: item.rowCount, timeMs: item.timeMs, error: item.error },
...get().queryHistory,
].slice(0, 50);
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
const key = norm(item.sql);
const entry: QueryHistoryItem = {
id: crypto.randomUUID(),
at: item.at ?? Date.now(),
sql: item.sql,
connectionId: item.connectionId,
rowCount: item.rowCount,
timeMs: item.timeMs,
error: item.error,
};
// 相同语句去重置顶:重复执行只留最新一次,历史即「最近用过的不同语句」
const next = [entry, ...get().queryHistory.filter((h) => norm(h.sql) !== key)].slice(0, 200);
localStorage.setItem(LS_HISTORY, JSON.stringify(next));
set({ queryHistory: next });
},