桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)

- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
/* OpsxAPI 文档站 —— 清爽专业风格(浅色) */
:root {
--blue: #2563eb;
--blue-dark: #1d4ed8;
--blue-soft: rgba(37, 99, 235, 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(37, 99, 235, 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(37, 99, 235, 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: #f9fbfe; 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; }
}
+219
View File
@@ -0,0 +1,219 @@
/* OpsxAPI 文档站交互:搜索 / 代码复制 / 目录高亮 / 反馈 / 移动端导航 */
(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 = "opsx-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");
}
});
}
})();
+36
View File
@@ -0,0 +1,36 @@
OpsxAPI 是一款**本地优先**的专业接口工作台,面向运维与开发,把 HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 六种协议的调试放进同一个窗口。
无需注册、无需联网同步,数据全部保存在本地浏览器(localStorage),导出备份随时带走。
## 功能总览
| 能力 | 说明 |
| --- | --- |
| 六种协议 | HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT |
| 集合管理 | 目录树、搜索、右键菜单、拖拽移动、多标签页 |
| 多环境变量 | `{{baseUrl}}`、`{{token}}` 全局替换,一键切换环境 |
| 数据导入 | OpenAPI/Swagger、Postman、Apifox、Apipost、cURL、HAR、Insomnia、Hoppscotch、Markdown 等 |
| 数据导出 | OpenAPI 3.0、Postman v2.1、Markdown 文档、全量备份 |
| 代码生成 | cURL / JavaScript fetch / Python requests / Go net/http |
| 代理能力 | 本地 Gin 代理绕开 CORS;WebSocket 隧道支持自定义握手 Header |
| 双端形态 | 浏览器(Web)与桌面端(Tauri)共用同一套界面 |
## 支持的协议与场景
| 协议 | 典型场景 | 特色 |
| --- | --- | --- |
| HTTP / REST | 调试 REST 接口、运维平台 API | 7 种方法、6 种请求体、3 种鉴权 |
| GraphQL | 查询网关、中台接口 | Query + Variables 独立编辑 |
| WebSocket | 实时推送、行情、IM | 自定义握手 Header(经本地隧道) |
| SSE | AI 流式输出、监控事件流 | 直连失败自动切代理、断点续传 |
| Socket.IO | Node 实时应用 | 事件监听、emit 参数、path/auth 配置 |
| MQTT | IoT 设备、消息总线 | 通配符订阅、QoS、自动重连 |
## 为什么选 OpsxAPI
- **数据主权**:所有集合、环境、历史保存在本机,不经过任何第三方服务器。
- **开箱即用**:`./run.sh web` 一条命令启动;首次打开自带覆盖全部协议的示例集合。
- **工程友好**:导入自动建目录、路径参数自动转变量;导出即标准 OpenAPI,可回流 Apifox / Swagger 生态。
- **轻量克制**:一个 Go 二进制 + 一份前端,资源占用小,内网可部署。
> **提示**:想快速感受?直接看下一章《快速开始》,30 秒发出第一个请求。
@@ -0,0 +1,48 @@
## 环境要求
| 组件 | 版本 | 用途 |
| --- | --- | --- |
| Node.js | ≥ 20 | Web 前端(Vite) |
| Go | ≥ 1.23 | 本地代理服务(Gin) |
| Rust + Cargo | 可选 | 仅编译桌面端(Tauri)时需要 |
## 启动
```bash
git clone <你的仓库地址> opsxapi
cd opsxapi
make install # 安装依赖(npm install,含桌面端)
./run.sh web # 启动:Gin(:8787) + Web UI(:5173)
```
看到下面的输出即可打开浏览器:
```bash
[OK] 后端 UP http://127.0.0.1:8787/health
[OK] Web UP http://127.0.0.1:5173
```
| 命令 | 说明 |
| --- | --- |
| `./run.sh web` | 浏览器版(推荐) |
| `./run.sh gui` | 桌面窗口(Tauri,共用同一 UI) |
| `./run.sh all` | Web + 桌面一起开 |
| `./run.sh status` / `stop` | 查看状态 / 停止 |
## 30 秒发出第一个请求
1. 打开 <http://127.0.0.1:5173>,左侧是内置的「示例集合」。
2. 点击 **HTTP · httpbin → 获取请求回显**。
3. 点击右上角 **发送**。
4. 下方响应区显示 `200`、耗时与 JSON 高亮响应体 —— 完成!
## 桌面端(可选)
```bash
./run.sh gui
```
桌面端复用同一份界面与数据通道,HTTP 请求走 Tauri 原生网络栈,不依赖浏览器 CORS。
> **提示**:首次打开会自动创建两个环境(正式 / 本地)和一个覆盖全部协议的示例集合,可随时在「设置」中重置。
@@ -0,0 +1,41 @@
OpsxAPI 主界面分为五个区域:
## 顶栏
| 元素 | 作用 |
| --- | --- |
| + 新建 | 快捷创建各协议请求 / 目录(默认建在第一个集合) |
| 导入 / 导出 | 打开数据导入、导出对话框 |
| 环境选择器 | 切换当前环境(变量随之生效),「⚙ 环境」管理环境与变量 |
| 🌙 / ☀️ | 明暗主题切换 |
| ⋯ | 设置(主题、超时、重置工作区) |
## 左侧集合树
- 点击请求 → 在标签页中打开;点击目录 → 展开 / 折叠。
- **右键**任意节点:新建、重命名、复制、导出、删除。
- **拖拽**请求或目录到另一个目录,即可移动(支持跨集合)。
- 顶部搜索框按名称 / 方法 / URL 实时过滤。
- 底部为**历史记录**:显示最近请求的状态码与耗时,点击可回跳到对应接口。
## 标签页栏
同时打开多个接口,中键或 × 关闭;右键支持「关闭其他 / 关闭全部」。正在连接中的实时协议标签会显示呼吸圆点。
## 请求编辑区(上半部分)
按协议呈现不同编辑器:
| 协议 | 标签页 |
| --- | --- |
| HTTP | Params · Body · Headers · Cookies · Auth · 说明 |
| GraphQL | Query · Variables · Headers · Auth |
| WebSocket | 消息 · 握手配置 · 说明 |
| SSE | 事件流 · 连接配置 · 说明 |
| Socket.IO | 事件 · 连接配置 · 说明 |
| MQTT | 消息 · Broker / 订阅 · 发布 · 说明 |
## 响应区(下半部分)
- **HTTP / GraphQL**:响应体(Pretty JSON 高亮)、Raw、响应头,右上角状态 / 耗时 / 大小 / 复制。
- **实时协议**:分色消息日志(接收 · 绿、发送 · 蓝、系统 · 灰、错误 · 红),顶部实时统计收发条数与连接时长。
+47
View File
@@ -0,0 +1,47 @@
## 发送请求
1. 选择方法(GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS)。
2. 输入 URL,支持 `{{baseUrl}}/path` 形式的变量(见《环境与变量》)。
3. 点击 **发送**,或直接在 URL 输入框按 **Enter**。
请求默认经本地 Gin 代理(`/v1/proxy/http`)发出,天然绕开浏览器 CORS 限制。
## Params(查询参数)
键值对编辑器,每行可勾选启用 / 停用、填写说明。URL 中已有的查询串会被拆分到表格里,两边双向同步。
## Body(请求体)
| 模式 | 适用场景 |
| --- | --- |
| none | GET 等无体请求 |
| JSON | REST 接口(默认自动补 `Content-Type: application/json`,带格式化按钮) |
| XML / Text / HTML | 原始报文 |
| x-www-form-urlencoded | 表单提交 |
| form-data | multipart 文本字段(文件上传请用代码生成的 cURL) |
## Headers / Cookies
- Headers:键值对,支持 `{{变量}}`;`Authorization`、`Cookie` 由 Auth / Cookies 标签统一管理时避免重复。
- Cookies:键值对,发送时自动拼接为 `Cookie` 请求头。
## 代码生成
点击 URL 栏右侧 **`</>` 代码**,可生成:
- cURL(可直接在终端执行)
- JavaScript fetch
- Python requests
- Go net/http
生成内容保留 `{{变量}}` 原样,便于粘贴到脚本后再替换。
## 响应面板
| 标签 | 内容 |
| --- | --- |
| 响应体 | JSON 自动格式化 + 语法高亮;其他类型原样展示 |
| Raw | 未格式化的原始文本 |
| 响应头 | 全部响应头表格 |
右上角常驻 **状态码 / 耗时 / 大小 / Content-Type**,并提供「复制体」按钮。
+29
View File
@@ -0,0 +1,29 @@
在 HTTP / GraphQL / SSE 请求的 **Auth** 标签中选择鉴权方式:
## Bearer Token
```text
Authorization: Bearer <token>
```
Token 支持 `{{token}}` 变量引用 —— 推荐把 token 放进环境变量,一次配置全局生效。
## Basic Auth
填写用户名 / 密码,自动进行 Base64 编码后放入 `Authorization` 头。
## API Key
| 字段 | 说明 |
| --- | --- |
| Key 名 | 如 `X-API-Key` |
| 值 | 支持 `{{变量}}` |
| 位置 | Header(默认)或 Query 参数 |
## 组合规则
- 鉴权产生的 Header 会与 Headers 标签中的同名项合并(鉴权优先)。
- API Key 放 Query 时,会追加到 Params,不会重复出现在 Headers。
- 导入 OpenAPI / Postman 时,文档中声明的 `securitySchemes` 会自动映射到对应鉴权方式。
> **注意**:鉴权信息保存在本地集合数据中。如需分享集合,请先通过「导出」审查是否包含敏感 token。
+67
View File
@@ -0,0 +1,67 @@
## 前置 URL
环境可配置**前置 URL**(如 `https://api.example.com`)。配置后,请求里只需写相对路径:
```text
前置 URL:https://api.example.com
请求路径:/graphql
实际请求:https://api.example.com/graphql
```
- URL 输入框会以**前缀块 + 路径**的形式一体展示,前缀部分不可误编辑(点击可跳转环境管理);
- **切换环境即自动换成对应服务地址**,无需改请求;
- 路径以 `/` 开头(或为空)才启用拼接;写完整 `https://…` 地址的请求不受影响;
- 未配置前置 URL 的环境,请求仍可写完整 URL 或用 `{{变量名}}`。
> 历史数据自动迁移:旧的 `{{baseUrl}}/path` 会被自动转为「前置 URL + /path」,`baseUrl` 变量写入环境的前置 URL 字段。
## 变量语法
在 URL、Params、Headers、Cookies、Body、鉴权字段中,使用双花括号引用变量:
```text
{{baseUrl}}/v1/users?page={{page}}
Authorization: Bearer {{token}}
```
发送时统一替换为**当前启用环境**中同名变量的值;未定义的变量保留原样,便于发现遗漏。
## 切换环境
顶栏环境切换器显示**彩色单字徽标 + 环境名 + 变量数**,点击弹出环境列表(当前环境带「当前」标记),一眼防误发;底部可进入「管理环境」或「不使用环境」。
## 管理环境
顶栏 **环境** 按钮打开管理弹窗(左侧环境列表 + 右侧编辑区):
- **左栏**:环境列表(徽标 + 名称 + 当前标记),底部虚线按钮新建环境。
- **右栏**:提示条(变量引用语法 + 文档直达)、环境重命名、设为当前 / 取消使用、**复制环境**、删除;变量表支持**按名称 / 描述搜索**、逐行启用停用、内联编辑。
- 所有修改即时生效并自动保存。
## 输入 {{ 自动补全
在 URL 输入框或任意「值」输入框键入 `{{`,会弹出当前环境的变量浮层:
- ↑↓ 选择、Enter / Tab 插入完整的 `{{变量名}}`;Esc 关闭。
- 浮层同时显示变量值,选之前即可确认。
内置示例环境:
| 环境 | baseUrl | token |
| --- | --- | --- |
| 正式 | `https://httpbin.org` | demo-token |
| 本地 | `http://127.0.0.1:8787` | dev-token |
## 变量从哪来自动生成
| 来源 | 行为 |
| --- | --- |
| 导入 OpenAPI / Swagger | `servers` 地址写入 `baseUrl`;路径参数 `{id}` 转为 `{{id}}` 并写入示例值 |
| 导入 Postman | `collection.variable` 写入当前环境 |
| 手动输入 | 在环境面板随时增改 |
## 实际地址预览
URL 含 `{{ }}` 时,编辑器会在 URL 栏下方实时显示替换后的实际地址,切换环境立即更新。
> **提示**:路径参数用变量而不是写死,是接口可复用的关键 —— 复制接口后只改变量值即可调试另一个资源。
@@ -0,0 +1,32 @@
## 编辑查询
GraphQL 请求由三部分组成:
| 部分 | 说明 |
| --- | --- |
| Query | GraphQL 查询语句,支持变量定义 `$code: ID!` |
| Variables | JSON 对象,随查询一起发送;非法 JSON 会红框提示 |
| Headers / Auth | 与 HTTP 请求一致 |
## 发送
点击 **执行**(或 URL 栏按 Enter)。实际发送的是标准 GraphQL over HTTP:
```json
{
"query": "query ($code: ID!) { country(code: $code) { name capital currency } }",
"variables": { "code": "CN" }
}
```
响应面板会自动格式化 `data` / `errors`,字段名高亮。
## 内置示例
示例集合中的「国家查询 · countries」连接公共 GraphQL 服务 <https://countries.trevorblades.com/graphql>,无需鉴权,可直接体验。
## 最佳实践
- 把 endpoint 存为 `{{baseUrl}}/graphql`,环境切换即切换网关。
- 需要鉴权时在 Auth 标签配置 Bearer,不必手写 Header。
- 想看实际报文?点 `</>` 代码 生成 cURL,即为此查询的完整 HTTP 请求。
@@ -0,0 +1,34 @@
## 连接
1. 填写 `ws://` 或 `wss://` 地址。
2. 点击 **连接**;状态点变绿(已连接)后即可收发消息。
3. 再次点击按钮即断开。
## 消息收发
- **消息日志**:接收(绿)/ 发送(蓝)/ 系统(灰)/ 错误(红)分色展示,顶部统计 ↑↓ 条数与连接时长。
- **发送框**:支持 JSON / 纯文本;**⌘/Ctrl + Enter** 快速发送;「格式化 JSON」一键美化。
## 子协议
「握手配置」标签中的**子协议**填逗号分隔列表,例如:
```text
graphql-transport-ws, v1.json
```
## 自定义握手 Header(隧道模式)
浏览器原生 WebSocket **不能**携带自定义 Header(如 `Authorization`、`X-API-Key`)。OpsxAPI 的处理方式:
1. 「握手配置」中填写 Header 后,工具自动改走**本地 Gin 隧道**:
- 浏览器 → Gin `/v1/proxy/ws/attach`(普通 WS)
- Gin → 目标服务器(携带你填的全部 Header)
2. 不填 Header 时直连目标,零额外开销。
> **注意**:隧道要求本地 Gin 服务(:8787)在运行,即用 `./run.sh web` / `gui` 启动的默认形态。
## 调试技巧
- 消息过多时点「清空」;日志上限 500 条,超出自动滚动淘汰。
- 把常用报文保存在「说明」里,或为不同报文建多个 WS 请求节点。
+29
View File
@@ -0,0 +1,29 @@
## 连接事件流
1. 填写 SSE 地址(如 `https://stream.wikimedia.org/v2/stream/recentchange`)。
2. 点击 **连接**,事件按到达顺序流入消息日志。
3. 每条消息按 `event:` 名称归类显示;`data` 为 JSON 时自动格式化。
## 直连与代理自动切换
- 默认**直连**目标站(fetch 流式读取,延迟最低)。
- 直连因 CORS / 网络失败时,自动降级走**本地 Gin 流式代理**,日志中会提示切换过程,无需手工干预。
## 断点续传(Last-Event-ID)
「连接配置」中填写 Last-Event-ID,连接时以 `Last-Event-ID` 请求头携带,支持断点续传的流会在该事件之后继续推送。
## 连接配置项
| 配置 | 说明 |
| --- | --- |
| Query 参数 / Headers | 以键值对附加 |
| Last-Event-ID | 断点续传游标 |
| 仅显示 data 内容 | 开启后只显示 `data:` 载荷;关闭则连同 `event:` 名称一起显示 |
| 鉴权 | 与 HTTP 相同(Bearer / Basic / API Key) |
## 典型场景
- LLM 流式输出接口(`text/event-stream`)
- 监控 / 审计事件流
- 需要长期挂机观察的推送通道
@@ -0,0 +1,45 @@
## 连接
1. 填写服务地址(**不含** `/socket.io` 路径),如 `http://127.0.0.1:3000`。
2. 点击 **连接**,状态变为「已连接」后即可收发事件。
## 监听事件
「连接配置」→ **监听事件** 填逗号分隔的事件名:
```text
message, notification
```
留空则接收全部服务端事件(onAny 模式)。事件名在每条日志中标注。
## 发送(emit)
「事件」标签底部的发送区:
- **事件名**:如 `message`
- **参数**:JSON 数组,作为 `socket.emit(event, ...args)` 的参数
```json
["hello", 42]
```
## 连接选项
以 JSON 配置传给 socket.io-client:
```json
{
"path": "/socket.io",
"transports": ["websocket"],
"auth": { "token": "{{token}}" }
}
```
| 选项 | 说明 |
| --- | --- |
| path | 服务端挂载路径,默认 `/socket.io` |
| transports | `websocket` / `polling`,默认两者皆可 |
| auth | 握手鉴权载荷,支持 `{{变量}}` |
> **提示**:浏览器直连外部站点同样受 CORS 限制,Socket.IO 服务端需配置对应 origin,或把服务与本工具部署在同源下。
+41
View File
@@ -0,0 +1,41 @@
## 连接 Broker
浏览器环境要求 **WebSocket 形式**的 Broker 地址:
```text
wss://broker.emqx.io:8084/mqtt
```
| 配置 | 说明 |
| --- | --- |
| 用户名 / 密码 | 可选,支持 `{{变量}}` |
| ClientId | 默认随机生成;同一 Broker 上必须唯一 |
| QoS | 0 至多一次 · 1 至少一次 · 2 恰好一次 |
## 订阅
「Broker / 订阅」中填写逗号分隔的主题,支持通配符:
```text
opsxapi/demo, sensor/+, home/#
```
> **注意**:订阅在**建立连接时**生效,修改主题后需断开重连。
## 发布
「发布」标签配置主题与 Payload(JSON / 纯文本),点击发布按钮即送达;已发布消息以蓝色出现在日志中并标注主题。
## 公共测试 Broker
| Broker | 地址 |
| --- | --- |
| EMQX | `wss://broker.emqx.io:8084/mqtt` |
| HiveMQ | `wss://broker.hivemq.com:8884/mqtt` |
两个浏览器窗口连接同一主题,即可互相收发验证。
## 行为说明
- 断线自动重连(3 秒间隔),状态点显示「重连中」。
- 消息日志上限 500 条;QoS 与主题逐条标注。
+40
View File
@@ -0,0 +1,40 @@
顶栏 **导入** 打开导入对话框,支持三种来源:
| 来源 | 说明 |
| --- | --- |
| 粘贴文本 / cURL | 直接粘贴任意受支持格式的内容 |
| 文件导入 | 选择或拖入 `.json` / `.yaml` / `.har` / `.md` / `.txt` |
| URL 导入(Swagger) | 填写 OpenAPI 地址,经本地 Gin 代理拉取,避开 CORS |
格式**自动识别**,也可手动指定。
## 支持格式
| 格式 | 细节 |
| --- | --- |
| OpenAPI 3.0 / 3.1、Swagger 2.0 | JSON 或 YAML;Apifox 导出的 OpenAPI 同样适用 |
| Postman Collection | v2.0 / v2.1,含鉴权、变量、GraphQL body |
| Apipost 项目 | `api_collection` 格式(宽容解析) |
| cURL | 引号、续行、`-d` / `-F` / `-u` / `--data-urlencode`、cookie、代理等参数 |
| HAR | 按域名分组、相同请求去重 |
| Insomnia | 导出文件 v3 / v4,含 workspace / group 层级 |
| Hoppscotch | 集合 JSON(数组或单对象) |
| Markdown | 提取文中的 ```curl 代码块 |
| OpsxAPI 备份 | 全量集合 + 环境,用于迁移恢复 |
YApi、Apidoc、Apizza、Eolink 请先在原平台导出为 **OpenAPI 或 Postman** 格式,再导入本工具。
## 导入时自动完成
| 原始内容 | 转换结果 |
| --- | --- |
| OpenAPI `tags` | 目录分组 |
| 路径参数 `/pets/{petId}` | `{{petId}}` 变量 + 环境示例值 |
| `servers` 地址 | 当前环境 `baseUrl` 变量 |
| requestBody schema | JSON 示例请求体 |
| securitySchemes | Bearer / Basic / API Key 鉴权配置 |
| Postman `variable` | 当前环境变量 |
## 结果确认
导入完成显示汇总:新建集合、接口数量、写入的环境变量与告警(如跳过的文件上传字段)。集合名重复时自动追加序号。
+30
View File
@@ -0,0 +1,30 @@
顶栏 **导出**(多集合时可选择目标集合):
| 格式 | 用途 |
| --- | --- |
| OpenAPI 3.0 · JSON / YAML | 回流 Apifox、Swagger UI、代码生成器 |
| Postman v2.1 | 直接导入 Postman |
| Markdown 接口文档 | 带目录、参数表格、请求体代码块,可放 Wiki |
| 全量备份 | 全部集合 + 环境,用于迁移与灾备 |
每种格式均支持**复制到剪贴板**与**下载文件**;对话框内实时预览导出内容。
## 全量备份与恢复
- 备份文件是包含 `__opsxapi__: "backup"` 标记的 JSON。
- 恢复:导入对话框 → 粘贴备份内容或选择备份文件 → 格式自动识别为「OpsxAPI 备份」。
## 代码生成(单请求)
在 HTTP / GraphQL 编辑器点 **`</>` 代码**:
| 目标 | 说明 |
| --- | --- |
| cURL | 含方法、头、体、cookie,可直接执行 |
| JavaScript fetch | `async/await` 风格 |
| Python requests | 含 `import requests` 完整脚本 |
| Go net/http | 完整 `main` 函数 |
`{{变量}}` 保留原样输出,方便接入你自己的配置体系。
> **提示**:导出的 OpenAPI 中,目录名会成为 `tags`,请求说明写入 `description`,鉴权自动生成 `securitySchemes`。
+90
View File
@@ -0,0 +1,90 @@
# Mock 数据
联调时后端接口没就绪?OpsxAPI 内置 **Mock.js 风格的数据生成器**,两种用法覆盖绝大多数场景:
1. **显式表达式** —— 在参数值、Body 中直接写 `@cname`、`@integer(1,100)`;
2. **智能匹配** —— 开启后按**字段名**自动套用规则表(`username` → 姓名、`email` → 邮箱…),一行不用写。
## 开启与配置
顶栏 **⋯ → Mock 数据**(或「设置 → Mock 数据」):
- **启用 Mock 数据**:总开关,关闭后 `@` 表达式按普通文本原样发送;
- **智能 Mock 匹配**:JSON Body 字段值未写表达式时,按字段名匹配规则自动生成;
- **智能匹配规则表**:数据类型(string / number / integer / boolean)、匹配方式(通配符 / 完全匹配 / 正则)、匹配规则(如 `*name*`)、Mock 表达式、备注与启用开关,支持搜索、新增、删除与恢复默认。
规则**从上到下**匹配,先命中先用;类型需与 JSON 字段实际类型一致(KV 参数一律按 string 匹配)。
## 表达式速查
| 表达式 | 说明 | 示例输出 |
| --- | --- | --- |
| `@cname` | 中文姓名 | `王国强` |
| `@email` | 邮箱 | `kx8f2@qq.com` |
| `@phone` | 手机号 | `138****2733` 形式 11 位 |
| `@id` | 身份证号 | 18 位 |
| `@guid` | UUID / GUID | `3f2b…-…` |
| `@integer(1, 100)` | 整数 | `42` |
| `@float(1, 9999, 2)` | 小数(保留 2 位) | `128.50` |
| `@boolean` | 布尔 | `true` |
| `@string(8, 16)` | 随机字符串 | `aZ3k…` |
| `@datetime(YYYY-MM-DD HH:mm:ss)` | 日期时间 | `2026-09-17 14:30:00` |
| `@date(YYYY-MM-DD)` / `@time(HH:mm:ss)` | 日期 / 时间 | — |
| `@timestamp` | 毫秒时间戳 | `1760000000000` |
| `@url` / `@domain` / `@ip` | URL / 域名 / IPv4 | — |
| `@province` / `@city(true)` / `@county(true)` | 省市区 | `广东省深圳市南山区` |
| `@ctitle(4, 12)` / `@csentence(10, 24)` | 中文标题 / 句子 | — |
| `@image(200x200)` | 图片地址 | picsum 随机图 |
| `@pick("A", "B", "C")` | 从列表中选择 | `B` |
未列出的写法会**原样发送**,可放心与真实值混用。
## 在请求中使用
**Params / x-www-form-urlencoded / form-data**:值一栏直接填表达式。
```text
username = @cname
page = @integer(1, 10)
```
**JSON Body**:叶子值写表达式,或交给智能匹配:
```json
{
"username": "@cname",
"email": "@email",
"age": "@integer(18, 60)",
"vip": "@boolean"
}
```
Body 工具栏提供两个辅助按钮:
- **智能填充**:按规则把 JSON 各字段值批量替换为对应 `@` 表达式,生成可编辑的 Mock 模板;
- **Mock 预览**:即时查看发送时实际生成的数据,不必真的发请求。
## 生效范围
- 生效:Query 参数、JSON / XML / Text Body、x-www-form-urlencoded、form-data、GraphQL Variables;
- 不生效:Headers、Cookies、鉴权字段(避免意外改坏签名与令牌)。
> 提示:Mock 在**发送瞬间**求值,同一请求每次发送都会得到新数据;预览结果仅供核对格式。
## 本地 Mock 服务(/mock)
除了发送时的离线 Mock,还能把接口**发布成本地 Mock 服务**,给前端 / 同事直接联调(类似 Apifox 的 Mock URL):
1. 登录并切换到「我的空间」或团队空间(Mock 按空间隔离);
2. HTTP 请求 URL 行点 **Mock 链接** → 选择响应状态码与延迟 → **发布**(一次发布当前空间集合内全部 HTTP 接口);
3. 得到地址:`http://127.0.0.1:8787/mock/{空间随机地址}{路径}`(每空间一个 16 位随机片段,不可枚举、不暴露归属),例如 `http://127.0.0.1:8787/mock/9f2c7a1b3d5e4f60/api/users`。
特点:
- **每次请求随机生成**:响应模板里的 `@cname`、`@integer(...)` 等表达式由服务端实时求值;
- 发布时自动把智能 Mock 规则烘焙进模板(开启智能匹配时);
- 非 GET 接口可用 `?_method=POST` 快速验证;延迟最高 5 秒,用于模拟慢接口 / loading 态。
### 频率限制(防滥用)
本地 Mock 服务按 **IP + 空间** 限流:**20 QPS(每秒 20 次)或 60 QPM(每分钟 60 次)**,超限返回 `429` 与 `Retry-After: 60`。依据[服务条款](/terms):未经授权或滥用行为平台有权采取限流、暂停服务等措施;**通过技术手段规避限流属于违规行为**。Mock 定位是联调辅助,请勿当作对外流量服务使用。
+39
View File
@@ -0,0 +1,39 @@
## 新建
| 入口 | 行为 |
| --- | --- |
| 顶栏「+ 新建」 | 快捷菜单,建在第一个集合 |
| 树右键「新建请求 / 目录」 | 精确指定位置 |
| 新建对话框 | 选择类型(六协议 + 目录)、命名、目标集合与目录 |
接口名自动避免重复(追加序号)。
## 集合树操作
| 操作 | 方式 |
| --- | --- |
| 重命名 | 右键 → 重命名 |
| 复制 | 右键 → 复制(深拷贝子树,名字加「副本」) |
| 移动 | 拖拽到目标目录(支持跨集合) |
| 删除 | 右键 → 删除(直接生效,请先备份重要数据) |
| 搜索 | 侧栏搜索框,匹配名称 / 方法 / URL |
## 标签页
- 点击树节点在标签页打开;同时打开数量不限。
- 中键 / × 关闭;右键「关闭其他 / 关闭全部」。
- 关闭实时协议(WS / SSE / MQTT…)标签时自动断开连接。
## 历史记录
- 每次 HTTP / GraphQL 发送记录:时间、方法、接口名、状态码,失败标红。
- 点击记录跳回对应接口;最多保留 100 条;可一键清空。
## 数据存储与备份
- 位置:浏览器 localStorage(键 `opsxapi.workspace.v2`),自动保存(防抖 400ms)。
- 内容:集合、环境、历史、设置。标签页会话与实时消息不落盘。
- 备份:导出 → 全量备份;恢复:导入备份文件。
- 重置:设置 → 重置工作区(恢复初始示例,请先备份)。
> **注意**:桌面端与浏览器是两份独立的 localStorage。跨端迁移请使用全量备份 / 导入。
@@ -0,0 +1,41 @@
# 自动化测试
把日常联调的接口串成**场景用例**,一条龙验证业务流程。三种触发方式共用同一套用例模型:
| 方式 | 说明 | 适合 |
| --- | --- | --- |
| 手动运行 | 用例编排好后点「运行」 | 日常自测、回归 |
| AI 生成 | 模型按接口清单自动编排步骤与断言 | 快速起盘、补断言思路 |
| 定时任务 | 应用运行期间按间隔自动执行 | 持续监控、冒烟 |
## 场景用例
从侧栏底部「自动化测试」进入。用例 = 有序步骤列表,每个步骤:
- **引用一个 HTTP 接口**(从集合中选择,可跨集合、跨目录);
- **断言**:状态码 / 耗时 / Header / Body 四类目标,支持 等于、不等于、大于、小于、包含、存在 与 **JSONPath** 取值比较;
- **变量提取**:`$.data.id` → 环境变量 `petId`,后续步骤 URL / Body 用 `{{petId}}` 引用,实现「创建 → 查询 → 删除」链式传值;
- 可单独禁用、上下移动、删除。
用例级选项「**失败后继续**」:默认失败即停(快速定位首个问题);打开后跑完全部步骤再看整体报告。
## 运行报告
- 统计卡:通过 / 失败步骤数、总耗时、断言数、通过率;
- 步骤明细:方法 + URL + 状态码 + 耗时,逐条断言的期望 / 实际对照(可按 通过 / 失败 过滤);
- 「再次运行」一键回归;「导出报告」生成 Markdown 存档或归档到缺陷单;
- 每个用例保留最近 50 次运行记录,可下拉切换历史报告。
## 定时任务
用例头部的 ⏱ 按钮或左侧「定时任务」入口:
- 间隔可选 5 / 15 / 30 / 60 分钟、6 小时、每天;
- 独立 启用 / 暂停 开关,显示下次运行时间与上次结果;
- **应用保持运行时生效**:OpsxAPI 窗口(或 Web 页面)开着才会按时触发并记录报告,关闭后自动暂停 —— 适合工作时段的持续监控,不能替代服务器侧的 CI。
> 提示:定时触发的请求同样走当前环境与 Mock 设置;跨环境回归前先在顶栏切换环境。
## AI 生成用例
见 [AI 助手](/docs/ai)。配置模型供应商后,「AI 生成」会读取集合的 HTTP 接口清单(方法、URL、Body 示例),按业务依赖编排步骤、断言与变量提取,生成前可附加一句要求(如「重点覆盖登录态与分页」),预览确认后保存。
+42
View File
@@ -0,0 +1,42 @@
# AI 助手
接入 **OpenAI 兼容协议**的大模型,为自动化测试提供 AI 能力。模型调用直连供应商;供应商配置(包括密钥)随当前工作区保存,团队工作区会同步工作区数据。
## 配置模型供应商
**设置 → 大模型**,选择预设并点击「+」,在右侧填写模型 ID、密钥和 Base URL,测试连接后点击「保存配置」。内置常用预设:
| 供应商 | Base URL | 模型示例 |
| --- | --- | --- |
| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` |
| 智谱 GLM | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` |
| Moonshot Kimi | `https://api.moonshot.cn/v1` | `moonshot-v1-8k` |
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
| Ollama 本地 | `http://127.0.0.1:11434/v1` | `qwen2.5:7b` |
- **API Key**:Ollama 等本地服务可留空;
- **测试连接**:发一条 ping 验证地址 / 密钥 / 模型名;离线时也可先保存,之后再测试;
- 多个供应商时可在左侧选择,保存后点「设为默认」指定自动化测试使用的模型。
## AI 生成场景用例
自动化测试视图 →「**AI 生成**」:
1. 选择集合(AI 读取其中全部 HTTP 接口:方法、URL、Body 示例);
2. 可选填写一句要求,如「覆盖下单 → 支付 → 查询流程,重点断言金额字段」;
3. 模型按业务依赖编排步骤,输出断言与变量提取(如 `$.data.token → token`,后续步骤用 `{{token}}`);
4. 预览生成的步骤清单(未识别的接口会自动跳过并提示),确认后保存为正式用例。
生成结果只是起点 —— 步骤、断言、提取规则都可再手动调整。
## 隐私与边界
- API Key 随当前工作区数据保存;团队工作区会同步,导出全量备份时也会包含。分享工作区或备份文件前请检查密钥;
- 供应商收到的内容为接口清单摘要(方法 / URL / Body 片段)与你的指令,**不含**环境变量值与 API Key;
- 未配置供应商时,Mock 数据、环境变量等离线功能不受影响。
## 常见问题
**生成失败?** 依次检查:Base URL 是否带 `/v1`、模型名是否属于该供应商、Key 是否有效(用「测试连接」验证)、本机能否直连该域名(内网代理环境注意放行)。
**想用公司内网模型?** 添加自定义供应商,Base URL 填内网网关地址即可(需兼容 OpenAI Chat Completions 协议)。
@@ -0,0 +1,75 @@
本地服务(Gin)默认监听 `:8787`,可用环境变量 `OPSXAPI_ADDR` 修改。所有端点仅监听本机回环,供 Web UI 与桌面端调用。
## 端点总览
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/health` | 健康检查 |
| POST | `/v1/proxy/http` | HTTP 代理(全方法、自定义头、超时) |
| POST | `/v1/proxy/http/stream` | NDJSON 流式代理(SSE / chunked 透传) |
| POST | `/v1/proxy/ws/connect` | 建立到目标 WS 的隧道会话 |
| GET | `/v1/proxy/ws/attach` | 浏览器 attach 到隧道会话(双向桥接) |
| POST | `/v1/proxy/ws/close` | 关闭隧道会话 |
| GET | `/docs` | 本文档站 |
## HTTP 代理
```bash
curl -s -X POST http://127.0.0.1:8787/v1/proxy/http \
-H 'Content-Type: application/json' \
-d '{"method":"GET","url":"https://httpbin.org/get","headers":{}}'
```
响应:
```json
{
"status": 200,
"headers": { "content-type": "application/json" },
"body": "{ ... }",
"timeMs": 320,
"size": 274
}
```
请求体 `body.mode` 支持 `json` / `xml` / `text` / `html` / `urlencoded` / `form-data`,`raw` 为原始报文;`timeoutMs` 可选。
## 流式代理
请求体与 HTTP 代理一致,响应为 NDJSON 事件流:
```text
{"type":"meta","status":200,"headers":{...}}
{"type":"chunk","data":"<base64>","size":1024}
{"type":"end","timeMs":1200,"size":8192}
```
## WebSocket 隧道
```bash
# 1. 建立会话(可带自定义 Header)
curl -s -X POST http://127.0.0.1:8787/v1/proxy/ws/connect \
-H 'Content-Type: application/json' \
-d '{"url":"wss://echo.example.com","headers":{"Authorization":"Bearer t"}}'
# → {"sessionId":"..."}
# 2. 浏览器 / 客户端 attach(普通 WebSocket)
# ws://127.0.0.1:8787/v1/proxy/ws/attach?sessionId=...
# 3. 关闭
curl -s -X POST http://127.0.0.1:8787/v1/proxy/ws/close \
-H 'Content-Type: application/json' -d '{"sessionId":"..."}'
```
attach 后第一条消息为控制帧 `{"type":"open"}`;此后浏览器与目标服务器双向透传。
## 安全边界
- 仅允许 `http/https`(代理)与 `ws/wss`(隧道)目标。
- 拒绝云元数据地址(169.254.169.254 等)防 SSRF。
- 单响应上限 10 MiB;隧道会话 10 分钟无活动自动回收。
## 部署建议
- 开发:`./run.sh web`(Vite :5173 反代 `/v1`、`/docs` 到 :8787)。
- 生产:`npm run build:web` 产物交给任意静态服务器,`opsxapi-server` 独立部署;生产环境建议置于内网并按需增加鉴权反向代理。
+45
View File
@@ -0,0 +1,45 @@
## 请求报 CORS 错误?
Web 端请求默认经本地 Gin 代理(gin-proxy)发出,不会遇到 CORS。若仍报错:
- 确认 Gin 在运行:`curl http://127.0.0.1:8787/health`。
- 用 `./run.sh web` / `gui` 场景启动(Vite 已配置 `/v1` 反代)。
- 桌面端优先走 Tauri 原生网络栈,同样不受 CORS 限制。
## 浏览器 WebSocket 不能加自定义 Header?
规范限制。在 WS「握手配置」里填写 Header 后,OpsxAPI 自动改走本地 Gin 隧道(见《WebSocket》章节),Header 由 Gin 在握手时代为携带。不填 Header 则直连。
## 外部 wss:// 地址连不上(DNS 失败)?
本机网络环境可能限制部分域名解析。可先用 `curl -v wss://…` 或换用公共 echo 服务验证;自建服务请确认防火墙与证书。
## MQTT 一定要 wss:// 吗?
浏览器只能通过 WebSocket 连 MQTT。常见 Broker 的 WS 端口:EMQX `8084/mqtt`(TLS)、`8083/mqtt`(明文,仅 http 页面)。`mqtt://` TCP 直连仅在桌面原生环境可行。
## 数据存在哪里?换电脑怎么办?
集合 / 环境 / 历史保存在浏览器 localStorage。迁移:**导出 → 全量备份** 生成 JSON,在新设备 **导入** 即可。
## 误删了接口 / 想回到初始状态?
设置(顶栏 ⋯)→ 重置工作区,恢复内置示例集合。日常请养成导出备份的习惯。
## SSE 事件流看着卡住了?
- 事件频率低(如告警流)属正常,等待即可。
- 部分网关会缓冲 SSE,工具会在直连失败时自动切换 Gin 流式代理;若仍无事件,检查中间层(Nginx 需关闭 `proxy_buffering`)。
## 发送按钮点击后没反应?
查看响应区是否有红色错误提示(如代理不可达)。`</>` 代码 生成 cURL 在终端执行可快速分辨是工具问题还是网络 / 接口问题。
## 支持文件上传吗?
form-data 当前支持文本字段;文件上传请用 `</>` 代码 生成的 cURL / 脚本执行。
## 更多问题?
- 服务端能力与边界:见《服务端 API 参考》
- 各协议用法:见左侧「请求调试」分组
@@ -0,0 +1,41 @@
# 账号与团队
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`server/data/opsxapi.db`),不上传任何第三方。
## 注册 / 登录
最左**活动栏**底部 **登录** 按钮打开弹窗:
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
- 登录态保存在本机,令牌失效会自动清除。
## 空间切换
活动栏**顶部空间徽标**(未登录时为产品 Logo,云端空间显示彩色徽标)点击弹出空间列表:
| 空间 | 说明 |
| --- | --- |
| 本地空间 | 未登录时默认;数据在浏览器 localStorage,不上云 |
| 我的空间 | 个人云端空间,注册即有;换浏览器 / 换设备登录即得同一份数据 |
| 团队 · ×× | 团队共享空间,成员读写同一份数据 |
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),活动栏底部头像上的状态点显示 已同步 / 同步中(闪烁)/ 同步失败(红);
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
## 团队管理
活动栏账号菜单或空间菜单 → **团队管理**:
- **新建团队**:创建后自动获得同名共享空间;
- **重命名 / 删除**:团队卡片右上角的 ✎ / 🗑(仅创建者可见)。重命名就地保存;删除需二次确认,将同时删除成员关系、共享空间与已发布 Mock 数据,不可恢复(若当前正停在该团队空间,会自动切回本地空间);
- **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间;
- **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。
## 数据与安全
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
- 云端空间单份数据上限 20MB;
- 团队空间权限:全体成员可读写,仅创建者可管理成员与修改他人擅长;
- 不登录不影响任何本地功能。
+155
View File
@@ -0,0 +1,155 @@
{
"productVersion": "1.0",
"groups": [
{
"name": "开始使用",
"pages": [
{
"file": "01-intro.md",
"slug": "intro",
"title": "产品介绍",
"updated": "2026-09-17"
},
{
"file": "02-quickstart.md",
"slug": "quickstart",
"title": "快速开始",
"updated": "2026-09-17"
},
{
"file": "03-interface.md",
"slug": "interface",
"title": "界面导览",
"updated": "2026-09-17"
}
]
},
{
"name": "请求调试",
"pages": [
{
"file": "04-http.md",
"slug": "http",
"title": "发送 HTTP 请求",
"updated": "2026-09-17"
},
{
"file": "05-auth.md",
"slug": "auth",
"title": "接口鉴权",
"updated": "2026-09-17"
},
{
"file": "06-env.md",
"slug": "env",
"title": "环境与变量",
"updated": "2026-09-17"
},
{
"file": "07-graphql.md",
"slug": "graphql",
"title": "GraphQL",
"updated": "2026-09-17"
},
{
"file": "08-websocket.md",
"slug": "websocket",
"title": "WebSocket",
"updated": "2026-09-17"
},
{
"file": "09-sse.md",
"slug": "sse",
"title": "SSE 事件流",
"updated": "2026-09-17"
},
{
"file": "10-socketio.md",
"slug": "socketio",
"title": "Socket.IO",
"updated": "2026-09-17"
},
{
"file": "11-mqtt.md",
"slug": "mqtt",
"title": "MQTT",
"updated": "2026-09-17"
}
]
},
{
"name": "数据管理",
"pages": [
{
"file": "12-import.md",
"slug": "import",
"title": "数据导入",
"updated": "2026-09-17"
},
{
"file": "13-export.md",
"slug": "export",
"title": "导出与代码生成",
"updated": "2026-09-17"
},
{
"file": "14-mock.md",
"slug": "mock",
"title": "Mock 数据",
"updated": "2026-09-17"
},
{
"file": "15-manage.md",
"slug": "manage",
"title": "集合与历史",
"updated": "2026-09-17"
}
]
},
{
"name": "自动化测试与 AI",
"pages": [
{
"file": "16-testing.md",
"slug": "testing",
"title": "自动化测试",
"updated": "2026-09-17"
},
{
"file": "17-ai.md",
"slug": "ai",
"title": "AI 助手",
"updated": "2026-09-17"
}
]
},
{
"name": "账号与团队",
"pages": [
{
"file": "20-account.md",
"slug": "account",
"title": "账号与团队",
"updated": "2026-09-17"
}
]
},
{
"name": "参考",
"pages": [
{
"file": "18-server-api.md",
"slug": "server-api",
"title": "服务端 API 参考",
"updated": "2026-09-17"
},
{
"file": "19-faq.md",
"slug": "faq",
"title": "常见问题",
"updated": "2026-09-17"
}
]
}
]
}
+264
View File
@@ -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 · OpsxAPI 文档</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)
}
+122
View File
@@ -0,0 +1,122 @@
package docs
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func newTestRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
New().Register(r)
return r
}
func TestDocsIndexRedirect(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil))
if w.Code != http.StatusFound {
t.Fatalf("expect 302, got %d", w.Code)
}
if !strings.HasPrefix(w.Header().Get("Location"), "/docs/") {
t.Fatalf("bad redirect: %s", w.Header().Get("Location"))
}
}
func TestDocsAllPagesRender(t *testing.T) {
h := New()
r := newTestRouter(t)
for _, slug := range h.order {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/docs/"+slug, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("page %s: expect 200, got %d", slug, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "<title>") || !strings.Contains(body, `class="md"`) {
t.Fatalf("page %s: html structure incomplete", slug)
}
// 正文有渲染出的标题与段落
if !strings.Contains(body, "<h1") {
t.Fatalf("page %s: missing h1", slug)
}
// 侧栏包含全部分组
for _, g := range h.manifest.Groups {
if !strings.Contains(body, g.Name) {
t.Fatalf("page %s: sidebar missing group %s", slug, g.Name)
}
}
}
}
func TestDocsPageContainsTOCAndNav(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/http", nil))
body := w.Body.String()
if !strings.Contains(body, "sec-1") {
t.Fatal("expect heading anchors")
}
if !strings.Contains(body, "上一页") {
t.Fatal("expect prev link")
}
}
func TestDocsSearchData(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/search-data.json", nil))
if w.Code != http.StatusOK {
t.Fatalf("expect 200, got %d", w.Code)
}
var entries []SearchEntry
if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil {
t.Fatalf("invalid json: %v", err)
}
if len(entries) < 10 {
t.Fatalf("expect >= 10 search entries, got %d", len(entries))
}
// MQTT 页应可被检索(标题或正文命中)
found := false
for _, e := range entries {
if e.Slug == "mqtt" && (e.Title == "MQTT" || strings.Contains(e.Text, "MQTT")) {
found = true
}
}
if !found {
t.Fatal("mqtt entry missing searchable text")
}
}
func TestDocsAssetsAnd404(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/assets/docs.css", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/css") {
t.Fatalf("css asset: %d %s", w.Code, w.Header().Get("Content-Type"))
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/assets/docs.js", nil))
if w.Code != http.StatusOK {
t.Fatalf("js asset: %d", w.Code)
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/not-exist-page", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("404 page: %d", w.Code)
}
if !strings.Contains(w.Body.String(), "返回文档首页") {
t.Fatal("404 page missing home link")
}
}
+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()
}
+110
View File
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Page.Title}} · OpsxAPI 文档</title>
<meta name="description" content="OpsxAPI 接口工作台用户文档 — HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 一站式调试,支持 OpenAPI/Postman/cURL 等格式导入导出。">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232563eb'/%3E%3Ctext x='16' y='22' font-size='18' font-weight='bold' fill='%23fff' text-anchor='middle' font-family='sans-serif'%3EO%3C/text%3E%3C/svg%3E">
<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%232563EB%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2306B6D4%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%28%23g%29%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M17%2025h26%22%2F%3E%3Cpath%20d%3D%22M37%2019l6%206-6%206%22%2F%3E%3Cpath%20d%3D%22M47%2039H21%22%2F%3E%3Cpath%20d%3D%22M27%2033l-6%206%206%206%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>
<symbol id="i-rocket" viewBox="0 0 24 24"><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /> <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09" /> <path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05" /></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-arrow-left-right" viewBox="0 0 24 24"><path d="M8 3 4 7l4 4" /> <path d="M4 7h16" /> <path d="m16 21 4-4-4-4" /> <path d="M20 17H4" /></symbol>
<symbol id="i-plug-zap" viewBox="0 0 24 24"><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z" /> <path d="m2 22 3-3" /> <path d="M7.5 13.5 10 11" /> <path d="M10.5 16.5 13 14" /> <path d="m18 3-4 4h6l-4 4" /></symbol>
<symbol id="i-package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /> <path d="M12 22V12" /> <polyline points="3.29 7 12 12 20.71 7" /> <path d="m7.5 4.27 9 5.15" /></symbol>
<symbol id="i-folder-tree" viewBox="0 0 24 24"><path d="M20 10a1 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="M20 21a1 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-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /> <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /> <path d="M2 12h20" /></symbol>
<symbol id="i-hexagon" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></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-radio" viewBox="0 0 24 24"><path d="M16.247 7.761a6 6 0 0 1 0 8.478" /> <path d="M19.075 4.933a10 10 0 0 1 0 14.134" /> <path d="M4.925 19.067a10 10 0 0 1 0-14.134" /> <path d="M7.753 16.239a6 6 0 0 1 0-8.478" /> <circle cx="12" cy="12" r="2" /></symbol>
<symbol id="i-mailbox" viewBox="0 0 24 24"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z" /> <polyline points="15,9 18,9 18,11" /> <path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2" /> <line x1="6" x2="7" y1="10" y2="10" /></symbol>
<symbol id="i-code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6" /> <path d="m8 6-6 6 6 6" /></symbol>
<symbol id="i-book-open" viewBox="0 0 24 24"><path d="M12 5v16" /> <path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" /></symbol>
<symbol id="i-sprout" viewBox="0 0 24 24"><path d="M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3" /> <path d="M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4" /> <path d="M5 21h14" /></symbol>
<symbol id="i-graduation-cap" viewBox="0 0 24 24"><path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" /> <path d="M22 10v6" /> <path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" /></symbol>
<symbol id="i-credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2" /> <line x1="2" x2="22" y1="10" y2="10" /> <path d="M6 14h2" /></symbol>
<symbol id="i-megaphone" viewBox="0 0 24 24"><path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" /> <path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" /> <path d="M8 6v8" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-library" viewBox="0 0 24 24"><path d="m16 6 4 14" /> <path d="M12 6v14" /> <path d="M8 8v12" /> <path d="M4 4v16" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></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="OpsxAPI"><defs><linearGradient id="lg-docs" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2563EB"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-docs)"/><g stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"><path d="M17 25h26"/><path d="M37 19l6 6-6 6"/><path d="M47 39H21"/><path d="M27 33l-6 6 6 6"/></g></svg></span>
<span class="brand-name">OpsxAPI <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="/" target="_blank" rel="noopener">打开工作台 ↗</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 OpsxAPI · 本地优先的接口工作台</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>