feat: 模块化命令框架 + runbook 严格断言与查询结果回传

- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter
- runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传
- cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败
- 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success
- 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度
- Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
2026-09-13 07:42:17 +08:00
parent 9085c490f3
commit 81939825d1
86 changed files with 6885 additions and 467 deletions
+197 -66
View File
@@ -3,34 +3,60 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>渠道配置 - 虾记</title>
<title>渠道配置 - Ops-Assistant</title>
<style>
:root{
--bg:#f5f7fb;
--text:#222;
--card:#fff;
--border:#e5e7eb;
--muted:#6b7280;
--accent:#ee5a24;
--accent-hover:#d63031;
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
}
[data-theme="dark"]{
--bg:#0f172a;
--text:#e5e7eb;
--card:#111827;
--border:#1f2937;
--muted:#9ca3af;
--accent:#f97316;
--accent-hover:#ea580c;
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
.header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
.header a { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.header { background: var(--header-bg); color: #fff; padding: 18px 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
.header a,.header button { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; border:none; cursor:pointer; }
.header button{display:inline-flex;align-items:center;justify-content:center}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.header a:hover { background: rgba(255,255,255,.35); }
.wrap { max-width: 600px; margin: 0 auto; padding: 15px; }
.wrap { max-width: 760px; margin: 0 auto; padding: 14px; }
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.tip { color:#6b7280; font-size:12px; margin-bottom:10px; }
.card { background:#fff; border-radius:12px; padding:14px; margin-bottom:10px; box-shadow:0 1px 4px rgba(0,0,0,.05); }
.tip { color:var(--muted); font-size:12px; margin-bottom:10px; }
.card { background:var(--card); border-radius:6px; padding:14px; margin-bottom:10px; border:1px solid var(--border); box-shadow:none; }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
.row label { width:110px; color:#4b5563; font-size:13px; }
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid #ddd; border-radius:8px; font-size:13px; }
.row textarea { min-height:74px; }
.row label { width:140px; color:var(--muted); font-size:13px; }
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:13px; background:var(--card); color:var(--text); }
.row textarea { min-height:74px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
button { border:none; border-radius:8px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
button { border:none; border-radius:6px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
button:disabled { opacity:.65; cursor:not-allowed; }
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:#ee5a24; }
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:#d63031; }
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:var(--accent); }
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:var(--accent-hover); }
.btn-disable { background:#9b2c2c; }
.btn-disable:hover { background:#7f1d1d; }
.badge { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; }
.state { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; margin-left:6px; }
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
.btn-ghost:hover { background:rgba(0,0,0,.04); }
.badge { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; }
.state { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; margin-left:6px; }
.ok { background:#dcfce7; color:#166534; }
.error { background:#fee2e2; color:#991b1b; }
.disabled { background:#e5e7eb; color:#374151; }
small { color:#6b7280; }
small { color:var(--muted); }
.hidden{display:none !important;}
.advanced{border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;}
@media(max-width:640px){
.header { padding: 14px 12px 10px; align-items:flex-start; flex-direction:column; }
@@ -41,25 +67,52 @@ small { color:#6b7280; }
.row input, .row textarea { min-width:100%; }
.btns button { flex: 1 1 calc(50% - 4px); }
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
<div><a href="/">返回首页</a><a href="/logout">退出</a></div>
<div>
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="2" x2="12" y2="4"></line>
<line x1="12" y1="20" x2="12" y2="22"></line>
<line x1="2" y1="12" x2="4" y2="12"></line>
<line x1="20" y1="12" x2="22" y2="12"></line>
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
</svg>
</button>
<a href="/">返回首页</a>
<a href="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="toolbar">
<button class="btn-disable" onclick="disableAll()">一键全部关闭</button>
<button class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
<button id="btnDisableAll" class="btn-disable" onclick="disableAll()">一键全部关闭</button>
<button id="btnReload" class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
</div>
<div class="tip">默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。</div>
<div class="tip">填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。</div>
<div id="app"></div>
</div>
<script>
let me=null;
const app = document.getElementById('app');
async function api(url, options = {}) {
const r = await fetch(url, options);
const out = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(out.message || ('HTTP ' + r.status));
return out?.data || {};
}
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
function renderError(msg) {
app.innerHTML = `<div class="card" style="border:1px solid #fecaca;background:#fef2f2;color:#991b1b;">${msg}</div>`;
}
@@ -85,15 +138,33 @@ function parseJSONSafe(text) {
try { return JSON.parse(text || '{}'); } catch { return null; }
}
function getFieldDefs(platform){
if(platform==='telegram'){
return [
{key:'token', label:'Telegram Bot Token'}
];
}
if(platform==='qqbot_official'){
return [
{key:'appid', label:'QQ Bot AppID'},
{key:'secret', label:'QQ Bot Secret'}
];
}
if(platform==='feishu'){
return [
{key:'app_id', label:'飞书 AppID'},
{key:'app_secret', label:'飞书 AppSecret'},
{key:'verification_token', label:'飞书 VerificationToken(可选)'},
{key:'encrypt_key', label:'飞书 EncryptKey(可选)'}
];
}
return [];
}
async function fetchChannels() {
const r = await fetch('/api/v1/admin/channels');
if (!r.ok) {
throw new Error('加载渠道失败: HTTP ' + r.status);
}
const data = await r.json();
if (!Array.isArray(data)) {
throw new Error('渠道返回格式异常');
}
const out = await api('/api/v1/admin/channels');
const data = out?.channels;
if (!Array.isArray(data)) throw new Error('渠道返回格式异常');
return data;
}
@@ -101,19 +172,32 @@ function render(channels) {
app.innerHTML = channels.map(ch => {
const draftCfg = pretty(ch.draft_config_json || ch.config_json);
const draftSec = pretty(ch.draft_secrets || ch.secrets);
return `<div class="card" data-platform="${ch.platform}">
<h3>${ch.name || ch.platform} ${statusBadge(ch.status)} ${runtimeState(ch)} ${ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : ''}</h3>
<small>平台:${ch.platform} | 发布:${ch.published_at || '-'} | 最近检测:${ch.last_check_at || '-'}</small>
const secObj = parseJSONSafe(draftSec) || {};
const hasDraft = ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : '';
const fields = getFieldDefs(ch.platform);
const fieldsHtml = fields.map(f => {
let v = secObj[f.key] || '';
if (String(v).trim() === '***') { v = ''; }
return `<div class="row"><label>${esc(f.label)}</label><input class="field" data-key="${esc(f.key)}" value="${esc(v)}" placeholder="留空表示不修改"></div>`;
}).join('');
return `<div class="card" data-platform="${esc(ch.platform)}">
<h3>${esc(ch.name || ch.platform)} ${statusBadge(ch.status)} ${runtimeState(ch)} ${hasDraft}</h3>
<small>平台:${esc(ch.platform)} | 发布:${esc(ch.published_at || '-')} | 最近检测:${esc(ch.last_check_at || '-')}</small>
<div class="row"><label>启用</label><input type="checkbox" class="enabled" ${ch.enabled ? 'checked' : ''}></div>
<div class="row"><label>显示名称</label><input class="name" value="${(ch.name||'').replace(/"/g,'&quot;')}"></div>
<div class="row"><label>草稿 config(JSON)</label><textarea class="config">${draftCfg}</textarea></div>
<div class="row"><label>草稿 secrets(JSON)</label><textarea class="secrets">${draftSec}</textarea></div>
<div class="row"><label>显示名称</label><input class="name" value="${esc(ch.name||'')}"></div>
${fieldsHtml}
<div class="btns">
<button class="btn-apply" onclick="applyNow('${ch.platform}')">保存并立即生效</button>
<button class="btn-save" onclick="saveDraft('${ch.platform}')">保存草稿</button>
<button class="btn-publish" onclick="publishDraft('${ch.platform}')">发布草稿</button>
<button class="btn-test" onclick="testConn('${ch.platform}')">测试连接</button>
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${ch.platform}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${ch.platform}', true)">开启通道</button>`}
<button class="btn-apply" onclick="applyNow('${esc(ch.platform)}')">保存并立即生效</button>
<button class="btn-save" onclick="saveDraft('${esc(ch.platform)}')">保存草稿</button>
<button class="btn-publish" onclick="publishDraft('${esc(ch.platform)}')">发布草稿</button>
<button class="btn-test" onclick="testConn('${esc(ch.platform)}')">测试连接</button>
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${esc(ch.platform)}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${esc(ch.platform)}', true)">开启通道</button>`}
<button class="btn-ghost" onclick="toggleAdvanced('${esc(ch.platform)}')">高级 JSON</button>
</div>
<div class="advanced hidden">
<div class="row"><label>配置 JSON</label><textarea class="config">${draftCfg}</textarea></div>
<div class="row"><label>密钥 JSON</label><textarea class="secrets">${draftSec}</textarea></div>
</div>
<small class="msg"></small>
</div>`;
@@ -124,31 +208,33 @@ function collectChannelForm(platform) {
const card = document.querySelector(`[data-platform="${platform}"]`);
const name = card.querySelector('.name').value.trim();
const enabled = card.querySelector('.enabled').checked;
const configText = card.querySelector('.config').value;
const secretsText = card.querySelector('.secrets').value;
const cfgText = card.querySelector('.config') ? card.querySelector('.config').value : '{}';
const secText = card.querySelector('.secrets') ? card.querySelector('.secrets').value : '{}';
const msg = card.querySelector('.msg');
const config = parseJSONSafe(configText);
const secrets = parseJSONSafe(secretsText);
if (!config || !secrets) {
msg.textContent = 'JSON 格式错误,请检查 config/secrets';
const config = parseJSONSafe(cfgText) || {};
const secrets = parseJSONSafe(secText) || {};
if (config === null || secrets === null) {
msg.textContent = 'JSON 格式错误,请检查高级 JSON';
return null;
}
return { card, msg, payload: { name, enabled, config, secrets } };
}
// 覆盖结构化字段,但保留未知字段
let hasSecretChange = false;
card.querySelectorAll('.field').forEach(input => {
const key = input.getAttribute('data-key');
const val = (input.value || '').trim();
if (val !== '') {
secrets[key] = val;
hasSecretChange = true;
}
});
async function apiJSON(url, options = {}) {
const r = await fetch(url, options);
const out = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(out.error || ('HTTP ' + r.status));
}
return out;
}
const payload = { name, enabled, config };
if (hasSecretChange) payload.secrets = secrets;
function msgOf(platform) {
return document.querySelector(`[data-platform="${platform}"] .msg`);
return { card, msg, payload };
}
function setCardBusy(card, busy) {
@@ -168,7 +254,7 @@ async function applyNow(platform) {
try {
msg.textContent = '保存并生效中...';
await apiJSON('/api/v1/admin/channels/' + platform + '/apply', {
await api('/api/v1/admin/channels/' + platform + '/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
@@ -189,7 +275,7 @@ async function saveDraft(platform) {
const { msg, payload } = f;
try {
await apiJSON('/api/v1/admin/channels/' + platform, {
await api('/api/v1/admin/channels/' + platform, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
@@ -204,7 +290,7 @@ async function saveDraft(platform) {
async function publishDraft(platform) {
const msg = msgOf(platform);
try {
await apiJSON('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
await api('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
msg.textContent = '发布成功,建议点“热加载运行参数”';
await reload();
} catch (e) {
@@ -216,8 +302,8 @@ async function testConn(platform) {
const msg = msgOf(platform);
try {
msg.textContent = '正在测试...';
const out = await apiJSON('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
msg.textContent = `测试结果:${out.status} ${out.detail ? ' / ' + out.detail : ''}`;
const out = await api('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
msg.textContent = `测试结果:${out.status || 'unknown'} ${out.detail ? ' / ' + out.detail : ''}`;
await reload();
} catch (e) {
msg.textContent = '测试失败:' + (e && e.message ? e.message : e);
@@ -228,7 +314,7 @@ async function toggleChannel(platform, enable) {
const msg = msgOf(platform);
try {
msg.textContent = enable ? '正在开启...' : '正在关闭...';
await apiJSON('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
await api('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
await reload();
} catch (e) {
@@ -238,7 +324,7 @@ async function toggleChannel(platform, enable) {
async function reloadRuntime() {
try {
const out = await apiJSON('/api/v1/admin/channels/reload', { method: 'POST' });
const out = await api('/api/v1/admin/channels/reload', { method: 'POST' });
alert('热加载成功:' + (out.detail || 'ok'));
await reload();
} catch (e) {
@@ -249,7 +335,7 @@ async function reloadRuntime() {
async function disableAll() {
if (!confirm('确认要关闭所有通道吗?')) return;
try {
const out = await apiJSON('/api/v1/admin/channels/disable-all', { method: 'POST' });
const out = await api('/api/v1/admin/channels/disable-all', { method: 'POST' });
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
await reload();
} catch (e) {
@@ -257,6 +343,48 @@ async function disableAll() {
}
}
function msgOf(platform) {
return document.querySelector(`[data-platform="${platform}"] .msg`);
}
function toggleAdvanced(platform){
const card = document.querySelector(`[data-platform="${platform}"]`);
if(!card) return;
const adv = card.querySelector('.advanced');
if(adv) adv.classList.toggle('hidden');
}
async function loadMe(){
const r=await fetch('/api/v1/me');
const out=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(out.message||'读取用户失败');
me=out?.data||{};
}
function initPermissionUI(){
document.getElementById('btnReload').classList.toggle('hidden',!can('can_edit_channels'));
document.getElementById('btnDisableAll').classList.toggle('hidden',!can('can_edit_channels'));
}
function applyTheme(theme){
if(theme==='dark'){
document.documentElement.setAttribute('data-theme','dark');
}else{
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme',theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
applyTheme(cur==='light'?'dark':'light');
}
function initTheme(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){ btn.addEventListener('click',toggleTheme); }
}
async function reload() {
try {
const channels = await fetchChannels();
@@ -276,7 +404,10 @@ window.addEventListener('unhandledrejection', (e) => {
renderError('前端请求异常:' + msg);
});
reload();
(async function(){
try{ initTheme(); await loadMe(); initPermissionUI(); await reload(); }
catch(e){ renderError('初始化失败:'+(e.message||e)); }
})();
</script>
</body>
</html>