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 0000000..eb72235 Binary files /dev/null and b/apps/desktop/src-tauri/icons/options/cylinder.png differ 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 0000000..169cacf Binary files /dev/null and b/apps/desktop/src-tauri/icons/options/star.png differ 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 0000000..6e9af19 Binary files /dev/null and b/apps/desktop/src-tauri/icons/options/tilt.png differ diff --git a/apps/desktop/src-tauri/icons/options/tilt.svg b/apps/desktop/src-tauri/icons/options/tilt.svg new file mode 100644 index 0000000..1c23a36 --- /dev/null +++ b/apps/desktop/src-tauri/icons/options/tilt.svg @@ -0,0 +1,7 @@ + + + + + + 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 侧栏。

- \ 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`(一次只起一个库)。