refactor(homepage): 官网/文档/定价源码迁出程序 + 新增独立授权与激活页 /license
- 官网源码归拢到 homepage/(index/pricing/terms/license + content/ + assets/ + docs-assets/),
删除程序内 go:embed 的 server/internal/{site,docs},后端仅静态托管 homepage/dist 做本地预览
- 新增独立静态页 /license(授权与激活):交付与激活四步(邮件交付授权码)、
在线授权码 LIC- 与离线许可证 LICOFF- 对比、授权服务器 licence.rlwj.cn 通信范围说明、
授权类型与价格、10 条授权 FAQ
- tools/homepage-build 支持可选单页,产出 <name>.html 与 <name>/index.html
- 导航/页脚接入 /license,跨页锚点补 / 前缀,页脚底栏文案同步为静态站口径
This commit is contained in:
1 parent
327a265572
commit
3cb9545121
49 files changed
+1308
-600
No files matched your search
@@ -1,246 +0,0 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/* 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, "&").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 = "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");
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,36 +0,0 @@
|
||||
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 秒发出第一个请求。
|
||||
@@ -1,48 +0,0 @@
|
||||
## 环境要求
|
||||
|
||||
| 组件 | 版本 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 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。
|
||||
|
||||
> **提示**:首次打开会自动创建两个环境(正式 / 本地)和一个覆盖全部协议的示例集合,可随时在「设置」中重置。
|
||||
@@ -1,41 +0,0 @@
|
||||
OpsxAPI 主界面分为五个区域:
|
||||
|
||||
## 顶栏
|
||||
|
||||
| 元素 | 作用 |
|
||||
| --- | --- |
|
||||
| + 新建 | 快捷创建各协议请求 / 目录(默认建在第一个集合) |
|
||||
| 导入 / 导出 | 打开数据导入、导出对话框 |
|
||||
| 环境选择器 | 切换当前环境(变量随之生效),「⚙ 环境」管理环境与变量 |
|
||||
| 🌙 / ☀️ | 明暗主题切换 |
|
||||
| ⋯ | 设置(主题、超时、重置工作区) |
|
||||
|
||||
## 左侧集合树
|
||||
|
||||
- 点击请求 → 在标签页中打开;点击目录 → 展开 / 折叠。
|
||||
- **右键**任意节点:新建、重命名、复制、导出、删除。
|
||||
- **拖拽**请求或目录到另一个目录,即可移动(支持跨集合)。
|
||||
- 顶部搜索框按名称 / 方法 / URL 实时过滤。
|
||||
- 底部为**历史记录**:显示最近请求的状态码与耗时,点击可回跳到对应接口。
|
||||
|
||||
## 标签页栏
|
||||
|
||||
同时打开多个接口,中键或 × 关闭;右键支持「关闭其他 / 关闭全部」。正在连接中的实时协议标签会显示呼吸圆点。
|
||||
|
||||
## 请求编辑区(上半部分)
|
||||
|
||||
按协议呈现不同编辑器:
|
||||
|
||||
| 协议 | 标签页 |
|
||||
| --- | --- |
|
||||
| HTTP | Params · Body · Headers · Cookies · Auth · 说明 |
|
||||
| GraphQL | Query · Variables · Headers · Auth |
|
||||
| WebSocket | 消息 · 握手配置 · 说明 |
|
||||
| SSE | 事件流 · 连接配置 · 说明 |
|
||||
| Socket.IO | 事件 · 连接配置 · 说明 |
|
||||
| MQTT | 消息 · Broker / 订阅 · 发布 · 说明 |
|
||||
|
||||
## 响应区(下半部分)
|
||||
|
||||
- **HTTP / GraphQL**:响应体(Pretty JSON 高亮)、Raw、响应头,右上角状态 / 耗时 / 大小 / 复制。
|
||||
- **实时协议**:分色消息日志(接收 · 绿、发送 · 蓝、系统 · 灰、错误 · 红),顶部实时统计收发条数与连接时长。
|
||||
@@ -1,47 +0,0 @@
|
||||
## 发送请求
|
||||
|
||||
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**,并提供「复制体」按钮。
|
||||
@@ -1,29 +0,0 @@
|
||||
在 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。
|
||||
@@ -1,67 +0,0 @@
|
||||
## 前置 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 栏下方实时显示替换后的实际地址,切换环境立即更新。
|
||||
|
||||
> **提示**:路径参数用变量而不是写死,是接口可复用的关键 —— 复制接口后只改变量值即可调试另一个资源。
|
||||
@@ -1,32 +0,0 @@
|
||||
## 编辑查询
|
||||
|
||||
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 请求。
|
||||
@@ -1,34 +0,0 @@
|
||||
## 连接
|
||||
|
||||
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 请求节点。
|
||||
@@ -1,29 +0,0 @@
|
||||
## 连接事件流
|
||||
|
||||
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`)
|
||||
- 监控 / 审计事件流
|
||||
- 需要长期挂机观察的推送通道
|
||||
@@ -1,45 +0,0 @@
|
||||
## 连接
|
||||
|
||||
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,或把服务与本工具部署在同源下。
|
||||
@@ -1,41 +0,0 @@
|
||||
## 连接 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 与主题逐条标注。
|
||||
@@ -1,40 +0,0 @@
|
||||
顶栏 **导入** 打开导入对话框,支持三种来源:
|
||||
|
||||
| 来源 | 说明 |
|
||||
| --- | --- |
|
||||
| 粘贴文本 / 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` | 当前环境变量 |
|
||||
|
||||
## 结果确认
|
||||
|
||||
导入完成显示汇总:新建集合、接口数量、写入的环境变量与告警(如跳过的文件上传字段)。集合名重复时自动追加序号。
|
||||
@@ -1,30 +0,0 @@
|
||||
顶栏 **导出**(多集合时可选择目标集合):
|
||||
|
||||
| 格式 | 用途 |
|
||||
| --- | --- |
|
||||
| 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`。
|
||||
@@ -1,90 +0,0 @@
|
||||
# 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 定位是联调辅助,请勿当作对外流量服务使用。
|
||||
@@ -1,39 +0,0 @@
|
||||
## 新建
|
||||
|
||||
| 入口 | 行为 |
|
||||
| --- | --- |
|
||||
| 顶栏「+ 新建」 | 快捷菜单,建在第一个集合 |
|
||||
| 树右键「新建请求 / 目录」 | 精确指定位置 |
|
||||
| 新建对话框 | 选择类型(六协议 + 目录)、命名、目标集合与目录 |
|
||||
|
||||
接口名自动避免重复(追加序号)。
|
||||
|
||||
## 集合树操作
|
||||
|
||||
| 操作 | 方式 |
|
||||
| --- | --- |
|
||||
| 重命名 | 右键 → 重命名 |
|
||||
| 复制 | 右键 → 复制(深拷贝子树,名字加「副本」) |
|
||||
| 移动 | 拖拽到目标目录(支持跨集合) |
|
||||
| 删除 | 右键 → 删除(直接生效,请先备份重要数据) |
|
||||
| 搜索 | 侧栏搜索框,匹配名称 / 方法 / URL |
|
||||
|
||||
## 标签页
|
||||
|
||||
- 点击树节点在标签页打开;同时打开数量不限。
|
||||
- 中键 / × 关闭;右键「关闭其他 / 关闭全部」。
|
||||
- 关闭实时协议(WS / SSE / MQTT…)标签时自动断开连接。
|
||||
|
||||
## 历史记录
|
||||
|
||||
- 每次 HTTP / GraphQL 发送记录:时间、方法、接口名、状态码,失败标红。
|
||||
- 点击记录跳回对应接口;最多保留 100 条;可一键清空。
|
||||
|
||||
## 数据存储与备份
|
||||
|
||||
- 位置:浏览器 localStorage(键 `opsxapi.workspace.v2`),自动保存(防抖 400ms)。
|
||||
- 内容:集合、环境、历史、设置。标签页会话与实时消息不落盘。
|
||||
- 备份:导出 → 全量备份;恢复:导入备份文件。
|
||||
- 重置:设置 → 重置工作区(恢复初始示例,请先备份)。
|
||||
|
||||
> **注意**:桌面端与浏览器是两份独立的 localStorage。跨端迁移请使用全量备份 / 导入。
|
||||
@@ -1,41 +0,0 @@
|
||||
# 自动化测试
|
||||
|
||||
把日常联调的接口串成**场景用例**,一条龙验证业务流程。三种触发方式共用同一套用例模型:
|
||||
|
||||
| 方式 | 说明 | 适合 |
|
||||
| --- | --- | --- |
|
||||
| 手动运行 | 用例编排好后点「运行」 | 日常自测、回归 |
|
||||
| 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 示例),按业务依赖编排步骤、断言与变量提取,生成前可附加一句要求(如「重点覆盖登录态与分页」),预览确认后保存。
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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 协议)。
|
||||
@@ -1,75 +0,0 @@
|
||||
本地服务(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` 独立部署;生产环境建议置于内网并按需增加鉴权反向代理。
|
||||
@@ -1,45 +0,0 @@
|
||||
## 请求报 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 参考》
|
||||
- 各协议用法:见左侧「请求调试」分组
|
||||
@@ -1,41 +0,0 @@
|
||||
# 账号与团队
|
||||
|
||||
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`server/data/opsxapi.db`),不上传任何第三方。
|
||||
|
||||
## 注册 / 登录
|
||||
|
||||
最左**活动栏**底部 **登录** 按钮打开弹窗:
|
||||
|
||||
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
|
||||
- 登录态保存在本机,令牌失效会自动清除。
|
||||
|
||||
## 空间切换
|
||||
|
||||
活动栏**顶部空间徽标**(未登录时为产品 Logo,云端空间显示彩色徽标)点击弹出空间列表:
|
||||
|
||||
| 空间 | 说明 |
|
||||
| --- | --- |
|
||||
| 本地空间 | 未登录时默认;数据在浏览器 localStorage,不上云 |
|
||||
| 我的空间 | 个人云端空间,注册即有;换浏览器 / 换设备登录即得同一份数据 |
|
||||
| 团队 · ×× | 团队共享空间,成员读写同一份数据 |
|
||||
|
||||
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
|
||||
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),活动栏底部头像上的状态点显示 已同步 / 同步中(闪烁)/ 同步失败(红);
|
||||
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
|
||||
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
|
||||
|
||||
## 团队管理
|
||||
|
||||
活动栏账号菜单或空间菜单 → **团队管理**:
|
||||
|
||||
- **新建团队**:创建后自动获得同名共享空间;
|
||||
- **重命名 / 删除**:团队卡片右上角的 ✎ / 🗑(仅创建者可见)。重命名就地保存;删除需二次确认,将同时删除成员关系、共享空间与已发布 Mock 数据,不可恢复(若当前正停在该团队空间,会自动切回本地空间);
|
||||
- **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间;
|
||||
- **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
|
||||
- 云端空间单份数据上限 20MB;
|
||||
- 团队空间权限:全体成员可读写,仅创建者可管理成员与修改他人擅长;
|
||||
- 不登录不影响任何本地功能。
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -9,11 +10,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxapi/server/internal/api"
|
||||
"opsxapi/server/internal/docs"
|
||||
"opsxapi/server/internal/echo"
|
||||
"opsxapi/server/internal/mocksrv"
|
||||
"opsxapi/server/internal/proxy"
|
||||
"opsxapi/server/internal/site"
|
||||
)
|
||||
|
||||
func NewRouter() *gin.Engine {
|
||||
@@ -43,15 +42,16 @@ func NewRouter() *gin.Engine {
|
||||
mocksrv.New(accountAPI.Store()).Register(r)
|
||||
|
||||
echo.Mount(r)
|
||||
// 官网/文档/定价已拆分独立静态站(源在 homepage/,构建见 scripts/build-homepage.sh),
|
||||
// 本地仅静态托管 homepage/dist 预览;不再内置 embed 兜底,与后端彻底解耦。
|
||||
if !mountHomepage(r) {
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
log.Printf("[warn] homepage/dist 未找到,官网/文档/定价未挂载;请先运行 scripts/build-homepage.sh 生成产物")
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// mountHomepage 本地预览 homepage/dist(落地页 + 文档)。不改 homepage/ 源。
|
||||
// mountHomepage 本地预览 homepage/dist(落地页 + 文档 + 定价)。只读产物,不碰 homepage/ 源。
|
||||
func mountHomepage(r *gin.Engine) bool {
|
||||
for _, dir := range []string{
|
||||
os.Getenv("OPSXAPI_HOMEPAGE"),
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/* OpsxAPI 官网 —— 营销单页样式 */
|
||||
:root {
|
||||
--blue: #2563eb;
|
||||
--blue-dark: #1d4ed8;
|
||||
--blue-soft: rgba(37, 99, 235, 0.08);
|
||||
--bg: #ffffff;
|
||||
--bg-alt: #f6f8fc;
|
||||
--border: #e4e9f1;
|
||||
--text: #111827;
|
||||
--text-2: #5b6880;
|
||||
--text-3: #98a2b3;
|
||||
--green: #059669;
|
||||
--amber: #d97706;
|
||||
--radius: 14px;
|
||||
--shadow: 0 1px 3px rgba(16,24,40,.06), 0 12px 32px rgba(16,24,40,.07);
|
||||
--mono: "JetBrains Mono", SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.75;
|
||||
}
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
h1,h2,h3 { letter-spacing: -0.015em; }
|
||||
section[id] { scroll-margin-top: 76px; }
|
||||
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
|
||||
.wrap.narrow { max-width: 820px; }
|
||||
/* ---------- SVG 图标(lucide sprite) ---------- */
|
||||
.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; }
|
||||
.i-lg { width: 28px; height: 28px; vertical-align: middle; }
|
||||
code { font-family: var(--mono); font-size: 13px; background: var(--blue-soft); color: var(--blue-dark); border-radius: 5px; padding: 1px 6px; }
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
border-radius: 10px; padding: 9px 20px; font-size: 14.5px; font-weight: 600; cursor: pointer;
|
||||
transition: all .18s; border: 1px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.btn-lg { padding: 12px 26px; font-size: 15.5px; }
|
||||
.btn-primary { background: var(--blue); color: #fff !important; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
|
||||
.btn-primary:hover { background: var(--blue-dark); transform: translateY(-1px); }
|
||||
.btn-ghost { border-color: var(--border); color: var(--text) !important; background: #fff; }
|
||||
.btn-ghost:hover { border-color: var(--blue); color: var(--blue) !important; }
|
||||
.btn-inverse { background: #fff; color: var(--blue) !important; }
|
||||
.btn-inverse:hover { transform: translateY(-1px); }
|
||||
.btn-outline { border-color: rgba(255,255,255,.5); color: #fff !important; }
|
||||
.btn-outline:hover { border-color: #fff; background: rgba(255,255,255,.1); }
|
||||
|
||||
/* ---------- 导航 ---------- */
|
||||
.nav {
|
||||
position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,.9);
|
||||
backdrop-filter: blur(10px); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-inner { max-width: 1120px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 28px; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; }
|
||||
.brand-logo { display: inline-flex; }
|
||||
.brand-logo svg { display: block; border-radius: 8px; }
|
||||
.brand-name { font-size: 17px; font-weight: 800; color: var(--text) !important; }
|
||||
.nav-links { display: flex; gap: 22px; }
|
||||
.nav-links a { font-size: 14px; font-weight: 500; color: var(--text-2) !important; }
|
||||
.nav-links a:hover { color: var(--blue) !important; }
|
||||
.nav-cta { margin-left: auto; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 85% -10%, rgba(37,99,235,.14), transparent 60%),
|
||||
radial-gradient(800px 400px at -10% 10%, rgba(5,150,105,.08), transparent 55%),
|
||||
linear-gradient(180deg, #f8faff 0%, #fff 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero-inner {
|
||||
max-width: 1120px; margin: 0 auto; padding: 72px 24px 64px;
|
||||
display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 48px; align-items: center;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--blue-dark);
|
||||
background: #fff; border: 1px solid rgba(37,99,235,.25); border-radius: 999px; padding: 4px 14px;
|
||||
box-shadow: 0 2px 8px rgba(37,99,235,.1); margin-bottom: 18px;
|
||||
}
|
||||
.hero h1 { font-size: 44px; line-height: 1.22; font-weight: 800; margin: 0 0 16px; }
|
||||
.hero h1 em { font-style: normal; color: var(--blue); }
|
||||
.hero-sub { color: var(--text-2); font-size: 16px; margin: 0 0 26px; }
|
||||
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.hero-actions.center { justify-content: center; }
|
||||
.hero-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
|
||||
.hero-pills span {
|
||||
font-size: 12px; color: var(--text-2); background: #fff; border: 1px solid var(--border);
|
||||
padding: 3px 12px; border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ---------- Mock 界面 ---------- */
|
||||
.mock {
|
||||
border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); border: 1px solid #1e2b45;
|
||||
background: #0d1526; transform: perspective(1400px) rotateY(-4deg) rotateX(1.5deg);
|
||||
}
|
||||
.mock-titlebar {
|
||||
display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 12px;
|
||||
background: #101a30; border-bottom: 1px solid #1e2b45;
|
||||
}
|
||||
.mock-titlebar i { width: 11px; height: 11px; border-radius: 50%; background: #3a4a6b; }
|
||||
.mock-titlebar i:nth-child(1) { background: #f87171; } .mock-titlebar i:nth-child(2) { background: #fbbf24; } .mock-titlebar i:nth-child(3) { background: #34d399; }
|
||||
.mock-titlebar span { margin-left: 8px; color: #8b99b0; font-size: 11px; }
|
||||
.mock-body { display: grid; grid-template-columns: 158px minmax(0,1fr); min-height: 320px; }
|
||||
.mock-side { border-right: 1px solid #1e2b45; padding: 10px 8px; background: #0c1322; }
|
||||
.mock-search { height: 22px; border-radius: 6px; background: #16213a; margin-bottom: 10px; }
|
||||
.mock-group { color: #8b99b0; font-size: 11px; padding: 6px 6px 3px; }
|
||||
.mock-item { display: flex; align-items: center; gap: 6px; color: #aab8d0; font-size: 11.5px; padding: 4px 6px; border-radius: 6px; }
|
||||
.mock-item.active { background: rgba(37,99,235,.18); color: #93c5fd; }
|
||||
.mi { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; opacity: .8; }
|
||||
.m-get { color: #34d399; font-style: normal; font-size: 9.5px; min-width: 30px; }
|
||||
.m-post { color: #fbbf24; font-style: normal; font-size: 9.5px; min-width: 30px; }
|
||||
.mock-main { padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.mock-urlbar { display: flex; align-items: center; gap: 8px; }
|
||||
.mock-url {
|
||||
flex: 1; background: #0a1120; border: 1px solid #22304f; border-radius: 7px; color: #cbd5e1;
|
||||
font-family: var(--mono); font-size: 11px; padding: 6px 10px;
|
||||
}
|
||||
.mock-send {
|
||||
background: var(--blue); color: #fff; font-size: 11px; font-weight: 600;
|
||||
border-radius: 7px; padding: 6px 14px;
|
||||
}
|
||||
.mock-req, .mock-resp { border: 1px solid #1e2b45; border-radius: 9px; padding: 8px 10px; background: #0c1322; }
|
||||
.mock-req .tab, .mock-resp .tab { color: #5c6a83; font-size: 10.5px; margin-right: 12px; }
|
||||
.mock-req .tab.on { color: #93c5fd; border-bottom: 2px solid var(--blue); padding-bottom: 2px; }
|
||||
.mock-resp .tab.on { color: #6ee7b7; border-bottom: 2px solid var(--green); padding-bottom: 2px; }
|
||||
.mock-status { float: right; color: #34d399; font-size: 10px; font-family: var(--mono); }
|
||||
.mock pre {
|
||||
margin: 8px 0 2px; font-family: var(--mono); font-size: 11px; line-height: 1.6; color: #cbd5e1;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.mock pre i { font-style: normal; color: #7dd3fc; }
|
||||
.mock pre u { text-decoration: none; color: #86efac; }
|
||||
|
||||
/* ---------- 通用 Section ---------- */
|
||||
.section { padding: 72px 0; }
|
||||
.section.alt { background: var(--bg-alt); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.sec-title { font-size: 30px; font-weight: 800; text-align: center; margin: 0 0 8px; }
|
||||
.sec-sub { text-align: center; color: var(--text-2); margin: 0 0 40px; }
|
||||
.grid { display: grid; gap: 18px; }
|
||||
.g3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.g4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
|
||||
|
||||
/* ---------- 卡片 ---------- */
|
||||
.card {
|
||||
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px; box-shadow: 0 1px 2px rgba(16,24,40,.04); transition: all .18s;
|
||||
}
|
||||
.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: rgba(37,99,235,.35); }
|
||||
.card h3 { font-size: 16px; margin: 0 0 6px; }
|
||||
.card p { color: var(--text-2); font-size: 13.5px; margin: 0; }
|
||||
.card-icon { color: var(--blue); margin-bottom: 10px; }
|
||||
.card-icon .i-lg { width: 30px; height: 30px; }
|
||||
.card.row { display: flex; gap: 14px; align-items: flex-start; }
|
||||
.card.row .card-icon { margin: 2px 0 0; }
|
||||
.card.mini h3 { font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||
.card.mini h3 .i-lg { width: 20px; height: 20px; color: var(--blue); }
|
||||
.card.mini p { font-size: 13px; }
|
||||
|
||||
/* ---------- 协议 ---------- */
|
||||
.proto {
|
||||
background: #fff; border: 1px solid var(--border); border-left: 3px solid var(--blue);
|
||||
border-radius: 12px; padding: 18px 20px;
|
||||
}
|
||||
.proto h3 { margin: 0 0 6px; font-size: 15.5px; display: flex; align-items: center; gap: 8px; }
|
||||
.proto h3 .i { color: var(--blue); width: 17px; height: 17px; }
|
||||
.proto p { margin: 0; color: var(--text-2); font-size: 13.5px; }
|
||||
|
||||
/* ---------- 导入导出 ---------- */
|
||||
.badges { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 18px; }
|
||||
.badges span {
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 999px;
|
||||
padding: 6px 16px; font-size: 13px; font-weight: 500; color: var(--text);
|
||||
}
|
||||
.badges span.more { border-style: dashed; color: var(--text-2); }
|
||||
.footnote { text-align: center; color: var(--text-3); font-size: 12.5px; margin: 10px 0 0; }
|
||||
.exchange-actions { display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; margin-top: 26px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 8px 16px;
|
||||
font-size: 13px; font-weight: 600; color: var(--text-2);
|
||||
}
|
||||
.chip.big { background: var(--blue); border-color: var(--blue); color: #fff; padding: 10px 22px; }
|
||||
.exchange-arrow { color: var(--text-3); font-size: 15px; }
|
||||
|
||||
/* ---------- 对比表 ---------- */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.cmp { border-collapse: collapse; width: 100%; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px var(--border); font-size: 14px; }
|
||||
.cmp th, .cmp td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
.cmp th { background: #f9fbfe; font-size: 13.5px; }
|
||||
.cmp tr:last-child td { border-bottom: none; }
|
||||
.cmp td.hl { background: rgba(37,99,235,.05); color: var(--blue-dark); }
|
||||
.cmp th.hl { background: var(--blue); color: #fff; }
|
||||
|
||||
/* ---------- 定价 ---------- */
|
||||
.pricing { display: grid; grid-template-columns: repeat(2, minmax(0, 380px)); gap: 22px; justify-content: center; }
|
||||
.pricing.three { grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 1080px; margin: 0 auto; }
|
||||
.price-card {
|
||||
position: relative; background: #fff; border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 30px 28px 26px; box-shadow: 0 1px 2px rgba(16,24,40,.04); display: flex; flex-direction: column;
|
||||
}
|
||||
.price-card.featured { border: 2px solid var(--blue); box-shadow: 0 12px 36px rgba(37,99,235,.16); }
|
||||
.price-card.enterprise {
|
||||
background: linear-gradient(160deg, #101a30 0%, #0d1526 70%);
|
||||
border: 1px solid #2a3b5e; box-shadow: 0 14px 40px rgba(13,21,38,.4);
|
||||
}
|
||||
.price-card.enterprise .plan { color: #8b99b0; }
|
||||
.price-card.enterprise .price b { color: #fff; font-size: 36px; }
|
||||
.price-card.enterprise .price span { color: #8b99b0; }
|
||||
.price-card.enterprise .plan-sub { color: #8b99b0; }
|
||||
.price-card.enterprise li { color: #aab8d0; border-bottom-color: #22304f; }
|
||||
.price-card.enterprise .deal-note {
|
||||
margin-top: 14px; font-size: 11.5px; line-height: 1.7; color: #8b99b0;
|
||||
background: rgba(255,255,255,.05); border: 1px dashed #2a3b5e; border-radius: 9px; padding: 9px 12px;
|
||||
}
|
||||
.price-card.enterprise .deal-note b { color: #93c5fd; }
|
||||
.btn-dark { background: #fff; color: #0d1526 !important; }
|
||||
.btn-dark:hover { transform: translateY(-1px); background: #e8eefc; }
|
||||
.plan-tag {
|
||||
position: absolute; top: -13px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--blue); color: #fff; font-size: 12px; font-weight: 700;
|
||||
padding: 3px 14px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.plan-tag.dark { background: #0d1526; border: 1px solid #2a3b5e; }
|
||||
.plan { font-size: 16px; font-weight: 700; color: var(--text-2); }
|
||||
.price { margin: 8px 0 2px; }
|
||||
.price b { font-size: 44px; font-weight: 800; letter-spacing: -.02em; color: var(--text); }
|
||||
.price span { color: var(--text-3); font-size: 14px; margin-left: 4px; }
|
||||
.plan-sub { color: var(--text-3); font-size: 13px; margin-bottom: 18px; }
|
||||
.price-card ul { list-style: none; padding: 0; margin: 0 0 22px; flex: 1; }
|
||||
.price-card li { font-size: 13.5px; color: var(--text-2); padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.price-card li:last-child { border-bottom: none; }
|
||||
.price-card .btn { width: 100%; }
|
||||
.growth-title { text-align: center; font-size: 20px; font-weight: 700; margin: 52px 0 22px; }
|
||||
|
||||
/* ---------- FAQ ---------- */
|
||||
.faq details {
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px 20px; margin-bottom: 12px;
|
||||
}
|
||||
.faq summary { font-weight: 600; cursor: pointer; font-size: 15px; list-style: none; display: flex; justify-content: space-between; }
|
||||
.faq summary::after { content: "+"; color: var(--text-3); font-weight: 400; }
|
||||
.faq details[open] summary::after { content: "-"; }
|
||||
.faq details p { margin: 10px 0 0; color: var(--text-2); font-size: 14px; }
|
||||
|
||||
/* ---------- CTA ---------- */
|
||||
.cta {
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #3b82f6 100%);
|
||||
color: #fff; text-align: center; padding: 72px 24px;
|
||||
}
|
||||
.cta h2 { font-size: 32px; font-weight: 800; margin: 0 0 10px; }
|
||||
.cta p { color: rgba(255,255,255,.85); margin: 0 0 28px; }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.footer { background: #0d1526; color: #8b99b0; padding: 52px 0 0; font-size: 13.5px; }
|
||||
.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; padding-bottom: 36px; }
|
||||
.footer .brand-name { color: #fff !important; }
|
||||
.footer .f-logo { display: block; border-radius: 8px; }
|
||||
.footer .brand { display: flex; align-items: center; gap: 9px; }
|
||||
.footer-desc { margin: 12px 0 0; font-size: 13px; line-height: 1.8; }
|
||||
.footer h4 { color: #fff; font-size: 13.5px; margin: 4px 0 12px; }
|
||||
.footer a { display: block; color: #8b99b0 !important; padding: 4px 0; font-size: 13px; }
|
||||
.footer a:hover { color: #fff !important; }
|
||||
.footer-bar { border-top: 1px solid #1e2b45; text-align: center; padding: 18px; font-size: 12px; color: #5c6a83; }
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
@media (max-width: 1020px) {
|
||||
.hero-inner { grid-template-columns: 1fr; padding-top: 52px; }
|
||||
.mock { transform: none; }
|
||||
.g4 { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.g3 { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.pricing, .pricing.three { grid-template-columns: minmax(0, 420px); }
|
||||
.footer-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.nav-links { display: none; }
|
||||
.hero h1 { font-size: 32px; }
|
||||
.sec-title { font-size: 24px; }
|
||||
.g3, .g4 { grid-template-columns: 1fr; }
|
||||
.footer-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Package site 在 /home(及 /)提供产品官网单页(SSR,资源嵌入二进制)。
|
||||
package site
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed template.html terms.html assets
|
||||
var files embed.FS
|
||||
|
||||
// Handler 官网服务
|
||||
type Handler struct {
|
||||
html string
|
||||
terms string
|
||||
}
|
||||
|
||||
func New() *Handler {
|
||||
raw, err := files.ReadFile("template.html")
|
||||
if err != nil {
|
||||
panic("site: template.html 缺失: " + err.Error())
|
||||
}
|
||||
terms, err := files.ReadFile("terms.html")
|
||||
if err != nil {
|
||||
panic("site: terms.html 缺失: " + err.Error())
|
||||
}
|
||||
return &Handler{html: string(raw), terms: string(terms)}
|
||||
}
|
||||
|
||||
// Register 注册路由:/ 与 /home 均渲染官网
|
||||
func (h *Handler) Register(r *gin.Engine) {
|
||||
r.GET("/", h.Page)
|
||||
r.GET("/home", h.Page)
|
||||
r.GET("/index.html", h.Page)
|
||||
r.GET("/site/assets/*file", h.Asset)
|
||||
r.GET("/terms", h.Terms)
|
||||
}
|
||||
|
||||
// Terms 渲染服务条款
|
||||
func (h *Handler) Terms(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
_, _ = c.Writer.WriteString(h.terms)
|
||||
}
|
||||
|
||||
// Page 渲染官网
|
||||
func (h *Handler) Page(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
_, _ = c.Writer.WriteString(h.html)
|
||||
}
|
||||
|
||||
// Asset 静态资源
|
||||
func (h *Handler) Asset(c *gin.Context) {
|
||||
name := strings.TrimPrefix(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)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"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 TestSitePageRender(t *testing.T) {
|
||||
r := newTestRouter(t)
|
||||
for _, path := range []string{"/", "/home", "/index.html"} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s: expect 200, got %d", path, w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"<title>OpsxAPI", // 标题
|
||||
`id="features"`, // 功能区
|
||||
`id="pricing"`, // 定价区
|
||||
"¥99", // 入门价
|
||||
"¥8,000", // 企业档标价
|
||||
"¥30,000", // 买断标价
|
||||
"¥5,000–8,000", // 成交区间
|
||||
"永久买断", // 买断 FAQ
|
||||
"早鸟创始价", // 让利策略
|
||||
"Socket.IO", // 协议覆盖
|
||||
"hello@opsxapi.dev", // 联系方式
|
||||
`href="/docs"`, // 文档互链
|
||||
`href="/site/assets/site.css"`, // 样式资源
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("%s: missing %q", path, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteAsset(t *testing.T) {
|
||||
r := newTestRouter(t)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/site/assets/site.css", nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/css") {
|
||||
t.Fatalf("css: %d %s", w.Code, w.Header().Get("Content-Type"))
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/site/assets/../template.html", nil))
|
||||
if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
|
||||
t.Fatalf("path traversal should be rejected, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpsxAPI — 本地优先的全协议接口工作台</title>
|
||||
<meta name="description" content="OpsxAPI:HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 一站式调试。本地优先数据主权,OpenAPI/Postman/cURL 全格式导入导出。个人版 ¥99/年,团队版 ¥99/人/年。">
|
||||
<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="/site/assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<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>
|
||||
</svg>
|
||||
|
||||
<!-- ===================== 导航 ===================== -->
|
||||
<header class="nav">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="/home">
|
||||
<span class="brand-logo"><svg width="30" height="30" viewBox="0 0 64 64" aria-label="OpsxAPI"><defs><linearGradient id="lg-site" 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-site)"/><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</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">功能</a>
|
||||
<a href="#protocols">协议</a>
|
||||
<a href="#exchange">导入导出</a>
|
||||
<a href="#pricing">定价</a>
|
||||
<a href="/docs" target="_blank" rel="noopener">文档</a>
|
||||
</nav>
|
||||
<div class="nav-cta">
|
||||
<a class="btn btn-primary" href="/" target="_blank" rel="noopener">打开工作台 ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===================== Hero ===================== -->
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-badge"><svg class="i"><use href="#i-rocket"/></svg> v1.0 正式发布 · 数据 100% 本地</div>
|
||||
<h1>一个工作台,<br>调完<em>六种协议</em>的接口</h1>
|
||||
<p class="hero-sub">
|
||||
HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT。<br>
|
||||
集合管理、多环境变量、全格式导入导出、代码生成、内置文档站 —— 运维与开发的自托管接口利器。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary btn-lg" href="/" target="_blank" rel="noopener">立即开始(免费体验)</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">快速开始 →</a>
|
||||
</div>
|
||||
<div class="hero-pills">
|
||||
<span>本地优先</span><span>自定义 WS 握手头</span><span>SSE 断点续传</span><span>OpenAPI 双向同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品界面示意(纯 CSS 绘制) -->
|
||||
<div class="mock" aria-hidden="true">
|
||||
<div class="mock-titlebar"><i></i><i></i><i></i><span>OpsxAPI · 示例集合</span></div>
|
||||
<div class="mock-body">
|
||||
<div class="mock-side">
|
||||
<div class="mock-search"></div>
|
||||
<div class="mock-group"><svg class="mi"><use href="#i-library"/></svg> 示例集合</div>
|
||||
<div class="mock-item"><b class="m-get">GET</b>获取请求回显</div>
|
||||
<div class="mock-item"><b class="m-post">POST</b>提交 JSON</div>
|
||||
<div class="mock-item"><b class="m-get">GET</b>Bearer 鉴权示例</div>
|
||||
<div class="mock-group"><svg class="mi"><use href="#i-radio"/></svg> 实时协议</div>
|
||||
<div class="mock-item active"><svg class="mi"><use href="#i-zap"/></svg> WebSocket · echo</div>
|
||||
<div class="mock-item"><svg class="mi"><use href="#i-mailbox"/></svg> MQTT · EMQX</div>
|
||||
<div class="mock-group"><svg class="mi"><use href="#i-hexagon"/></svg> GraphQL</div>
|
||||
<div class="mock-item">国家查询 · countries</div>
|
||||
</div>
|
||||
<div class="mock-main">
|
||||
<div class="mock-urlbar">
|
||||
<b class="m-post">POST</b>
|
||||
<span class="mock-url">{{baseUrl}}/v1/orders</span>
|
||||
<span class="mock-send">发送</span>
|
||||
</div>
|
||||
<div class="mock-req">
|
||||
<span class="tab on">Body</span><span class="tab">Params</span><span class="tab">Headers</span><span class="tab">Auth</span>
|
||||
<pre>{
|
||||
<i>"sku"</i>: <u>"A-1"</u>,
|
||||
<i>"qty"</i>: <u>2</u>
|
||||
}</pre>
|
||||
</div>
|
||||
<div class="mock-resp">
|
||||
<span class="tab on">响应体</span>
|
||||
<span class="mock-status">200 · 87ms · 274B</span>
|
||||
<pre>{
|
||||
<i>"orderId"</i>: <u>"ord_9f2a"</u>,
|
||||
<i>"status"</i>: <u>"created"</u>,
|
||||
<i>"paid"</i>: <u>false</u>
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 为什么选 ===================== -->
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">为什么团队选择 OpsxAPI</h2>
|
||||
<p class="sec-sub">不追赶云端协作的花活,专注把「调试接口」这件事做到极致。</p>
|
||||
<div class="grid g4">
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-shield-check"/></svg></div>
|
||||
<h3>本地优先,数据主权</h3>
|
||||
<p>集合、环境、历史全部保存在本机,不经过任何第三方服务器。内网、涉密项目放心用。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-arrow-left-right"/></svg></div>
|
||||
<h3>六协议一个窗口</h3>
|
||||
<p>HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT 统一工作台,实时消息分色日志。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-plug-zap"/></svg></div>
|
||||
<h3>浏览器也能带握手头</h3>
|
||||
<p>内置 WebSocket 隧道与流式代理,突破浏览器 CORS 与 Header 限制,调试网关级接口。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-package"/></svg></div>
|
||||
<h3>生态互通</h3>
|
||||
<p>Apifox / Postman / Swagger / cURL / HAR 一键导入导出,迁移零成本,文档即代码。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 功能 ===================== -->
|
||||
<section class="section alt" id="features">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">开箱即用的专业能力</h2>
|
||||
<p class="sec-sub">从单人调试到团队交付,该有的都有。</p>
|
||||
<div class="grid g3">
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-folder-tree"/></svg></div><div><h3>集合与多标签</h3><p>目录树、搜索、右键菜单、拖拽移动;多标签并行调试。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-globe"/></svg></div><div><h3>多环境变量</h3><p><code>{{baseUrl}}</code> 全局替换,正式 / 测试 / 本地一键切换。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-shield-check"/></svg></div><div><h3>鉴权管理</h3><p>Bearer / Basic / API Key,token 走变量,一处配置全局生效。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-zap"/></svg></div><div><h3>实时协议调试</h3><p>WS 握手头、SSE 断点续传、Socket.IO 事件收发、MQTT 通配符订阅。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-code"/></svg></div><div><h3>代码生成</h3><p>任意请求一键生成 cURL / fetch / Python / Go,直接进脚本与 CI。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-book-open"/></svg></div><div><h3>内置文档站</h3><p>随服务自带 /docs 用户文档与 Markdown 接口文档导出,交付即完整。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 协议 ===================== -->
|
||||
<section class="section" id="protocols">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">六种协议,全覆盖</h2>
|
||||
<p class="sec-sub">每个协议都有为它定制的编辑器,而不是塞进同一个表单。</p>
|
||||
<div class="grid g3">
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-globe"/></svg> HTTP / REST</h3><p>7 种方法 · 6 种请求体 · 响应 Pretty 高亮 · 历史回跳</p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-hexagon"/></svg> GraphQL</h3><p>Query + Variables 编辑 · errors 高亮 · 生成 cURL 报文</p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-zap"/></svg> WebSocket</h3><p>自定义握手 Header(本地隧道)· 子协议 · 分色消息日志</p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-radio"/></svg> SSE</h3><p>直连失败自动切代理 · Last-Event-ID 续传 · 事件按名称归类</p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-plug-zap"/></svg> Socket.IO</h3><p>事件监听(onAny)· emit 参数 JSON · path / auth 配置</p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-mailbox"/></svg> MQTT</h3><p>wss Broker · 通配符订阅 · QoS 0/1/2 · 断线自动重连</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 导入导出 ===================== -->
|
||||
<section class="section alt" id="exchange">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">导入导出,迁移零成本</h2>
|
||||
<p class="sec-sub">粘贴、文件、URL 三种来源,格式自动识别。</p>
|
||||
<div class="badges">
|
||||
<span>OpenAPI 3.0 / 3.1</span><span>Swagger 2.0</span><span>Apifox</span><span>Postman v2.1</span>
|
||||
<span>Apipost</span><span>cURL</span><span>HAR</span><span>Insomnia</span><span>Hoppscotch</span><span>Markdown</span>
|
||||
<span class="more">YApi / Apidoc / Apizza / Eolink ①</span>
|
||||
</div>
|
||||
<p class="footnote">① 经 OpenAPI / Postman 格式中转导入。导入自动:tag 建目录、路径参数转变量、schema 生成示例体、security 映射鉴权。</p>
|
||||
<div class="exchange-actions">
|
||||
<div class="chip"><svg class="i"><use href="#i-download"/></svg> 导入</div><div class="exchange-arrow">──→</div><div class="chip big">OpsxAPI</div><div class="exchange-arrow">──→</div>
|
||||
<div class="chip"><svg class="i"><use href="#i-upload"/></svg> OpenAPI / Postman</div><div class="chip"><svg class="i"><use href="#i-upload"/></svg> Markdown 文档</div><div class="chip"><svg class="i"><use href="#i-upload"/></svg> 代码生成</div><div class="chip"><svg class="i"><use href="#i-upload"/></svg> 全量备份</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 对比 ===================== -->
|
||||
<section class="section" id="compare">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">与主流工具的对比</h2>
|
||||
<p class="sec-sub">价格为各官网公开标价(2026-09 整理),以官方为准。</p>
|
||||
<div class="table-wrap">
|
||||
<table class="cmp">
|
||||
<thead><tr><th>能力</th><th class="hl">OpsxAPI</th><th>Apifox</th><th>Apipost</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>入门付费价格</td><td class="hl"><b>¥99/年</b>(个人)</td><td>¥99/人/年②</td><td>免费 + 商业版</td></tr>
|
||||
<tr><td>专业档价格</td><td class="hl"><b>¥99/人/年</b>(团队)</td><td>¥199/人/年</td><td>按席位订阅</td></tr>
|
||||
<tr><td>数据存储位置</td><td class="hl"><b>100% 本地</b></td><td>云端 / 私有化</td><td>云端 / 私有化</td></tr>
|
||||
<tr><td>调试协议</td><td class="hl">HTTP · GraphQL · WS · SSE · Socket.IO · MQTT</td><td>HTTP · WS · GraphQL</td><td>HTTP · WS · gRPC 等</td></tr>
|
||||
<tr><td>WS 自定义握手 Header(Web)</td><td class="hl"><b>✓ 内置隧道</b></td><td>桌面端支持</td><td>桌面端支持</td></tr>
|
||||
<tr><td>自托管部署</td><td class="hl"><b>单二进制 + 静态页</b></td><td>私有化付费</td><td>私有化付费</td></tr>
|
||||
<tr><td>私有化 / 买断参考价</td><td class="hl"><b>¥8,000/年起 · ¥30,000 买断</b></td><td>询价</td><td>询价</td></tr>
|
||||
<tr><td>内置用户文档站</td><td class="hl"><b>✓ /docs</b></td><td>—</td><td>—</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="footnote">② Apifox 免费版即含大部分调试能力,付费档面向团队协作;对比仅帮助按需选型,各有所长。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 定价 ===================== -->
|
||||
<section class="section alt" id="pricing">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">简单透明的定价</h2>
|
||||
<p class="sec-sub">先在工作台免费用起来,需要授权与服务时再付费。</p>
|
||||
<div class="pricing three">
|
||||
<div class="price-card">
|
||||
<div class="plan">个人版</div>
|
||||
<div class="price"><b>¥99</b><span>/ 年</span></div>
|
||||
<div class="plan-sub">单设备授权 · 全功能</div>
|
||||
<ul>
|
||||
<li>✓ 六种协议全部调试能力</li>
|
||||
<li>✓ 集合 / 环境 / 历史无限量</li>
|
||||
<li>✓ 全格式导入导出 + 代码生成</li>
|
||||
<li>✓ 桌面端 + Web 双形态</li>
|
||||
<li>✓ 1 年免费升级</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="#contact">购买咨询</a>
|
||||
</div>
|
||||
<div class="price-card featured">
|
||||
<div class="plan-tag">高性价比之选</div>
|
||||
<div class="plan">团队版</div>
|
||||
<div class="price"><b>¥99</b><span>/ 人 / 年</span></div>
|
||||
<div class="plan-sub">按席位授权 · 5 席起</div>
|
||||
<ul>
|
||||
<li>✓ 个人版全部能力</li>
|
||||
<li>✓ 集合批量导入分发</li>
|
||||
<li>✓ 内网自托管(单二进制)</li>
|
||||
<li>✓ 优先技术支持</li>
|
||||
<li>✓ 1 年免费升级</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="#contact">联系商务</a>
|
||||
</div>
|
||||
<div class="price-card enterprise">
|
||||
<div class="plan-tag dark">私有化 · 单法人不限席位</div>
|
||||
<div class="plan">企业私有化版</div>
|
||||
<div class="price"><b>¥8,000</b><span>/ 年 · 标价</span></div>
|
||||
<div class="plan-sub">或 ¥30,000 一次性永久买断(标价)</div>
|
||||
<ul>
|
||||
<li>✓ 整机 / 内网部署,数据不出企业</li>
|
||||
<li>✓ 不限席位,团队版全部能力</li>
|
||||
<li>✓ 远程部署协助 + 专属支持</li>
|
||||
<li>✓ 定制开发通道(另行报价)</li>
|
||||
<li>✓ 买断可选:永久使用 + 1 年升级</li>
|
||||
</ul>
|
||||
<a class="btn btn-dark" href="#contact">获取报价</a>
|
||||
<div class="deal-note">
|
||||
标价透明,成交灵活:经早鸟 / 教育 / 多年预付 / 案例合作,常见成交区间 <b>¥5,000–8,000/年</b>、买断 <b>¥15,000–30,000</b>。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 让利策略 -->
|
||||
<h3 class="growth-title">早鸟与让利计划 —— 把优惠变成共同成长</h3>
|
||||
<div class="grid g4">
|
||||
<div class="card mini">
|
||||
<h3><svg class="i-lg"><use href="#i-sprout"/></svg> 早鸟创始价</h3>
|
||||
<p>前 20 个付费席位享 <b>5-7 折永久优惠</b>,并进入创始客户名单,参与需求优先排期。</p>
|
||||
</div>
|
||||
<div class="card mini">
|
||||
<h3><svg class="i-lg"><use href="#i-graduation-cap"/></svg> 教育 / 开源</h3>
|
||||
<p>高校师生、开源维护者凭证享 <b>6-8 折</b>。教育用途可申请批量授权。</p>
|
||||
</div>
|
||||
<div class="card mini">
|
||||
<h3><svg class="i-lg"><use href="#i-credit-card"/></svg> 多年预付</h3>
|
||||
<p>一次预付 2 年 <b>9 折</b>、3 年 <b>8 折</b>,锁定当期价格,改善双方现金流。</p>
|
||||
</div>
|
||||
<div class="card mini">
|
||||
<h3><svg class="i-lg"><use href="#i-megaphone"/></svg> 案例换授权</h3>
|
||||
<p>愿意公开使用案例、撰写评测或作为推荐客户的,可获 <b>额外折扣或免费席位</b>。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footnote">以上为官网标价机制;最终成交价支持按规模与场景商务沟通,多年预付 / 服务范围(部署协助、定制开发)可组合。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FAQ ===================== -->
|
||||
<section class="section" id="faq">
|
||||
<div class="wrap narrow">
|
||||
<h2 class="sec-title">购买常见问题</h2>
|
||||
<div class="faq">
|
||||
<details open><summary>授权如何交付?包含哪些内容?</summary><p>邮件交付授权码与部署包(单二进制 + Web 静态资源),含使用文档。团队版附内网部署指南。</p></details>
|
||||
<details><summary>一年后不续费还能用吗?</summary><p>可以。授权期内下载的版本可 <b>永久使用</b>,续费获得后续升级与支持。</p></details>
|
||||
<details><summary>团队版怎么计费?人员变动怎么办?</summary><p>按启用席位每年计费,5 席起。人员变动可在授权期内置换席位,无需额外费用。</p></details>
|
||||
<details><summary>可以开发票吗?</summary><p>支持增值税普通发票与专票,对公转账或线上支付均可。</p></details>
|
||||
<details><summary>我们能自己部署吗?安全吗?</summary><p>完全可以,这正是 OpsxAPI 的设计初衷:数据 100% 本地,服务仅监听本机回环,内网部署无外联。</p></details>
|
||||
<details><summary>可以永久买断吗?</summary><p>可以。企业私有化版支持一次性买断(标价 ¥30,000,含 1 年免费升级与支持),买断版本<b>永久使用</b>,续费仅用于继续获得升级。订阅档同理:授权期内下载的版本永久可用。</p></details>
|
||||
<details><summary>预算有限,有优惠空间吗?</summary><p>我们不直接降价,但提供四种「让利换增长」机制:<b>早鸟创始价</b>(前 20 席 5–7 折永久)、<b>教育 / 开源</b> 6–8 折、<b>多年预付</b> 8–9 折、<b>以案例 / 评测换折扣</b>。相比按年订阅的 SaaS,5 人以上团队通常 1–2 年即可回本;可自行部署(我们提供文档)或以案例分享换取创始价。</p></details>
|
||||
<details><summary>先试用再决定可以吗?</summary><p>欢迎。工作台全部调试能力可直接体验;授权版差异主要在商业支持与升级服务。</p></details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== CTA / 联系 ===================== -->
|
||||
<section class="cta" id="contact">
|
||||
<div class="wrap">
|
||||
<h2>把接口调试收回自己手里</h2>
|
||||
<p>现在打开工作台,30 秒发出第一个请求;采购事宜邮件联系,当天回复。</p>
|
||||
<div class="hero-actions center">
|
||||
<a class="btn btn-inverse btn-lg" href="/" target="_blank" rel="noopener">打开工作台 ↗</a>
|
||||
<a class="btn btn-outline btn-lg" href="mailto:hello@opsxapi.dev?subject=OpsxAPI%20购买咨询">hello@opsxapi.dev</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== Footer ===================== -->
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-grid">
|
||||
<div>
|
||||
<div class="brand"><span class="brand-logo"><svg width="30" height="30" viewBox="0 0 64 64" aria-label="OpsxAPI"><defs><linearGradient id="lg-site" 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-site)"/><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</span></div>
|
||||
<p class="footer-desc">本地优先的全协议接口工作台。<br>数据在自己手里,才是真的效率工具。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>产品</h4>
|
||||
<a href="#features">功能特性</a>
|
||||
<a href="#pricing">定价与让利</a>
|
||||
<a href="#compare">工具对比</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>资源</h4>
|
||||
<a href="/docs" target="_blank" rel="noopener">用户文档</a>
|
||||
<a href="/docs/server-api" target="_blank" rel="noopener">服务端 API</a>
|
||||
<a href="/docs/faq" target="_blank" rel="noopener">常见问题</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>支持</h4>
|
||||
<a href="mailto:hello@opsxapi.dev">hello@opsxapi.dev</a>
|
||||
<a href="/docs/quickstart" target="_blank" rel="noopener">快速开始</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bar">© 2026 OpsxAPI · <a href="/terms" target="_blank">服务条款</a> · 本页由单二进制内置提供(/home)</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,77 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>服务条款 · OpsxAPI</title>
|
||||
<meta name="description" content="OpsxAPI 服务条款:使用规范、频率限制与禁止滥用条款" />
|
||||
<link rel="icon" type="image/svg+xml" href="/site/assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="/site/assets/site.css" />
|
||||
<style>
|
||||
.terms-wrap { max-width: 820px; margin: 0 auto; padding: 48px 24px 80px; }
|
||||
.terms-wrap h1 { font-size: 30px; margin-bottom: 8px; }
|
||||
.terms-meta { color: var(--muted, #6b7280); font-size: 13px; margin-bottom: 32px; }
|
||||
.terms-wrap h2 { font-size: 18px; margin: 32px 0 12px; }
|
||||
.terms-wrap p, .terms-wrap li { line-height: 1.9; font-size: 14px; color: var(--text, #374151); }
|
||||
.terms-wrap ul { padding-left: 20px; }
|
||||
.terms-box { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px 20px; margin: 12px 0; background: #f9fafb; }
|
||||
.terms-box strong { color: #111827; }
|
||||
table.limits { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 14px; }
|
||||
table.limits th, table.limits td { border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }
|
||||
table.limits th { background: #f9fafb; }
|
||||
.back-home { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 24px; color: #2563eb; text-decoration: none; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="terms-wrap">
|
||||
<a class="back-home" href="/home">← 返回官网</a>
|
||||
<h1>服务条款</h1>
|
||||
<div class="terms-meta">最近更新:2026 年 9 月 17 日 · 生效范围:OpsxAPI 本地服务(含官网 /docs、/home、/mock 及 /api)</div>
|
||||
|
||||
<h2>一、服务说明</h2>
|
||||
<p>OpsxAPI 是运行在你本机的接口调试与 Mock 服务。官网(/home)、文档(/docs)、账号与云端空间(/api)及本地 Mock 服务(/mock)默认仅监听 127.0.0.1,供个人与团队内部开发联调使用。</p>
|
||||
|
||||
<h2>二、账号与安全</h2>
|
||||
<ul>
|
||||
<li>注册需提供有效邮箱,密码经加盐哈希后存储;请妥善保管凭据,退出登录即作废令牌。</li>
|
||||
<li>团队成员凭邮箱邀请加入,共享空间内数据对全体成员可见;仅团队创建者可管理成员。</li>
|
||||
<li>冒用他人身份注册、批量恶意注册账号的行为将被禁止。</li>
|
||||
</ul>
|
||||
|
||||
<h2>三、频率限制</h2>
|
||||
<p>为保障本地服务稳定,Mock 接口(/mock)实施以下默认限流,超限返回 <strong>HTTP 429</strong> 与 <code>Retry-After</code>:</p>
|
||||
<table class="limits">
|
||||
<tr><th>类别</th><th>阈值</th><th>超出行为</th></tr>
|
||||
<tr><td>通用 API 请求频率</td><td>60 QPM(每分钟 60 次)或 20 QPS(每秒 20 次)</td><td>返回 429 错误</td></tr>
|
||||
<tr><td>单空间 Mock 接口数</td><td>200 个</td><td>发布请求被拒绝(400)</td></tr>
|
||||
<tr><td>云端空间数据体积</td><td>20 MB</td><td>保存请求被拒绝(413)</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>四、禁止滥用</h2>
|
||||
<div class="terms-box">
|
||||
<p><strong>若账户或使用行为存在「未经授权或滥用」情形,平台有权采取限流、暂停服务等措施。</strong>通过技术手段规避限流(如频繁更换来源地址、伪造标识等)本身即属于违规行为。</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>不得将本地 Mock 服务长期暴露到公网对外提供流量服务,或用于任何压测 / 攻击 / 爬虫目标;</li>
|
||||
<li>不得利用服务存储违法违规内容,或侵害第三方(含任何第三方接口、服务)权益;</li>
|
||||
<li>不得逆向、干扰、破坏服务及其组件,或绕过任何使用限制与安全措施;</li>
|
||||
<li>对违反上述条款的行为,我们有权不经通知采取限流、下线 Mock 接口、停用账号等措施。</li>
|
||||
</ul>
|
||||
|
||||
<h2>五、数据与隐私</h2>
|
||||
<ul>
|
||||
<li>所有数据(账号、团队空间、Mock 接口)默认存储于本机 SQLite(data/opsxapi.db),不经任何第三方中转、不上传外部服务器;</li>
|
||||
<li>AI 功能直连你自己配置的模型供应商,密钥仅存本机;</li>
|
||||
<li>请自行做好数据备份(客户端支持全量备份导出);对未备份的数据丢失不承担责任。</li>
|
||||
</ul>
|
||||
|
||||
<h2>六、免责声明</h2>
|
||||
<p>本软件按「现状」提供,不承诺适销性或特定用途适用性。因不可抗力、系统维护或依本条款采取措施导致的服务中断,不视为违约。在法律允许范围内,对间接损失不承担赔偿责任。</p>
|
||||
|
||||
<h2>七、条款变更</h2>
|
||||
<p>我们可能随版本更新修订本条款,修订后在官网公示并更新日期;继续使用即视为接受修订后的条款。</p>
|
||||
|
||||
<p class="terms-meta" style="margin-top:40px">如有疑问,请通过官网页脚的联系方式反馈。</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user