chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
@@ -0,0 +1,246 @@
|
||||
/* OpsxDB 文档站 —— 清爽专业风格(浅色) */
|
||||
:root {
|
||||
--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; }
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
.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 10px; border-radius: 8px; font-size: 13.5px;
|
||||
color: var(--text-2); text-decoration: none !important; border-left: 2px solid transparent;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* OpsxDB 文档站交互:搜索 / 代码复制 / 目录高亮 / 反馈 / 移动端导航 */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/* ---------- 代码块:语言标签 + 复制按钮 + 轻量高亮 ---------- */
|
||||
document.querySelectorAll("article.md pre").forEach(function (pre) {
|
||||
var code = pre.querySelector("code");
|
||||
if (!code) return;
|
||||
var lang = (code.className.match(/language-([\w-]+)/) || [, ""])[1];
|
||||
if (lang) {
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "lang-tag";
|
||||
tag.textContent = lang;
|
||||
pre.appendChild(tag);
|
||||
}
|
||||
// 复制按钮
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "copy-btn";
|
||||
btn.type = "button";
|
||||
btn.textContent = "复制";
|
||||
btn.addEventListener("click", function () {
|
||||
navigator.clipboard.writeText(code.textContent).then(
|
||||
function () {
|
||||
btn.textContent = "已复制 ✓";
|
||||
setTimeout(function () { btn.textContent = "复制"; }, 1400);
|
||||
},
|
||||
function () { btn.textContent = "复制失败"; }
|
||||
);
|
||||
});
|
||||
pre.appendChild(btn);
|
||||
// JSON / bash 轻量高亮
|
||||
if (lang === "json") highlightJSON(code);
|
||||
else if (lang === "bash" || lang === "sh" || lang === "shell") highlightBash(code);
|
||||
});
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
function highlightJSON(code) {
|
||||
var src = code.textContent;
|
||||
code.innerHTML = esc(src).replace(
|
||||
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
function (m, str, colon, kw, num) {
|
||||
if (str && colon) return '<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),本地后端随应用自动拉起 |
|
||||
|
||||
## 支持的数据库
|
||||
|
||||
| 家族 | 覆盖 | 默认端口 |
|
||||
| --- | --- | --- |
|
||||
| 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。
|
||||
- **开箱即用**:桌面版双击即用,本地后端随应用自动拉起;官网与文档也由它内置提供。
|
||||
- **为快设计**:点表即看数据、⌘Enter 即执行、结果三键取走(复制 / CSV / INSERT)。
|
||||
- **轻量克制**:一个 Go 二进制 + 一份前端,内网可部署,无遥测无账号。
|
||||
|
||||
> **提示**:想马上动手?直接看下一章《快速开始》,30 秒连上第一个库。
|
||||
@@ -0,0 +1,62 @@
|
||||
本章从零到出结果:起服务 → 建连接 → 执行第一条 SQL。
|
||||
|
||||
## 1. 启动
|
||||
|
||||
桌面版(主交付形态)双击 OpsxDB 应用即可,本地后端随应用自动拉起。
|
||||
|
||||
从源码运行(开发):
|
||||
|
||||
```bash
|
||||
git clone <repo> opsxdb && cd opsxdb
|
||||
npm install
|
||||
|
||||
./run.sh gui # 桌面形态(开发):Tauri 窗口 + 本地后端
|
||||
./run.sh web # 仅启动官网与文档 → http://127.0.0.1:8788/
|
||||
./run.sh package # 打桌面安装包(DMG,内嵌后端)
|
||||
```
|
||||
|
||||
> **注意**:客户端只在桌面 App 里运行。`./run.sh gui` 起的 `:5174` 是桌面 devUrl 的资产服务,不是浏览器客户端;浏览器只用于官网 `/` 与文档 `/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,41 @@
|
||||
OpsxDB 的界面为「高频操作」设计:从打开应用到看到数据,路径越短越好。
|
||||
|
||||
## 连接选择器
|
||||
|
||||
启动后的第一个界面:
|
||||
|
||||
- **搜索**:按名称快速过滤已保存的连接;
|
||||
- **分类**:关系型 / NoSQL / 国产库 / 时序·搜索 分组浏览;
|
||||
- **最近**:最近使用的连接排在最前;
|
||||
- **图标网格**:按数据库类型直选,未就绪的类型会标注「未就绪」(仅可保存配置);
|
||||
- **URL 填入**:粘贴 `mysql://user:pass@host:3306/db` 点「填入」,字段自动拆解。
|
||||
|
||||
## 工作台布局
|
||||
|
||||
连接成功后进入工作台,三栏结构:
|
||||
|
||||
| 区域 | 内容 |
|
||||
| --- | --- |
|
||||
| 左侧 | 连接列表 + 对象树(库 → 表 → 列,懒加载) |
|
||||
| 中部 | SQL 编辑器(多 Tab)+ 结果面板,可拖动分栏 |
|
||||
| 右侧 | AI 面板(生成 / 解释 / 优化 / 修复 / 方言 / 样例) |
|
||||
|
||||
## 对象树
|
||||
|
||||
- 点**表名**:直接打开前 100 行数据;
|
||||
- 展开**表**:查看列结构;
|
||||
- Redis:按键分隔符编文件夹,点键打开类型化详情(TTL / JSON / 增删字段);
|
||||
- 库列表懒加载,大实例也不卡。
|
||||
|
||||
## 编辑器与结果
|
||||
|
||||
- CodeMirror 语法高亮,⌘/Ctrl+Enter 执行**当前语句或选区**(不是整页脚本);
|
||||
- 多 Tab 并行查询,互不阻塞;
|
||||
- 结果面板:复制 / CSV / INSERT 三种取数方式,双击单元格复制;
|
||||
- 超过截断阈值时结果有明确提示。
|
||||
|
||||
## 个性化
|
||||
|
||||
- 深色 / 浅色主题切换;
|
||||
- Tab 布局与主题选择持久化,下次打开保持原样;
|
||||
- 查询历史本地留存,可回跳重跑。
|
||||
@@ -0,0 +1,50 @@
|
||||
本章说明各数据库类型的连接表单、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 隧道,再把数据库连接走隧道转发,无需把库端口暴露到公网。
|
||||
|
||||
## Redis 哨兵与集群
|
||||
|
||||
- **哨兵**:模式选 Sentinel,填写 Sentinel 节点列表(`host:port`,多行)和主节点名 `masterName`;
|
||||
- **集群**:模式选 Cluster,填写至少一个种子节点。对象树只显示 DB 0(Cluster 无 `SELECT`);
|
||||
- 单机模式只填 host/port,与原先一致。
|
||||
|
||||
字段细节与键浏览见 [Redis 工作台](redis)。
|
||||
|
||||
> **提示**:连接保存后,后续查询仅携带 `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,47 @@
|
||||
SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设计。
|
||||
|
||||
## 执行模型
|
||||
|
||||
- **⌘/Ctrl + Enter**:执行光标所在的**当前语句**;有选区时执行**选区**;
|
||||
- 不是「整页脚本」模式 —— 多条语句互不干扰,想跑哪句把光标放哪;
|
||||
- Redis 连接共用同一编辑器,按**行**执行命令(见 [Redis 工作台](redis)),不另开 CLI;
|
||||
- 执行在会话上运行,连接断开后自动提示。
|
||||
|
||||
## 多 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,93 @@
|
||||
Redis 连接使用专用表单(redis)。连上后,对象树是键空间浏览器,编辑器是 Redis 命令行 —— **和 SQL 共用同一套输入框**,不另开 CLI 页。
|
||||
|
||||
## 为什么命令行不单独做一套
|
||||
|
||||
SQL 工作台已经有:多 Tab、⌘/Ctrl+Enter、查询历史、书签、危险确认、取消。Redis 命令同样是「输入 → 执行 → 结果表」。
|
||||
|
||||
拆成独立 CLI(终端 REPL)会让快捷键、历史、危险确认分叉,也和 Navicat 式多引擎工作台不一致。
|
||||
|
||||
因此:
|
||||
|
||||
| | SQL | Redis |
|
||||
| --- | --- | --- |
|
||||
| 输入框 | 同一 CodeMirror | 同一 CodeMirror(占位/补全换成命令) |
|
||||
| 切分 | 分号语句 | **按行**(`SET k "a;b"` 不会被分号拆开) |
|
||||
| 执行 | ⌘/Ctrl+Enter 当前语句或选区 | ⌘/Ctrl+Enter 当前行或选区 |
|
||||
| 结果 | 结果表 | 命令结果表;从对象树打开键则进入键详情 |
|
||||
|
||||
Another Redis Desktop Manager 的独立 CLI Tab、opsxsql 的单行命令 + 输出日志,列为后续 P1,不是现在的主路径。
|
||||
|
||||
## 键空间浏览
|
||||
|
||||
对象树对 Redis 显示 DB0–15(Cluster 仅 0)。展开库后:
|
||||
|
||||
- **SCAN 过滤**:输入 `user:*` 点「应用」;生产大库请带前缀,避免扫全库。最多展示 500 个键,超出提示截断。
|
||||
- **按分隔符编文件夹**:连接表单的「键分隔符」默认 `:`,`CEX:FOLLOW:FOLLOWERS:1` 显示为 `CEX / FOLLOW / FOLLOWERS / 1`。文件夹显示数量;右键可「只看此前缀」「在此新建键」。
|
||||
- **类型色点**:string 绿 / hash 蓝 / list 橙 / set 紫 / zset 青。
|
||||
- **点键**:打开键详情(不是假 SELECT)。
|
||||
|
||||
## 键详情
|
||||
|
||||
对标 Another Redis Desktop Manager 的 Hash/List/Set 视图:
|
||||
|
||||
- 类型徽章、键名(回车重命名)、TTL(回车或「保存 TTL」,`-1` 永久)、刷新、导出还原命令、删除;
|
||||
- **结构**表:Hash 为 ID / Field / Value,JSON 值自动美化;可新增字段、编辑、删除;
|
||||
- **JSON** 视图:整体格式化;
|
||||
- 表内关键字过滤。
|
||||
|
||||
String 可直接改值保存。
|
||||
|
||||
## 命令执行
|
||||
|
||||
编辑器直接写 Redis 命令:
|
||||
|
||||
```bash
|
||||
PING
|
||||
SET demo:key "hello" EX 60
|
||||
GET demo:key
|
||||
HGETALL demo:hash
|
||||
SCAN 0 MATCH demo:* COUNT 100
|
||||
INFO memory
|
||||
```
|
||||
|
||||
- 未点名的命令走通用 `Do`(`EXISTS` / `ECHO` / `INFO` / `CLIENT LIST` 等不必再加白名单);
|
||||
- `FLUSHALL` / `FLUSHDB` / `KEYS` / `CONFIG` 仍要二段确认;
|
||||
- `KEYS *` 在大库上会阻塞 —— 请用 `SCAN MATCH`。
|
||||
|
||||
### 切换逻辑库(SELECT)
|
||||
|
||||
`SELECT 1` 是 Redis 官方的切库命令,编辑器直接支持 —— 语义与 redis-cli 一致:**切换后本 Tab 的后续命令都在目标库执行**:
|
||||
|
||||
```bash
|
||||
SELECT 1
|
||||
GET db1:only:key # 在 db1 执行
|
||||
```
|
||||
|
||||
- 没有也没有 `USE`(那是 SQL 语法)—— 输入 `USE db1` 会得到 Redis 原生报错
|
||||
`ERR unknown command 'USE'`,与 redis-cli 行为一致;
|
||||
- 切库只影响当前查询 Tab(多 Tab 可各连各的库),左侧对象树点开对应库可浏览键;
|
||||
- Cluster 模式只有 DB 0,`SELECT` 无效。
|
||||
|
||||
## 连接参数
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| host / port | 单机默认 127.0.0.1:6379 |
|
||||
| password / user | ACL / requirepass |
|
||||
| database | 逻辑库序号(0–15 常见;Cluster 忽略) |
|
||||
| 键分隔符 | 对象树文件夹分隔,默认 `:` |
|
||||
| ssl | TLS |
|
||||
| redisMode | `standalone`(默认)/ `sentinel` / `cluster` |
|
||||
| redisNodes | Sentinel / Cluster 节点列表,`host:port` 多行或逗号分隔 |
|
||||
| masterName | Sentinel 主节点名(必填) |
|
||||
| sentinelPassword | Sentinel 密码(可空) |
|
||||
|
||||
### Sentinel
|
||||
|
||||
模式选「哨兵」,填写至少一个 Sentinel 地址和 `masterName`(如 `mymaster`)。逻辑库序号仍可用。
|
||||
|
||||
### Cluster
|
||||
|
||||
模式选「集群」,填写至少一个种子节点。Cluster 不支持 `SELECT` 多库,对象树只暴露 DB 0。槽位路由由客户端处理,键浏览走 `SCAN`。
|
||||
|
||||
> **环境**:Sentinel / Cluster 表单与后端已接好;真实拓扑联调见 `docs/BACKLOG.md` 环境缺口。
|
||||
@@ -0,0 +1,35 @@
|
||||
连生产库的工具,安全必须是默认项。OpsxDB 的安全设计围绕一句话:**数据不出你的机器。**
|
||||
|
||||
## 本地优先
|
||||
|
||||
- 连接配置、查询历史、主题偏好全部保存在本机;
|
||||
- 无账号体系、无遥测、无云端同步;
|
||||
- 后端默认只监听 `127.0.0.1`,对外服务需要显式配置(`OPSXDB_BIND_PUBLIC=1` 或 `OPSXDB_ADDR`)。
|
||||
|
||||
## 凭据保护
|
||||
|
||||
| 层面 | 机制 |
|
||||
| --- | --- |
|
||||
| 存储 | 连接密码在本机混淆存储,不落明文 |
|
||||
| 传输 | 连接建立后,后续查询只携带 `sessionId`,账密不再每次上送 |
|
||||
| 会话 | 会话在服务端维护,可主动关闭(连接断开即失效) |
|
||||
|
||||
## 加密通道
|
||||
|
||||
**SSL / TLS**:每个连接可独立开启;数据库支持 TLS 即可启用(MySQL、PostgreSQL、SQL Server、Redis、MongoDB 均支持)。
|
||||
|
||||
**SSH 隧道**:数据库在跳板机后时,配置 SSH 字段组,OpsxDB 先建隧道再转发数据库连接 —— 库端口不必暴露公网。
|
||||
|
||||
## 危险操作防护
|
||||
|
||||
- DROP / TRUNCATE 执行前确认;
|
||||
- 无 WHERE 的 UPDATE / DELETE 执行前确认;
|
||||
- 结果集按阈值截断,避免误操作拖垮界面。
|
||||
|
||||
## 内网部署形态
|
||||
|
||||
单二进制 + 内置静态页(官网 / 文档 / 工作台),无外部依赖:
|
||||
|
||||
- 任意内网主机可部署;
|
||||
- 无外联请求,可在纯内网环境运行;
|
||||
- 升级即替换一个二进制。
|
||||
@@ -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:能在浏览器里操作数据库吗?**
|
||||
不能。OpsxDB 客户端唯一形态是桌面 App(Tauri 2);浏览器只承载官网与文档(由本地后端 `:8788` 内置提供)。开发时的 `:5174` 只是桌面 devUrl 的资产服务,不是网页客户端。
|
||||
|
||||
**Q:官网和文档部署在哪?**
|
||||
就内置在你运行的那个二进制里:`http://127.0.0.1:8788/` 是官网,`/docs` 是文档;无需额外部署。
|
||||
|
||||
**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 工作台》。
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// 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 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
|
||||
}
|
||||
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
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/"+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")
|
||||
page, ok := h.pages[slug]
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
_, _ = c.Writer.WriteString(notFoundHTML(h.order[0])) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
_, _ = 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 := path.Clean(c.Param("file"))
|
||||
if strings.Contains(name, "..") {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := files.ReadFile("assets" + name)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
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("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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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>
|
||||
<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" 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>
|
||||
Reference in new issue
Block a user