From 26f459d1b820edd52373636ad79c3cd8f49be1fd Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 20 Sep 2026 16:32:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A1=B6=E6=A0=8F=E6=96=87=E6=A1=88?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E4=B8=8E=E6=96=87=E6=A1=A3/=E8=BF=9E?= =?UTF-8?q?=E5=BA=93=E4=BD=93=E9=AA=8C=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顶栏定为:连接 · 新建连接 · 新建查询 · 查询历史 · 快捷键; 同步 docs/落地页、对象树与 connection-types 注册表整理。 --- .gitignore | 1 + README.md | 9 +- .../src-tauri/icons/options/cylinder.png | Bin 0 -> 12861 bytes .../src-tauri/icons/options/cylinder.svg | 10 + apps/desktop/src-tauri/icons/options/star.png | Bin 0 -> 17922 bytes apps/desktop/src-tauri/icons/options/star.svg | 7 + apps/desktop/src-tauri/icons/options/tilt.png | Bin 0 -> 17300 bytes apps/desktop/src-tauri/icons/options/tilt.svg | 7 + apps/server/go.mod | 1 + apps/server/go.sum | 2 + apps/server/internal/docs/assets/docs.css | 265 ++++++++-- apps/server/internal/docs/assets/docs.js | 219 +++++++++ apps/server/internal/docs/content/01-intro.md | 40 ++ .../internal/docs/content/02-quickstart.md | 61 +++ .../internal/docs/content/03-interface.md | 40 ++ .../internal/docs/content/04-connect.md | 42 ++ .../internal/docs/content/05-drivers.md | 41 ++ .../internal/docs/content/06-workbench.md | 46 ++ .../internal/docs/content/07-results.md | 27 ++ apps/server/internal/docs/content/08-redis.md | 34 ++ .../internal/docs/content/09-security.md | 35 ++ apps/server/internal/docs/content/10-ai.md | 32 ++ apps/server/internal/docs/content/11-faq.md | 40 ++ .../server/internal/docs/content/connect.html | 7 - apps/server/internal/docs/content/faq.html | 7 - .../server/internal/docs/content/install.html | 13 - .../internal/docs/content/manifest.json | 41 ++ .../internal/docs/content/workbench.html | 7 - apps/server/internal/docs/docs.go | 251 ++++++++-- apps/server/internal/docs/render.go | 25 + apps/server/internal/docs/template.html | 82 +++- apps/server/internal/site/assets/site.css | 302 ++++++++++-- apps/server/internal/site/template.html | 302 +++++++++++- connection-types/NOTICE.md | 12 + connection-types/README.md | 40 ++ connection-types/connection-types.json | 110 +++++ connection-types/dameng.yaml | 39 -- connection-types/mongodb.yaml | 37 -- connection-types/mysql.yaml | 32 -- connection-types/oracle.yaml | 48 -- connection-types/postgres.yaml | 36 -- connection-types/redis.yaml | 29 -- connection-types/sqlserver.yaml | 41 -- packages/ui/src/ObjectTree.tsx | 451 ++++++++++++++++-- packages/ui/src/SchemaPane.tsx | 147 ++++++ packages/ui/src/SqlEditor.tsx | 27 ++ packages/ui/src/Workspace.tsx | 94 +++- packages/ui/src/store.ts | 33 +- tests/README.md | 2 + 49 files changed, 2682 insertions(+), 492 deletions(-) create mode 100644 apps/desktop/src-tauri/icons/options/cylinder.png create mode 100644 apps/desktop/src-tauri/icons/options/cylinder.svg create mode 100644 apps/desktop/src-tauri/icons/options/star.png create mode 100644 apps/desktop/src-tauri/icons/options/star.svg create mode 100644 apps/desktop/src-tauri/icons/options/tilt.png create mode 100644 apps/desktop/src-tauri/icons/options/tilt.svg create mode 100644 apps/server/internal/docs/assets/docs.js create mode 100644 apps/server/internal/docs/content/01-intro.md create mode 100644 apps/server/internal/docs/content/02-quickstart.md create mode 100644 apps/server/internal/docs/content/03-interface.md create mode 100644 apps/server/internal/docs/content/04-connect.md create mode 100644 apps/server/internal/docs/content/05-drivers.md create mode 100644 apps/server/internal/docs/content/06-workbench.md create mode 100644 apps/server/internal/docs/content/07-results.md create mode 100644 apps/server/internal/docs/content/08-redis.md create mode 100644 apps/server/internal/docs/content/09-security.md create mode 100644 apps/server/internal/docs/content/10-ai.md create mode 100644 apps/server/internal/docs/content/11-faq.md delete mode 100644 apps/server/internal/docs/content/connect.html delete mode 100644 apps/server/internal/docs/content/faq.html delete mode 100644 apps/server/internal/docs/content/install.html create mode 100644 apps/server/internal/docs/content/manifest.json delete mode 100644 apps/server/internal/docs/content/workbench.html create mode 100644 apps/server/internal/docs/render.go create mode 100644 connection-types/NOTICE.md create mode 100644 connection-types/README.md create mode 100644 connection-types/connection-types.json delete mode 100644 connection-types/dameng.yaml delete mode 100644 connection-types/mongodb.yaml delete mode 100644 connection-types/mysql.yaml delete mode 100644 connection-types/oracle.yaml delete mode 100644 connection-types/postgres.yaml delete mode 100644 connection-types/redis.yaml delete mode 100644 connection-types/sqlserver.yaml diff --git a/.gitignore b/.gitignore index b6f11fe..1d4530a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ opsxdb-refs.tar.gz apps/server/bin/ logs/ apps/desktop/src-tauri/binaries/opsxdb-server-* +*.bak diff --git a/README.md b/README.md index 1e87108..341694b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ packages/core 共享工具(保留) packages/transport 浏览器 / Gin / Tauri(保留兼容) apps/web Vite + React(:5174) apps/desktop Tauri 2 壳 -apps/server Gin:health + MySQL 驱动 + AI stub(:8788) +apps/server Gin:health + 内置驱动(MySQL 族/PG 族/SQL Server/Oracle/SQLite/Redis)+ AI stub(:8788) ``` ## 怎么跑 @@ -53,6 +53,13 @@ curl -s -X POST http://127.0.0.1:8788/api/ai/chat \ - 驱动:MySQL 族 / PostgreSQL 族(含 GaussDB、人大金仓)/ SQL Server / Oracle / SQLite / Redis - AI 面板仍是 stub(生成/解释/优化/修复/方言/样例) +## 驱动与连接类型 + +- **注册表**:`connection-types/connection-types.json`(schema v1,字段说明见该目录 `README.md`)。规划为驱动目录的单一事实来源;生成器接入前以 `packages/ui/src/catalog.ts` 与 `apps/server/internal/db/open.go` 为准。 +- **就绪状态**:`ready` = 可测试/连接;`verified` = 本机 docker 验收通过,依据 `tests/README.md` 驱动验收表。当前 MySQL/MariaDB/PG/SQL Server/SQLite/Redis 已验收,Oracle 代码就绪但本机未验证,达梦/MongoDB/ZK/ES/InfluxDB 未就绪。 +- **路线**:常用库走 native Go 驱动编译内置(go-ora 连 Oracle,不引入 JVM);长尾库(达梦/MongoDB/ZK/InfluxDB 等)规划 agent 按需接入,驱动分发走 Maven 坐标钉版本 + sha256 + 镜像回退,计划托管于 `opsxdb-driver` 仓库(Gitee/GitHub)。 +- **许可**:`connection-types/` 注册表数据从自有代码与厂商公开事实推导(见 `connection-types/NOTICE.md`);仓库整体许可证以根 LICENSE 为准。 + ## 端口 | 服务 | 端口 | 环境变量 | diff --git a/apps/desktop/src-tauri/icons/options/cylinder.png b/apps/desktop/src-tauri/icons/options/cylinder.png new file mode 100644 index 0000000000000000000000000000000000000000..eb7223509a1a0e8152d168e2c706e0c26a01ab40 GIT binary patch literal 12861 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/options/cylinder.svg b/apps/desktop/src-tauri/icons/options/cylinder.svg new file mode 100644 index 0000000..8f30d42 --- /dev/null +++ b/apps/desktop/src-tauri/icons/options/cylinder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/desktop/src-tauri/icons/options/star.png b/apps/desktop/src-tauri/icons/options/star.png new file mode 100644 index 0000000000000000000000000000000000000000..169cacf690ccadd426f4877b42ac10dcc9ae6615 GIT binary patch literal 17922 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 V>ckUfJsl{*;OXk;vd$@?2>?Kg!GZt) literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/options/star.svg b/apps/desktop/src-tauri/icons/options/star.svg new file mode 100644 index 0000000..a5a4b2d --- /dev/null +++ b/apps/desktop/src-tauri/icons/options/star.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/apps/desktop/src-tauri/icons/options/tilt.png b/apps/desktop/src-tauri/icons/options/tilt.png new file mode 100644 index 0000000000000000000000000000000000000000..6e9af19b642ebb57ddf3de0aa8aa4e6873a1ecd3 GIT binary patch literal 17300 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

*lO`^j`|1%jldVn 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 eUkJ{e1O=Xd=^EN?+v$ei0Cg2D + + + + + diff --git a/apps/server/go.mod b/apps/server/go.mod index 7c13c1d..d8c9a26 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -49,6 +49,7 @@ require ( github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.51.0 // indirect diff --git a/apps/server/go.sum b/apps/server/go.sum index c02743e..885694d 100644 --- a/apps/server/go.sum +++ b/apps/server/go.sum @@ -130,6 +130,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= diff --git a/apps/server/internal/docs/assets/docs.css b/apps/server/internal/docs/assets/docs.css index 9b85ea8..18dc68a 100644 --- a/apps/server/internal/docs/assets/docs.css +++ b/apps/server/internal/docs/assets/docs.css @@ -1,39 +1,246 @@ +/* OpsxDB 文档站 —— 清爽专业风格(浅色) */ :root { - --bg: #0e1116; --panel: #151a21; --border: #243044; - --text: #e6edf7; --muted: #8b9bb4; --accent: #3b82f6; + --blue: #059669; + --blue-dark: #047857; + --blue-soft: rgba(5, 150, 105, 0.08); + --bg: #f6f8fb; + --panel: #ffffff; + --border: #e4e9f1; + --text: #1c2434; + --text-2: #5b6880; + --text-3: #98a2b3; + --code-bg: #f3f6fb; + --code-border: #e1e8f3; + --green: #059669; + --amber: #b45309; + --red: #dc2626; + --sidebar-w: 262px; + --toc-w: 220px; + --topbar-h: 56px; + --radius: 10px; + --shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.05); + --mono: "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace; } * { box-sizing: border-box; } -body { margin: 0; font: 13px/1.55 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); } -.top { - height: 44px; border-bottom: 1px solid var(--border); background: var(--panel); - display: flex; align-items: center; justify-content: space-between; padding: 0 16px; +html { scroll-behavior: smooth; scroll-padding-top: 72px; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14.5px; + line-height: 1.75; } -.brand { display: flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; font-weight: 600; } -.brand em { color: var(--muted); font-style: normal; font-weight: 500; } -.logo { - width: 24px; height: 24px; border-radius: 6px; background: var(--accent); - display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; +a { color: var(--blue); text-decoration: none; } +.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; } +a:hover { color: var(--blue-dark); text-decoration: underline; } + +/* ---------- 顶栏 ---------- */ +.topbar { + position: sticky; top: 0; z-index: 50; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: saturate(1.4) blur(8px); + border-bottom: 1px solid var(--border); } -.top a { color: var(--muted); text-decoration: none; font-size: 12.5px; } -.layout { display: flex; min-height: calc(100vh - 44px); } -.side { - width: 200px; border-right: 1px solid var(--border); padding: 12px 10px; - background: var(--panel); display: flex; flex-direction: column; gap: 2px; +.topbar-inner { + max-width: 1440px; margin: 0 auto; height: var(--topbar-h); + display: flex; align-items: center; gap: 14px; padding: 0 20px; +} +.menu-btn { + display: none; border: 1px solid var(--border); background: #fff; + border-radius: 8px; width: 34px; height: 34px; cursor: pointer; color: var(--text-2); + place-items: center; +} +.menu-btn .i { width: 17px; height: 17px; } +.brand { display: flex; align-items: center; gap: 9px; text-decoration: none !important; } +.brand-logo { display: inline-flex; } +.brand-logo svg { display: block; border-radius: 7px; } +.brand-name { font-size: 16px; font-weight: 700; color: var(--text); } +.brand-name em { font-style: normal; font-weight: 500; color: var(--text-2); margin-left: 2px; } +.ver-badge { + font-size: 11px; font-weight: 600; color: var(--blue); + background: var(--blue-soft); border: 1px solid rgba(5, 150, 105, 0.22); + padding: 1px 9px; border-radius: 999px; white-space: nowrap; +} +.search { position: relative; flex: 1; max-width: 420px; margin-left: 8px; } +.search input { + width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 9px; + background: var(--bg); padding: 0 14px 0 34px; font-size: 13.5px; color: var(--text); outline: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none' stroke='%2398a2b3' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: 12px center; +} +.search input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-soft); } +.search-panel { + position: absolute; top: 44px; left: 0; right: 0; background: #fff; + border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); + max-height: 420px; overflow: auto; padding: 6px; +} +.search-item { display: block; padding: 8px 12px; border-radius: 8px; text-decoration: none !important; } +.search-item:hover, .search-item.sel { background: var(--blue-soft); } +.search-item .si-title { font-weight: 600; color: var(--text); font-size: 13.5px; } +.search-item .si-group { color: var(--text-3); font-size: 11px; margin-left: 6px; } +.search-item .si-snippet { color: var(--text-2); font-size: 12px; margin-top: 2px; display: block; } +.search-empty { padding: 14px; text-align: center; color: var(--text-3); font-size: 13px; } +.top-links { margin-left: auto; display: flex; gap: 16px; align-items: center; } +.top-links a { font-size: 13.5px; font-weight: 500; } + +/* ---------- 三栏布局 ---------- */ +.layout { + max-width: 1440px; margin: 0 auto; display: grid; + grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--toc-w); + gap: 0 28px; padding: 24px 20px 60px; +} + +/* 侧栏 */ +.sidebar { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-right: 8px; } +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-thumb { background: #d6dde8; border-radius: 4px; } +.nav-group { margin-bottom: 18px; } +.nav-group-title { + font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-3); padding: 0 10px 6px; } .nav-link { - display: block; padding: 6px 8px; border-radius: 6px; color: var(--muted); - text-decoration: none; font-size: 12.5px; + display: block; padding: 6px 10px; border-radius: 8px; font-size: 13.5px; + color: var(--text-2); text-decoration: none !important; border-left: 2px solid transparent; } -.nav-link:hover { color: var(--text); background: #1a2230; } -.nav-link.active { color: var(--text); background: color-mix(in srgb, var(--accent) 18%, transparent); } -.content { flex: 1; padding: 24px 28px; max-width: 720px; } -.content h1 { font-size: 20px; margin: 0 0 14px; } -.content h2 { font-size: 15px; margin: 22px 0 8px; } -.content p, .content li { color: #c5d0e0; } -.content code, .content pre { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; +.nav-link:hover { background: rgba(5, 150, 105, 0.06); color: var(--text); } +.nav-link.active { + background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; + border-left-color: var(--blue); } -.content pre { - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; +.sidebar-foot { margin-top: 24px; padding: 12px 10px 0; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-3); } + +/* 内容 */ +.content { min-width: 0; max-width: 860px; } +.breadcrumb { font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; } +.breadcrumb span { margin: 0 6px; } +.page-title { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 6px; line-height: 1.3; } +.page-meta { font-size: 12.5px; color: var(--text-3); margin-bottom: 22px; } + +/* Markdown 排版 */ +.md h2 { font-size: 21px; font-weight: 700; margin: 38px 0 12px; padding-top: 8px; letter-spacing: -0.01em; } +.md h3 { font-size: 16.5px; font-weight: 700; margin: 26px 0 8px; } +.md h2 + h3, .md h1 + h2 { margin-top: 18px; } +.md p { margin: 10px 0; color: #2a3348; } +.md ul, .md ol { padding-left: 22px; margin: 10px 0; } +.md li { margin: 5px 0; color: #2a3348; } +.md li::marker { color: var(--blue); } +.md strong { color: var(--text); } +.md hr { border: none; border-top: 1px solid var(--border); margin: 30px 0; } +.md img { max-width: 100%; border-radius: var(--radius); border: 1px solid var(--border); } +.md blockquote { + margin: 14px 0; padding: 10px 16px; border-left: 3px solid var(--blue); + background: var(--blue-soft); border-radius: 0 8px 8px 0; color: #33415e; +} + +/* 表格 */ +.md table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13.5px; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: 0 0 0 1px var(--border); } +.md th, .md td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } +.md th { background: #f7fbf9; font-weight: 600; color: var(--text); white-space: nowrap; } +.md tr:last-child td { border-bottom: none; } + +/* 代码块 */ +.md code { font-family: var(--mono); font-size: 12.8px; background: var(--code-bg); border: 1px solid var(--code-border); border-radius: 5px; padding: 1px 6px; color: #334155; } +.md pre { + position: relative; background: #0f172a; color: #dbe4f3; border-radius: var(--radius); + padding: 16px 16px 14px; overflow-x: auto; margin: 16px 0; font-size: 13px; line-height: 1.65; +} +.md pre code { background: none; border: none; color: inherit; padding: 0; font-size: inherit; } +.md pre .lang-tag { + position: absolute; top: 8px; right: 12px; font-size: 10.5px; color: #7c8db0; + font-family: var(--mono); text-transform: lowercase; letter-spacing: 0.04em; +} +.md pre .copy-btn { + position: absolute; top: 6px; right: 60px; font-size: 11px; color: #9fb0d0; + background: rgba(255, 255, 255, 0.07); border: none; border-radius: 6px; padding: 2px 9px; cursor: pointer; + opacity: 0; transition: opacity 0.15s; +} +.md pre:hover .copy-btn { opacity: 1; } +.md pre .copy-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.14); } +/* 行内高亮 token */ +.md pre .tok-key { color: #7dd3fc; } +.md pre .tok-str { color: #86efac; } +.md pre .tok-num { color: #fcd34d; } +.md pre .tok-com { color: #64748b; font-style: italic; } + +/* 提示块(通过 blockquote 首字符约定) */ +.md blockquote.tip { border-left-color: var(--green); background: rgba(5, 150, 105, 0.07); } +.md blockquote.warn { border-left-color: var(--amber); background: rgba(180, 83, 9, 0.07); } + +/* 上下页 */ +.pn-nav { display: flex; gap: 14px; margin-top: 48px; } +.pn { + flex: 1; border: 1px solid var(--border); background: #fff; border-radius: var(--radius); + padding: 13px 16px; text-decoration: none !important; transition: border-color 0.15s, box-shadow 0.15s; +} +.pn:hover { border-color: var(--blue); box-shadow: var(--shadow); } +.pn.next { text-align: right; } +.pn-label { display: block; font-size: 11.5px; color: var(--text-3); } +.pn-title { display: block; font-weight: 600; color: var(--text); font-size: 14px; margin-top: 2px; } + +/* 反馈 */ +.feedback { + margin-top: 28px; padding: 16px 18px; border-top: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; color: var(--text-2); font-size: 13px; flex-wrap: wrap; +} +.fb-btn { + border: 1px solid var(--border); background: #fff; border-radius: 8px; padding: 5px 12px; + font-size: 12.5px; cursor: pointer; color: var(--text-2); transition: all 0.15s; +} +.fb-btn:hover { border-color: var(--blue); color: var(--blue); } +.fb-btn.done { border-color: var(--green); color: var(--green); background: rgba(5, 150, 105, 0.06); } +.fb-thanks { color: var(--green); font-size: 12.5px; } + +/* 右侧目录 */ +.toc { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-top: 34px; } +.toc-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-3); margin-bottom: 8px; } +.toc-nav { display: flex; flex-direction: column; gap: 1px; border-left: 2px solid var(--border); padding-left: 0; } +.toc-nav a { + font-size: 12.5px; color: var(--text-2); padding: 4px 0 4px 12px; text-decoration: none !important; + border-left: 2px solid transparent; margin-left: -2px; line-height: 1.5; +} +.toc-nav a:hover { color: var(--blue); } +.toc-nav a.toc-l3 { padding-left: 26px; font-size: 12px; color: var(--text-3); } +.toc-nav a.active { color: var(--blue); border-left-color: var(--blue); font-weight: 600; } +.print-btn { + margin-top: 18px; width: 100%; border: 1px solid var(--border); background: #fff; + border-radius: 8px; padding: 7px; font-size: 12.5px; color: var(--text-2); cursor: pointer; +} +.print-btn:hover { border-color: var(--blue); color: var(--blue); } + +/* 404 */ +.doc-404 { display: grid; place-items: center; min-height: 100vh; text-align: center; } +.doc-404 h1 { font-size: 56px; margin: 0; color: var(--blue); } +.doc-404 .btn { + display: inline-block; margin-top: 12px; background: var(--blue); color: #fff; + padding: 8px 18px; border-radius: 8px; text-decoration: none !important; font-weight: 600; +} + +/* ---------- 响应式 ---------- */ +@media (max-width: 1200px) { + .layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr); } + .toc { display: none; } +} +@media (max-width: 880px) { + .menu-btn { display: inline-grid; place-items: center; } + .layout { grid-template-columns: minmax(0, 1fr); padding-top: 12px; } + .sidebar { + position: fixed; z-index: 60; left: 0; top: var(--topbar-h); bottom: 0; + width: 280px; background: #fff; border-right: 1px solid var(--border); + padding: 16px; transform: translateX(-100%); transition: transform 0.2s; max-height: none; + } + .sidebar.open { transform: none; box-shadow: var(--shadow); } + .ver-badge { display: none; } + .top-links a { font-size: 12.5px; } + .page-title { font-size: 24px; } +} + +/* ---------- 打印 ---------- */ +@media print { + .topbar, .sidebar, .toc, .pn-nav, .feedback, .menu-btn { display: none !important; } + .layout { display: block; max-width: none; padding: 0; } + body { background: #fff; } + .md pre { background: #f1f5f9 !important; color: #1e293b !important; } + .md pre .copy-btn, .md pre .lang-tag { display: none; } } -.content code { background: #1a2230; padding: 1px 5px; border-radius: 4px; } diff --git a/apps/server/internal/docs/assets/docs.js b/apps/server/internal/docs/assets/docs.js new file mode 100644 index 0000000..f3110c9 --- /dev/null +++ b/apps/server/internal/docs/assets/docs.js @@ -0,0 +1,219 @@ +/* OpsxDB 文档站交互:搜索 / 代码复制 / 目录高亮 / 反馈 / 移动端导航 */ +(function () { + "use strict"; + + /* ---------- 代码块:语言标签 + 复制按钮 + 轻量高亮 ---------- */ + document.querySelectorAll("article.md pre").forEach(function (pre) { + var code = pre.querySelector("code"); + if (!code) return; + var lang = (code.className.match(/language-([\w-]+)/) || [, ""])[1]; + if (lang) { + var tag = document.createElement("span"); + tag.className = "lang-tag"; + tag.textContent = lang; + pre.appendChild(tag); + } + // 复制按钮 + var btn = document.createElement("button"); + btn.className = "copy-btn"; + btn.type = "button"; + btn.textContent = "复制"; + btn.addEventListener("click", function () { + navigator.clipboard.writeText(code.textContent).then( + function () { + btn.textContent = "已复制 ✓"; + setTimeout(function () { btn.textContent = "复制"; }, 1400); + }, + function () { btn.textContent = "复制失败"; } + ); + }); + pre.appendChild(btn); + // JSON / bash 轻量高亮 + if (lang === "json") highlightJSON(code); + else if (lang === "bash" || lang === "sh" || lang === "shell") highlightBash(code); + }); + + function esc(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + function highlightJSON(code) { + var src = code.textContent; + code.innerHTML = esc(src).replace( + /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + function (m, str, colon, kw, num) { + if (str && colon) return '' + str + "" + colon; + if (str) return '' + str + ""; + if (kw) return '' + kw + ""; + if (num) return '' + num + ""; + return m; + } + ); + } + function highlightBash(code) { + var lines = code.textContent.split("\n"); + code.innerHTML = lines + .map(function (line) { + if (/^\s*#/.test(line)) return '' + esc(line) + ""; + return esc(line).replace(/(^|\s)(-{1,2}[\w-]+)/g, '$1$2'); + }) + .join("\n"); + } + + /* ---------- blockquote 提示样式约定:> **提示** / > **注意** ---------- */ + document.querySelectorAll("article.md blockquote").forEach(function (bq) { + var t = (bq.textContent || "").trim(); + if (/^(注意|警告|⚠)/.test(t)) bq.classList.add("warn"); + else if (/^(提示|说明|💡|Tip)/i.test(t)) bq.classList.add("tip"); + }); + + /* ---------- 搜索 ---------- */ + var input = document.getElementById("search-input"); + var panel = document.getElementById("search-panel"); + var entries = null; + var selIndex = -1; + + function ensureIndex(cb) { + if (entries) return cb(); + fetch("/docs/search-data.json") + .then(function (r) { return r.json(); }) + .then(function (data) { entries = data; cb(); }) + .catch(function () { entries = []; cb(); }); + } + + function snippet(text, q) { + var i = text.toLowerCase().indexOf(q); + if (i < 0) return text.slice(0, 60) + "…"; + var start = Math.max(0, i - 24); + return (start > 0 ? "…" : "") + text.slice(start, i + q.length + 40) + "…"; + } + + function doSearch() { + var q = input.value.trim().toLowerCase(); + selIndex = -1; + if (!q) { panel.hidden = true; return; } + ensureIndex(function () { + var hits = []; + entries.forEach(function (e) { + var titleHit = e.title.toLowerCase().indexOf(q) >= 0; + var idx = e.text.toLowerCase().indexOf(q); + if (titleHit || idx >= 0) { + hits.push({ + slug: e.slug, + title: e.title, + group: e.group, + score: (titleHit ? 10 : 0) + (idx === 0 ? 3 : 0), + snippet: titleHit ? snippet(e.text, q) : snippet(e.text, q), + }); + } + }); + hits.sort(function (a, b) { return b.score - a.score; }); + hits = hits.slice(0, 8); + if (!hits.length) { + panel.innerHTML = '

没有找到相关内容,换个关键词试试
'; + } else { + panel.innerHTML = hits + .map(function (h) { + return ( + '
' + + '' + h.title + '' + h.group + "" + + '' + h.snippet + "" + ); + }) + .join(""); + } + panel.hidden = false; + }); + } + + var debounce; + if (input) { + input.addEventListener("input", function () { + clearTimeout(debounce); + debounce = setTimeout(doSearch, 160); + }); + input.addEventListener("focus", function () { + if (input.value.trim()) doSearch(); + }); + input.addEventListener("keydown", function (e) { + var items = panel.querySelectorAll(".search-item"); + if (e.key === "ArrowDown" && items.length) { + e.preventDefault(); + selIndex = Math.min(selIndex + 1, items.length - 1); + } else if (e.key === "ArrowUp" && items.length) { + e.preventDefault(); + selIndex = Math.max(selIndex - 1, 0); + } else if (e.key === "Enter") { + if (selIndex >= 0 && items[selIndex]) { location.href = items[selIndex].href; return; } + var first = items[0]; + if (first) location.href = first.href; + return; + } else if (e.key === "Escape") { + panel.hidden = true; + return; + } else { + return; + } + items.forEach(function (el, i) { el.classList.toggle("sel", i === selIndex); }); + }); + document.addEventListener("click", function (e) { + if (!e.target.closest(".search")) panel.hidden = true; + }); + } + + /* ---------- 目录滚动高亮 ---------- */ + var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc-nav a")); + if (tocLinks.length && "IntersectionObserver" in window) { + var byId = {}; + tocLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; }); + var io = new IntersectionObserver( + function (entries) { + entries.forEach(function (en) { + if (en.isIntersecting) { + tocLinks.forEach(function (a) { a.classList.remove("active"); }); + var a = byId[en.target.id]; + if (a) a.classList.add("active"); + } + }); + }, + { rootMargin: "-72px 0px -70% 0px", threshold: 0 } + ); + document.querySelectorAll("article.md h2[id], article.md h3[id]").forEach(function (h) { io.observe(h); }); + } + + /* ---------- 反馈组件 ---------- */ + var fb = document.getElementById("feedback"); + if (fb) { + var pageKey = "opsxdb-docs-fb:" + location.pathname; + var voted = null; + try { voted = localStorage.getItem(pageKey); } catch (e) {} + var btns = fb.querySelectorAll(".fb-btn"); + var thanks = document.getElementById("fb-thanks"); + function mark(v) { + btns.forEach(function (b) { + if (b.dataset.v === v) b.classList.add("done"); + }); + thanks.hidden = false; + try { localStorage.setItem(pageKey, v); } catch (e) {} + } + if (voted) mark(voted); + btns.forEach(function (b) { + b.addEventListener("click", function () { mark(b.dataset.v); }); + }); + } + + /* ---------- 打印 ---------- */ + var printBtn = document.getElementById("print-btn"); + if (printBtn) printBtn.addEventListener("click", function () { window.print(); }); + + /* ---------- 移动端侧栏 ---------- */ + var menuBtn = document.getElementById("menu-btn"); + var sidebar = document.getElementById("sidebar"); + if (menuBtn && sidebar) { + menuBtn.addEventListener("click", function () { sidebar.classList.toggle("open"); }); + document.addEventListener("click", function (e) { + if (sidebar.classList.contains("open") && !e.target.closest(".sidebar") && !e.target.closest(".menu-btn")) { + sidebar.classList.remove("open"); + } + }); + } +})(); diff --git a/apps/server/internal/docs/content/01-intro.md b/apps/server/internal/docs/content/01-intro.md new file mode 100644 index 0000000..18016cb --- /dev/null +++ b/apps/server/internal/docs/content/01-intro.md @@ -0,0 +1,40 @@ +OpsxDB 是一款**本地优先**的桌面数据库工作台,面向运维与开发:连库、写 SQL、看结果,全在本机完成。 + +主交付形态是可安装的桌面应用(Tauri 2),本地 Go 后端随应用自动启动;同一套界面也可用单个二进制在 Web / 内网部署。数据全部保存在本机,不经过任何第三方服务器。 + +## 功能总览 + +| 能力 | 说明 | +| --- | --- | +| 多驱动连库 | MySQL 族 / PostgreSQL 族 / SQL Server / Oracle / SQLite / Redis,native Go 驱动编译内置 | +| 连接选择器 | 搜索 / 分类 / 最近 / 图标网格;粘贴连接 URL 一键填入表单 | +| SQL 工作台 | CodeMirror 编辑器、⌘/Ctrl+Enter 执行语句或选区、多 Tab、可拖分栏 | +| 对象树 | 懒加载库 / 表 / 列;点表名直接看前 100 行 | +| 结果处理 | 一键复制 / CSV / 生成 INSERT;双击单元格复制;截断有提示 | +| Redis 工作台 | 编辑器执行 PING / GET / SET / DEL / KEYS,键空间浏览 | +| 安全通道 | 每连接独立配置 SSL/TLS 与 SSH 隧道;密码本机混淆存储 | +| AI 面板 | 生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL | +| 危险操作防护 | DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前确认 | +| 双形态 | 桌面 GUI(Tauri 2)与 Web(单二进制 + 静态页)共用一套界面 | + +## 支持的数据库 + +| 家族 | 覆盖 | 默认端口 | +| --- | --- | --- | +| MySQL 族 | MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL | 3306 | +| PostgreSQL 族 | PostgreSQL · GaussDB / openGauss · 人大金仓 | 5432 | +| SQL Server | 原生协议直连 | 1433 | +| Oracle | 纯 Go 驱动 go-ora(不引入 JVM) | 1521 | +| SQLite | 本地文件库 | — | +| Redis | 命令执行 + 键管理 | 6379 | + +> **提示**:达梦 DM8、MongoDB、ZooKeeper、Elasticsearch、InfluxDB 已列入路线,规划 agent 按需接入。 + +## 为什么选 OpsxDB + +- **数据主权**:连接配置与查询历史只存在本机;后端默认仅监听 127.0.0.1。 +- **开箱即用**:`./run.sh web` 一条命令起 Web 形态;桌面版双击即用,后端随应用拉起。 +- **为快设计**:点表即看数据、⌘Enter 即执行、结果三键取走(复制 / CSV / INSERT)。 +- **轻量克制**:一个 Go 二进制 + 一份前端,内网可部署,无遥测无账号。 + +> **提示**:想马上动手?直接看下一章《快速开始》,30 秒连上第一个库。 diff --git a/apps/server/internal/docs/content/02-quickstart.md b/apps/server/internal/docs/content/02-quickstart.md new file mode 100644 index 0000000..f21079a --- /dev/null +++ b/apps/server/internal/docs/content/02-quickstart.md @@ -0,0 +1,61 @@ +本章从零到出结果:起服务 → 建连接 → 执行第一条 SQL。 + +## 1. 启动 + +桌面版(主交付形态)双击 OpsxDB 应用即可,本地后端随应用自动拉起。 + +从源码跑 Web / 桌面形态: + +```bash +git clone opsxdb && cd opsxdb +npm install + +./run.sh web # Web 形态:Gin :8788 + Vite 开发壳 :5174 +./run.sh gui # 桌面形态:Tauri 2 窗口(复用同一套界面) +``` + +> **注意**:浏览器里的 `:5174` 是 Vite 热更新开发壳,仅调试用;产品页面(官网 `/`、文档 `/docs`)由后端 `:8788` 直接提供。 + +## 2. 新建连接 + +1. 打开应用,进入**连接选择器**,选择数据库类型(如 MySQL); +2. 或者直接粘贴连接 URL,点「填入」自动拆解字段: + + ``` + mysql://root:pass@127.0.0.1:3306/shop + ``` + +3. 点 **测试** 验证连通性,再点 **保存并连接**。 + +连接配置保存在本机,密码混淆存储;后续查询只携带会话 ID,不再上送账密。 + +## 3. 第一次查询 + +1. 左侧对象树展开库,**点表名**直接查看前 100 行(展开表可看列结构); +2. 打开 SQL 编辑器,输入: + + ```sql + SELECT * FROM orders ORDER BY id DESC LIMIT 100; + ``` + +3. 按 **⌘/Ctrl + Enter** 执行当前语句(或选中的片段); +4. 结果面板支持**复制 / 导出 CSV / 生成 INSERT**,双击单元格即复制。 + +## 4. 验证服务 + +```bash +curl -s http://127.0.0.1:8788/health +# {"ok":true,"service":"opsxdb"} +``` + +## 5. 手边没有数据库? + +本地还没有可连的库?用仓库自带的辅助脚本一键起一个(需要 Docker,一次只起一个): + +```bash +./tests/dbctl.sh up a # MySQL +./tests/dbctl.sh up c # PostgreSQL(用户 opsx) +./tests/dbctl.sh up f # Redis +``` + +各驱动的验收状态见《驱动支持》。 diff --git a/apps/server/internal/docs/content/03-interface.md b/apps/server/internal/docs/content/03-interface.md new file mode 100644 index 0000000..1a0a30f --- /dev/null +++ b/apps/server/internal/docs/content/03-interface.md @@ -0,0 +1,40 @@ +OpsxDB 的界面为「高频操作」设计:从打开应用到看到数据,路径越短越好。 + +## 连接选择器 + +启动后的第一个界面: + +- **搜索**:按名称快速过滤已保存的连接; +- **分类**:关系型 / NoSQL / 国产库 / 时序·搜索 分组浏览; +- **最近**:最近使用的连接排在最前; +- **图标网格**:按数据库类型直选,未就绪的类型会标注「未就绪」(仅可保存配置); +- **URL 填入**:粘贴 `mysql://user:pass@host:3306/db` 点「填入」,字段自动拆解。 + +## 工作台布局 + +连接成功后进入工作台,三栏结构: + +| 区域 | 内容 | +| --- | --- | +| 左侧 | 连接列表 + 对象树(库 → 表 → 列,懒加载) | +| 中部 | SQL 编辑器(多 Tab)+ 结果面板,可拖动分栏 | +| 右侧 | AI 面板(生成 / 解释 / 优化 / 修复 / 方言 / 样例) | + +## 对象树 + +- 点**表名**:直接打开前 100 行数据; +- 展开**表**:查看列结构; +- 库列表懒加载,大实例也不卡。 + +## 编辑器与结果 + +- CodeMirror 语法高亮,⌘/Ctrl+Enter 执行**当前语句或选区**(不是整页脚本); +- 多 Tab 并行查询,互不阻塞; +- 结果面板:复制 / CSV / INSERT 三种取数方式,双击单元格复制; +- 超过截断阈值时结果有明确提示。 + +## 个性化 + +- 深色 / 浅色主题切换; +- Tab 布局与主题选择持久化,下次打开保持原样; +- 查询历史本地留存,可回跳重跑。 diff --git a/apps/server/internal/docs/content/04-connect.md b/apps/server/internal/docs/content/04-connect.md new file mode 100644 index 0000000..75f5e76 --- /dev/null +++ b/apps/server/internal/docs/content/04-connect.md @@ -0,0 +1,42 @@ +本章说明各数据库类型的连接表单、URL 格式与高级选项(SSL / SSH 隧道)。 + +## 通用流程 + +1. 连接选择器点「新建」或选某个数据库类型; +2. 填写表单(不同驱动表单不同,见下表); +3. **测试** → 通过后 **保存并连接**。 + +支持直接粘贴 URL 自动填表: + +``` +mysql://root:pass@127.0.0.1:3306/shop +postgres://opsx:pass@127.0.0.1:5432/app +``` + +## 各驱动表单 + +| 驱动 | 表单 | 特有字段 | +| --- | --- | --- | +| MySQL / PostgreSQL / SQL Server | standard | host · port · user · password · database · ssl · ssh | +| Oracle | oracle | host · port · user · password · **service**(如 ORCL) | +| SQLite | file | database 填本地文件路径(如 `~/data/demo.sqlite`) | +| Redis | redis | host · port · password · database 序号 | + +表单只收集服务端实际消费的字段;历史遗留的死参数不会出现在表单里。 + +## MySQL 族与 PostgreSQL 族 + +协议兼容的国产库无需单独驱动,直接复用家族驱动: + +- **MySQL 族**:MariaDB(3307)、TiDB(4000)、OceanBase(2881)、PolarDB / TDSQL(3306)—— 表单里选对应类型即可; +- **PostgreSQL 族**:GaussDB / openGauss、人大金仓 Kingbase(54321)—— 走 postgres 表单,注意默认库与用户名差异。 + +## SSL / TLS + +连接表单中开启 **ssl** 即可;按连接独立配置,互不影响。内网自签证书场景按需放宽校验策略。 + +## SSH 隧道 + +数据库在跳板机后面时,开启 **ssh** 字段组,填写跳板机地址与凭据:OpsxDB 会先建 SSH 隧道,再把数据库连接走隧道转发,无需把库端口暴露到公网。 + +> **提示**:连接保存后,后续查询仅携带 `sessionId`,账密不会随每次请求上送;密码在本机混淆存储。 diff --git a/apps/server/internal/docs/content/05-drivers.md b/apps/server/internal/docs/content/05-drivers.md new file mode 100644 index 0000000..d4bcd94 --- /dev/null +++ b/apps/server/internal/docs/content/05-drivers.md @@ -0,0 +1,41 @@ +OpsxDB 的常用数据库驱动以 native Go 驱动编译内置,开箱即用;更多数据库在路线中。 + +## 内置驱动 + +| 驱动 | 连接库 | 说明 | +| --- | --- | --- | +| mysql | MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL | 协议族复用,默认端口 3306 | +| postgres | PostgreSQL · GaussDB / openGauss · Kingbase | 协议族复用,默认端口 5432 | +| sqlserver | SQL Server | 原生协议,默认端口 1433 | +| oracle | Oracle | go-ora 纯 Go 驱动,不引入 JVM,端口 1521 | +| sqlite | SQLite | 本地文件库,无端口 | +| redis | Redis | 默认端口 6379 | + +## 支持状态 + +以下为逐项实测的结果 —— 你可以按这个表判断「现在就能连什么」: + +| 数据库 | 连接 | 库表浏览 | 数据操作 | 备注 | +| --- | --- | --- | --- | --- | +| MySQL / MariaDB | ✅ | ✅ | ✅ | MariaDB 默认端口 3307 | +| PostgreSQL | ✅ | ✅ | ✅ | GaussDB / 金仓同样适用 | +| SQL Server | ✅ | ✅ | ✅ | Apple Silicon 上连接稍慢属正常 | +| SQLite | ✅ | ✅ | ✅ | 填本地文件路径即连 | +| Redis | ✅ | ✅ | ✅ | 支持命令执行与键管理 | +| Oracle | 代码就绪 | ✅ | 待实测 | 见下方说明 | + +> **说明(Oracle)**:驱动已内置(纯 Go 实现,无需安装 Java),库表浏览可用;完整数据操作正在验收中 —— 在 Apple Silicon 的容器环境会遇到 Oracle 限制(ORA-00442),x86 环境或真实数据库不受影响。 + +## 暂不支持(规划中) + +| 数据库 | 状态 | +| --- | --- | +| 达梦 DM8 | 规划中,暂不可用 | +| MongoDB | 规划中,暂不可用 | +| ZooKeeper / Elasticsearch / InfluxDB | 规划中,暂不可用 | + +> **注意**:选择器中标注「未就绪」的类型仍可保存连接配置,但不能测试 / 连接;开放支持后会随版本更新自动可用。 + +## 后续驱动的接入方式 + +常用库继续以原生驱动内置;小众数据库(达梦、MongoDB 等)将通过**按需下载**方式接入 —— 首次使用时下载对应驱动组件,版本固定并校验完整性(sha256),不随包体膨胀。 diff --git a/apps/server/internal/docs/content/06-workbench.md b/apps/server/internal/docs/content/06-workbench.md new file mode 100644 index 0000000..c858fac --- /dev/null +++ b/apps/server/internal/docs/content/06-workbench.md @@ -0,0 +1,46 @@ +SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设计。 + +## 执行模型 + +- **⌘/Ctrl + Enter**:执行光标所在的**当前语句**;有选区时执行**选区**; +- 不是「整页脚本」模式 —— 多条语句互不干扰,想跑哪句把光标放哪; +- 执行在会话上运行,连接断开后自动提示。 + +## 多 Tab 与分栏 + +- 每个查询一个 Tab,并行执行互不阻塞; +- 编辑器 / 结果区可拖动分栏,长查询时编辑器不被结果挤占; +- Tab 与布局持久化,重开应用保持原样。 + +## 对象树联动 + +- 左侧对象树懒加载库 / 表 / 列; +- **点表名 = 查询前 100 行**,自动生成对应 SQL; +- 展开表看列名与类型,写 SQL 时可对照。 + +## 查询历史 + +- 本地留存最近 200 条(不上传、不同步); +- **相同语句自动去重置顶** —— 历史即「最近用过的不同语句」,高频查询永远在第一条; +- 点击条目回填编辑器;悬停出现 **▶** 按钮,回填并直接重跑; +- 顶部过滤框支持按语句内容或连接名搜索; +- 条目显示时间、连接名、行数与耗时;失败的语句红色标出。 + +## 危险 SQL 防护 + +执行以下语句前会弹确认框: + +| 类型 | 规则 | +| --- | --- | +| 删除对象 | DROP / TRUNCATE | +| 全表更新 | 无 WHERE 的 UPDATE / DELETE | + +> **注意**:确认框只拦「明确危险」的模式,不替代事务与备份习惯;生产库操作请配合审批流程。 + +## 快捷键 + +| 快捷键 | 动作 | +| --- | --- | +| ⌘/Ctrl + Enter | 执行当前语句 / 选区 | +| 双击结果单元格 | 复制单元格内容 | +| Esc | 关闭弹层 / 取消确认 | diff --git a/apps/server/internal/docs/content/07-results.md b/apps/server/internal/docs/content/07-results.md new file mode 100644 index 0000000..a9858f3 --- /dev/null +++ b/apps/server/internal/docs/content/07-results.md @@ -0,0 +1,27 @@ +查询跑完只是开始,把结果带走才算完成 —— OpsxDB 给了三条最快路径。 + +## 三种取数方式 + +| 方式 | 用途 | +| --- | --- | +| **复制** | 直接进剪贴板,贴到群聊 / 文档 / 表格 | +| **CSV** | 导出标准 CSV 文件,Excel / Numbers 直接打开 | +| **INSERT** | 把结果行生成为 INSERT 语句,直接可在别的库回放 | + +## 单元格操作 + +- **双击单元格**:复制该单元格内容; +- 列头即字段名,长文本自动截断显示,完整内容在复制时保留。 + +## 截断提示 + +为保护界面与内存,超大结果集会按阈值截断展示: + +- 结果面板顶部显示「已截断」提示与实际行数; +- 需要完整数据时,缩小查询范围(加 LIMIT / WHERE)或使用导出。 + +> **提示**:默认「点表名看前 100 行」就是截断策略的一部分 —— 快速浏览用 100 行,精确取数写 WHERE。 + +## 与 AI 面板联动 + +写不出来的查询可以直接在 AI 面板用自然语言描述(「查最近 7 天下单但未支付的用户」),生成 SQL 后放回编辑器执行,见《AI 助手》。 diff --git a/apps/server/internal/docs/content/08-redis.md b/apps/server/internal/docs/content/08-redis.md new file mode 100644 index 0000000..f6b8e97 --- /dev/null +++ b/apps/server/internal/docs/content/08-redis.md @@ -0,0 +1,34 @@ +Redis 连接使用专用表单(redis),连上后既是命令行又是键空间浏览器。 + +## 命令执行 + +SQL 编辑器同样适用于 Redis —— 直接输入命令,⌘/Ctrl + Enter 执行: + +```bash +PING +SET demo:key "hello" EX 60 +GET demo:key +KEYS demo:* +DEL demo:key +``` + +- 返回值直接显示在结果面板; +- 集群 / 哨兵模式按各自连接方式配置(哨兵填写主节点名)。 + +## 键空间浏览 + +- **KEYS 扫描**:按模式浏览键(生产大库建议用前缀收窄范围); +- 键类型视图:string / hash / list / set / zset 分类型展示; +- **TTL 管理**:查看与设置过期时间,常用于缓存排障; +- 批量操作:选中多个键批量删除。 + +> **注意**:`KEYS *` 在大库上会阻塞 Redis,OpsxDB 不做强制拦截 —— 生产环境请自觉使用带前缀的模式。 + +## 连接参数 + +| 字段 | 说明 | +| --- | --- | +| host / port | 默认 127.0.0.1:6379 | +| password | ACL / requirepass | +| database | 逻辑库序号(0-15 常见) | +| ssl | 红色连接(TLS)开关 | diff --git a/apps/server/internal/docs/content/09-security.md b/apps/server/internal/docs/content/09-security.md new file mode 100644 index 0000000..2eaaffa --- /dev/null +++ b/apps/server/internal/docs/content/09-security.md @@ -0,0 +1,35 @@ +连生产库的工具,安全必须是默认项。OpsxDB 的安全设计围绕一句话:**数据不出你的机器。** + +## 本地优先 + +- 连接配置、查询历史、主题偏好全部保存在本机; +- 无账号体系、无遥测、无云端同步; +- 后端默认只监听 `127.0.0.1`,对外服务需要显式配置(`OPSXDB_BIND_PUBLIC=1` 或 `OPSXDB_ADDR`)。 + +## 凭据保护 + +| 层面 | 机制 | +| --- | --- | +| 存储 | 连接密码在本机混淆存储,不落明文 | +| 传输 | 连接建立后,后续查询只携带 `sessionId`,账密不再每次上送 | +| 会话 | 会话在服务端维护,可主动关闭(连接断开即失效) | + +## 加密通道 + +**SSL / TLS**:每个连接可独立开启;数据库支持 TLS 即可启用(MySQL、PostgreSQL、SQL Server、Redis、MongoDB 均支持)。 + +**SSH 隧道**:数据库在跳板机后时,配置 SSH 字段组,OpsxDB 先建隧道再转发数据库连接 —— 库端口不必暴露公网。 + +## 危险操作防护 + +- DROP / TRUNCATE 执行前确认; +- 无 WHERE 的 UPDATE / DELETE 执行前确认; +- 结果集按阈值截断,避免误操作拖垮界面。 + +## 内网部署形态 + +单二进制 + 内置静态页(官网 / 文档 / 工作台),无外部依赖: + +- 任意内网主机可部署; +- 无外联请求,可在纯内网环境运行; +- 升级即替换一个二进制。 diff --git a/apps/server/internal/docs/content/10-ai.md b/apps/server/internal/docs/content/10-ai.md new file mode 100644 index 0000000..5cfdd55 --- /dev/null +++ b/apps/server/internal/docs/content/10-ai.md @@ -0,0 +1,32 @@ +AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻塞你。 + +## 六种动作 + +| 动作 | 用途 | +| --- | --- | +| 生成 | 自然语言 → SQL(「查最近 7 天下单但未支付的用户」) | +| 解释 | 把一段复杂 SQL 翻译成中文说明 | +| 优化 | 分析慢查询,给出索引 / 改写建议 | +| 修复 | 报错信息 + SQL → 修正版本 | +| 方言 | MySQL ↔ PostgreSQL 等跨方言转换 | +| 样例 | 按表结构生成示例查询 | + +## 使用方式 + +1. 连接库后打开右侧 AI 面板; +2. 选择动作,输入需求或粘贴 SQL(可附上对象树上下文); +3. 生成结果一键放回编辑器,⌘/Ctrl + Enter 执行。 + +> **提示**:生成的 SQL 依旧走危险语句确认流程 —— AI 产出与手写 SQL 同等对待,不放宽防护。 + +## 当前状态(v0.1) + +AI 面板当前为**本地 stub**:六种动作的交互框架与 API 已就位(`POST /api/ai/chat`),模型服务接入版本会明确标注数据流向与开关位置。 + +## 数据流向承诺 + +接入模型服务时遵循三条原则: + +- 默认只发送你明确提交的内容(不含连接凭据); +- 可完全关闭 AI 功能; +- 使用云端模型时在界面上明确标识。 diff --git a/apps/server/internal/docs/content/11-faq.md b/apps/server/internal/docs/content/11-faq.md new file mode 100644 index 0000000..43721c3 --- /dev/null +++ b/apps/server/internal/docs/content/11-faq.md @@ -0,0 +1,40 @@ +## 产品形态 + +**Q:浏览器里的 5174 端口是什么?** +Vite 热更新开发壳,仅开发调试用。产品形态是桌面应用(Tauri 2)或自托管 Web 服务,页面与 API 由后端 `:8788` 提供。 + +**Q:桌面版和 Web 版功能有差别吗?** +没有。同一套 UI、同一份后端,桌面版只是多了应用壳(自动拉起本地后端、原生窗口、安装包形态)。 + +**Q:怎么打安装包?** +`./run.sh package` 产出 Tauri 安装包(DMG)并内嵌 sidecar 后端二进制。 + +## 数据与安全 + +**Q:我的连接密码存在哪?** +本机,混淆存储;查询只带会话 ID,密码不会随请求重复上送,也不会同步到云端。 + +**Q:会上传我的 SQL 或查询结果吗?** +不会。没有遥测、没有账号、没有云依赖;AI 功能当前为本地 stub,接入模型服务时会明确标注数据流向。 + +**Q:能在公司内网用吗?** +能。单二进制 + 内置静态页,无外部依赖;默认只监听回环地址,对外需显式配置。 + +## 驱动相关 + +**Q:Oracle 能用吗?** +驱动代码就绪(go-ora 纯 Go 实现,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过(ORA-00442),需 x86 环境或真库完成验收,详见《驱动支持》。 + +**Q:达梦 / MongoDB 什么时候支持?** +已列入路线:长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。 + +**Q:MySQL 族 / PG 族的国产库怎么连?** +协议兼容直接复用家族驱动:TiDB / OceanBase / PolarDB / TDSQL 选对应类型(MySQL 协议);GaussDB / Kingbase 走 PostgreSQL 表单。注意各自默认端口与用户名差异。 + +## 其他 + +**Q:和 OpsxAPI 是什么关系?** +同一团队的姊妹项目:OpsxAPI 是接口工作台,OpsxDB 是数据库工作台,共用一套 monorepo 脚手架(web + Gin + 可选 Tauri),产品面各自独立。 + +**Q:快捷键有哪些?** +核心就一个:⌘/Ctrl + Enter 执行当前语句或选区;双击结果单元格复制。完整列表见《SQL 工作台》。 diff --git a/apps/server/internal/docs/content/connect.html b/apps/server/internal/docs/content/connect.html deleted file mode 100644 index 70583b7..0000000 --- a/apps/server/internal/docs/content/connect.html +++ /dev/null @@ -1,7 +0,0 @@ -

连库入门

-

在连库页选择驱动,填写主机 / 端口 / 账号,测试通过后保存并打开。

-
    -
  • 密码仅存本机(本地加密存储)。
  • -
  • 会话由本地后端维护;后端未启动时顶部会提示,不会白屏。
  • -
  • 断线后可在底栏重连,恢复当前库上下文。
  • -
\ No newline at end of file diff --git a/apps/server/internal/docs/content/faq.html b/apps/server/internal/docs/content/faq.html deleted file mode 100644 index 12ba477..0000000 --- a/apps/server/internal/docs/content/faq.html +++ /dev/null @@ -1,7 +0,0 @@ -

常见问题

-

打开后提示本地后端未就绪?

-

确认应用已拉起 sidecar;或手动 ./run.sh server 后重试。默认只监听 127.0.0.1:8788。

-

浏览器也能用吗?

-

可以调试,但产品说明与对外沟通应以桌面版为准,不要暗示 Web 即产品。

-

官网 / 文档在哪?

-

本地后端提供:http://127.0.0.1:8788/(官网)、/docs(文档)。

\ No newline at end of file diff --git a/apps/server/internal/docs/content/install.html b/apps/server/internal/docs/content/install.html deleted file mode 100644 index 550a417..0000000 --- a/apps/server/internal/docs/content/install.html +++ /dev/null @@ -1,13 +0,0 @@ -

安装与启动

-

OpsxDB 正式形态是桌面 GUI(Tauri)。开发态浏览器 :5174 只用于调试,不是产品交付。

-

桌面版

-
    -
  1. 安装本机构建产物(macOS .dmg / .app,或 npm run build:desktop 产出)。
  2. -
  3. 双击启动:应用会拉起本地后端(默认 127.0.0.1:8788),进入工作台。
  4. -
  5. 无需手动执行 ./run.sh web。
  6. -
-

开发调试

-
./run.sh gui   # 或 ./run.sh web
-
-

开发壳地址:http://127.0.0.1:5174。

-

改后端(apps/server)后请执行 ./run.sh restart(或等价重启 Gin),避免 :8788 仍跑旧二进制。

diff --git a/apps/server/internal/docs/content/manifest.json b/apps/server/internal/docs/content/manifest.json new file mode 100644 index 0000000..2f6834a --- /dev/null +++ b/apps/server/internal/docs/content/manifest.json @@ -0,0 +1,41 @@ +{ + "productVersion": "0.1", + "groups": [ + { + "name": "开始使用", + "pages": [ + { "file": "01-intro.md", "slug": "intro", "title": "产品介绍", "updated": "2026-09-20" }, + { "file": "02-quickstart.md", "slug": "quickstart", "title": "快速开始", "updated": "2026-09-20" }, + { "file": "03-interface.md", "slug": "interface", "title": "界面导览", "updated": "2026-09-20" } + ] + }, + { + "name": "连接与驱动", + "pages": [ + { "file": "04-connect.md", "slug": "connect", "title": "新建连接", "updated": "2026-09-20" }, + { "file": "05-drivers.md", "slug": "drivers", "title": "驱动支持", "updated": "2026-09-20" } + ] + }, + { + "name": "SQL 工作台", + "pages": [ + { "file": "06-workbench.md", "slug": "workbench", "title": "SQL 工作台", "updated": "2026-09-20" }, + { "file": "07-results.md", "slug": "results", "title": "结果与导出", "updated": "2026-09-20" }, + { "file": "08-redis.md", "slug": "redis", "title": "Redis 工作台", "updated": "2026-09-20" } + ] + }, + { + "name": "安全与 AI", + "pages": [ + { "file": "09-security.md", "slug": "security", "title": "安全设计", "updated": "2026-09-20" }, + { "file": "10-ai.md", "slug": "ai", "title": "AI 助手", "updated": "2026-09-20" } + ] + }, + { + "name": "参考", + "pages": [ + { "file": "11-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-20" } + ] + } + ] +} diff --git a/apps/server/internal/docs/content/workbench.html b/apps/server/internal/docs/content/workbench.html deleted file mode 100644 index c617bec..0000000 --- a/apps/server/internal/docs/content/workbench.html +++ /dev/null @@ -1,7 +0,0 @@ -

工作台简介

-

深色密度布局:左侧连接与对象树,中间编辑器与多结果 Tab,可选 AI 侧栏。

-
    -
  • 多结果集:成功结果保留;失败不盖掉已有成功集。
  • -
  • 执行计划:有则展示,无则禁用。
  • -
  • 书签:紧凑列表,回填 SQL,本地持久化。
  • -
\ No newline at end of file diff --git a/apps/server/internal/docs/docs.go b/apps/server/internal/docs/docs.go index ab90030..d26848b 100644 --- a/apps/server/internal/docs/docs.go +++ b/apps/server/internal/docs/docs.go @@ -1,89 +1,264 @@ -// Package docs:用户文档骨架(嵌入 HTML,侧栏+正文密度对齐工作台)。 +// Package docs 在 /docs 路径提供产品用户文档(Markdown 嵌入二进制,SSR 渲染)。 package docs import ( "embed" + "encoding/json" + "fmt" "net/http" + "path" + "regexp" "strings" "github.com/gin-gonic/gin" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + gmhtml "github.com/yuin/goldmark/renderer/html" ) //go:embed content assets template.html var files embed.FS -type page struct { +type PageDef struct { + File string `json:"file"` + Slug string `json:"slug"` + Title string `json:"title"` + Updated string `json:"updated,omitempty"` +} + +type GroupDef struct { + Name string `json:"name"` + Pages []PageDef `json:"pages"` +} + +type Manifest struct { + ProductVersion string `json:"productVersion"` + Groups []GroupDef `json:"groups"` +} + +// TocItem 本页目录项 +type TocItem struct { + ID string `json:"id"` + Level int `json:"level"` + Text string `json:"text"` +} + +type SearchEntry struct { + Slug string `json:"slug"` + Title string `json:"title"` + Group string `json:"group"` + Text string `json:"text"` +} + +type NavLink struct { Slug string Title string - Body string } -var order = []page{ - {Slug: "install", Title: "安装与启动"}, - {Slug: "connect", Title: "连库入门"}, - {Slug: "workbench", Title: "工作台简介"}, - {Slug: "faq", Title: "常见问题"}, +type Page struct { + Slug string + Title string + Group string + Updated string + HTML string + TOC []TocItem + Prev *NavLink + Next *NavLink } +// Handler 文档服务 type Handler struct { - tpl string + manifest Manifest + pages map[string]*Page + order []string + searchData []byte + tpl string } func New() *Handler { - raw, err := files.ReadFile("template.html") + h := &Handler{pages: map[string]*Page{}} + raw, err := files.ReadFile("content/manifest.json") + if err != nil { + panic("docs: manifest.json 缺失: " + err.Error()) + } + if err := json.Unmarshal(raw, &h.manifest); err != nil { + panic("docs: manifest.json 解析失败: " + err.Error()) + } + tplRaw, err := files.ReadFile("template.html") if err != nil { panic("docs: template.html 缺失: " + err.Error()) } - return &Handler{tpl: string(raw)} + h.tpl = string(tplRaw) + + md := goldmark.New( + goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), + goldmark.WithRendererOptions(gmhtml.WithHardWraps()), + ) + + search := []SearchEntry{} + var prev *NavLink + for _, group := range h.manifest.Groups { + for _, def := range group.Pages { + src, err := files.ReadFile("content/" + def.File) + if err != nil { + panic("docs: 缺少内容文件 " + def.File) + } + var buf strings.Builder + if err := md.Convert(src, &buf); err != nil { + panic("docs: 渲染失败 " + def.File + ": " + err.Error()) + } + body, toc := injectHeadingIDs(buf.String()) + page := &Page{ + Slug: def.Slug, + Title: def.Title, + Group: group.Name, + Updated: def.Updated, + HTML: body, + TOC: toc, + Prev: prev, + } + h.pages[def.Slug] = page + h.order = append(h.order, def.Slug) + prev = &NavLink{Slug: def.Slug, Title: def.Title} + search = append(search, SearchEntry{ + Slug: def.Slug, + Title: def.Title, + Group: group.Name, + Text: plainText(string(src)), + }) + } + } + // 下一页链接 + for i, slug := range h.order { + if i+1 < len(h.order) { + next := h.pages[h.order[i+1]] + h.pages[slug].Next = &NavLink{Slug: next.Slug, Title: next.Title} + } + } + searchJSON, _ := json.Marshal(search) + h.searchData = searchJSON + return h } +// Register 注册路由(单一 catch-all,避免静态/参数段冲突) func (h *Handler) Register(r *gin.Engine) { - r.GET("/docs", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs/install") }) - r.GET("/docs/:slug", h.Page) - r.GET("/docs/assets/*file", h.Asset) + r.GET("/docs", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/docs/"+h.order[0]) + }) + r.GET("/docs/*path", h.dispatch) } +func (h *Handler) dispatch(c *gin.Context) { + p := strings.Trim(c.Param("path"), "/") + switch { + case p == "": + c.Redirect(http.StatusFound, "/docs/"+h.order[0]) + case p == "search-data.json": + h.SearchData(c) + case strings.HasPrefix(p, "assets/"): + c.Params = []gin.Param{{Key: "file", Value: "/" + strings.TrimPrefix(p, "assets")}} + h.Asset(c) + default: + // 取第一段作为 slug(忽略子路径) + slug := p + if i := strings.IndexByte(p, '/'); i >= 0 { + slug = p[:i] + } + c.Params = []gin.Param{{Key: "slug", Value: slug}} + h.Page(c) + } +} + +// Page 渲染文档页 func (h *Handler) Page(c *gin.Context) { slug := c.Param("slug") - bodyBytes, err := files.ReadFile("content/" + slug + ".html") - if err != nil { + page, ok := h.pages[slug] + if !ok { c.Status(http.StatusNotFound) + _, _ = c.Writer.WriteString(notFoundHTML(h.order[0])) //nolint:errcheck return } - title := slug - nav := strings.Builder{} - for _, p := range order { - cls := "nav-link" - if p.Slug == slug { - cls += " active" - title = p.Title - } - nav.WriteString(`` + p.Title + ``) - } - html := h.tpl - html = strings.ReplaceAll(html, "{{TITLE}}", title) - html = strings.ReplaceAll(html, "{{NAV}}", nav.String()) - html = strings.ReplaceAll(html, "{{BODY}}", string(bodyBytes)) c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Cache-Control", "no-cache") - _, _ = c.Writer.WriteString(html) + _, _ = c.Writer.WriteString(renderPage(h, page)) } +// SearchData 搜索索引 +func (h *Handler) SearchData(c *gin.Context) { + c.Data(http.StatusOK, "application/json; charset=utf-8", h.searchData) +} + +// Asset 静态资源 func (h *Handler) Asset(c *gin.Context) { - name := strings.TrimPrefix(c.Param("file"), "/") + name := path.Clean(c.Param("file")) if strings.Contains(name, "..") { c.Status(http.StatusBadRequest) return } - data, err := files.ReadFile("assets/" + name) + data, err := files.ReadFile("assets" + name) if err != nil { c.Status(http.StatusNotFound) return } - ct := "application/octet-stream" - if strings.HasSuffix(name, ".css") { - ct = "text/css; charset=utf-8" + switch { + case strings.HasSuffix(name, ".css"): + c.Header("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(name, ".js"): + c.Header("Content-Type", "application/javascript; charset=utf-8") + case strings.HasSuffix(name, ".svg"): + c.Header("Content-Type", "image/svg+xml") + default: + c.Header("Content-Type", "application/octet-stream") } - c.Header("Content-Type", ct) - c.Data(http.StatusOK, ct, data) + c.Header("Cache-Control", "public, max-age=3600") + c.Data(http.StatusOK, "", data) +} + +// ManifestExport 供测试使用 +func (h *Handler) ManifestExport() Manifest { return h.manifest } + +func (h *Handler) FirstSlug() string { return h.order[0] } + +var ( + hRe = regexp.MustCompile(`(?s)<(h[23])>(.*?)`) + tagRe = regexp.MustCompile(`<[^>]+>`) +) + +// injectHeadingIDs 给 h2/h3 加锚点 id,并产出目录 +func injectHeadingIDs(html string) (string, []TocItem) { + var toc []TocItem + counter := 0 + out := hRe.ReplaceAllStringFunc(html, func(m string) string { + sub := hRe.FindStringSubmatch(m) + tag, inner := sub[1], sub[2] + counter++ + id := fmt.Sprintf("sec-%d", counter) + text := strings.TrimSpace(tagRe.ReplaceAllString(inner, "")) + level := 2 + if tag == "h3" { + level = 3 + } + toc = append(toc, TocItem{ID: id, Level: level, Text: text}) + return fmt.Sprintf(`<%s id="%s">%s`, tag, id, inner, tag) + }) + return out, toc +} + +var mdNoise = regexp.MustCompile("(?s)```.*?```|`[^`]*`|!\\[[^\\]]*\\]\\([^)]*\\)|\\[[^\\]]*\\]\\([^)]*\\)|[#>*_\\-|]+") + +// plainText 供搜索索引的纯文本 +func plainText(src string) string { + s := mdNoise.ReplaceAllString(src, " ") + s = strings.Join(strings.Fields(s), " ") + if len(s) > 6000 { + s = s[:6000] + } + return s +} + +func notFoundHTML(home string) string { + return fmt.Sprintf(`404 · OpsxDB 文档 + +

404

文档页面不存在或已被移动。

返回文档首页
+`, home) } diff --git a/apps/server/internal/docs/render.go b/apps/server/internal/docs/render.go new file mode 100644 index 0000000..9f464ab --- /dev/null +++ b/apps/server/internal/docs/render.go @@ -0,0 +1,25 @@ +package docs + +import ( + "html/template" + "strings" +) + +func renderPage(h *Handler, p *Page) string { + tpl, err := template.New("doc").Parse(h.tpl) + if err != nil { + return "errortemplate error: " + template.HTMLEscapeString(err.Error()) + } + + var sb strings.Builder + data := map[string]any{ + "Manifest": h.manifest, + "Page": p, + "TOC": p.TOC, + "Content": template.HTML(p.HTML), //nolint:gosec —— 内容为内置受信 Markdown + } + if err := tpl.Execute(&sb, data); err != nil { + return "errorrender error: " + template.HTMLEscapeString(err.Error()) + } + return sb.String() +} diff --git a/apps/server/internal/docs/template.html b/apps/server/internal/docs/template.html index 9198619..a2eca04 100644 --- a/apps/server/internal/docs/template.html +++ b/apps/server/internal/docs/template.html @@ -3,17 +3,85 @@ -{{TITLE}} · OpsxDB 文档 +{{.Page.Title}} · OpsxDB 文档 + + -
- OpsxDB 文档 - + +
+
+ + + + OpsxDB 文档 + + v{{.Manifest.ProductVersion}} + + +
-
- -
{{BODY}}
+ +
+ + +
+ +

{{.Page.Title}}

+ {{if .Page.Updated}}
更新于 {{.Page.Updated}}
{{end}} +
+{{.Content}} +
+ +
+ {{with .Page.Prev}} + + {{else}}{{end}} + {{with .Page.Next}} + + {{else}}{{end}} +
+ + +
+ +
+ + diff --git a/apps/server/internal/site/assets/site.css b/apps/server/internal/site/assets/site.css index 16d74d5..866fa09 100644 --- a/apps/server/internal/site/assets/site.css +++ b/apps/server/internal/site/assets/site.css @@ -1,50 +1,268 @@ +/* OpsxDB 官网 —— 营销单页样式(祖母绿 → 青家族色) */ :root { - --bg: #0e1116; - --panel: #151a21; - --border: #243044; - --text: #e6edf7; - --muted: #8b9bb4; - --accent: #3b82f6; + --green: #059669; + --green-dark: #047857; + --green-soft: rgba(5, 150, 105, 0.08); + --cyan: #06b6d4; + --bg: #ffffff; + --bg-alt: #f5faf8; + --border: #e2ebe7; + --text: #101828; + --text-2: #52606d; + --text-3: #98a2b3; + --red: #dc2626; + --amber: #d97706; + --radius: 14px; + --shadow: 0 1px 3px rgba(6, 23, 18, .06), 0 12px 32px rgba(6, 23, 18, .08); + --mono: "JetBrains Mono", SFMono-Regular, Menlo, monospace; } -* { box-sizing: border-box; } +* { box-sizing: border-box; scroll-behavior: smooth; } body { - margin: 0; - font: 14px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif; - background: var(--bg); - color: var(--text); + margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.75; } -.nav { border-bottom: 1px solid var(--border); background: var(--panel); } -.nav .inner { - max-width: 880px; margin: 0 auto; padding: 0 20px; height: 48px; - display: flex; align-items: center; justify-content: space-between; -} -.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font-weight: 600; } -.logo { - width: 28px; height: 28px; border-radius: 7px; background: var(--accent); - display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; -} -.nav a { color: var(--muted); text-decoration: none; margin-left: 16px; font-size: 13px; } -.nav a:hover { color: var(--text); } -.hero { max-width: 880px; margin: 0 auto; padding: 56px 20px 72px; } -.eyebrow { color: var(--accent); font-size: 12px; letter-spacing: .04em; margin: 0 0 12px; } -h1 { font-size: 28px; line-height: 1.25; margin: 0 0 14px; font-weight: 650; } -.lead { color: var(--muted); max-width: 52ch; margin: 0 0 24px; } -.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 36px; } +a { color: var(--green-dark); text-decoration: none; } +h1,h2,h3 { letter-spacing: -0.015em; } +section[id] { scroll-margin-top: 76px; } +.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; } +.wrap.narrow { max-width: 820px; } +/* ---------- SVG 图标 ---------- */ +.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; } +.i-lg { width: 28px; height: 28px; vertical-align: middle; } +code { font-family: var(--mono); font-size: 13px; background: var(--green-soft); color: var(--green-dark); border-radius: 5px; padding: 1px 6px; } + +/* ---------- 按钮 ---------- */ .btn { - display: inline-flex; align-items: center; height: 36px; padding: 0 14px; - border-radius: 8px; text-decoration: none; font-size: 13px; font-weight: 550; - border: 1px solid var(--border); + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + border-radius: 10px; padding: 9px 20px; font-size: 14.5px; font-weight: 600; cursor: pointer; + transition: all .18s; border: 1px solid transparent; white-space: nowrap; } -.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.btn.ghost { color: var(--text); background: transparent; } -.caps { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; } -.caps li { - background: var(--panel); border: 1px solid var(--border); border-radius: 10px; - padding: 12px 14px; display: grid; gap: 4px; +.btn-lg { padding: 12px 26px; font-size: 15.5px; } +.btn-primary { background: var(--green); color: #fff !important; box-shadow: 0 6px 18px rgba(5,150,105,.30); } +.btn-primary:hover { background: var(--green-dark); transform: translateY(-1px); } +.btn-ghost { border-color: var(--border); color: var(--text) !important; background: #fff; } +.btn-ghost:hover { border-color: var(--green); color: var(--green-dark) !important; } +.btn-inverse { background: #fff; color: var(--green-dark) !important; } +.btn-inverse:hover { transform: translateY(-1px); } +.btn-outline { border-color: rgba(255,255,255,.5); color: #fff !important; } +.btn-outline:hover { border-color: #fff; background: rgba(255,255,255,.1); } + +/* ---------- 导航 ---------- */ +.nav { + position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,.9); + backdrop-filter: blur(10px); border-bottom: 1px solid var(--border); } -.caps strong { font-size: 13px; } -.caps span { color: var(--muted); font-size: 12.5px; } -.foot { - border-top: 1px solid var(--border); color: var(--muted); - text-align: center; padding: 18px; font-size: 12px; +.nav-inner { max-width: 1120px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 28px; } +.brand { display: flex; align-items: center; gap: 9px; } +.brand-logo { display: inline-flex; } +.brand-logo svg { display: block; border-radius: 8px; } +.brand-name { font-size: 17px; font-weight: 800; color: var(--text) !important; } +.nav-links { display: flex; gap: 22px; } +.nav-links a { font-size: 14px; font-weight: 500; color: var(--text-2) !important; } +.nav-links a:hover { color: var(--green-dark) !important; } +.nav-cta { margin-left: auto; } + +/* ---------- Hero ---------- */ +.hero { + position: relative; overflow: hidden; + background: + radial-gradient(1200px 500px at 85% -10%, rgba(5,150,105,.13), transparent 60%), + radial-gradient(800px 400px at -10% 10%, rgba(6,182,212,.09), transparent 55%), + linear-gradient(180deg, #f5fbf8 0%, #fff 100%); + border-bottom: 1px solid var(--border); +} +.hero-inner { + max-width: 1120px; margin: 0 auto; padding: 72px 24px 56px; + display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 48px; align-items: center; +} +.hero-badge { + display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--green-dark); + background: #fff; border: 1px solid rgba(5,150,105,.25); border-radius: 999px; padding: 4px 14px; + box-shadow: 0 2px 8px rgba(5,150,105,.1); margin-bottom: 18px; +} +.hero h1 { font-size: 44px; line-height: 1.22; font-weight: 800; margin: 0 0 16px; } +.hero h1 em { font-style: normal; color: var(--green); } +.hero-sub { color: var(--text-2); font-size: 16px; margin: 0 0 26px; } +.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; } +.hero-actions.center { justify-content: center; } +.hero-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; } +.hero-pills span { + font-size: 12px; color: var(--text-2); background: #fff; border: 1px solid var(--border); + padding: 3px 12px; border-radius: 999px; +} + +/* ---------- 工作台 Mock(纯 CSS) ---------- */ +.mock { + border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); border: 1px solid #1b2b26; + background: #0c1512; transform: perspective(1400px) rotateY(-4deg) rotateX(1.5deg); +} +.mock-titlebar { + display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 12px; + background: #0f1a16; border-bottom: 1px solid #1b2b26; +} +.mock-titlebar i { width: 11px; height: 11px; border-radius: 50%; background: #37504a; } +.mock-titlebar i:nth-child(1) { background: #f87171; } .mock-titlebar i:nth-child(2) { background: #fbbf24; } .mock-titlebar i:nth-child(3) { background: #34d399; } +.mock-titlebar span { margin-left: 8px; color: #8aa39b; font-size: 11px; } +.mock-body { display: grid; grid-template-columns: 158px minmax(0,1fr); min-height: 330px; } +.mock-side { border-right: 1px solid #1b2b26; padding: 10px 8px; background: #0b1310; } +.mock-search { height: 22px; border-radius: 6px; background: #142019; margin-bottom: 10px; } +.mock-group { color: #8aa39b; font-size: 11px; padding: 8px 6px 3px; } +.mock-item { display: flex; align-items: center; gap: 6px; color: #a9c0b8; font-size: 11.5px; padding: 4px 6px; border-radius: 6px; } +.mock-item.active { background: rgba(5,150,105,.18); color: #6ee7b7; } +.mock-item.conn .dot { width: 7px; height: 7px; border-radius: 50%; background: #47564f; flex-shrink: 0; } +.mock-item.conn .dot.on { background: #34d399; } +.mi { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; opacity: .8; } +.mock-main { padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; } +.mock-tabs { display: flex; gap: 4px; } +.mock-tabs .tab { color: #5f7a70; font-size: 10.5px; padding: 4px 10px; border-radius: 6px 6px 0 0; } +.mock-tabs .tab.on { color: #6ee7b7; background: rgba(5,150,105,.12); border-bottom: 2px solid var(--green); } +.mock-sql { border: 1px solid #1b2b26; border-radius: 9px; padding: 10px 12px; background: #0b1310; } +.mock-sql pre { + margin: 0; font-family: var(--mono); font-size: 11px; line-height: 1.7; color: #c3d4cd; white-space: pre-wrap; +} +.mock-sql .kw { color: #6ee7b7; } +.mock-sql .str { color: #fcd34d; } +.mock-sql .num { color: #7dd3fc; } +.mock-grid { + display: grid; grid-template-columns: 52px 62px 1fr 68px; gap: 0 10px; + border: 1px solid #1b2b26; border-radius: 9px; padding: 8px 10px; background: #0b1310; + font-family: var(--mono); font-size: 10.5px; line-height: 1.9; color: #b7c8c1; +} +.mock-grid .gh { color: #7fe3c3; border-bottom: 1px solid #1b2b26; margin-bottom: 3px; font-weight: 600; } +.mock-grid .ok { color: #34d399; } +.mock-grid .warn { color: #fbbf24; } +.mock-status { display: flex; justify-content: space-between; color: #5f7a70; font-size: 10px; font-family: var(--mono); padding: 0 2px; } +.mock-status .ok { color: #34d399; } + +/* ---------- 数据统计条 ---------- */ +.stats { border-top: 1px solid var(--border); background: rgba(255,255,255,.7); } +.stats-inner { + max-width: 1120px; margin: 0 auto; padding: 18px 24px; + display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; text-align: center; +} +.stat b { display: block; font-size: 26px; font-weight: 800; color: var(--green-dark); line-height: 1.2; } +.stat span { font-size: 12.5px; color: var(--text-2); } + +/* ---------- 通用 Section ---------- */ +.section { padding: 72px 0; } +.section.alt { background: var(--bg-alt); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.sec-title { font-size: 30px; font-weight: 800; text-align: center; margin: 0 0 8px; } +.sec-sub { text-align: center; color: var(--text-2); margin: 0 0 40px; } +.grid { display: grid; gap: 18px; } +.g3 { grid-template-columns: repeat(3, minmax(0,1fr)); } +.g4 { grid-template-columns: repeat(4, minmax(0,1fr)); } + +/* ---------- 卡片 ---------- */ +.card { + background: #fff; border: 1px solid var(--border); border-radius: var(--radius); + padding: 22px; box-shadow: 0 1px 2px rgba(6,23,18,.04); transition: all .18s; +} +.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: rgba(5,150,105,.35); } +.card h3 { font-size: 16px; margin: 0 0 6px; } +.card p { color: var(--text-2); font-size: 13.5px; margin: 0; } +.card-icon { color: var(--green); margin-bottom: 10px; } +.card-icon .i-lg { width: 30px; height: 30px; } +.card.row { display: flex; gap: 14px; align-items: flex-start; } +.card.row .card-icon { margin: 2px 0 0; } + +/* ---------- 驱动矩阵 ---------- */ +.proto { + background: #fff; border: 1px solid var(--border); border-left: 3px solid var(--green); + border-radius: 12px; padding: 18px 20px; +} +.proto h3 { margin: 0 0 6px; font-size: 15.5px; display: flex; align-items: center; gap: 8px; } +.proto h3 .i { color: var(--green); width: 17px; height: 17px; } +.proto p { margin: 0; color: var(--text-2); font-size: 13.5px; } +.proto .port { margin-left: 10px; font-family: var(--mono); font-size: 11.5px; color: var(--green-dark); background: var(--green-soft); border-radius: 5px; padding: 1px 8px; white-space: nowrap; } + +/* ---------- 徽章 / 脚注 ---------- */ +.badges { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 18px; } +.badges span { + background: #fff; border: 1px solid var(--border); border-radius: 999px; + padding: 6px 16px; font-size: 13px; font-weight: 500; color: var(--text); +} +.badges span.more { border-style: dashed; color: var(--text-2); } +.footnote { text-align: center; color: var(--text-3); font-size: 12.5px; margin: 10px 0 0; } + +/* ---------- 形态卡(下载) ---------- */ +.pricing.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; max-width: 1080px; margin: 0 auto; } +.price-card { + position: relative; background: #fff; border: 1px solid var(--border); border-radius: 16px; + padding: 30px 28px 26px; box-shadow: 0 1px 2px rgba(6,23,18,.04); display: flex; flex-direction: column; +} +.price-card.featured { border: 2px solid var(--green); box-shadow: 0 12px 36px rgba(5,150,105,.16); } +.price-card.enterprise { + background: linear-gradient(160deg, #0f1f1a 0%, #0c1512 70%); + border: 1px solid #22453a; box-shadow: 0 14px 40px rgba(12,21,18,.4); +} +.price-card.enterprise .plan { color: #8aa39b; } +.price-card.enterprise .price b { color: #fff; font-size: 34px; } +.price-card.enterprise .price span { color: #8aa39b; } +.price-card.enterprise .plan-sub { color: #8aa39b; } +.price-card.enterprise li { color: #a9c0b8; border-bottom-color: #1b2b26; } +.btn-dark { background: #fff; color: #0c1512 !important; } +.btn-dark:hover { transform: translateY(-1px); background: #e6f7f0; } +.plan-tag { + position: absolute; top: -13px; left: 50%; transform: translateX(-50%); + background: var(--green); color: #fff; font-size: 12px; font-weight: 700; + padding: 3px 14px; border-radius: 999px; white-space: nowrap; +} +.plan-tag.dark { background: #0c1512; border: 1px solid #22453a; } +.plan { font-size: 16px; font-weight: 700; color: var(--text-2); } +.price { margin: 8px 0 2px; } +.price b { font-size: 40px; font-weight: 800; letter-spacing: -.02em; color: var(--text); } +.price span { color: var(--text-3); font-size: 14px; margin-left: 4px; } +.plan-sub { color: var(--text-3); font-size: 13px; margin-bottom: 18px; } +.price-card ul { list-style: none; padding: 0; margin: 0 0 22px; flex: 1; } +.price-card li { font-size: 13.5px; color: var(--text-2); padding: 6px 0; border-bottom: 1px dashed var(--border); } +.price-card li:last-child { border-bottom: none; } +.price-card li code { font-size: 12px; } +.price-card .btn { width: 100%; } + +/* ---------- FAQ ---------- */ +.faq details { + background: #fff; border: 1px solid var(--border); border-radius: 12px; + padding: 16px 20px; margin-bottom: 12px; +} +.faq summary { font-weight: 600; cursor: pointer; font-size: 15px; list-style: none; display: flex; justify-content: space-between; } +.faq summary::after { content: "+"; color: var(--text-3); font-weight: 400; } +.faq details[open] summary::after { content: "-"; } +.faq details p { margin: 10px 0 0; color: var(--text-2); font-size: 14px; } + +/* ---------- CTA ---------- */ +.cta { + background: linear-gradient(135deg, #047857 0%, #059669 55%, #0d9488 100%); + color: #fff; text-align: center; padding: 72px 24px; +} +.cta h2 { font-size: 32px; font-weight: 800; margin: 0 0 10px; } +.cta p { color: rgba(255,255,255,.85); margin: 0 0 28px; } + +/* ---------- Footer ---------- */ +.footer { background: #0c1512; color: #8aa39b; padding: 52px 0 0; font-size: 13.5px; } +.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; padding-bottom: 36px; } +.footer .brand-name { color: #fff !important; } +.footer .brand { display: flex; align-items: center; gap: 9px; } +.footer-desc { margin: 12px 0 0; font-size: 13px; line-height: 1.8; } +.footer h4 { color: #fff; font-size: 13.5px; margin: 4px 0 12px; } +.footer a { display: block; color: #8aa39b !important; padding: 4px 0; font-size: 13px; } +.footer a:hover { color: #fff !important; } +.footer-bar { border-top: 1px solid #1b2b26; text-align: center; padding: 18px; font-size: 12px; color: #5f7a70; } + +/* ---------- 响应式 ---------- */ +@media (max-width: 1020px) { + .hero-inner { grid-template-columns: 1fr; padding-top: 52px; } + .mock { transform: none; } + .g4 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .g3 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .pricing.three { grid-template-columns: minmax(0, 420px); } + .footer-grid { grid-template-columns: repeat(2, 1fr); } + .stats-inner { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 680px) { + .nav-links { display: none; } + .hero h1 { font-size: 32px; } + .sec-title { font-size: 24px; } + .g3, .g4 { grid-template-columns: 1fr; } + .footer-grid { grid-template-columns: 1fr; } + .stats-inner { grid-template-columns: repeat(2, 1fr); } } diff --git a/apps/server/internal/site/template.html b/apps/server/internal/site/template.html index 9767bde..0922041 100644 --- a/apps/server/internal/site/template.html +++ b/apps/server/internal/site/template.html @@ -4,33 +4,297 @@ OpsxDB — 本地优先的桌面数据库工作台 - + + + + + -
-

桌面 GUI · 本地优先

-

连库、写 SQL、看结果——都在本机完成

-

正式形态是可安装的桌面应用,本地后端随应用启动。浏览器里的 :5174 只是开发壳,不是产品。

-
- 下载桌面版(占位) - 阅读文档 + + +
+
+
+
v0.1 · 桌面 GUI · 数据 100% 本机
+

一个窗口,
管好全公司的数据库

+

+ 连库、写 SQL、看结果,全在本机完成。
+ MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 内置驱动, + SSH 隧道与 SSL 加密直达内网库,AI 帮你把 SQL 写得又快又稳。 +

+ +
+ 本地优先SSH 隧道SSL 加密国产库兼容AI 写 SQL +
+
+ + +
-
    -
  • 多驱动连库MySQL / Postgres / SQLite 等,会话保活
  • -
  • 工作台密度结果集、对象树、书签,深色紧凑布局
  • -
  • 本机后端双击启动即带本地服务,不依赖手动起 Vite
  • -
-
-
© 2026 OpsxDB · 产品形态为桌面 GUI
+ + +
+
+
7内置驱动
+
18可连数据库
+
0数据外发
+
1个二进制即可自托管
+
+
+ + + +
+
+

为什么运维和开发选 OpsxDB

+

不把生产库连到别人的云上 —— 这是我们做这款工具的出发点。

+
+
+
+

本地优先,数据主权

+

连接配置、查询历史全部保存在本机,服务只监听 127.0.0.1,不经过任何第三方服务器。

+
+
+
+

六类库一个窗口

+

MySQL 族 / PostgreSQL 族 / SQL Server / Oracle / SQLite / Redis 统一工作台,切换零负担。

+
+
+
+

安全通道直达内网

+

每个连接可独立配置 SSL/TLS 与 SSH 隧道,跳板机后面的库也能安全直连。

+
+
+
+

AI 把 SQL 写快

+

自然语言生成、解释、优化、修复、方言转换,写不出的那句 SQL 交给 AI 起草。

+
+
+
+
+ + +
+
+

开箱即用的专业能力

+

为「每天都要碰库的人」设计的工作台密度。

+
+

连接选择器

搜索 / 分类 / 最近 / 图标网格;粘贴 mysql://user:pass@host:3306/db 一键填入表单。

+

SQL 工作台

CodeMirror 编辑器,⌘/Ctrl+Enter 执行当前语句或选区,多 Tab 并行,可拖分栏。

+

结果随手取

结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。

+

对象树与表数据

懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。

+

Redis 工作台

编辑器直接执行 PING / GET / SET / KEYS,键空间浏览、TTL 管理。

+

AI 面板

生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL,六种动作贴着查询上下文。

+
+
+
+ + +
+
+

数据库支持

+

常用库 native Go 驱动编译内置;长尾库规划 agent 按需接入。

+
+

MySQL 族

MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL:3306

+

PostgreSQL 族

PostgreSQL · GaussDB / openGauss · 人大金仓:5432

+

SQL Server

原生协议直连,SSL 可选:1433

+

Oracle

纯 Go 驱动 go-ora,不引入 JVM:1521

+

SQLite

本地文件库,填路径即连file

+

Redis

连接即可执行命令与键管理:6379

+
+
+ 规划中 · agent 按需接入:达梦 DM8 · MongoDB · ZooKeeper · Elasticsearch · InfluxDB +
+

驱动验收状态(本机 docker 实测)见 文档 · 驱动支持。

+
+
+ + +
+
+

安全是默认项,不是选项

+

连生产库的工具,必须先过安全这一关。

+
+

密码不出本机

连接密码本机混淆存储;查询只带 sessionId,账密不再每次上送。

+

SSL / TLS 每库可配

按连接独立开启 TLS,证书校验策略随库配置。

+

SSH 隧道

跳板机场景一条隧道直达内网库,无需暴露公网端口。

+

仅监听回环

后端默认绑定 127.0.0.1,对外服务需显式开启。

+

危险 SQL 确认

DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前二次确认。

+

查询历史本地留存

历史只存在本机,随时回跳重跑,不上传不同步。

+
+
+
+ + +
+
+

三种形态,同一套体验

+

主交付是桌面 GUI;Web 与内网部署复用同一个 Go 二进制。

+
+ +
+
Web 自托管
+
1 个二进制
+
Gin + 内置静态页
+
    +
  • ✓ ./run.sh web 一条命令启动
  • +
  • ✓ 官网与文档随二进制内置
  • +
  • ✓ 浏览器直接用,无需安装
  • +
  • ✓ 适合快速体验与调试
  • +
+ 查看快速开始 +
+
+
内网部署
+
企业内网版
+
无外联
+
数据不出企业网
+
    +
  • ✓ 单二进制 + 静态页,内网任意主机部署
  • +
  • ✓ 默认仅回环监听,对外需显式配置
  • +
  • ✓ SSH 隧道 / TLS 直达内网数据库
  • +
  • ✓ 无遥测、无账号、无云依赖
  • +
+ 安全设计 +
+
+
+
+ + +
+
+

常见问题

+
+
我的连接密码保存在哪?安全吗?

全部保存在本机(混淆存储),查询时只携带会话 ID,密码不会随每次请求上送,也不会同步到任何云端。

+
浏览器里打开的 5174 端口是什么?

那是开发调试用的 Vite 热更新壳,不是产品形态。正式形态是桌面应用(或自托管的 Web 服务),由后端 :8788 提供页面与 API。

+
和 DBeaver / Navicat 有什么差别?

OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、单二进制自托管、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。

+
Oracle 支持好了吗?

驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。

+
AI 功能需要联网吗?

当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。

+
支持达梦 / MongoDB 吗?

已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。

+
+
+
+ + +
+
+

把数据库客户端收回自己手里

+

30 秒连上第一个库;官网与文档都由你手里的这个二进制提供。

+ +
+
+ + + + diff --git a/connection-types/NOTICE.md b/connection-types/NOTICE.md new file mode 100644 index 0000000..eaed60b --- /dev/null +++ b/connection-types/NOTICE.md @@ -0,0 +1,12 @@ +# 数据来源说明(NOTICE) + +`connection-types.json` 的字段取值来源如下,供维护与审计对照: + +| 数据 | 来源 | +|---|---| +| `id` / `label` / `category` / `defaultPort` / `color` / `icon` / `mysqlCompat` / `ready` / `form` / `hints` | `packages/ui/src/catalog.ts` 的 `DRIVERS` 数组(注册表顺序 = 该数组顺序) | +| `driverFamily` / `runtime` | `apps/server/internal/db/open.go` 的服务端驱动分发 | +| `verified` / `notes` | `tests/README.md` 驱动验收表 | +| 默认端口、产品名、品牌色等产品事实 | 各数据库官方公开文档(MySQL 3306、PostgreSQL 5432、SQL Server 1433、Oracle 1521、Redis 6379、MongoDB 27017、达梦 DM8 5236 等),属不受著作权保护的技术事实 | + +opsxdb 整体许可证以仓库根 LICENSE 为准。 diff --git a/connection-types/README.md b/connection-types/README.md new file mode 100644 index 0000000..1a7aae4 --- /dev/null +++ b/connection-types/README.md @@ -0,0 +1,40 @@ +# opsxdb 连接类型注册表(connection-types.json,schema v1) + +`connection-types.json` 是 opsxdb 驱动目录的**单一事实来源**(规划中):`packages/ui/src/catalog.ts` +的 `DRIVERS` 与服务端 `apps/server/internal/db/open.go` 的驱动分发,最终都应从本注册表生成。 +当前阶段注册表与代码手工保持一致,生成器接入前以代码为准。 + +数据来源说明见 `NOTICE.md`。 + +## 设计 + +- **单个 JSON 注册表**:`types` 数组顺序即选择器展示顺序,与 `catalog.ts` 的 `DRIVERS` + 数组保持同序;不设独立排序字段,避免引入任意常数。 +- **JSON 无注释**:字段语义统一在本文件说明,条目级备注(如验收阻塞原因)写入 `notes` 字段。 + +## 字段说明 + +| 字段 | 必填 | 说明 | +|---|---|---| +| `id` | ✓ | 驱动 ID,与 `catalog.ts` 的 `DriverId` 一致 | +| `label` | ✓ | 显示名 | +| `category` | ✓ | `关系型` / `NoSQL` / `国产库` / `时序/搜索`(选择器分组) | +| `driverFamily` | ✓ | 服务端分发:`mysql` / `postgres` / `sqlserver` / `oracle` / `sqlite` / `redis` / `agent`(未实现,走外部驱动) | +| `runtime` | ✓ | `native`(Go 驱动编译内置)/ `agent`(按需下载的外部驱动)/ `file`(本地文件库) | +| `form` | ✓ | 连接表单:`standard` / `file` / `redis` / `oracle`(对应 `ConnectionForm`) | +| `ready` | ✓ | 用户可测试/连接;`false` 时选择器显示"未就绪",仅可保存 | +| `verified` | ✓ | 本机 docker 验收通过(依据 `tests/README.md` 驱动验收表);`false` 表示代码就绪但未经本机验证 | +| `defaultPort` | ✓ | 默认端口;文件库为 `0` | +| `mysqlCompat` | | MySQL 协议族(TiDB/OceanBase/PolarDB/TDSQL 等复用 mysql 驱动) | +| `color` / `icon` | | 选择器展示 | +| `hints` | | 表单占位提示(host/port/user/database/service) | +| `fields` | ✓ | 连接表单收集且服务端实际消费的字段;表单不渲染、服务端不接收的字段**禁止**列入 | +| `notes` | | 条目级备注(如 `verified: false` 的原因) | + +## 纪律 + +- `fields` 必须与 `apps/server/internal/db/types.go` 的 `ConnReq` 实际消费字段一致 + (审计记录:Oracle 的 `oracleRole`/`oracleIdKind`、SQL Server 的 `authMode`、 + Redis 的 `keySeparator`/`redisMode` 为已知的死参数,不得回流)。 +- 新增驱动时同步更新 `tests/README.md` 验收表与 `catalog.ts`,并保持注册表与 + `DRIVERS` 数组同序。 diff --git a/connection-types/connection-types.json b/connection-types/connection-types.json new file mode 100644 index 0000000..254dc4c --- /dev/null +++ b/connection-types/connection-types.json @@ -0,0 +1,110 @@ +{ + "schemaVersion": 1, + "types": [ + { + "id": "mysql", + "label": "MySQL", + "category": "关系型", + "driverFamily": "mysql", + "runtime": "native", + "form": "standard", + "ready": true, + "verified": true, + "defaultPort": 3306, + "mysqlCompat": true, + "color": "#00618A", + "icon": "/icons/databases/mysql.svg", + "hints": { "host": "127.0.0.1", "port": "3306", "user": "root" }, + "fields": ["host", "port", "user", "password", "database", "ssl", "ssh"] + }, + { + "id": "postgresql", + "label": "PostgreSQL", + "category": "关系型", + "driverFamily": "postgres", + "runtime": "native", + "form": "standard", + "ready": true, + "verified": true, + "defaultPort": 5432, + "color": "#336791", + "icon": "/icons/databases/postgresql.svg", + "hints": { "host": "127.0.0.1", "port": "5432", "user": "postgres", "database": "postgres" }, + "fields": ["host", "port", "user", "password", "database", "ssl", "ssh"] + }, + { + "id": "sqlserver", + "label": "SQL Server", + "category": "关系型", + "driverFamily": "sqlserver", + "runtime": "native", + "form": "standard", + "ready": true, + "verified": true, + "defaultPort": 1433, + "color": "#CC2927", + "icon": "/icons/databases/sqlserver.svg", + "hints": { "host": "127.0.0.1", "port": "1433", "user": "sa", "database": "master" }, + "fields": ["host", "port", "user", "password", "database", "ssl", "ssh"] + }, + { + "id": "oracle", + "label": "Oracle", + "category": "关系型", + "driverFamily": "oracle", + "runtime": "native", + "form": "oracle", + "ready": true, + "verified": false, + "defaultPort": 1521, + "color": "#F80000", + "icon": "/icons/databases/oracle.svg", + "hints": { "host": "127.0.0.1", "port": "1521", "user": "system", "service": "ORCL" }, + "fields": ["host", "port", "user", "password", "service", "ssl", "ssh"], + "notes": "本机未验收:gvenzl/oracle-xe 在 Apple Silicon 报 ORA-00442,需 x86 环境或真库(tests/README.md)" + }, + { + "id": "mongodb", + "label": "MongoDB", + "category": "NoSQL", + "driverFamily": "agent", + "runtime": "agent", + "form": "standard", + "ready": false, + "verified": false, + "defaultPort": 27017, + "color": "#00ED64", + "icon": "/icons/databases/mongodb.svg", + "fields": ["host", "port", "user", "password", "database", "ssl", "ssh"] + }, + { + "id": "redis", + "label": "Redis", + "category": "NoSQL", + "driverFamily": "redis", + "runtime": "native", + "form": "redis", + "ready": true, + "verified": true, + "defaultPort": 6379, + "color": "#DC382D", + "icon": "/icons/databases/redis.svg", + "hints": { "host": "127.0.0.1", "port": "6379" }, + "fields": ["host", "port", "user", "password", "database", "ssl", "ssh"] + }, + { + "id": "dameng", + "label": "达梦", + "category": "国产库", + "driverFamily": "agent", + "runtime": "agent", + "form": "standard", + "ready": false, + "verified": false, + "defaultPort": 5236, + "color": "#1E40AF", + "icon": "/icons/databases/dameng.svg", + "fields": ["host", "port", "user", "password", "database", "ssl", "ssh"] + } + ] +} diff --git a/connection-types/dameng.yaml b/connection-types/dameng.yaml deleted file mode 100644 index f342de1..0000000 --- a/connection-types/dameng.yaml +++ /dev/null @@ -1,39 +0,0 @@ -schemaVersion: 1 -order: 260 -dbType: dameng -rustVariant: Dameng -label: 达梦 DM8 -dialect: Dameng -runtimeMode: agent -mcpMode: bridge -agentKey: dameng -driverStoreVisible: true -driverStoreOrder: 1 -singleConnectionPool: true -metadataConnectionScoped: false -skipTcpProbe: true -defaultPort: 5236 -traits: - schemaAware: true - singleDatabase: true - clearableQuerySchema: true - fetchFirst: true - diagramSql: true -supportLevel: operate -capabilities: - queryExecution: true - metadataBrowse: true - objectBrowser: true - objectSource: true - schemaSearch: true - diagram: true - tableDataEdit: true - tableStructureEdit: true - tableImport: true - dataTransfer: true - sqlFileExecution: true - databaseCreate: false - fieldLineage: true - sqlExplain: true - userAdmin: false - driverManagement: true diff --git a/connection-types/mongodb.yaml b/connection-types/mongodb.yaml deleted file mode 100644 index 6c9e7c4..0000000 --- a/connection-types/mongodb.yaml +++ /dev/null @@ -1,37 +0,0 @@ -schemaVersion: 1 -order: 110 -dbType: mongodb -rustVariant: MongoDb -label: MongoDB (Legacy) -runtimeMode: agent -mcpMode: bridge -agentKey: mongodb -driverStoreVisible: true -driverStoreOrder: 41 -driverProfiles: - - profile: mongodb-legacy - agentKey: mongodb - label: MongoDB (Legacy) - storeVisible: false -singleConnectionPool: true -metadataConnectionScoped: false -skipTcpProbe: true -defaultPort: 27017 -supportLevel: connect -capabilities: - queryExecution: true - metadataBrowse: false - objectBrowser: true - objectSource: false - schemaSearch: false - diagram: false - tableDataEdit: false - tableStructureEdit: false - tableImport: false - dataTransfer: true - sqlFileExecution: false - databaseCreate: false - fieldLineage: false - sqlExplain: false - userAdmin: false - driverManagement: false diff --git a/connection-types/mysql.yaml b/connection-types/mysql.yaml deleted file mode 100644 index c1e69f8..0000000 --- a/connection-types/mysql.yaml +++ /dev/null @@ -1,32 +0,0 @@ -schemaVersion: 1 -order: 10 -dbType: mysql -rustVariant: Mysql -label: MySQL -dialect: MySQL -runtimeMode: native -mcpMode: direct -singleConnectionPool: false -metadataConnectionScoped: true -skipTcpProbe: false -defaultPort: 3306 -traits: - diagramSql: true -supportLevel: operate -capabilities: - queryExecution: true - metadataBrowse: true - objectBrowser: true - objectSource: true - schemaSearch: true - diagram: true - tableDataEdit: true - tableStructureEdit: true - tableImport: true - dataTransfer: true - sqlFileExecution: true - databaseCreate: true - fieldLineage: true - sqlExplain: true - userAdmin: true - driverManagement: false diff --git a/connection-types/oracle.yaml b/connection-types/oracle.yaml deleted file mode 100644 index 45d4fc7..0000000 --- a/connection-types/oracle.yaml +++ /dev/null @@ -1,48 +0,0 @@ -schemaVersion: 1 -order: 130 -dbType: oracle -rustVariant: Oracle -label: Oracle -dialect: Oracle -runtimeMode: agent -mcpMode: bridge -agentKey: oracle -driverStoreVisible: true -driverStoreOrder: 18 -driverProfiles: - - profile: oracle-legacy - agentKey: oracle - label: Oracle - storeVisible: false - - profile: oracle-10g - agentKey: oracle - label: Oracle - storeVisible: false -singleConnectionPool: true -metadataConnectionScoped: false -skipTcpProbe: true -defaultPort: 1521 -traits: - schemaAware: true - singleDatabase: true - clearableQuerySchema: true - fetchFirst: true - diagramSql: true -supportLevel: operate -capabilities: - queryExecution: true - metadataBrowse: true - objectBrowser: true - objectSource: true - schemaSearch: true - diagram: true - tableDataEdit: true - tableStructureEdit: true - tableImport: true - dataTransfer: true - sqlFileExecution: true - databaseCreate: false - fieldLineage: true - sqlExplain: true - userAdmin: false - driverManagement: true diff --git a/connection-types/postgres.yaml b/connection-types/postgres.yaml deleted file mode 100644 index c48cab3..0000000 --- a/connection-types/postgres.yaml +++ /dev/null @@ -1,36 +0,0 @@ -schemaVersion: 1 -order: 20 -dbType: postgres -rustVariant: Postgres -label: PostgreSQL -dialect: PostgreSQL -runtimeMode: native -mcpMode: direct -singleConnectionPool: false -metadataConnectionScoped: false -skipTcpProbe: false -defaultPort: 5432 -traits: - schemaAware: true - treeSchema: true - pgVacuum: true - pgLikeStructure: true - diagramSql: true -supportLevel: operate -capabilities: - queryExecution: true - metadataBrowse: true - objectBrowser: true - objectSource: true - schemaSearch: true - diagram: true - tableDataEdit: true - tableStructureEdit: true - tableImport: true - dataTransfer: true - sqlFileExecution: true - databaseCreate: true - fieldLineage: true - sqlExplain: true - userAdmin: true - driverManagement: false diff --git a/connection-types/redis.yaml b/connection-types/redis.yaml deleted file mode 100644 index 4a58e0b..0000000 --- a/connection-types/redis.yaml +++ /dev/null @@ -1,29 +0,0 @@ -schemaVersion: 1 -order: 70 -dbType: redis -rustVariant: Redis -label: Redis -runtimeMode: native -mcpMode: bridge -singleConnectionPool: false -metadataConnectionScoped: false -skipTcpProbe: false -defaultPort: 6379 -supportLevel: connect -capabilities: - queryExecution: true - metadataBrowse: false - objectBrowser: false - objectSource: false - schemaSearch: false - diagram: false - tableDataEdit: false - tableStructureEdit: false - tableImport: false - dataTransfer: false - sqlFileExecution: false - databaseCreate: false - fieldLineage: false - sqlExplain: false - userAdmin: false - driverManagement: false diff --git a/connection-types/sqlserver.yaml b/connection-types/sqlserver.yaml deleted file mode 100644 index cdc3ff0..0000000 --- a/connection-types/sqlserver.yaml +++ /dev/null @@ -1,41 +0,0 @@ -schemaVersion: 1 -order: 100 -dbType: sqlserver -rustVariant: SqlServer -label: SQL Server -dialect: SQL Server -runtimeMode: native -mcpMode: bridge -driverProfiles: - - profile: sqlserver-legacy - agentKey: sqlserver-legacy - label: SQL Server legacy compatibility component - storeVisible: true - storeOrder: 47 -singleConnectionPool: false -metadataConnectionScoped: false -skipTcpProbe: false -defaultPort: 1433 -traits: - schemaAware: true - databaseSchemaQualified: true - treeSchema: true - diagramSql: true -supportLevel: operate -capabilities: - queryExecution: true - metadataBrowse: true - objectBrowser: true - objectSource: true - schemaSearch: true - diagram: true - tableDataEdit: true - tableStructureEdit: true - tableImport: true - dataTransfer: true - sqlFileExecution: true - databaseCreate: true - fieldLineage: true - sqlExplain: true - userAdmin: false - driverManagement: false diff --git a/packages/ui/src/ObjectTree.tsx b/packages/ui/src/ObjectTree.tsx index 50bd053..6768385 100644 --- a/packages/ui/src/ObjectTree.tsx +++ b/packages/ui/src/ObjectTree.tsx @@ -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 ( + + ); +} + +/** 分类计数徽章:分类色底 12% 透明 + 分类色文字 */ +function CountBadge({ kind, count }: { kind: TreeColorKey; count: number }) { + const c = TREE_COLORS[kind]; + return ( + + {count} + + ); +} + 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(null); const [keyFilter, setKeyFilter] = useState(""); + /** 库/表过滤(非 Redis 连接);只匹配库名与已加载的对象名 */ + const [treeFilter, setTreeFilter] = useState(""); + /** 单击/双击消歧:单击延迟打开表,双击取消定时器立即打开 */ + const clickTimerRef = useRef(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>, ) => { 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 }) => ( +
onCtx(e, col.name, onRefresh)} + > + + + {col.key === "PRI" ? "🔑 " : ""} + {col.name} + {col.type} + +
+ ); + + /** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */ + 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 (
-
+
对象 + {!isRedisConn(conn?.driver) && ( + setTreeFilter(e.target.value)} + /> + )}
- {(obj.children ?? []).map((col) => ( -
onCtx(e, col.name, () => void expandTable(db.name, obj.name))} - > - · - - {col.key === "PRI" ? "🔑 " : ""} - {col.name} - {col.type} - -
- ))} + {(obj.children ?? []).map((sub) => + sub.kind === "group" && sub.groupId === "columns" ? ( +
+ + ▸ + + + {sub.name} + + + +
+ {(sub.children ?? []).map((col) => ( + void expandTable(db.name, obj.name)} /> + ))} +
+
+ ) : ( + void expandTable(db.name, obj.name)} /> + ), + )} {obj.loading && (
列… @@ -730,7 +1031,7 @@ export function ObjectTree() { } onDoubleClick={() => void copyName(obj.name)} > - + {obj.name}
), @@ -746,10 +1047,77 @@ export function ObjectTree() {
))} + {f && visibleTree.length === 0 && !treeLoading && ( +
+ 没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。 +
+ )}
{ctx && (
e.stopPropagation()}> + {ctx.onQueryData && ( + + )} + {ctx.onSchema && ( + + )} + {ctx.onNewQuery && ( + + )} + {ctx.onView && ( + + )} + {ctx.onGen && ( + <> +
+ {(["select", "insert", "update", "delete"] as GenSqlKind[]).map((k) => ( + + ))} + + )} +
- {ctx.onSchema && ( + {(ctx.onTruncate || ctx.onDropTable || ctx.onDelete) && ( +
+ )} + {ctx.onTruncate && ( )} - {ctx.onView && ( + {ctx.onDropTable && ( )} {ctx.onDelete && ( diff --git a/packages/ui/src/SchemaPane.tsx b/packages/ui/src/SchemaPane.tsx index 9b8dee2..ee0fca7 100644 --- a/packages/ui/src/SchemaPane.tsx +++ b/packages/ui/src/SchemaPane.tsx @@ -1,8 +1,63 @@ +import { useEffect, useState } from "react"; +import { execSql } from "./api"; +import { ensureSession } from "./ObjectTree"; import { useWorkspaceStore } from "./store"; +type MetaRow = Record; + +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(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 (
@@ -86,6 +143,96 @@ export function SchemaPane() { )} + + {mysql && ( +
+
+ + 索引({meta.indexes.length}) + + {meta.indexes.length === 0 ? ( +
无索引
+ ) : ( + + + + + + + + + + {meta.indexes.map((r, i) => ( + + + + + + ))} + +
索引名唯一列
{str(r.name)}{r.uniq ? "✓" : ""}{str(r.cols)}
+ )} +
+ +
+ + 外键({meta.fks.length}) + + {meta.fks.length === 0 ? ( +
无外键
+ ) : ( + + + + + + + + + + {meta.fks.map((r, i) => ( + + + + + + ))} + +
约束名列引用
{str(r.name)}{str(r.col)} + {str(r.refTable)}.{str(r.refCol)} +
+ )} +
+ +
+ + 触发器({meta.triggers.length}) + + {meta.triggers.length === 0 ? ( +
无触发器
+ ) : ( + + + + + + + + + + {meta.triggers.map((r, i) => ( + + + + + + ))} + +
触发器时机事件
{str(r.name)}{str(r.timing)}{str(r.event)}
+ )} +
+
+ )}
); diff --git a/packages/ui/src/SqlEditor.tsx b/packages/ui/src/SqlEditor.tsx index c4394a1..ea7c301 100644 --- a/packages/ui/src/SqlEditor.tsx +++ b/packages/ui/src/SqlEditor.tsx @@ -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 }); diff --git a/packages/ui/src/Workspace.tsx b/packages/ui/src/Workspace.tsx index 5c2334e..ff2d815 100644 --- a/packages/ui/src/Workspace.tsx +++ b/packages/ui/src/Workspace.tsx @@ -55,7 +55,7 @@ function TopNav() { className={cx("ops-btn-ghost", showHistory && "ops-tree-item-active")} onClick={() => set("showHistory", !showHistory)} > - 历史 + 查询历史
)} {showHistory && ( -
-
- 最近 {history.length} 条 +
+
+ + 最近 {history.length} 条 + + 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", + }} + />
- {history.length === 0 &&
暂无历史
} - {history.map((h) => ( - - ))} +
+ {history.length === 0 && ( +
+ 暂无历史。执行过的语句会留在这里:点击行回填编辑器,▶ 直接重跑;重复语句自动去重置顶。 +
+ )} + {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 ( +
+ + +
+ ); + })} +
)} diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index 2181d32..ecc16c4 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -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((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((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((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 }); }, diff --git a/tests/README.md b/tests/README.md index a6163cc..4eef488 100644 --- a/tests/README.md +++ b/tests/README.md @@ -161,4 +161,6 @@ docker run -d --name demo-nginx -p 8080:80 nginx:alpine | InfluxDB | 镜像已备 | — | — | `./dbctl.sh up i`;驱动未就绪,opsx / Demo@123 / bucket demo | | Oracle | 代码就绪 | — | 本机未跑通 | Apple Silicon 上 `gvenzl/oracle-xe` 报 ORA-00442,需 x86 环境或真库 | +> 本表是 `connection-types/connection-types.json` 中 `verified` 字段的依据;验收状态变化时两边同步更新。 + 冒烟脚本:`./tests/verify-crud.sh`(一次只起一个库)。