Files
opsxdb/packages/ui/src/SchemaPane.tsx
T
logan a9510235bb
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

驱动管理:
- 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口
- opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh)
- 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退

UI/交互:
- 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell)
- 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角)
- 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行)
- 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
2026-09-22 01:07:46 +08:00

303 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { execSql, listColumns } from "./api";
import { DdlModal } from "./DdlModal";
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);
/** R2-F3:DDL 查看弹窗 */
const [showDdl, setShowDdl] = useState(false);
const mysql = isMysqlFamily(conn?.driver);
/** Redis 键的结构页不展示 DDL(服务端仅支持表/视图) */
const ddlEnabled = !!conn && (conn.driver || "").toLowerCase() !== "redis";
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 (
<div className="ops-empty" style={{ minHeight: 120 }}>
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>表结构</h2>
<p>在对象树点表,或右键「查看结构」。</p>
</div>
);
}
/** E1:结构加载失败重试 —— 重新拉列元数据 */
const retryLoad = async () => {
if (!conn || !schema) return;
set("schemaView", { ...schema, loading: true, error: undefined });
try {
const sid = await ensureSession(conn);
const { columns } = await listColumns(conn, schema.database, schema.table, sid);
set("schemaView", { database: schema.database, table: schema.table, columns, loading: false });
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
set("resultMeta", { database: schema.database, table: schema.table, pkCols });
set("connState", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
set("schemaView", { database: schema.database, table: schema.table, columns: [], loading: false, error: msg });
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
}
};
if (schema.loading) {
return (
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
加载 {schema.table} 结构…
</div>
);
}
if (schema.error) {
return (
<div
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
style={{
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
color: "var(--ops-danger)",
}}
>
<div>{schema.error}</div>
<button
type="button"
className="mt-1 hover:underline"
style={{ fontSize: "var(--ops-text-xs)" }}
onClick={() => void retryLoad()}
>
重试
</button>
</div>
);
}
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">
<span>
结构 · {schema.database ? `${schema.database}.` : ""}
{schema.table}
</span>
<div className="flex shrink-0 items-center gap-2 normal-case tracking-normal">
{ddlEnabled && (
<button
type="button"
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
title="查看建表语句(DDL)"
onClick={() => setShowDdl(true)}
>
DDL
</button>
)}
<button
type="button"
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={() => set("resultTab", "data")}
>
回结果
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{schema.columns.length === 0 ? (
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
无列信息
</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th style={{ width: 36 }}>#</th>
<th>列名</th>
<th>类型</th>
<th style={{ width: 52 }}>键</th>
<th style={{ width: 64 }}>可空</th>
<th>默认值</th>
</tr>
</thead>
<tbody>
{schema.columns.map((c, i) => (
<tr key={c.name}>
<td className="ops-rownum">{i + 1}</td>
<td>
{c.key === "PRI" ? <span title="主键">🔑 </span> : null}
{c.name}
</td>
<td className="font-mono text-[12px]">{c.type}</td>
<td>
{c.key === "PRI" ? (
<span
className="rounded px-1 font-semibold"
style={{
fontSize: "var(--ops-text-xs)",
color: "var(--ops-warning)",
background: "color-mix(in srgb, var(--ops-warning) 14%, transparent)",
}}
>
PRI
</span>
) : (
""
)}
</td>
<td>{c.nullable ? "YES" : "NO"}</td>
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
{c.default == null ? <span className="ops-null">NULL</span> : String(c.default)}
</td>
</tr>
))}
</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>
{showDdl && conn && (
<DdlModal conn={conn} database={schema.database} table={schema.table} onClose={() => setShowDdl(false)} />
)}
</div>
);
}