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
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 配置 - 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: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 .right{display:flex;align-items:center;gap:8px}
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
.header button{width:28px;padding:0;transition:transform .2s ease}
.header button.spin{transform:rotate(180deg)}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
small{color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🤖 AI 配置 · {{.version}}</div>
<div class="right">
<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="card">
<h3>AI 模型配置</h3>
<div class="row"><label>启用 AI 翻译</label><input id="enabled" type="checkbox"></div>
<div class="row"><label>Base URL</label><input id="base_url" placeholder="https://api.xxx/v1"></div>
<div class="row"><label>API Key</label><input id="api_key" type="password" placeholder="sk-..." autocomplete="new-password"></div>
<div class="row"><label>Model</label><input id="model" placeholder="gemini-3-flash-preview"></div>
<div class="row"><label>Timeout (秒)</label><input id="timeout" placeholder="15"></div>
<small>用于将“非命令文本”翻译为标准命令(仅翻译,不自动执行)。</small>
<div class="btns">
<button onclick="save()">保存</button>
<button class="secondary" onclick="load()">刷新</button>
</div>
<small id="msg"></small>
</div>
</div>
<script>
async function api(url,opt={}){
const r=await fetch(url,opt);
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]));}
async function load(){
try{
const d=await api('/api/v1/admin/ai/settings');
const s=d.settings||{};
document.getElementById('enabled').checked = (String(s.ai_enabled||'').toLowerCase()==='true');
document.getElementById('base_url').value = s.ai_base_url || '';
document.getElementById('api_key').value = s.ai_api_key || '';
document.getElementById('model').value = s.ai_model || '';
document.getElementById('timeout').value = s.ai_timeout_seconds || '15';
document.getElementById('msg').textContent='已加载';
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
}
async function save(){
const payload={
enabled: document.getElementById('enabled').checked,
base_url: document.getElementById('base_url').value.trim(),
api_key: document.getElementById('api_key').value.trim(),
model: document.getElementById('model').value.trim(),
timeout_seconds: parseInt(document.getElementById('timeout').value||'15',10)
};
try{
await api('/api/v1/admin/ai/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('msg').textContent='已保存';
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
}
function setThemeIcon(theme){
const icon=document.getElementById('themeIcon');
if(!icon) return;
if(theme==='dark'){
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
}else{
icon.innerHTML = '<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>';
}
}
function applyTheme(theme){
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
else{document.documentElement.removeAttribute('data-theme');}
localStorage.setItem('theme',theme);
setThemeIcon(theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
const next = cur==='light'?'dark':'light';
const btn=document.getElementById('themeToggle');
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
applyTheme(next);
}
(function(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){btn.addEventListener('click',toggleTheme)}
load();
})();
</script>
</body>
</html>
+108 -32
View File
@@ -3,37 +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}
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 .title{font-weight:700}
.header .sub{font-size:12px;opacity:.9}
.header a{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px}
.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}
.filters{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05);margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.filters input,.filters select{width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff}
small{color:#6b7280}
.wrap{max-width:760px;margin:0 auto;padding:14px}
.filters{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.filters input,.filters select{width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
small{color:var(--muted)}
.actions{margin:0 0 10px;display:flex;gap:8px;flex-wrap:wrap}
button{border:none;border-radius:8px;padding:8px 12px;cursor:pointer;color:#fff;background:#ee5a24;font-size:13px}
button:hover{background:#d63031}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button:hover{background:var(--accent-hover)}
button.secondary{background:#6b7280}
button.secondary:hover{background:#4b5563}
.list{display:flex;flex-direction:column;gap:10px}
.log-card{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05)}
.log-card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none}
.row{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
.tag{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.tag{display:inline-block;padding:2px 8px;border-radius:6px;font-size:12px}
.tag.success{background:#dcfce7;color:#166534}
.tag.failed{background:#fee2e2;color:#991b1b}
.tag.denied{background:#fef3c7;color:#92400e}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#4b5563;word-break:break-all}
.note{font-size:13px;color:#374151;margin-top:6px;white-space:pre-wrap;word-break:break-word}
.empty{text-align:center;padding:40px 10px;color:#999}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
.note{font-size:13px;color:var(--text);margin-top:6px;white-space:pre-wrap;word-break:break-word}
.empty{text-align:center;padding:40px 10px;color:var(--muted)}
.hidden{display:none !important;}
@media(max-width:640px){
.header{padding:14px 12px 10px;align-items:flex-start;flex-direction:column}
@@ -41,6 +64,7 @@ button.secondary:hover{background:#4b5563}
.wrap{padding:12px}
.filters{grid-template-columns:1fr}
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
@@ -50,8 +74,21 @@ button.secondary:hover{background:#4b5563}
<div class="sub">{{.version}}</div>
</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="/channels">渠道配置</a>
<a id="btnChannels" href="/channels">渠道配置</a>
<a href="/logout">退出</a>
</div>
</div>
@@ -73,8 +110,11 @@ button.secondary:hover{background:#4b5563}
</div>
<script>
let me=null;
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function qs(id){return document.getElementById(id).value.trim();}
async function api(url,opt={}){const r=await fetch(url,opt);const out=await r.json().catch(()=>({}));if(!r.ok) throw new Error(out.message||('HTTP '+r.status));return out?.data||{};}
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
const actionMap={
'auth.login.success':'登录成功','auth.login.failed':'登录失败','auth.logout':'退出登录',
@@ -111,25 +151,61 @@ async function loadAudit(){
const listEl=document.getElementById('list');
listEl.innerHTML='<div class="empty">加载中...</div>';
const r=await fetch('/api/v1/admin/audit?'+p.toString());
const data=await r.json().catch(()=>[]);
const list=Array.isArray(data)?data:[];
if(!list.length){listEl.innerHTML='<div class="empty">暂无审计记录</div>';return;}
try{
const out=await api('/api/v1/admin/audit?'+p.toString());
const list=Array.isArray(out.logs)?out.logs:[];
if(!list.length){listEl.innerHTML='<div class="empty">暂无审计记录</div>';return;}
listEl.innerHTML=list.map(it=>{
const result=parseResult(it.note);
const resultClass=result||'success';
const note=String(it.note||'').replace(/\s*\|\s*result=(success|failed|denied)\s*$/,'').trim();
return `<div class="log-card">
<div class="row"><strong>${actionLabel(it.action)}</strong><span class="tag ${resultClass}">${resultLabel(result)}</span></div>
<div class="mono" style="margin-top:4px;">#${it.id} · ${esc(it.created_at||'')}</div>
<div class="mono" style="margin-top:2px;">操作人: ${it.actor_id} · 目标: ${targetLabel(it.target_type)} (${esc(it.target_id||'')})</div>
<div class="note">${esc(note||'(无备注)')}</div>
</div>`;
}).join('');
listEl.innerHTML=list.map(it=>{
const result=parseResult(it.note);
const resultClass=result||'success';
const note=String(it.note||'').replace(/\s*\|\s*result=(success|failed|denied)\s*$/,'').trim();
return `<div class="log-card">
<div class="row"><strong>${actionLabel(it.action)}</strong><span class="tag ${resultClass}">${resultLabel(result)}</span></div>
<div class="mono" style="margin-top:4px;">#${it.id} · ${esc(it.created_at||'')}</div>
<div class="mono" style="margin-top:2px;">操作人: ${it.actor_id} · 目标: ${targetLabel(it.target_type)} (${esc(it.target_id||'')})</div>
<div class="note">${esc(note||'(无备注)')}</div>
</div>`;
}).join('');
}catch(e){
listEl.innerHTML='<div class="empty">加载失败:'+esc(e.message||e)+'</div>';
}
}
loadAudit();
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('btnChannels').classList.toggle('hidden',!can('can_view_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(){
try{ initTheme(); await loadMe(); initPermissionUI(); await loadAudit(); }
catch(e){ document.getElementById('list').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>'; }
})();
</script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare 配置 - Ops-Assistant</title>
<style>
:root{
--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: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 .right{display:flex;align-items:center;gap:8px}
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center}
.header button{width:28px;padding:0}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:760px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
small{color:var(--muted)}
</style>
</head>
<body>
<div class="header">
<div>☁️ Cloudflare 配置</div>
<div class="right">
<a href="/">返回首页</a>
<a href="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="card">
<h3>账号凭据</h3>
<div class="row"><label>Account ID</label><input id="account" placeholder="请输入 Account ID"></div>
<div class="row"><label>API Token</label><input id="token" placeholder="请输入 API Token"></div>
<small>用于查询/修改 DNS & Workers(单账号)</small>
<div class="btns">
<button onclick="save()">保存</button>
<button class="secondary" onclick="load()">刷新</button>
</div>
<small id="msg"></small>
</div>
</div>
<script>
async function api(url,opt={}){
const r=await fetch(url,opt);
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]));}
async function load(){
try{
const d=await api('/api/v1/admin/cf/settings');
document.getElementById('account').value = d.settings?.cf_account_id || '';
document.getElementById('token').value = d.settings?.cf_api_token || '';
document.getElementById('msg').textContent='已加载';
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
}
async function save(){
const payload={
account_id: document.getElementById('account').value.trim(),
api_token: document.getElementById('token').value.trim()
};
try{
await api('/api/v1/admin/cf/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('msg').textContent='已保存';
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
}
load();
</script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cfdav 配置 - Ops-Assistant</title>
<style>
:root{--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: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 .right{display:flex;align-items:center;gap:8px}
.header a{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;line-height:28px;height:28px;display:inline-flex;align-items:center}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
button.danger{background:#9b1c1c}
small{color:var(--muted)}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
</style>
</head>
<body>
<div class="header">
<div>🗂️ cfdav 管理</div>
<div class="right">
<a href="/">返回首页</a>
<a href="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="card">
<h3>连接配置</h3>
<div class="row"><label>API Base</label><input id="apiBase" placeholder="例如 https://cfdav.ouai.nyc.mn"></div>
<div class="row"><label>管理员账号</label><input id="adminUser" placeholder="例如 kyy"></div>
<div class="row"><label>管理员密码</label><input id="adminPass" placeholder="请输入密码"></div>
<div class="btns">
<button onclick="saveSettings()">保存</button>
<button class="secondary" onclick="loadSettings()">刷新</button>
</div>
<small id="settingsMsg"></small>
</div>
<div class="card">
<h3>创建用户</h3>
<div class="row"><label>邮箱/用户名</label><input id="newEmail" placeholder="例如 user01"></div>
<div class="row"><label>密码</label><input id="newPass" placeholder="请输入密码"></div>
<div class="row"><label>管理员</label><input id="newAdmin" type="checkbox"></div>
<div class="btns">
<button onclick="createUser()">新增用户</button>
<button class="secondary" onclick="loadUsers()">刷新列表</button>
</div>
<small id="userMsg"></small>
</div>
<div class="card">
<h3>用户列表</h3>
<table class="table">
<thead><tr><th>ID</th><th>账号</th><th>管理员</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</div>
<script>
async function api(url,opt={}){
const r=await fetch(url,opt);
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]));}
async function loadSettings(){
try{
const d=await api('/api/v1/admin/cfdav/settings');
const s=d.settings||{};
document.getElementById('apiBase').value=s.cfdav_api_base||'';
document.getElementById('adminUser').value=s.cfdav_admin_user||'';
document.getElementById('adminPass').value=s.cfdav_admin_pass||'';
document.getElementById('settingsMsg').textContent='已加载';
}catch(e){document.getElementById('settingsMsg').textContent='加载失败:'+esc(e.message||e);}
}
async function saveSettings(){
const payload={
api_base:document.getElementById('apiBase').value.trim(),
admin_user:document.getElementById('adminUser').value.trim(),
admin_pass:document.getElementById('adminPass').value.trim()
};
try{
await api('/api/v1/admin/cfdav/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('settingsMsg').textContent='已保存';
}catch(e){document.getElementById('settingsMsg').textContent='保存失败:'+esc(e.message||e);}
}
function normalizeUsers(data){
if(Array.isArray(data)) return data;
if(Array.isArray(data?.data)) return data.data;
return [];
}
async function loadUsers(){
const tbody=document.getElementById('usersBody');
try{
const d=await api('/api/v1/admin/cfdav/users');
const list=normalizeUsers(d);
if(!list.length){tbody.innerHTML='<tr><td colspan="5"><small>暂无用户</small></td></tr>';return;}
tbody.innerHTML=list.map(u=>`<tr>
<td>${esc(u.id)}</td>
<td>${esc(u.email)}</td>
<td>${u.is_admin===1?'<span class="badge on">YES</span>':'<span class="badge off">NO</span>'}</td>
<td>${esc(u.created_at)}</td>
<td><button class="danger" onclick="deleteUser('${encodeURIComponent(u.id)}')">删除</button></td>
</tr>`).join('');
}catch(e){
tbody.innerHTML='<tr><td colspan="5"><small>加载失败:'+esc(e.message||e)+'</small></td></tr>';
}
}
async function createUser(){
const payload={
email:document.getElementById('newEmail').value.trim(),
password:document.getElementById('newPass').value,
isAdmin:document.getElementById('newAdmin').checked
};
try{
await api('/api/v1/admin/cfdav/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('userMsg').textContent='创建成功';
document.getElementById('newPass').value='';
await loadUsers();
}catch(e){document.getElementById('userMsg').textContent='创建失败:'+esc(e.message||e);}
}
async function deleteUser(id){
if(!confirm('确认删除该用户?')) return;
try{
await api('/api/v1/admin/cfdav/users/'+id,{method:'DELETE'});
document.getElementById('userMsg').textContent='删除成功';
await loadUsers();
}catch(e){document.getElementById('userMsg').textContent='删除失败:'+esc(e.message||e);}
}
(async function init(){
await loadSettings();
await loadUsers();
})();
</script>
</body>
</html>
+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>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CPA 配置 - 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: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 .right{display:flex;align-items:center;gap:8px}
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
.header button{width:28px;padding:0;transition:transform .2s ease}
.header button.spin{transform:rotate(180deg)}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
button.danger{background:#9b1c1c}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
small{color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🔧 CPA 配置 · {{.version}}</div>
<div class="right">
<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="card">
<h3>CPA 管理接口</h3>
<div class="row"><label>CPA Management Base</label><input id="base" placeholder="https://cpa.pao.xx.kg/v0/management"></div>
<div class="row"><label>CPA Management Token</label><input id="token" placeholder="请输入 Token"></div>
<small>用于访问 CPA 管理接口(Bearer Token)</small>
<div class="btns">
<button onclick="save()">保存</button>
<button class="secondary" onclick="load()">刷新</button>
</div>
<small id="msg"></small>
</div>
<div class="card">
<h3>目标主机(Ops Targets)</h3>
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
<div class="btns"><button onclick="createTarget()">新增</button><button class="secondary" onclick="loadTargets()">刷新列表</button></div>
<small id="tmsg"></small>
<div style="margin-top:10px;">
<table class="table" style="width:100%;border-collapse:collapse;font-size:13px;">
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
</div>
<script>
async function api(url,opt={}){
const r=await fetch(url,opt);
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]));}
async function load(){
try{
const d=await api('/api/v1/admin/cpa/settings');
document.getElementById('token').value = d.settings?.cpa_management_token || '';
document.getElementById('base').value = d.settings?.cpa_management_base || '';
document.getElementById('msg').textContent='已加载';
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
}
async function save(){
const token=document.getElementById('token').value.trim();
const base=document.getElementById('base').value.trim();
try{
await api('/api/v1/admin/cpa/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({management_token:token,management_base:base})});
document.getElementById('msg').textContent='已保存';
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
}
async function loadTargets(){
try{
const d=await api('/api/v1/admin/ops/targets');
const list=Array.isArray(d.targets)?d.targets:[];
const tbody=document.getElementById('tbody');
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
tbody.innerHTML=list.map(t=>`<tr>
<td>${esc(t.name)}</td>
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
<td>
<button class="secondary" onclick="saveTarget(${t.id})">保存</button>
<button class="danger" onclick="toggleTarget(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
</td>
</tr>`).join('');
}catch(e){document.getElementById('tmsg').textContent='加载失败:'+esc(e.message||e);}
}
async function createTarget(){
const payload={
name:document.getElementById('name').value.trim(),
host:document.getElementById('host').value.trim(),
port:parseInt(document.getElementById('port').value||'22',10),
user:document.getElementById('user').value.trim(),
enabled:document.getElementById('enabled').checked
};
try{
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('tmsg').textContent='已新增';
await loadTargets();
}catch(e){document.getElementById('tmsg').textContent='失败:'+esc(e.message||e);}
}
async function saveTarget(id){
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
const payload={enabled:true};
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
payload.port=parseInt(payload.port||'22',10);
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
await loadTargets();
}catch(e){alert('保存失败:'+(e.message||e));}
}
async function toggleTarget(id, enable){
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
await loadTargets();
}catch(e){alert('失败:'+(e.message||e));}
}
function setThemeIcon(theme){
const icon=document.getElementById('themeIcon');
if(!icon) return;
if(theme==='dark'){
// 月亮:同线条风格
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
}else{
// 太阳:保留原样式
icon.innerHTML = '<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>';
}
}
function applyTheme(theme){
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
else{document.documentElement.removeAttribute('data-theme');}
localStorage.setItem('theme',theme);
setThemeIcon(theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
const next = cur==='light'?'dark':'light';
const btn=document.getElementById('themeToggle');
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
applyTheme(next);
}
(function(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){btn.addEventListener('click',toggleTheme)}
load();
loadTargets();
})();
</script>
</body>
</html>
+226 -204
View File
@@ -3,234 +3,256 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🦞 虾记记账</title>
<title>🛠️ Ops-Assistant</title>
<style>
*{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;text-align:center;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);position:relative}
.header h1{font-size:24px;margin-bottom:4px}
.header .subtitle{font-size:13px;opacity:.9}
.logout-btn{position:absolute;right:16px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.2);color:#fff;text-decoration:none;padding:5px 14px;border-radius:6px;font-size:13px}
.logout-btn:hover{background:rgba(255,255,255,.35)}
.stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:15px;max-width:600px;margin:0 auto}
.stat-card{background:#fff;border-radius:12px;padding:15px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.06)}
.stat-card .num{font-size:22px;font-weight:700;color:#ee5a24}
.stat-card .label{font-size:12px;color:#999;margin-top:4px}
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 15px 10px;max-width:600px;margin:0 auto}
.toolbar .spacer{flex:1}
.filter-select{padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff;min-width:110px}
.toolbar a{padding:8px 12px;background:#ee5a24;color:#fff;border-radius:8px;text-decoration:none;font-size:13px;white-space:nowrap}
.toolbar a:hover{background:#d63031}
#flagsPanel{display:none;max-width:600px;margin:0 auto 12px;background:#fff;border-radius:12px;padding:12px 14px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
#flagsList{display:flex;flex-direction:column;gap:8px}
.tx-list{padding:0 15px 80px;max-width:600px;margin:0 auto}
.tx-card{background:#fff;border-radius:12px;padding:14px 16px;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);display:flex;align-items:center;gap:12px}
.tx-icon{width:42px;height:42px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.tx-info{flex:1;min-width:0}
.tx-category{font-weight:600;font-size:15px}
.tx-note{font-size:12px;color:#999;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tx-right{text-align:right;flex-shrink:0}
.tx-amount{font-size:17px;font-weight:700;color:#e74c3c}
.tx-date{font-size:11px;color:#bbb;margin-top:2px}
.btn-del{background:none;border:1px solid #e74c3c;color:#e74c3c;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;margin-top:4px}
.btn-del:hover{background:#e74c3c;color:#fff}
.empty{text-align:center;padding:60px 20px;color:#999}
.empty .icon{font-size:48px;margin-bottom:10px}
.cat-餐饮{background:#fff3e0}.cat-交通{background:#e3f2fd}.cat-购物{background:#fce4ec}.cat-娱乐{background:#f3e5f5}.cat-其他{background:#f5f5f5}
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:200;align-items:center;justify-content:center}
.modal.show{display:flex}
.modal-body{background:#fff;border-radius:16px;padding:24px;width:280px;text-align:center}
.modal-body .btns{display:flex;gap:10px;margin-top:16px}
.modal-body .btns button{flex:1;padding:10px;border:none;border-radius:8px;cursor:pointer}
@media(max-width:640px){
.header{padding:14px 12px 10px;text-align:left}
.header h1{font-size:20px;margin-right:90px}
.header .subtitle{font-size:12px;margin-right:90px}
.logout-btn{right:10px;top:14px;transform:none;padding:4px 10px;font-size:12px}
.stats{grid-template-columns:1fr;padding:12px}
.stat-card{text-align:left;padding:12px}
.toolbar{padding:0 12px 10px}
.toolbar .spacer{display:none}
.toolbar a,.filter-select{flex:1 1 calc(50% - 4px);text-align:center}
#flagsPanel{margin:0 12px 12px}
.tx-list{padding:0 12px 72px}
:root{
--bg:#f5f7fb;
--text:#222;
--card:#fff;
--border:#e5e7eb;
--muted:#6b7280;
--accent:#ee5a24;
--accent-hover:#d63031;
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
--chip:#f3f4f6;
}
[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);
--chip:#111827;
}
*{margin:0;padding:0;box-sizing:border-box}
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;align-items:center;justify-content:space-between;gap:12px}
.header h1{font-size:22px}
.header .subtitle{font-size:12px;opacity:.9}
.header .right{display:flex;align-items:center;gap:8px}
.header .right a,.header .right button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center}
.header .right button{width:28px;padding:0;transition:transform .2s ease}
.header .right button.spin{transform:rotate(180deg)}
.header .right button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none}
.card h3{font-size:14px;margin-bottom:10px}
.stat{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px;color:var(--muted)}
.stat b{font-size:18px;color:var(--accent)}
.section{margin-top:12px}
.section-title{font-size:13px;color:var(--muted);margin:10px 2px}
.tags{display:flex;flex-wrap:wrap;gap:8px}
.tag{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;background:var(--card)}
.tag .dot{width:8px;height:8px;border-radius:50%}
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}.dot.off{background:#9ca3af}
.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.actions a{display:inline-block;text-decoration:none;background:var(--accent);color:#fff;border-radius:6px;padding:8px 12px;font-size:13px;border:1px solid rgba(0,0,0,.06)}
.actions a.secondary{background:#6b7280}
.actions a.hidden{display:none}
.list{display:grid;gap:8px}
.job{border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;background:var(--card)}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
.empty{padding:30px;text-align:center;color:var(--muted)}
@media(max-width:720px){
.grid{grid-template-columns:1fr}
.header{flex-direction:column;align-items:flex-start}
.header .right{align-self:flex-end}
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<h1>🦞 虾记记账</h1>
<div class="subtitle">{{.version}}</div>
<a href="/logout" class="logout-btn">退出</a>
<div>
<h1>🛠️ Ops-Assistant</h1>
<div class="subtitle">{{.version}}</div>
</div>
<div class="right">
<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="/logout">退出</a>
</div>
</div>
<div class="stats">
<div class="stat-card"><div class="num" id="todayTotal">0.00</div><div class="label">今日支出</div></div>
<div class="stat-card"><div class="num" id="monthTotal">0.00</div><div class="label">本月支出</div></div>
<div class="stat-card"><div class="num" id="txCount">0</div><div class="label">总记录数</div></div>
</div>
<div class="toolbar">
<select class="filter-select" id="catFilter" onchange="filterList()"><option value="">全部分类</option></select>
<select class="filter-select" id="scopeFilter" onchange="loadData()" style="display:none;"><option value="self">仅本人</option><option value="all">全员</option></select>
<div class="spacer"></div>
<a href="#" id="btnFlags" style="display:none;">⚙️ 高级功能</a>
<a href="/audit" id="btnAudit" style="display:none;">🧾 审计日志</a>
<a href="/channels" id="btnChannels" style="display:none;">🔌 渠道配置</a>
<a href="#" id="btnExport">📥 导出CSV</a>
</div>
<div id="flagsPanel">
<div style="font-weight:600;margin-bottom:8px;">高级功能开关(高风险默认关闭)</div>
<div id="flagsList"></div>
</div>
<div class="tx-list" id="txList"></div>
<div class="modal" id="delModal">
<div class="modal-body">
<h3>确认删除?</h3>
<p id="delInfo" style="font-size:13px;color:#666;"></p>
<div class="btns">
<button onclick="closeModal()">取消</button>
<button style="background:#e74c3c;color:#fff" onclick="confirmDelete()">删除</button>
<div class="wrap">
<div class="grid">
<div class="card">
<h3>任务概览</h3>
<div class="stat"><span>Pending</span><b id="sPending">0</b></div>
<div class="stat"><span>Running</span><b id="sRunning">0</b></div>
<div class="stat"><span>Success</span><b id="sSuccess">0</b></div>
<div class="stat"><span>Failed</span><b id="sFailed">0</b></div>
<div class="stat"><span>Cancelled</span><b id="sCancelled">0</b></div>
<div class="actions">
<a id="btnOps" href="/ops" class="hidden">🛠️ 任务中心</a>
</div>
</div>
<div class="card">
<h3>模块状态</h3>
<div class="tags" id="modulesTag"></div>
<div class="actions">
<a id="btnCPA" href="/cpa" class="secondary hidden">🔧 CPA 配置</a>
<a id="btnCF" href="/cf" class="secondary hidden">☁️ CF 配置</a>
<a id="btnCFDAV" href="/cfdav" class="secondary hidden">🗂️ cfdav 配置</a>
<a id="btnAI" href="/ai" class="secondary hidden" style="display:none">🤖 AI 配置</a>
<a id="btnModules" href="#" class="secondary hidden" onclick="toggleModulesPanel();return false;">⚙️ 模块开关</a>
</div>
</div>
<div class="card">
<h3>通道状态</h3>
<div class="tags" id="channelsTag"></div>
<div class="actions">
<a id="btnChannels" href="/channels" class="hidden">🔌 渠道配置</a>
<a id="btnAudit" href="/audit" class="secondary hidden">🧾 审计日志</a>
</div>
</div>
</div>
<div class="section">
<div class="section-title">最近任务</div>
<div id="recentJobs" class="list"></div>
</div>
</div>
<script>
const catIcons={ '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
let allData=[],deleteId=null,me=null,flags=[];
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
let me=null;
const state={overview:null};
async function loadMe(){ const r=await fetch('/api/v1/me'); if(!r.ok) throw new Error('读取用户信息失败'); me=await r.json(); }
function initPermissionUI(){
const caps=(me&&me.effective_capabilities)||{};
const scopeSel=document.getElementById('scopeFilter');
const btnChannels=document.getElementById('btnChannels');
const btnAudit=document.getElementById('btnAudit');
const btnFlags=document.getElementById('btnFlags');
scopeSel.style.display=(caps.can_read_all || (me.flags&&me.flags.allow_cross_user_read===false)) ? '' : 'none';
btnChannels.style.display=caps.can_view_channels ? '' : 'none';
btnAudit.style.display=caps.can_view_audit ? '' : 'none';
btnFlags.style.display=caps.can_view_flags ? '' : 'none';
btnFlags.onclick=async (e)=>{
e.preventDefault();
const p=document.getElementById('flagsPanel');
p.style.display=(p.style.display==='none'||!p.style.display)?'block':'none';
if(p.style.display==='block'){ await loadFlags(); renderFlags(); }
};
document.getElementById('btnExport').onclick=(e)=>{
e.preventDefault();
const scope=document.getElementById('scopeFilter').value||'self';
if(scope==='all'&&!caps.can_export_all){ alert('没有导出全员权限或开关未启用'); return; }
if(scope==='self'&&!caps.can_export_self){ alert('没有导出本人权限'); return; }
window.location.href='/api/v1/export?scope='+encodeURIComponent(scope);
};
}
async function loadFlags(){
const caps=(me&&me.effective_capabilities)||{};
if(!caps.can_view_flags) return;
const r=await fetch('/api/v1/admin/settings/flags');
const data=await r.json();
flags=Array.isArray(data)?data:[];
}
function renderFlags(){
const list=document.getElementById('flagsList');
const caps=(me&&me.effective_capabilities)||{};
list.innerHTML=flags.map(f=>{
const disabled=!caps.can_edit_flags?'disabled':'';
return `<label style="display:flex;align-items:center;justify-content:space-between;border:1px solid #eef2f7;border-radius:8px;padding:8px 10px;">
<span><strong>${esc(f.key)}</strong><br><small>${esc(f.description||'')}(${esc(f.risk_level||'unknown')})</small></span>
<input type="checkbox" ${f.enabled?'checked':''} ${disabled} onchange="toggleFlag('${esc(f.key)}',this.checked)">
</label>`;
}).join('');
}
async function toggleFlag(key,enabled){
const caps=(me&&me.effective_capabilities)||{};
if(!caps.can_edit_flags){ alert('无修改权限'); return; }
const reason=prompt('请输入修改原因(审计必填)')||'';
const r=await fetch('/api/v1/admin/settings/flags/'+encodeURIComponent(key),{
method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled,reason})
});
async function api(url,opt={}){
const r=await fetch(url,opt);
const out=await r.json().catch(()=>({}));
if(!r.ok){ alert(out.error||'修改失败'); }
await loadMe(); await loadFlags(); renderFlags();
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]);}
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||{};
}
async function loadData(){
try{
const caps=(me&&me.effective_capabilities)||{};
const scopeEl=document.getElementById('scopeFilter');
let scope=scopeEl.value||'self';
if(scope==='all'&&!caps.can_read_all){ alert('没有查看全员数据权限或开关未启用'); scope='self'; scopeEl.value='self'; }
const r=await fetch('/api/v1/records?scope='+encodeURIComponent(scope));
allData=await r.json();
if(!Array.isArray(allData)) allData=[];
renderStats(); renderList(allData); populateFilter();
}catch(e){
console.error(e);
document.getElementById('txList').innerHTML='<div class="empty"><div class="icon">⚠️</div>数据加载失败</div>';
function initUI(){
document.getElementById('btnOps').classList.toggle('hidden',!can('can_view_ops'));
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
document.getElementById('btnCPA').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnCF').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnCFDAV').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnAI').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnAudit').classList.toggle('hidden',!can('can_view_audit'));
document.getElementById('btnModules').classList.toggle('hidden',!can('can_view_flags'));
}
function renderOverview(){
const ov=state.overview||{};
const sc=ov.jobs?.status_count||{};
document.getElementById('sPending').textContent=sc.pending??0;
document.getElementById('sRunning').textContent=sc.running??0;
document.getElementById('sSuccess').textContent=sc.success??0;
document.getElementById('sFailed').textContent=sc.failed??0;
document.getElementById('sCancelled').textContent=sc.cancelled??0;
const mods=Array.isArray(ov.modules)?ov.modules:[];
document.getElementById('modulesTag').innerHTML=mods.length?mods.map(m=>{
const dot=m.enabled?'ok':'off';
return `<span class="tag"><span class="dot ${dot}"></span>${esc(m.module)}</span>`;
}).join(''):'<div class="empty">暂无模块</div>';
const chs=Array.isArray(ov.channels)?ov.channels:[];
document.getElementById('channelsTag').innerHTML=chs.length?chs.map(c=>{
const dot=c.status==='ok'?'ok':(c.enabled?'err':'off');
return `<span class="tag"><span class="dot ${dot}"></span>${esc(c.platform)}</span>`;
}).join(''):'<div class="empty">暂无通道</div>';
const jobs=Array.isArray(ov.jobs?.recent)?ov.jobs.recent:[];
const box=document.getElementById('recentJobs');
if(!jobs.length){ box.innerHTML='<div class="empty">暂无任务</div>'; return; }
box.innerHTML=jobs.map(j=>`<div class="job">
<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<strong>#${j.id} ${esc(j.command||'')}</strong>
<span class="badge ${(j.status||'pending').toLowerCase()}">${esc(j.status||'pending')}</span>
</div>
<div style="color:#666;margin-top:4px;">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')}</div>
</div>`).join('');
}
async function loadOverview(){
state.overview=await api('/api/v1/dashboard/overview');
renderOverview();
}
function toggleModulesPanel(){
window.location.href='/modules';
}
function setThemeIcon(theme){
const icon=document.getElementById('themeIcon');
if(!icon) return;
if(theme==='dark'){
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
}else{
icon.innerHTML = '<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>';
}
}
function renderStats(){
const today=new Date().toISOString().slice(0,10), month=today.slice(0,7);
let todaySum=0,monthSum=0;
allData.forEach(tx=>{ const amt=Number(tx.amount||0); if(tx.date===today) todaySum+=amt; if(tx.date&&tx.date.startsWith(month)) monthSum+=amt; });
document.getElementById('todayTotal').textContent=todaySum.toFixed(2);
document.getElementById('monthTotal').textContent=monthSum.toFixed(2);
document.getElementById('txCount').textContent=allData.length;
function applyTheme(theme){
if(theme==='dark'){
document.documentElement.setAttribute('data-theme','dark');
}else{
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme',theme);
setThemeIcon(theme);
}
function renderList(data){
const el=document.getElementById('txList');
const caps=(me&&me.effective_capabilities)||{};
if(!data.length){ el.innerHTML='<div class="empty"><div class="icon">📭</div>暂无记录<br><small>通过 Telegram/QQ 发送消息记账</small></div>'; return; }
el.innerHTML=data.map(tx=>{
const icon=catIcons[tx.category]||'📦', cls='cat-'+(tx.category||'其他');
const amount=Number(tx.amount||0), note=esc(tx.note), canDelete=caps.can_delete_self||caps.can_delete_all;
const delBtn=canDelete ? `<button class="btn-del" onclick="showDelete(${tx.id},'${note.replace(/'/g,"&#39;")}',${amount})">删除</button>` : `<button class="btn-del" style="opacity:.45;cursor:not-allowed;" onclick="alert('无删除权限')">删除</button>`;
return `<div class="tx-card"><div class="tx-icon ${cls}">${icon}</div><div class="tx-info"><div class="tx-category">${esc(tx.category||'其他')}</div><div class="tx-note" title="${note}">${note}</div></div><div class="tx-right"><div class="tx-amount">-${amount.toFixed(2)}</div><div class="tx-date">${esc(tx.date||'')}</div>${delBtn}</div></div>`;
}).join('');
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
const next=cur==='light'?'dark':'light';
const btn=document.getElementById('themeToggle');
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
applyTheme(next);
}
function populateFilter(){
const cats=[...new Set(allData.map(t=>t.category).filter(Boolean))].sort();
const sel=document.getElementById('catFilter'), cur=sel.value;
sel.innerHTML='<option value="">全部分类</option>'+cats.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('');
sel.value=cur;
}
function filterList(){ const cat=document.getElementById('catFilter').value; renderList(cat?allData.filter(t=>t.category===cat):allData); }
function showDelete(id,note,amount){ deleteId=id; document.getElementById('delInfo').textContent=`${note} (${Number(amount).toFixed(2)}元)`; document.getElementById('delModal').classList.add('show'); }
function closeModal(){ document.getElementById('delModal').classList.remove('show'); deleteId=null; }
async function confirmDelete(){
if(!deleteId) return;
const r=await fetch('/api/v1/records/'+deleteId+'/delete',{method:'POST'});
const out=await r.json().catch(()=>({}));
if(!r.ok) alert(out.error||'删除失败');
closeModal(); loadData();
function initTheme(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){ btn.addEventListener('click',toggleTheme); }
}
(async function(){
try{ await loadMe(); initPermissionUI(); await loadData(); setInterval(loadData,30000); }
catch(e){ console.error(e); document.getElementById('txList').innerHTML='<div class="empty"><div class="icon">⚠️</div>页面初始化失败</div>'; }
try{ initTheme(); await loadMe(); initUI(); await loadOverview(); setInterval(loadOverview,15000);}catch(e){
document.getElementById('recentJobs').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>';
}
})();
</script>
</body>
+41 -25
View File
@@ -3,26 +3,39 @@
<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:#0f172a;
--card:#111827;
--text:#e5e7eb;
--muted:#9ca3af;
--border:#1f2937;
--accent:#f97316;
--accent2:#ea580c;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #f39c12 100%);
background: radial-gradient(1200px 600px at 10% 10%, rgba(249,115,22,.18), transparent 60%),
radial-gradient(900px 500px at 90% 10%, rgba(234,88,12,.18), transparent 60%),
var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text);
}
.login-card {
background: #fff;
border-radius: 20px;
padding: 40px 32px;
background: var(--card);
border-radius: 16px;
padding: 36px 30px;
width: 100%;
max-width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0,0,0,.35);
animation: slideUp .4s ease;
}
@@ -33,51 +46,54 @@ body {
.login-logo {
text-align: center;
margin-bottom: 28px;
margin-bottom: 24px;
}
.login-logo .icon { font-size: 52px; }
.login-logo .icon { font-size: 48px; }
.login-logo h1 {
font-size: 22px;
color: #333;
color: var(--text);
margin-top: 8px;
}
.login-logo .subtitle {
font-size: 13px;
color: #999;
color: var(--muted);
margin-top: 4px;
}
.form-group {
margin-bottom: 18px;
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: #666;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #eee;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 15px;
transition: border-color .2s;
transition: border-color .2s, box-shadow .2s;
outline: none;
background: #0b1220;
color: var(--text);
}
.form-group input:focus {
border-color: #ee5a24;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(249,115,22,.2);
}
.btn-login {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
padding: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity .2s, transform .1s;
@@ -87,14 +103,14 @@ body {
.btn-login:active { transform: scale(.98); }
.error-msg {
background: #fff2f0;
color: #e74c3c;
background: rgba(239,68,68,.12);
color: #fca5a5;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
margin-bottom: 14px;
text-align: center;
border: 1px solid #fde2e0;
border: 1px solid rgba(239,68,68,.3);
}
</style>
</head>
@@ -102,7 +118,7 @@ body {
<div class="login-card">
<div class="login-logo">
<div class="icon">🦞</div>
<h1>虾记记账</h1>
<h1>Ops-Assistant</h1>
<div class="subtitle">{{.version}}</div>
</div>
@@ -119,7 +135,7 @@ body {
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
<button class="btn-login" type="submit">登录</button>
</form>
</div>
</body>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模块开关 - Ops-Assistant</title>
<style>
:root{--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;--accent:#ee5a24;--accent-hover:#d63031;--ok:#16a34a;--off:#64748b;}
[data-theme="dark"]{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--ok:#22c55e;--off:#94a3b8;}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);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 .right{display:flex;align-items:center;gap:8px}
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
.header button{width:28px;padding:0;transition:transform .2s ease}
.header button.spin{transform:rotate(180deg)}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:10px;text-align:left}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
.btn{border:none;border-radius:6px;padding:6px 10px;cursor:pointer;color:#fff;background:var(--accent);font-size:12px}
.btn.secondary{background:#6b7280}
.btn.danger{background:#9b1c1c}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
input{padding:6px 8px;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card);color:var(--text)}
small{color:var(--muted)}
</style>
</head>
<body>
<div class="header">
<div>⚙️ 模块开关 · {{.version}}</div>
<div class="right">
<button id="themeToggle" 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="card">
<div class="row" style="justify-content:space-between;">
<div><strong>模块列表</strong> <small>(需要填写变更原因)</small></div>
<div class="row">
<input id="reason" placeholder="请输入变更原因" style="min-width:240px" />
<button class="btn secondary" onclick="load()">刷新</button>
</div>
</div>
<div id="msg" style="margin:8px 0;color:var(--muted)"></div>
<table class="table">
<thead><tr><th>模块</th><th>开关</th><th>Flag Key</th><th>操作</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
<script>
let theme='dark';
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
async function api(url,opt={}){
const r=await fetch(url,opt);
const out=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
return out?.data||{};
}
async function load(){
try{
const data=await api('/api/v1/modules');
const rows=(data.modules||[]).map(m=>{
const badge=m.enabled?'on':'off';
const label=m.enabled?'启用':'禁用';
const next=!m.enabled;
const btnClass=m.enabled?'danger':'btn';
return `<tr>
<td>${esc(m.display_name||m.module)}</td>
<td><span class="badge ${badge}">${label}</span></td>
<td><small>${esc(m.flag_key||'')}</small></td>
<td><button class="btn ${btnClass}" onclick="toggle('${esc(m.module)}',${next})">${next?'启用':'禁用'}</button></td>
</tr>`;
}).join('');
document.getElementById('rows').innerHTML=rows || '<tr><td colspan="4">暂无模块</td></tr>';
document.getElementById('msg').textContent='';
}catch(e){
document.getElementById('msg').textContent='加载失败:'+(e.message||e);
}
}
async function toggle(module, enabled){
const reason=(document.getElementById('reason').value||'').trim();
if(!reason){alert('请填写变更原因');return;}
try{
await api('/api/v1/modules/'+module+'/toggle',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled,reason})
});
await load();
}catch(e){alert('操作失败:'+(e.message||e));}
}
function setThemeIcon(theme){
const icon=document.getElementById('themeIcon');
if(!icon) return;
if(theme==='dark'){
icon.innerHTML='<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
}else{
icon.innerHTML='<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>';
}
}
function applyTheme(t){
theme=t; if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
else{document.documentElement.removeAttribute('data-theme');}
localStorage.setItem('theme',theme); setThemeIcon(theme);
}
function toggleTheme(){
const next=theme==='light'?'dark':'light';
const btn=document.getElementById('themeToggle');
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
applyTheme(next);
}
(function(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){ btn.addEventListener('click',toggleTheme); }
load();
})();
</script>
</body>
</html>
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🛠️ OPS任务 - 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: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}
.wrap{max-width:1000px;margin:0 auto;padding:14px}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
input,select{padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
button.danger{background:#9b1c1c}
.card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.row{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
pre{background:#0b1020;color:#d1d5db;border-radius:6px;padding:8px;overflow:auto;font-size:12px;max-height:220px}
.small{font-size:12px;color:var(--muted)}
.empty{padding:24px;text-align:center;color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🛠️ OPS任务面板 · {{.version}}</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">
<select id="qStatus">
<option value="">全部状态</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="success">success</option>
<option value="failed">failed</option>
<option value="cancelled">cancelled</option>
</select>
<input id="qTarget" placeholder="target (如 hwsg)">
<input id="qRunbook" placeholder="runbook (如 cpa_usage_backup)">
<input id="qRequest" placeholder="request_id">
<input id="qOperator" placeholder="operator (user_id)">
<input id="qFrom" placeholder="from (RFC3339)">
<input id="qTo" placeholder="to (RFC3339)">
<button onclick="loadJobs()">查询</button>
<button class="secondary" onclick="resetFilter()">重置</button>
</div>
<div id="jobs"></div>
</div>
<script>
let me=null;
let pollTimer=null;
async function api(url,opt={}){
const r=await fetch(url,opt);
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 statusTag(s){const k=(s||'pending').toLowerCase();return `<span class="badge ${k}">${esc(k)}</span>`;}
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 buildQuery(){
const q={
status:document.getElementById('qStatus').value.trim(),
target:document.getElementById('qTarget').value.trim(),
runbook:document.getElementById('qRunbook').value.trim(),
request_id:document.getElementById('qRequest').value.trim(),
operator:document.getElementById('qOperator').value.trim(),
from:document.getElementById('qFrom').value.trim(),
to:document.getElementById('qTo').value.trim(),
limit:'50'
};
const qs=Object.entries(q).filter(([,v])=>v).map(([k,v])=>k+'='+encodeURIComponent(v)).join('&');
return '/api/v1/ops/jobs'+(qs?'?'+qs:'');
}
async function loadJobs(){
const box=document.getElementById('jobs');
box.innerHTML='<div class="card">加载中...</div>';
try{
const data=await api(buildQuery());
const jobs=Array.isArray(data.jobs)?data.jobs:[];
if(!jobs.length){box.innerHTML='<div class="card empty">暂无任务</div>';return;}
box.innerHTML=jobs.map(j=>`<div class="card">
<div class="row"><strong>#${j.id} ${esc(j.command||'')}</strong>${statusTag(j.status)}</div>
<div class="small">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')} · risk=${esc(j.risk_level||'-')}</div>
<div class="mono">request=${esc(j.request_id||'-')} · start=${esc(j.started_at||'-')} · end=${esc(j.ended_at||'-')}</div>
<div class="toolbar" style="margin-top:8px;">
<button class="secondary" onclick="viewDetail(${j.id})">查看步骤</button>
${can('can_cancel_ops')?`<button class="danger" onclick="cancelJob(${j.id},'${esc(j.status||'')}')">取消</button>`:''}
${can('can_retry_ops')?`<button onclick="retryJob(${j.id},'${esc(j.status||'')}')">重试</button>`:''}
</div>
<div id="detail-${j.id}" class="small"></div>
</div>`).join('');
}catch(e){box.innerHTML='<div class="card">加载失败:'+esc(e.message||e)+'</div>';}
schedulePoll();
}
function schedulePoll(){
if(pollTimer) clearTimeout(pollTimer);
pollTimer=setTimeout(async ()=>{
try{ await loadJobs(); }catch(e){}
},8000);
}
async function viewDetail(id){
const el=document.getElementById('detail-'+id);
el.textContent='加载步骤中...';
try{
const d=await api('/api/v1/ops/jobs/'+id);
const steps=Array.isArray(d.steps)?d.steps:[];
const stats=d.step_stats||{};
const total=d.step_total||steps.length||0;
const dur=d.duration||{};
const head=`<div class="small" style="margin:6px 0;">steps=${total} · running=${stats.running||0} · success=${stats.success||0} · failed=${stats.failed||0} · job_ms=${dur.job_ms||0}</div>`;
if(!steps.length){el.innerHTML=head+'无步骤';return;}
el.innerHTML=head+steps.map(s=>`<div style="margin-top:6px;padding:6px;border:1px solid #eee;border-radius:8px;">
<div><strong>${esc(s.step_id)}</strong> (${esc(s.action)}) ${statusTag(s.status)}</div>
<div class="mono">rc=${s.rc} · ${esc(s.started_at||'')} -> ${esc(s.ended_at||'')}</div>
<details><summary>stdout/stderr</summary><pre>${esc((s.stdout_tail||'')+'\n---\n'+(s.stderr_tail||''))}</pre></details>
</div>`).join('');
}catch(e){el.textContent='加载失败:'+(e.message||e);}
}
async function cancelJob(id,status){
if(!['pending','running'].includes(String(status||'').toLowerCase())){alert('仅 pending/running 可取消');return;}
const reason=prompt('请输入取消原因(必填)')||'';
if(!reason.trim()){alert('取消原因不能为空');return;}
if(!confirm('确认取消任务 #'+id+' ?')) return;
try{await api('/api/v1/ops/jobs/'+id+'/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已取消');loadJobs();}
catch(e){alert('取消失败:'+(e.message||e));}
}
async function retryJob(id,status){
if(String(status||'').toLowerCase()!=='failed'){alert('仅 failed 可重试');return;}
const reason=prompt('请输入重试原因(必填)')||'';
if(!reason.trim()){alert('重试原因不能为空');return;}
try{const d=await api('/api/v1/ops/jobs/'+id+'/retry',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已重试,新任务ID='+d.new_job_id);loadJobs();}
catch(e){alert('重试失败:'+(e.message||e));}
}
function resetFilter(){
document.getElementById('qStatus').value='';
document.getElementById('qTarget').value='';
document.getElementById('qRunbook').value='';
document.getElementById('qRequest').value='';
document.getElementById('qOperator').value='';
document.getElementById('qFrom').value='';
document.getElementById('qTo').value='';
loadJobs();
}
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(){
try{initTheme();await loadMe();await loadJobs();}catch(e){document.getElementById('jobs').innerHTML='<div class="card">初始化失败:'+esc(e.message||e)+'</div>';}
})();
</script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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: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}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
.row label{width:120px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:160px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
button.danger{background:#9b1c1c}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
small{color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🎯 目标主机配置 · {{.version}}</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="card">
<h3>新增目标</h3>
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
<div class="row"><button onclick="create()">新增</button><small id="msg"></small></div>
</div>
<div class="card">
<h3>已有目标</h3>
<table class="table">
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<script>
async function api(url,opt={}){
const r=await fetch(url,opt);
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]));}
async function load(){
const d=await api('/api/v1/admin/ops/targets');
const list=Array.isArray(d.targets)?d.targets:[];
const tbody=document.getElementById('tbody');
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
tbody.innerHTML=list.map(t=>`<tr>
<td>${esc(t.name)}</td>
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
<td>
<button class="secondary" onclick="save(${t.id})">保存</button>
<button class="danger" onclick="toggle(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
</td>
</tr>`).join('');
}
async function create(){
const payload={
name:document.getElementById('name').value.trim(),
host:document.getElementById('host').value.trim(),
port:parseInt(document.getElementById('port').value||'22',10),
user:document.getElementById('user').value.trim(),
enabled:document.getElementById('enabled').checked
};
try{
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('msg').textContent='已新增';
await load();
}catch(e){document.getElementById('msg').textContent='失败:'+esc(e.message||e);}
}
async function save(id){
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
const payload={enabled:true};
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
payload.port=parseInt(payload.port||'22',10);
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
await load();
}catch(e){alert('保存失败:'+(e.message||e));}
}
async function toggle(id, enable){
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
await load();
}catch(e){alert('失败:'+(e.message||e));}
}
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(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){btn.addEventListener('click',toggleTheme)}
load();
})();
</script>
</body>
</html>