fix: 顶栏文案去重与文档/连库体验更新
顶栏定为:连接 · 新建连接 · 新建查询 · 查询历史 · 快捷键; 同步 docs/落地页、对象树与 connection-types 注册表整理。
This commit is contained in:
1 parent
f8740d27f7
commit
26f459d1b8
49 files changed
+2682
-492
No files matched your search
+412
-39
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user