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

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

No files matched your search

+1
View File
@@ -31,3 +31,4 @@ opsxdb-refs.tar.gz
apps/server/bin/
logs/
apps/desktop/src-tauri/binaries/opsxdb-server-*
*.bak
+8 -1
View File
@@ -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 为准。
## 端口
| 服务 | 端口 | 环境变量 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#0B182C"/>
<ellipse cx="256" cy="156" rx="128" ry="40" fill="#60A5FA"/>
<path d="M128 156v64c0 22 57 40 128 40s128-18 128-40v-64" fill="#3B82F6"/>
<ellipse cx="256" cy="220" rx="128" ry="40" fill="#93C5FD"/>
<path d="M128 220v64c0 22 57 40 128 40s128-18 128-40v-64" fill="#3B82F6"/>
<ellipse cx="256" cy="284" rx="128" ry="40" fill="#BFDBFE"/>
<path d="M128 284v88c0 22 57 40 128 40s128-18 128-40v-88" fill="#2563EB"/>
<ellipse cx="256" cy="372" rx="128" ry="40" fill="#3B82F6"/>
</svg>

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#EF4444"/>
<g transform="rotate(8 256 256)">
<path d="M256 88 L298 214 L432 214 L324 292 L364 420 L256 344 L148 420 L188 292 L80 214 L214 214 Z"
fill="none" stroke="#FFFFFF" stroke-width="36" stroke-linejoin="round" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#EF4444"/>
<g transform="rotate(12 256 256)">
<path d="M256 96 L294 214 L420 214 L318 288 L354 408 L256 336 L158 408 L194 288 L92 214 L218 214 Z"
fill="none" stroke="#FFFFFF" stroke-width="34" stroke-linejoin="round" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 408 B

+1
View File
@@ -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
+2
View File
@@ -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=
+236 -29
View File
@@ -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; }
+219
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 '<span class="tok-key">' + str + "</span>" + colon;
if (str) return '<span class="tok-str">' + str + "</span>";
if (kw) return '<span class="tok-num">' + kw + "</span>";
if (num) return '<span class="tok-num">' + num + "</span>";
return m;
}
);
}
function highlightBash(code) {
var lines = code.textContent.split("\n");
code.innerHTML = lines
.map(function (line) {
if (/^\s*#/.test(line)) return '<span class="tok-com">' + esc(line) + "</span>";
return esc(line).replace(/(^|\s)(-{1,2}[\w-]+)/g, '$1<span class="tok-key">$2</span>');
})
.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 = '<div class="search-empty">没有找到相关内容,换个关键词试试</div>';
} else {
panel.innerHTML = hits
.map(function (h) {
return (
'<a class="search-item" href="/docs/' + h.slug + '">' +
'<span class="si-title">' + h.title + '</span><span class="si-group">' + h.group + "</span>" +
'<span class="si-snippet">' + h.snippet + "</span></a>"
);
})
.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");
}
});
}
})();
@@ -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 秒连上第一个库。
@@ -0,0 +1,61 @@
本章从零到出结果:起服务 → 建连接 → 执行第一条 SQL。
## 1. 启动
桌面版(主交付形态)双击 OpsxDB 应用即可,本地后端随应用自动拉起。
从源码跑 Web / 桌面形态:
```bash
git clone <repo> 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
```
各驱动的验收状态见《驱动支持》。
@@ -0,0 +1,40 @@
OpsxDB 的界面为「高频操作」设计:从打开应用到看到数据,路径越短越好。
## 连接选择器
启动后的第一个界面:
- **搜索**:按名称快速过滤已保存的连接;
- **分类**:关系型 / NoSQL / 国产库 / 时序·搜索 分组浏览;
- **最近**:最近使用的连接排在最前;
- **图标网格**:按数据库类型直选,未就绪的类型会标注「未就绪」(仅可保存配置);
- **URL 填入**:粘贴 `mysql://user:pass@host:3306/db` 点「填入」,字段自动拆解。
## 工作台布局
连接成功后进入工作台,三栏结构:
| 区域 | 内容 |
| --- | --- |
| 左侧 | 连接列表 + 对象树(库 → 表 → 列,懒加载) |
| 中部 | SQL 编辑器(多 Tab)+ 结果面板,可拖动分栏 |
| 右侧 | AI 面板(生成 / 解释 / 优化 / 修复 / 方言 / 样例) |
## 对象树
- 点**表名**:直接打开前 100 行数据;
- 展开**表**:查看列结构;
- 库列表懒加载,大实例也不卡。
## 编辑器与结果
- CodeMirror 语法高亮,⌘/Ctrl+Enter 执行**当前语句或选区**(不是整页脚本);
- 多 Tab 并行查询,互不阻塞;
- 结果面板:复制 / CSV / INSERT 三种取数方式,双击单元格复制;
- 超过截断阈值时结果有明确提示。
## 个性化
- 深色 / 浅色主题切换;
- Tab 布局与主题选择持久化,下次打开保持原样;
- 查询历史本地留存,可回跳重跑。
@@ -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`,账密不会随每次请求上送;密码在本机混淆存储。
@@ -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),不随包体膨胀。
@@ -0,0 +1,46 @@
SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设计。
## 执行模型
- **⌘/Ctrl + Enter**:执行光标所在的**当前语句**;有选区时执行**选区**;
- 不是「整页脚本」模式 —— 多条语句互不干扰,想跑哪句把光标放哪;
- 执行在会话上运行,连接断开后自动提示。
## 多 Tab 与分栏
- 每个查询一个 Tab,并行执行互不阻塞;
- 编辑器 / 结果区可拖动分栏,长查询时编辑器不被结果挤占;
- Tab 与布局持久化,重开应用保持原样。
## 对象树联动
- 左侧对象树懒加载库 / 表 / 列;
- **点表名 = 查询前 100 行**,自动生成对应 SQL;
- 展开表看列名与类型,写 SQL 时可对照。
## 查询历史
- 本地留存最近 200 条(不上传、不同步);
- **相同语句自动去重置顶** —— 历史即「最近用过的不同语句」,高频查询永远在第一条;
- 点击条目回填编辑器;悬停出现 **▶** 按钮,回填并直接重跑;
- 顶部过滤框支持按语句内容或连接名搜索;
- 条目显示时间、连接名、行数与耗时;失败的语句红色标出。
## 危险 SQL 防护
执行以下语句前会弹确认框:
| 类型 | 规则 |
| --- | --- |
| 删除对象 | DROP / TRUNCATE |
| 全表更新 | 无 WHERE 的 UPDATE / DELETE |
> **注意**:确认框只拦「明确危险」的模式,不替代事务与备份习惯;生产库操作请配合审批流程。
## 快捷键
| 快捷键 | 动作 |
| --- | --- |
| ⌘/Ctrl + Enter | 执行当前语句 / 选区 |
| 双击结果单元格 | 复制单元格内容 |
| Esc | 关闭弹层 / 取消确认 |
@@ -0,0 +1,27 @@
查询跑完只是开始,把结果带走才算完成 —— OpsxDB 给了三条最快路径。
## 三种取数方式
| 方式 | 用途 |
| --- | --- |
| **复制** | 直接进剪贴板,贴到群聊 / 文档 / 表格 |
| **CSV** | 导出标准 CSV 文件,Excel / Numbers 直接打开 |
| **INSERT** | 把结果行生成为 INSERT 语句,直接可在别的库回放 |
## 单元格操作
- **双击单元格**:复制该单元格内容;
- 列头即字段名,长文本自动截断显示,完整内容在复制时保留。
## 截断提示
为保护界面与内存,超大结果集会按阈值截断展示:
- 结果面板顶部显示「已截断」提示与实际行数;
- 需要完整数据时,缩小查询范围(加 LIMIT / WHERE)或使用导出。
> **提示**:默认「点表名看前 100 行」就是截断策略的一部分 —— 快速浏览用 100 行,精确取数写 WHERE。
## 与 AI 面板联动
写不出来的查询可以直接在 AI 面板用自然语言描述(「查最近 7 天下单但未支付的用户」),生成 SQL 后放回编辑器执行,见《AI 助手》。
@@ -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)开关 |
@@ -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 执行前确认;
- 结果集按阈值截断,避免误操作拖垮界面。
## 内网部署形态
单二进制 + 内置静态页(官网 / 文档 / 工作台),无外部依赖:
- 任意内网主机可部署;
- 无外联请求,可在纯内网环境运行;
- 升级即替换一个二进制。
@@ -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 功能;
- 使用云端模型时在界面上明确标识。
@@ -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 工作台》。
@@ -1,7 +0,0 @@
<h1>连库入门</h1>
<p>在连库页选择驱动,填写主机 / 端口 / 账号,测试通过后保存并打开。</p>
<ul>
<li>密码仅存本机(本地加密存储)。</li>
<li>会话由本地后端维护;后端未启动时顶部会提示,不会白屏。</li>
<li>断线后可在底栏重连,恢复当前库上下文。</li>
</ul>
@@ -1,7 +0,0 @@
<h1>常见问题</h1>
<h2>打开后提示本地后端未就绪?</h2>
<p>确认应用已拉起 sidecar;或手动 <code>./run.sh server</code> 后重试。默认只监听 <code>127.0.0.1:8788</code>。</p>
<h2>浏览器也能用吗?</h2>
<p>可以调试,但<strong>产品说明与对外沟通应以桌面版为准</strong>,不要暗示 Web 即产品。</p>
<h2>官网 / 文档在哪?</h2>
<p>本地后端提供:<code>http://127.0.0.1:8788/</code>(官网)、<code>/docs</code>(文档)。</p>
@@ -1,13 +0,0 @@
<h1>安装与启动</h1>
<p>OpsxDB <strong>正式形态是桌面 GUI</strong>(Tauri)。开发态浏览器 <code>:5174</code> 只用于调试,不是产品交付。</p>
<h2>桌面版</h2>
<ol>
<li>安装本机构建产物(macOS <code>.dmg</code> / <code>.app</code>,或 <code>npm run build:desktop</code> 产出)。</li>
<li>双击启动:应用会拉起本地后端(默认 <code>127.0.0.1:8788</code>),进入工作台。</li>
<li>无需手动执行 <code>./run.sh web</code>。</li>
</ol>
<h2>开发调试</h2>
<pre><code>./run.sh gui # 或 ./run.sh web
</code></pre>
<p>开发壳地址:<code>http://127.0.0.1:5174</code>。</p>
<p>改后端(<code>apps/server</code>)后请执行 <code>./run.sh restart</code>(或等价重启 Gin),避免 <code>:8788</code> 仍跑旧二进制。</p>
@@ -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" }
]
}
]
}
@@ -1,7 +0,0 @@
<h1>工作台简介</h1>
<p>深色密度布局:左侧连接与对象树,中间编辑器与多结果 Tab,可选 AI 侧栏。</p>
<ul>
<li>多结果集:成功结果保留;失败不盖掉已有成功集。</li>
<li>执行计划:有则展示,无则禁用。</li>
<li>书签:紧凑列表,回填 SQL,本地持久化。</li>
</ul>
+212 -37
View File
@@ -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 {
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(`<a class="` + cls + `" href="/docs/` + p.Slug + `">` + p.Title + `</a>`)
}
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])>(.*?)</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</%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(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>404 · OpsxDB 文档</title>
<link rel="stylesheet" href="/docs/assets/docs.css"></head><body class="doc-404">
<div class="wrap"><h1>404</h1><p>文档页面不存在或已被移动。</p><a class="btn" href="%s">返回文档首页</a></div>
</body></html>`, home)
}
+25
View File
@@ -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 "<!doctype html><meta charset=utf-8><title>error</title>template 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 "<!doctype html><meta charset=utf-8><title>error</title>render error: " + template.HTMLEscapeString(err.Error())
}
return sb.String()
}
+75 -7
View File
@@ -3,17 +3,85 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}} · OpsxDB 文档</title>
<title>{{.Page.Title}} · OpsxDB 文档</title>
<meta name="description" content="OpsxDB 桌面数据库工作台用户文档 — 连库、写 SQL、看结果;MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 内置驱动,SSH 隧道与 SSL 加密。">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%2264%22%20y2%3D%2264%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23059669%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%2206B6D4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22url(%23g)%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%224.5%22%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cellipse%20cx%3D%2232%22%20cy%3D%2220%22%20rx%3D%2215%22%20ry%3D%226.5%22%2F%3E%3Cpath%20d%3D%22M17%2020v24c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5V20%22%2F%3E%3Cpath%20d%3D%22M17%2032c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<link rel="stylesheet" href="/docs/assets/docs.css">
</head>
<body>
<header class="top">
<a class="brand" href="/docs"><span class="logo">DB</span> OpsxDB <em>文档</em></a>
<nav><a href="/home">官网</a></nav>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/></symbol>
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
<symbol id="i-thumbs-up" viewBox="0 0 24 24"><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/><path d="M7 10v12"/></symbol>
<symbol id="i-thumbs-down" viewBox="0 0 24 24"><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/><path d="M17 14V2"/></symbol>
</svg>
<header class="topbar">
<div class="topbar-inner">
<button class="menu-btn" id="menu-btn" aria-label="打开导航"><svg class="i"><use href="#i-menu"/></svg></button>
<a class="brand" href="/docs">
<span class="brand-logo"><svg width="28" height="28" viewBox="0 0 64 64" aria-label="OpsxDB"><defs><linearGradient id="lg-docs" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#059669"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-docs)"/><g stroke="#fff" stroke-width="4.5" fill="none" stroke-linecap="round"><ellipse cx="32" cy="20" rx="15" ry="6.5"/><path d="M17 20v24c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5V20"/><path d="M17 32c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5"/></g></svg></span>
<span class="brand-name">OpsxDB <em>文档</em></span>
</a>
<span class="ver-badge">v{{.Manifest.ProductVersion}}</span>
<div class="search">
<input id="search-input" type="search" placeholder="搜索文档…" autocomplete="off" spellcheck="false">
<div class="search-panel" id="search-panel" hidden></div>
</div>
<nav class="top-links">
<a href="/home">官网</a>
<a href="/docs/quickstart">快速开始</a>
</nav>
</div>
</header>
<div class="layout">
<aside class="side">{{NAV}}</aside>
<main class="content">{{BODY}}</main>
<div class="layout" id="layout">
<aside class="sidebar" id="sidebar">
{{range .Manifest.Groups}}
<div class="nav-group">
<div class="nav-group-title">{{.Name}}</div>
{{range .Pages}}
<a class="nav-link{{if eq .Slug $.Page.Slug}} active{{end}}" href="/docs/{{.Slug}}">{{.Title}}</a>
{{end}}
</div>
{{end}}
<div class="sidebar-foot">© 2026 OpsxDB · 本地优先的数据库工作台</div>
</aside>
<main class="content">
<div class="breadcrumb">文档 <span>/</span> {{.Page.Group}} <span>/</span> {{.Page.Title}}</div>
<h1 class="page-title">{{.Page.Title}}</h1>
{{if .Page.Updated}}<div class="page-meta">更新于 {{.Page.Updated}}</div>{{end}}
<article class="md">
{{.Content}}
</article>
<div class="pn-nav">
{{with .Page.Prev}}
<a class="pn prev" href="/docs/{{.Slug}}"><span class="pn-label">← 上一页</span><span class="pn-title">{{.Title}}</span></a>
{{else}}<span class="pn"></span>{{end}}
{{with .Page.Next}}
<a class="pn next" href="/docs/{{.Slug}}"><span class="pn-label">下一页 →</span><span class="pn-title">{{.Title}}</span></a>
{{else}}<span class="pn"></span>{{end}}
</div>
<div class="feedback" id="feedback">
<span class="feedback-q">本页内容是否对您有帮助?</span>
<button class="fb-btn" data-v="yes"><svg class="i"><use href="#i-thumbs-up"/></svg> 有帮助</button>
<button class="fb-btn" data-v="no"><svg class="i"><use href="#i-thumbs-down"/></svg> 没有帮助</button>
<span class="fb-thanks" id="fb-thanks" hidden>感谢您的反馈!</span>
</div>
</main>
<aside class="toc">
<div class="toc-title">本页目录</div>
<nav class="toc-nav">
{{range .TOC}}<a class="toc-l{{.Level}}" href="#{{.ID}}">{{.Text}}</a>
{{end}}
</nav>
<button class="print-btn" id="print-btn"><svg class="i"><use href="#i-printer"/></svg> 打印 / 导出 PDF</button>
</aside>
</div>
<script src="/docs/assets/docs.js" defer></script>
</body>
</html>
+260 -42
View File
@@ -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); }
}
+282 -18
View File
@@ -4,33 +4,297 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpsxDB — 本地优先的桌面数据库工作台</title>
<meta name="description" content="OpsxDB 主交付是桌面 GUI:连库、写 SQL、看结果。开发态 Vite 仅调试用。">
<meta name="description" content="OpsxDB:连库、写 SQL、看结果,全在本机完成。MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 一个窗口管完,SSH 隧道与 SSL 加密,AI 辅助写 SQL。">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%2264%22%20y2%3D%2264%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23059669%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%2206B6D4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22url(%23g)%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%224.5%22%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cellipse%20cx%3D%2232%22%20cy%3D%2220%22%20rx%3D%2215%22%20ry%3D%226.5%22%2F%3E%3Cpath%20d%3D%22M17%2020v24c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5V20%22%2F%3E%3Cpath%20d%3D%22M17%2032c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<link rel="stylesheet" href="/site/assets/site.css">
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></symbol>
<symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></symbol>
<symbol id="i-table" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></symbol>
<symbol id="i-tree" viewBox="0 0 24 24"><path d="M13 10h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/><path d="M13 21h7a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/><path d="M3 5a2 2 0 0 0 2 2h3"/><path d="M3 3v13a2 2 0 0 0 2 2h3"/></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z"/></symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></symbol>
<symbol id="i-key" viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></symbol>
<symbol id="i-monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></symbol>
</svg>
<!-- ===================== 导航 ===================== -->
<header class="nav">
<div class="inner">
<a class="brand" href="/home"><span class="logo">DB</span> OpsxDB</a>
<nav>
<a href="/docs">文档</a>
<div class="nav-inner">
<a class="brand" href="/home">
<span class="brand-logo"><svg width="30" height="30" viewBox="0 0 64 64" aria-label="OpsxDB"><defs><linearGradient id="lg-site" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#059669"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-site)"/><g stroke="#fff" stroke-width="4.5" fill="none" stroke-linecap="round"><ellipse cx="32" cy="20" rx="15" ry="6.5"/><path d="M17 20v24c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5V20"/><path d="M17 32c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5"/></g></svg></span>
<span class="brand-name">OpsxDB</span>
</a>
<nav class="nav-links">
<a href="#features">功能</a>
<a href="#drivers">驱动</a>
<a href="#security">安全</a>
<a href="#download">下载</a>
<a href="/docs" target="_blank" rel="noopener">文档</a>
</nav>
<div class="nav-cta">
<a class="btn btn-primary" href="/docs/quickstart" target="_blank" rel="noopener">快速开始 →</a>
</div>
</div>
</header>
<main class="hero">
<p class="eyebrow">桌面 GUI · 本地优先</p>
<h1>连库、写 SQL、看结果——都在本机完成</h1>
<p class="lead">正式形态是可安装的桌面应用,本地后端随应用启动。浏览器里的 :5174 只是开发壳,不是产品。</p>
<div class="actions">
<a class="btn primary" id="download" href="#download">下载桌面版(占位)</a>
<a class="btn ghost" href="/docs">阅读文档</a>
<!-- ===================== Hero ===================== -->
<section class="hero">
<div class="hero-inner">
<div class="hero-copy">
<div class="hero-badge"><svg class="i"><use href="#i-sparkles"/></svg> v0.1 · 桌面 GUI · 数据 100% 本机</div>
<h1>一个窗口,<br>管好<em>全公司的数据库</em></h1>
<p class="hero-sub">
连库、写 SQL、看结果,全在本机完成。<br>
MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 内置驱动,
SSH 隧道与 SSL 加密直达内网库,AI 帮你把 SQL 写得又快又稳。
</p>
<div class="hero-actions">
<a class="btn btn-primary btn-lg" href="#download">下载桌面版</a>
<a class="btn btn-ghost btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">30 秒连上第一个库 →</a>
</div>
<ul class="caps">
<li><strong>多驱动连库</strong><span>MySQL / Postgres / SQLite 等,会话保活</span></li>
<li><strong>工作台密度</strong><span>结果集、对象树、书签,深色紧凑布局</span></li>
<li><strong>本机后端</strong><span>双击启动即带本地服务,不依赖手动起 Vite</span></li>
<div class="hero-pills">
<span>本地优先</span><span>SSH 隧道</span><span>SSL 加密</span><span>国产库兼容</span><span>AI 写 SQL</span>
</div>
</div>
<!-- 工作台示意(纯 CSS 绘制) -->
<div class="mock" aria-hidden="true">
<div class="mock-titlebar"><i></i><i></i><i></i><span>OpsxDB · prod-mysql</span></div>
<div class="mock-body">
<div class="mock-side">
<div class="mock-search"></div>
<div class="mock-item conn"><i class="dot on"></i>prod-mysql</div>
<div class="mock-item conn"><i class="dot on"></i>pg-analytics</div>
<div class="mock-item conn"><i class="dot"></i>sqlite-demo</div>
<div class="mock-group">shop(库)</div>
<div class="mock-item active"><svg class="mi"><use href="#i-table"/></svg> orders</div>
<div class="mock-item"><svg class="mi"><use href="#i-table"/></svg> users</div>
<div class="mock-item"><svg class="mi"><use href="#i-table"/></svg> products</div>
</div>
<div class="mock-main">
<div class="mock-tabs"><span class="tab on">orders · 前 100 行</span><span class="tab">query-2</span></div>
<div class="mock-sql">
<pre><span class="kw">SELECT</span> id, user_id, amount, status
<span class="kw">FROM</span> orders
<span class="kw">WHERE</span> created_at &gt; <span class="str">'2026-09-01'</span>
<span class="kw">ORDER BY</span> id <span class="kw">DESC</span> <span class="kw">LIMIT</span> <span class="num">100</span>;</pre>
</div>
<div class="mock-grid">
<span class="gh">id</span><span class="gh">user_id</span><span class="gh">amount</span><span class="gh">status</span>
<span>9f2a</span><span>u_1024</span><span>¥ 1,299.00</span><span class="ok">PAID</span>
<span>9f29</span><span>u_0987</span><span>¥ 89.00</span><span class="ok">PAID</span>
<span>9f28</span><span>u_2333</span><span>¥ 459.00</span><span class="warn">PENDING</span>
<span>9f27</span><span>u_1024</span><span>¥ 3,280.00</span><span class="ok">PAID</span>
</div>
<div class="mock-status"><span class="ok">✓ 100 行 · 38ms</span><span>MySQL 8 · 127.0.0.1:3306</span></div>
</div>
</div>
</div>
</div>
<!-- 数据统计条 -->
<div class="stats">
<div class="stats-inner">
<div class="stat"><b>7</b><span>内置驱动</span></div>
<div class="stat"><b>18</b><span>可连数据库</span></div>
<div class="stat"><b>0</b><span>数据外发</span></div>
<div class="stat"><b>1</b><span>个二进制即可自托管</span></div>
</div>
</div>
</section>
<!-- ===================== 为什么选 ===================== -->
<section class="section">
<div class="wrap">
<h2 class="sec-title">为什么运维和开发选 OpsxDB</h2>
<p class="sec-sub">不把生产库连到别人的云上 —— 这是我们做这款工具的出发点。</p>
<div class="grid g4">
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-shield-check"/></svg></div>
<h3>本地优先,数据主权</h3>
<p>连接配置、查询历史全部保存在本机,服务只监听 127.0.0.1,不经过任何第三方服务器。</p>
</div>
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div>
<h3>六类库一个窗口</h3>
<p>MySQL 族 / PostgreSQL 族 / SQL Server / Oracle / SQLite / Redis 统一工作台,切换零负担。</p>
</div>
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-key"/></svg></div>
<h3>安全通道直达内网</h3>
<p>每个连接可独立配置 SSL/TLS 与 SSH 隧道,跳板机后面的库也能安全直连。</p>
</div>
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div>
<h3>AI 把 SQL 写快</h3>
<p>自然语言生成、解释、优化、修复、方言转换,写不出的那句 SQL 交给 AI 起草。</p>
</div>
</div>
</div>
</section>
<!-- ===================== 功能 ===================== -->
<section class="section alt" id="features">
<div class="wrap">
<h2 class="sec-title">开箱即用的专业能力</h2>
<p class="sec-sub">为「每天都要碰库的人」设计的工作台密度。</p>
<div class="grid g3">
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-terminal"/></svg></div><div><h3>连接选择器</h3><p>搜索 / 分类 / 最近 / 图标网格;粘贴 <code>mysql://user:pass@host:3306/db</code> 一键填入表单。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-zap"/></svg></div><div><h3>SQL 工作台</h3><p>CodeMirror 编辑器,⌘/Ctrl+Enter 执行当前语句或选区,多 Tab 并行,可拖分栏。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-table"/></svg></div><div><h3>结果随手取</h3><p>结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-tree"/></svg></div><div><h3>对象树与表数据</h3><p>懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div><div><h3>Redis 工作台</h3><p>编辑器直接执行 <code>PING</code> / <code>GET</code> / <code>SET</code> / <code>KEYS</code>,键空间浏览、TTL 管理。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI 面板</h3><p>生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL,六种动作贴着查询上下文。</p></div></div>
</div>
</div>
</section>
<!-- ===================== 驱动矩阵 ===================== -->
<section class="section" id="drivers">
<div class="wrap">
<h2 class="sec-title">数据库支持</h2>
<p class="sec-sub">常用库 native Go 驱动编译内置;长尾库规划 agent 按需接入。</p>
<div class="grid g3">
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> MySQL 族</h3><p>MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL<span class="port">:3306</span></p></div>
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> PostgreSQL 族</h3><p>PostgreSQL · GaussDB / openGauss · 人大金仓<span class="port">:5432</span></p></div>
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> SQL Server</h3><p>原生协议直连,SSL 可选<span class="port">:1433</span></p></div>
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> Oracle</h3><p>纯 Go 驱动 go-ora,不引入 JVM<span class="port">:1521</span></p></div>
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> SQLite</h3><p>本地文件库,填路径即连<span class="port">file</span></p></div>
<div class="proto"><h3><svg class="i"><use href="#i-zap"/></svg> Redis</h3><p>连接即可执行命令与键管理<span class="port">:6379</span></p></div>
</div>
<div class="badges">
<span class="more">规划中 · agent 按需接入:达梦 DM8 · MongoDB · ZooKeeper · Elasticsearch · InfluxDB</span>
</div>
<p class="footnote">驱动验收状态(本机 docker 实测)见 <a href="/docs/drivers" target="_blank" rel="noopener">文档 · 驱动支持</a>。</p>
</div>
</section>
<!-- ===================== 安全 ===================== -->
<section class="section alt" id="security">
<div class="wrap">
<h2 class="sec-title">安全是默认项,不是选项</h2>
<p class="sec-sub">连生产库的工具,必须先过安全这一关。</p>
<div class="grid g3">
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-key"/></svg></div><div><h3>密码不出本机</h3><p>连接密码本机混淆存储;查询只带 <code>sessionId</code>,账密不再每次上送。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-shield-check"/></svg></div><div><h3>SSL / TLS 每库可配</h3><p>按连接独立开启 TLS,证书校验策略随库配置。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-server"/></svg></div><div><h3>SSH 隧道</h3><p>跳板机场景一条隧道直达内网库,无需暴露公网端口。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-monitor"/></svg></div><div><h3>仅监听回环</h3><p>后端默认绑定 127.0.0.1,对外服务需显式开启。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-clipboard"/></svg></div><div><h3>危险 SQL 确认</h3><p>DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前二次确认。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-clock"/></svg></div><div><h3>查询历史本地留存</h3><p>历史只存在本机,随时回跳重跑,不上传不同步。</p></div></div>
</div>
</div>
</section>
<!-- ===================== 下载 / 形态 ===================== -->
<section class="section" id="download">
<div class="wrap">
<h2 class="sec-title">三种形态,同一套体验</h2>
<p class="sec-sub">主交付是桌面 GUI;Web 与内网部署复用同一个 Go 二进制。</p>
<div class="pricing three">
<div class="price-card featured">
<div class="plan-tag">推荐 · 主交付</div>
<div class="plan">桌面版</div>
<div class="price"><b>Tauri 2</b></div>
<div class="plan-sub">macOS(Apple Silicon / Intel)· Windows 规划中</div>
<ul>
<li>✓ 双击启动,本地后端随应用自动拉起</li>
<li>✓ DMG 安装包内置 sidecar,无需手动起服务</li>
<li>✓ 连接 / 查询 / 结果全功能</li>
<li>✓ 数据 100% 留在本机</li>
</ul>
</main>
<footer class="foot">© 2026 OpsxDB · 产品形态为桌面 GUI</footer>
<a class="btn btn-primary" href="/docs/quickstart" target="_blank" rel="noopener">从源码构建</a>
</div>
<div class="price-card">
<div class="plan">Web 自托管</div>
<div class="price"><b>1 个二进制</b></div>
<div class="plan-sub">Gin + 内置静态页</div>
<ul>
<li>✓ <code>./run.sh web</code> 一条命令启动</li>
<li>✓ 官网与文档随二进制内置</li>
<li>✓ 浏览器直接用,无需安装</li>
<li>✓ 适合快速体验与调试</li>
</ul>
<a class="btn btn-primary" href="/docs/quickstart" target="_blank" rel="noopener">查看快速开始</a>
</div>
<div class="price-card enterprise">
<div class="plan-tag dark">内网部署</div>
<div class="plan">企业内网版</div>
<div class="price"><b>无外联</b></div>
<div class="plan-sub">数据不出企业网</div>
<ul>
<li>✓ 单二进制 + 静态页,内网任意主机部署</li>
<li>✓ 默认仅回环监听,对外需显式配置</li>
<li>✓ SSH 隧道 / TLS 直达内网数据库</li>
<li>✓ 无遥测、无账号、无云依赖</li>
</ul>
<a class="btn btn-dark" href="/docs/security" target="_blank" rel="noopener">安全设计</a>
</div>
</div>
</div>
</section>
<!-- ===================== FAQ ===================== -->
<section class="section alt" id="faq">
<div class="wrap narrow">
<h2 class="sec-title">常见问题</h2>
<div class="faq">
<details open><summary>我的连接密码保存在哪?安全吗?</summary><p>全部保存在本机(混淆存储),查询时只携带会话 ID,密码不会随每次请求上送,也不会同步到任何云端。</p></details>
<details><summary>浏览器里打开的 5174 端口是什么?</summary><p>那是开发调试用的 Vite 热更新壳,不是产品形态。正式形态是桌面应用(或自托管的 Web 服务),由后端 :8788 提供页面与 API。</p></details>
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、单二进制自托管、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
<details><summary>Oracle 支持好了吗?</summary><p>驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。</p></details>
<details><summary>AI 功能需要联网吗?</summary><p>当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。</p></details>
<details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
</div>
</div>
</section>
<!-- ===================== CTA ===================== -->
<section class="cta">
<div class="wrap">
<h2>把数据库客户端收回自己手里</h2>
<p>30 秒连上第一个库;官网与文档都由你手里的这个二进制提供。</p>
<div class="hero-actions center">
<a class="btn btn-inverse btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">快速开始 ↗</a>
<a class="btn btn-outline btn-lg" href="/docs" target="_blank" rel="noopener">阅读文档</a>
</div>
</div>
</section>
<!-- ===================== Footer ===================== -->
<footer class="footer">
<div class="wrap footer-grid">
<div>
<div class="brand"><span class="brand-logo"><svg width="30" height="30" viewBox="0 0 64 64" aria-label="OpsxDB"><defs><linearGradient id="lg-foot" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#059669"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-foot)"/><g stroke="#fff" stroke-width="4.5" fill="none" stroke-linecap="round"><ellipse cx="32" cy="20" rx="15" ry="6.5"/><path d="M17 20v24c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5V20"/><path d="M17 32c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5"/></g></svg></span><span class="brand-name">OpsxDB</span></div>
<p class="footer-desc">本地优先的桌面数据库工作台。<br>数据在自己手里,才是真的效率工具。</p>
</div>
<div>
<h4>产品</h4>
<a href="#features">功能特性</a>
<a href="#drivers">数据库支持</a>
<a href="#download">下载与形态</a>
</div>
<div>
<h4>资源</h4>
<a href="/docs" target="_blank" rel="noopener">用户文档</a>
<a href="/docs/drivers" target="_blank" rel="noopener">驱动验收状态</a>
<a href="/docs/faq" target="_blank" rel="noopener">常见问题</a>
</div>
<div>
<h4>安全</h4>
<a href="#security">安全设计</a>
<a href="/docs/security" target="_blank" rel="noopener">SSL 与 SSH 隧道</a>
<a href="/docs/quickstart" target="_blank" rel="noopener">快速开始</a>
</div>
</div>
<div class="footer-bar">© 2026 OpsxDB · 本页由单二进制内置提供(/ · /home)</div>
</footer>
</body>
</html>
+12
View File
@@ -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 为准。
+40
View File
@@ -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` 数组同序。
+110
View File
@@ -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"]
}
]
}
-39
View File
@@ -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
-37
View File
@@ -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
-32
View File
@@ -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
-48
View File
@@ -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
-36
View File
@@ -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
-29
View File
@@ -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
-41
View File
@@ -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
+403 -30
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import {
deleteRedisKey,
execSql,
@@ -21,6 +21,105 @@ const GROUPS: { id: string; label: string; kind: TreeNode["kind"] }[] = [
{ id: "functions", label: "函数", kind: "group" },
];
/** 对象树分类色板:高饱和色,深浅主题下都够亮 */
const TREE_COLORS = {
database: "#10b981", // 库 · 祖母绿
tables: "#3b82f6", // 表 · 蓝
views: "#8b5cf6", // 视图 · 紫
procedures: "#f59e0b", // 存储过程 · 琥珀
functions: "#06b6d4", // 函数 · 青
keys: "#ef4444", // Redis 键 · 红
columns: "#22c55e", // 字段 · 绿
} as const;
type TreeColorKey = keyof typeof TREE_COLORS;
function groupIdColor(groupId?: string): TreeColorKey {
switch (groupId) {
case "tables":
return "tables";
case "views":
return "views";
case "procedures":
return "procedures";
case "functions":
return "functions";
case "keys":
return "keys";
case "columns":
return "columns";
default:
return "tables";
}
}
/** 分类图标:实底 16×16 线面结合,颜色即分类色 */
function TreeIcon({ kind, size = 13 }: { kind: TreeColorKey; size?: number }) {
const c = TREE_COLORS[kind];
const common = { stroke: c, fill: "none", strokeWidth: 1.7, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
return (
<svg width={size} height={size} viewBox="0 0 16 16" className="shrink-0" aria-hidden="true">
{kind === "database" && (
<g {...common}>
<ellipse cx="8" cy="3.6" rx="5.2" ry="2.1" fill={c} fillOpacity="0.35" />
<path d="M2.8 3.6v8.8c0 1.16 2.33 2.1 5.2 2.1s5.2-.94 5.2-2.1V3.6" />
<path d="M2.8 8c0 1.16 2.33 2.1 5.2 2.1S13.2 9.16 13.2 8" />
</g>
)}
{kind === "tables" && (
<g {...common}>
<rect x="2" y="2" width="12" height="12" rx="2" fill={c} fillOpacity="0.18" />
<path d="M2 6h12M2 10h12M6 2v12M10 2v12" strokeOpacity="0.85" />
</g>
)}
{kind === "views" && (
<g {...common}>
<path d="M1.8 8s2.2-4 6.2-4 6.2 4 6.2 4-2.2 4-6.2 4-6.2-4-6.2-4Z" fill={c} fillOpacity="0.15" />
<circle cx="8" cy="8" r="1.9" fill={c} stroke="none" />
</g>
)}
{kind === "procedures" && (
<g {...common}>
<rect x="2" y="2.5" width="12" height="11" rx="2" fill={c} fillOpacity="0.15" />
<path d="M6.5 5.8v4.4L10.4 8Z" fill={c} stroke="none" />
</g>
)}
{kind === "functions" && (
<g {...common}>
<rect x="2" y="2" width="12" height="12" rx="6" fill={c} fillOpacity="0.15" />
<path d="M9.4 5.2c-1.2 0-1.2 1-1.2 1.8v2c0 .8 0 1.8-1.3 1.8 1.3 0 1.3 1 1.3 1.8" />
<path d="M6.6 5.2c1.2 0 1.2 1 1.2 1.8" strokeOpacity="0" />
</g>
)}
{kind === "keys" && (
<g {...common}>
<circle cx="5.4" cy="10.6" r="3" fill={c} fillOpacity="0.2" />
<path d="m7.6 8.4 5-5M10 6l1.8 1.8M11.6 4.4l1.8 1.8" />
</g>
)}
{kind === "columns" && (
<g {...common}>
<rect x="2" y="2.5" width="12" height="11" rx="2" fill={c} fillOpacity="0.14" />
<path d="M4.6 5.6h6.8M4.6 8h6.8M4.6 10.4h4.4" />
</g>
)}
</svg>
);
}
/** 分类计数徽章:分类色底 12% 透明 + 分类色文字 */
function CountBadge({ kind, count }: { kind: TreeColorKey; count: number }) {
const c = TREE_COLORS[kind];
return (
<span
className="ml-1.5 rounded px-1 text-[10px] font-semibold leading-4 tabular-nums"
style={{ background: `${c}1f`, color: c }}
>
{count}
</span>
);
}
function isRedisConn(driver?: string) {
return (driver || "").toLowerCase() === "redis";
@@ -64,7 +163,7 @@ function emptyGroups(): TreeNode[] {
}));
}
type Ctx = {
type CtxMenu = {
x: number;
y: number;
label: string;
@@ -72,7 +171,64 @@ type Ctx = {
onSchema?: () => void;
onView?: () => void;
onDelete?: () => void;
} | null;
/** 表/视图:查询数据(回填 SELECT 到编辑器) */
onQueryData?: () => void;
/** 表/视图:生成 SQL 模板回填编辑器 */
onGen?: (kind: GenSqlKind) => void;
/** 表:截断(清数据保结构,二次确认) */
onTruncate?: () => void;
/** 表:DROP(二次确认) */
onDropTable?: () => void;
/** 库:新建查询 Tab */
onNewQuery?: () => void;
};
type Ctx = CtxMenu | null;
export type GenSqlKind = "select" | "insert" | "update" | "delete";
/** 标识符按方言加引号(Oracle 裸名) */
function quoteIdent(driver: string | undefined, ident: string): string {
const d = (driver || "mysql").toLowerCase();
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") return `"${ident}"`;
if (d === "sqlserver" || d === "mssql") return `[${ident}]`;
if (d === "sqlite") return `"${ident}"`;
if (d === "oracle") return ident;
return `\`${ident}\``;
}
/** 库.表 全限定(SQLite 为单文件无库级) */
function qualTable(driver: string | undefined, db: string, table: string): string {
const d = (driver || "mysql").toLowerCase();
if (d === "sqlite") return quoteIdent(d, table);
return `${quoteIdent(d, db)}.${quoteIdent(d, table)}`;
}
/** 生成 SQL 模板:? 占位符,主键自动进 WHERE;无列信息时退化为骨架 */
function buildGenSql(
kind: GenSqlKind,
driver: string | undefined,
db: string,
table: string,
cols: ColumnInfo[],
): string {
const qt = qualTable(driver, db, table);
const q = (s: string) => quoteIdent(driver, s);
const names = cols.map((c) => c.name).filter(Boolean);
const pk = cols.find((c) => c.key === "PRI" || c.key === "PK")?.name;
if (kind === "insert") {
if (!names.length) return `INSERT INTO ${qt} (...) VALUES (...);`;
return `INSERT INTO ${qt}\n (${names.map(q).join(", ")})\nVALUES\n (${names.map(() => "?").join(", ")});`;
}
if (kind === "update") {
const sets = (names.length ? names : ["col"]).map((c) => `${q(c)} = ?`);
return `UPDATE ${qt}\nSET\n ${sets.join(",\n ")}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
}
if (kind === "delete") {
return `DELETE FROM ${qt}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
}
return sampleSelectSql(driver, db, table);
}
export function ObjectTree() {
const connections = useWorkspaceStore((s) => s.connections);
@@ -90,6 +246,10 @@ export function ObjectTree() {
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
const [ctx, setCtx] = useState<Ctx>(null);
const [keyFilter, setKeyFilter] = useState("");
/** 库/表过滤(非 Redis 连接);只匹配库名与已加载的对象名 */
const [treeFilter, setTreeFilter] = useState("");
/** 单击/双击消歧:单击延迟打开表,双击取消定时器立即打开 */
const clickTimerRef = useRef<number | undefined>(undefined);
const conn = connections.find((c) => c.id === activeId);
@@ -289,6 +449,13 @@ export function ObjectTree() {
mark({
loading: false,
loaded: true,
children: [
{
name: "字段",
kind: "group" as const,
groupId: "columns",
count: columns.length,
loaded: true,
children: columns.map((col) => ({
name: col.name,
kind: "column" as const,
@@ -296,6 +463,8 @@ export function ObjectTree() {
nullable: col.nullable,
key: col.key,
})),
},
],
});
} catch (e) {
mark({ loading: false });
@@ -451,6 +620,8 @@ export function ObjectTree() {
newQueryTab();
await Promise.resolve();
useWorkspaceStore.getState().updateActiveSql(sql);
// 新 Tab 标题用表名,避免一排「查询 N」找不到哪页是哪张表
useWorkspaceStore.getState().setActiveTabTitle(table);
} else {
updateActiveSql(sql);
}
@@ -494,16 +665,118 @@ export function ObjectTree() {
onSchema?: () => void,
onView?: () => void,
onDelete?: () => void,
extra?: Partial<Pick<CtxMenu, "onQueryData" | "onGen" | "onTruncate" | "onDropTable" | "onNewQuery">>,
) => {
e.preventDefault();
e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete });
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete, ...extra });
};
/** 生成表 SQL 模板:优先用树上已加载的列,取不到再查库 */
const genTableSql = async (dbName: string, table: string, kind: GenSqlKind) => {
if (!conn) return;
const st = useWorkspaceStore.getState();
const tNode = st.tree
.find((n) => n.name === dbName)
?.children?.find((g) => g.groupId === "tables")
?.children?.find((c) => c.name === table);
let cols = ((tNode?.children ?? []).find((c) => c.groupId === "columns")?.children ?? []) as ColumnInfo[];
if (!cols.length) {
try {
const sid = await ensureSession(conn);
cols = (await listColumns(conn, dbName, table, sid)).columns;
} catch {
/* 拿不到列也能生成骨架模板 */
}
}
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
};
/** 截断 / 删除表:二次确认后执行并刷新库 */
const runTableDanger = async (dbName: string, table: string, action: "truncate" | "drop") => {
if (!conn) return;
const ok = await askConfirm({
title: action === "truncate" ? "截断表" : "删除表",
message:
action === "truncate"
? `确认清空表「${dbName}.${table}」的全部数据?结构保留,数据不可恢复。`
: `确认删除表「${dbName}.${table}」?表结构与数据将一并删除,不可恢复。`,
okLabel: action === "truncate" ? "截断" : "删除",
danger: true,
});
if (!ok) return;
try {
const sid = await ensureSession(conn);
const sqlText =
action === "truncate"
? `TRUNCATE TABLE ${qualTable(conn.driver, dbName, table)};`
: `DROP TABLE ${qualTable(conn.driver, dbName, table)};`;
const res = await execSql(conn, sqlText, { sessionId: sid, database: dbName });
if (res.error) throw new Error(res.error);
notify({
kind: "success",
title: action === "truncate" ? "已截断" : "已删除",
message: `${dbName}.${table}`,
mode: "toast",
durationMs: 1600,
});
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
} catch (e) {
notifyError(e, "toast");
}
};
/** 字段行:绿色短横图标 + 类型淡显,主键加 🔑 */
const ColumnRow = ({ col, onRefresh }: { col: TreeNode; onRefresh: () => void }) => (
<div
className="ops-tree-item opacity-85"
title={col.type}
onContextMenu={(e) => onCtx(e, col.name, onRefresh)}
>
<span
className="ml-1 mr-0.5 h-[3px] w-2.5 shrink-0 self-center rounded-full"
style={{ background: TREE_COLORS.columns, opacity: 0.85 }}
/>
<span className="truncate">
{col.key === "PRI" ? "🔑 " : ""}
{col.name}
<span className="ml-1 opacity-50">{col.type}</span>
</span>
</div>
);
/** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */
const f = treeFilter.trim().toLowerCase();
const nameHit = (n: string) => !f || n.toLowerCase().includes(f);
const visibleTree = f
? tree
.map((db) => {
if (nameHit(db.name)) return db;
const groups = (db.children ?? [])
.map((g) => {
const children = (g.children ?? []).filter((o) => nameHit(o.name));
return children.length ? ({ ...g, children, count: children.length } as TreeNode) : null;
})
.filter((g): g is TreeNode => g !== null);
return groups.length ? ({ ...db, children: groups } as TreeNode) : null;
})
.filter((db): db is TreeNode => db !== null)
: tree;
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="ops-section-label justify-between border-b">
<div className="ops-section-label justify-between gap-1.5 border-b">
<span>对象</span>
{!isRedisConn(conn?.driver) && (
<input
className="ops-input min-w-0 max-w-40 flex-1 py-0.5 text-[11px]"
placeholder="过滤库 / 表…"
value={treeFilter}
onChange={(e) => setTreeFilter(e.target.value)}
/>
)}
<button
type="button"
className="normal-case tracking-normal hover:underline"
@@ -557,10 +830,11 @@ export function ObjectTree() {
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
</div>
)}
{tree.map((db) => (
{visibleTree.map((db) => (
<details
key={db.name}
className="mb-px"
open={f ? true : undefined}
onToggle={(e) => {
if ((e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
}}
@@ -568,11 +842,14 @@ export function ObjectTree() {
onCtx(e, db.name, () => {
patchDb(db.name, { loaded: false, loading: false, children: undefined });
void expandDb(db.name);
}, undefined, undefined, undefined, {
onNewQuery: () => useWorkspaceStore.getState().newQueryTab(),
})
}
>
<summary className="ops-tree-item cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">▸</span>
<TreeIcon kind="database" />
<span className="truncate font-medium">{db.name}</span>
{db.loading && <span className="ml-auto text-[10px] opacity-40">…</span>}
</summary>
@@ -580,7 +857,7 @@ export function ObjectTree() {
{(db.children ?? (isRedisConn(conn?.driver)
? [{ name: "键", kind: "group" as const, groupId: "keys", count: 0, children: [], loaded: false }]
: emptyGroups())).map((g) => (
<details key={g.groupId || g.name} className="mb-px">
<details key={g.groupId || g.name} className="mb-px" open={f ? true : undefined}>
<summary
className="ops-tree-item list-none [&::-webkit-details-marker]:hidden"
onContextMenu={(e) =>
@@ -591,9 +868,10 @@ export function ObjectTree() {
}
>
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
<TreeIcon kind={groupIdColor(g.groupId)} />
<span className="ops-tree-group min-w-0 flex-1 truncate">
{g.name}
<span className="ml-1 font-normal opacity-70">{g.count ?? g.children?.length ?? 0}</span>
<CountBadge kind={groupIdColor(g.groupId)} count={g.count ?? g.children?.length ?? 0} />
</span>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
@@ -621,7 +899,10 @@ export function ObjectTree() {
)
}
>
<span className="w-3 shrink-0 text-center text-[10px] opacity-30">·</span>
<span
className="ml-1 mr-0.5 h-2 w-2 shrink-0 self-center rounded-full"
style={{ background: TREE_COLORS.keys, opacity: 0.8 }}
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
@@ -673,44 +954,64 @@ export function ObjectTree() {
}
},
() => void openSchema(db.name, obj.name),
undefined,
undefined,
{
onQueryData: () => void putSelectSql(db.name, obj.name),
onGen: (k) => void genTableSql(db.name, obj.name, k),
onTruncate: obj.kind === "table" ? () => void runTableDanger(db.name, obj.name, "truncate") : undefined,
onDropTable: obj.kind === "table" ? () => void runTableDanger(db.name, obj.name, "drop") : undefined,
},
)
}
>
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden">
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
<TreeIcon kind={obj.kind === "view" ? "views" : "tables"} size={12} />
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
title="双击生成 SELECT"
title="单击打开前 100 行 · 双击立即打开"
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.clearTimeout(clickTimerRef.current);
void putSelectSql(db.name, obj.name);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.clearTimeout(clickTimerRef.current);
clickTimerRef.current = window.setTimeout(() => {
void putSelectSql(db.name, obj.name);
}, 240);
}}
>
{obj.name}
</button>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{(obj.children ?? []).map((col) => (
<div
key={col.name}
className="ops-tree-item opacity-80"
title={col.type}
onContextMenu={(e) => onCtx(e, col.name, () => void expandTable(db.name, obj.name))}
>
<span className="w-3 shrink-0 text-center text-[10px] opacity-30">·</span>
<span className="truncate">
{col.key === "PRI" ? "🔑 " : ""}
{col.name}
<span className="ml-1 opacity-50">{col.type}</span>
{(obj.children ?? []).map((sub) =>
sub.kind === "group" && sub.groupId === "columns" ? (
<details key={sub.groupId} className="mb-px">
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden">
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
<TreeIcon kind="columns" size={11} />
<span className="ops-tree-group min-w-0 flex-1 truncate">
{sub.name}
<CountBadge kind="columns" count={sub.count ?? sub.children?.length ?? 0} />
</span>
</div>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{(sub.children ?? []).map((col) => (
<ColumnRow key={col.name} col={col} onRefresh={() => void expandTable(db.name, obj.name)} />
))}
</div>
</details>
) : (
<ColumnRow key={sub.name} col={sub} onRefresh={() => void expandTable(db.name, obj.name)} />
),
)}
{obj.loading && (
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
列…
@@ -730,7 +1031,7 @@ export function ObjectTree() {
}
onDoubleClick={() => void copyName(obj.name)}
>
<span className="w-3 shrink-0" />
<TreeIcon kind={groupIdColor(g.groupId)} size={11} />
<span className="truncate">{obj.name}</span>
</div>
),
@@ -746,10 +1047,77 @@ export function ObjectTree() {
</div>
</details>
))}
{f && visibleTree.length === 0 && !treeLoading && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。
</div>
)}
</div>
{ctx && (
<div className="ops-ctx" style={{ left: ctx.x, top: ctx.y }} onClick={(e) => e.stopPropagation()}>
{ctx.onQueryData && (
<button
type="button"
onClick={() => {
ctx.onQueryData?.();
setCtx(null);
}}
>
查询数据
</button>
)}
{ctx.onSchema && (
<button
type="button"
onClick={() => {
ctx.onSchema?.();
setCtx(null);
}}
>
查看结构
</button>
)}
{ctx.onNewQuery && (
<button
type="button"
onClick={() => {
ctx.onNewQuery?.();
setCtx(null);
}}
>
新建查询
</button>
)}
{ctx.onView && (
<button
type="button"
onClick={() => {
ctx.onView?.();
setCtx(null);
}}
>
查看值
</button>
)}
{ctx.onGen && (
<>
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
{(["select", "insert", "update", "delete"] as GenSqlKind[]).map((k) => (
<button
key={k}
type="button"
onClick={() => {
ctx.onGen?.(k);
setCtx(null);
}}
>
生成 {k.toUpperCase()}
</button>
))}
</>
)}
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
<button
type="button"
onClick={() => {
@@ -768,26 +1136,31 @@ export function ObjectTree() {
>
复制名
</button>
{ctx.onSchema && (
{(ctx.onTruncate || ctx.onDropTable || ctx.onDelete) && (
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
)}
{ctx.onTruncate && (
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => {
ctx.onSchema?.();
ctx.onTruncate?.();
setCtx(null);
}}
>
查看结构
截断表…
</button>
)}
{ctx.onView && (
{ctx.onDropTable && (
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => {
ctx.onView?.();
ctx.onDropTable?.();
setCtx(null);
}}
>
查看值
删除表…
</button>
)}
{ctx.onDelete && (
+147
View File
@@ -1,8 +1,63 @@
import { useEffect, useState } from "react";
import { execSql } from "./api";
import { ensureSession } from "./ObjectTree";
import { useWorkspaceStore } from "./store";
type MetaRow = Record<string, unknown>;
type TableMeta = {
indexes: MetaRow[];
fks: MetaRow[];
triggers: MetaRow[];
};
const EMPTY_META: TableMeta = { indexes: [], fks: [], triggers: [] };
/** MySQL 协议族才走 information_schema 元数据(PG/Oracle 的系统目录另行适配) */
function isMysqlFamily(driver?: string) {
return ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"].includes((driver || "").toLowerCase());
}
/** 标识符转义进 information_schema 字符串比较 */
const esc = (s: string) => s.replace(/'/g, "''");
export function SchemaPane() {
const schema = useWorkspaceStore((s) => s.schemaView);
const set = useWorkspaceStore((s) => s.set);
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
const mysql = isMysqlFamily(conn?.driver);
useEffect(() => {
let alive = true;
setMeta(EMPTY_META);
if (!conn || !mysql || !schema || schema.loading || schema.error || !schema.table) return;
(async () => {
try {
const sid = await ensureSession(conn);
const db = esc(schema.database);
const t = esc(schema.table);
const run = (sqlText: string) => execSql(conn, sqlText, { database: schema.database, sessionId: sid, limit: 200 });
const [ix, fk, tg] = await Promise.all([
run(
`SELECT INDEX_NAME AS name, IF(NON_UNIQUE=0,'UNIQUE','') AS uniq, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS cols FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' GROUP BY INDEX_NAME, NON_UNIQUE ORDER BY INDEX_NAME`,
),
run(
`SELECT CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS refTable, REFERENCED_COLUMN_NAME AS refCol FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' AND REFERENCED_TABLE_NAME IS NOT NULL`,
),
run(
`SELECT TRIGGER_NAME AS name, ACTION_TIMING AS timing, EVENT_MANIPULATION AS event, EVENT_OBJECT_TABLE AS onTable FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA='${db}' AND EVENT_OBJECT_TABLE='${t}'`,
),
]);
if (alive) setMeta({ indexes: ix.rows, fks: fk.rows, triggers: tg.rows });
} catch {
/* 元数据不可用时静默降级:结构页至少保底显示列 */
}
})();
return () => {
alive = false;
};
}, [conn, mysql, schema?.database, schema?.table, schema?.loading, schema?.error]);
if (!schema) {
return (
@@ -36,6 +91,8 @@ export function SchemaPane() {
);
}
const str = (v: unknown) => (v == null ? "" : String(v));
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="ops-section-label justify-between border-b">
@@ -86,6 +143,96 @@ export function SchemaPane() {
</tbody>
</table>
)}
{mysql && (
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
<details open className="px-3 py-1.5">
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
索引({meta.indexes.length})
</summary>
{meta.indexes.length === 0 ? (
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无索引</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th>索引名</th>
<th style={{ width: 70 }}>唯一</th>
<th>列</th>
</tr>
</thead>
<tbody>
{meta.indexes.map((r, i) => (
<tr key={i}>
<td className="font-mono text-[12px]">{str(r.name)}</td>
<td>{r.uniq ? "✓" : ""}</td>
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>{str(r.cols)}</td>
</tr>
))}
</tbody>
</table>
)}
</details>
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
外键({meta.fks.length})
</summary>
{meta.fks.length === 0 ? (
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无外键</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th>约束名</th>
<th>列</th>
<th>引用</th>
</tr>
</thead>
<tbody>
{meta.fks.map((r, i) => (
<tr key={i}>
<td className="font-mono text-[12px]">{str(r.name)}</td>
<td className="font-mono text-[12px]">{str(r.col)}</td>
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
{str(r.refTable)}.{str(r.refCol)}
</td>
</tr>
))}
</tbody>
</table>
)}
</details>
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
触发器({meta.triggers.length})
</summary>
{meta.triggers.length === 0 ? (
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无触发器</div>
) : (
<table className="ops-grid" style={{ width: "100%" }}>
<thead>
<tr>
<th>触发器</th>
<th style={{ width: 80 }}>时机</th>
<th style={{ width: 80 }}>事件</th>
</tr>
</thead>
<tbody>
{meta.triggers.map((r, i) => (
<tr key={i}>
<td className="font-mono text-[12px]">{str(r.name)}</td>
<td>{str(r.timing)}</td>
<td>{str(r.event)}</td>
</tr>
))}
</tbody>
</table>
)}
</details>
</div>
)}
</div>
</div>
);
+27
View File
@@ -2,10 +2,36 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { sql } from "@codemirror/lang-sql";
import { tags as t } from "@lezer/highlight";
import { oneDark } from "@codemirror/theme-one-dark";
import { formatSqlText, statementAt } from "./sql";
/**
* SQL 高亮走克制的四色方案(VS Code Dark+ 观感):
* 关键字+数字 = 蓝,字符串 = 橙,注释 = 绿,标识符/运算符 = 前景浅白。
* 注册在 oneDark 之后,覆盖其默认的五彩配色。
*/
const sqlDarkHighlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#569CD6" },
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#569CD6" },
{ tag: [t.string, t.special(t.string)], color: "#CE9178" },
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#6A9955" },
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#D4D4D4" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#D4D4D4" },
]);
/** 浅色主题四色方案:关键字蓝、字符串枣红、注释绿、标识符近黑 */
const sqlLightHighlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#0000FF" },
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#0000FF" },
{ tag: [t.string, t.special(t.string)], color: "#A31515" },
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#008000" },
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#24292F" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#24292F" },
]);
export type SqlEditorHandle = {
/** Selection trim if nonempty; else statement at cursor (statementAt). */
getRunSql: () => string;
@@ -96,6 +122,7 @@ export const SqlEditor = forwardRef<
".cm-gutters": { border: "none" },
}),
...(theme === "dark" ? [oneDark] : []),
syntaxHighlighting(theme === "dark" ? sqlDarkHighlight : sqlLightHighlight),
],
});
const view = new EditorView({ state, parent: host.current });
+72 -12
View File
@@ -55,7 +55,7 @@ function TopNav() {
className={cx("ops-btn-ghost", showHistory && "ops-tree-item-active")}
onClick={() => set("showHistory", !showHistory)}
>
历史
查询历史
</button>
<button type="button" className="ops-btn-ghost" onClick={() => set("showHelp", !showHelp)}>
快捷键
@@ -135,6 +135,7 @@ function EditorPane() {
const notifyError = useWorkspaceStore((s) => s.notifyError);
const showHistory = useWorkspaceStore((s) => s.showHistory);
const history = useWorkspaceStore((s) => s.queryHistory);
const [histFilter, setHistFilter] = useState("");
const showBookmarks = useWorkspaceStore((s) => s.showBookmarks);
const bookmarks = useWorkspaceStore((s) => s.bookmarks);
const editorRef = useRef<SqlEditorHandle>(null);
@@ -583,9 +584,22 @@ function EditorPane() {
</div>
)}
{showHistory && (
<div className="max-h-36 shrink-0 overflow-auto border-b px-2 py-1 text-[11px]" style={{ borderColor: "var(--ops-border)" }}>
<div className="mb-1 flex items-center justify-between">
<span style={{ color: "var(--ops-muted)" }}>最近 {history.length} 条</span>
<div className="flex max-h-56 shrink-0 flex-col border-b text-[11px]" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex items-center gap-2 px-2 py-1">
<span className="shrink-0" style={{ color: "var(--ops-muted)" }}>
最近 {history.length} 条
</span>
<input
value={histFilter}
onChange={(e) => setHistFilter(e.target.value)}
placeholder="过滤历史…"
className="min-w-0 flex-1 rounded px-1.5 py-0.5 text-[11px]"
style={{
background: "var(--ops-input-bg, transparent)",
border: "1px solid var(--ops-border)",
color: "inherit",
}}
/>
<button
type="button"
className="hover:underline"
@@ -595,19 +609,65 @@ function EditorPane() {
清空
</button>
</div>
{history.length === 0 && <div style={{ color: "var(--ops-muted)" }}>暂无历史</div>}
{history.map((h) => (
<div className="min-h-0 flex-1 overflow-auto px-1 pb-1">
{history.length === 0 && (
<div className="px-1.5 py-2 leading-5" style={{ color: "var(--ops-muted)" }}>
暂无历史。执行过的语句会留在这里:点击行回填编辑器,▶ 直接重跑;重复语句自动去重置顶。
</div>
)}
{history
.filter((h) => {
const q = histFilter.trim().toLowerCase();
if (!q) return true;
return (
h.sql.replace(/\s+/g, " ").toLowerCase().includes(q) ||
connections.find((c) => c.id === h.connectionId)?.name.toLowerCase().includes(q)
);
})
.map((h) => {
const connName = connections.find((c) => c.id === h.connectionId)?.name;
return (
<div key={h.id} className="ops-tree-item group w-full" title={h.sql}>
<button
key={h.id}
type="button"
className="ops-tree-item w-full"
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
onClick={() => updateActiveSql(h.sql)}
title={h.sql}
>
<span className="w-16 shrink-0 opacity-50">{new Date(h.at).toLocaleTimeString()}</span>
<span className="truncate font-mono">{h.sql.replace(/\s+/g, " ")}</span>
<span className="w-14 shrink-0 opacity-50">{new Date(h.at).toLocaleTimeString()}</span>
{connName && (
<span
className="max-w-20 shrink-0 truncate rounded px-1 text-[10px]"
style={{ background: "var(--ops-accent-soft, rgba(127,127,127,.15))", color: "var(--ops-accent)" }}
>
{connName}
</span>
)}
<span
className={`truncate font-mono ${h.error ? "opacity-60" : ""}`}
style={h.error ? { color: "var(--ops-danger, #e11d48)" } : undefined}
>
{h.sql.replace(/\s+/g, " ")}
</span>
<span className="ml-auto shrink-0 tabular-nums opacity-45">
{h.error ? "失败" : `${h.rowCount ?? 0} 行 · ${h.timeMs ?? 0}ms`}
</span>
</button>
))}
<button
type="button"
className="ml-1 shrink-0 rounded px-1 opacity-0 transition-opacity group-hover:opacity-90"
style={{ color: "var(--ops-accent)" }}
title="回填并执行"
onClick={() => {
updateActiveSql(h.sql);
void run(h.sql);
}}
>
▶
</button>
</div>
);
})}
</div>
</div>
)}
+29 -4
View File
@@ -231,6 +231,8 @@ type State = {
openConnectionList: () => void;
editConnection: (c: SavedConnection) => void;
newQueryTab: () => void;
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
setActiveTabTitle: (title: string) => void;
closeTab: (id: string) => void;
updateActiveSql: (sql: string) => void;
pushAi: (role: AiMessage["role"], content: string) => void;
@@ -488,6 +490,13 @@ export const useWorkspaceStore = create<State>((set, get) => ({
sshPassword: c.savePassword === false ? "" : c.sshPassword,
};
let list = [...get().connections];
// 同名防护:不同 id 同名时自动加序号后缀,避免侧栏出现重名条目
const nameTaken = (n: string) => list.some((x) => x.id !== next.id && x.name === n);
if (nameTaken(next.name)) {
let k = 2;
while (nameTaken(`${next.name} ${k}`)) k++;
next.name = `${next.name} ${k}`;
}
if (next.isDefault) {
list = list.map((x) => ({ ...x, isDefault: x.id === next.id ? true : false }));
}
@@ -643,6 +652,13 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
},
setActiveTabTitle: (title) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, title } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
closeTab: (id) => {
const tabs = get().tabs.filter((t) => t.id !== id);
const next = tabs.length ? tabs : [defaultTab()];
@@ -678,10 +694,19 @@ export const useWorkspaceStore = create<State>((set, get) => ({
},
pushHistory: (item) => {
const next: QueryHistoryItem[] = [
{ id: crypto.randomUUID(), at: item.at ?? Date.now(), sql: item.sql, connectionId: item.connectionId, rowCount: item.rowCount, timeMs: item.timeMs, error: item.error },
...get().queryHistory,
].slice(0, 50);
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
const key = norm(item.sql);
const entry: QueryHistoryItem = {
id: crypto.randomUUID(),
at: item.at ?? Date.now(),
sql: item.sql,
connectionId: item.connectionId,
rowCount: item.rowCount,
timeMs: item.timeMs,
error: item.error,
};
// 相同语句去重置顶:重复执行只留最新一次,历史即「最近用过的不同语句」
const next = [entry, ...get().queryHistory.filter((h) => norm(h.sql) !== key)].slice(0, 200);
localStorage.setItem(LS_HISTORY, JSON.stringify(next));
set({ queryHistory: next });
},
+2
View File
@@ -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`(一次只起一个库)。