init: ops-assistant codebase
This commit is contained in:
145
templates/ai_settings.html
Normal file
145
templates/ai_settings.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
211
templates/audit.html
Normal file
211
templates/audit.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!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 .title{font-weight:700}
|
||||
.header .sub{font-size:12px;opacity:.9}
|
||||
.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: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: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: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: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: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}
|
||||
.header>div:last-child{display:flex;gap:8px}
|
||||
.wrap{padding:12px}
|
||||
.filters{grid-template-columns:1fr}
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">🧾 审计日志</div>
|
||||
<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 id="btnChannels" href="/channels">渠道配置</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="filters">
|
||||
<div><small>操作类型</small><input id="fAction" placeholder="如:record.delete.self"></div>
|
||||
<div><small>目标类型</small><input id="fTarget" placeholder="如:transaction"></div>
|
||||
<div><small>结果</small><select id="fResult"><option value="">全部</option><option value="success">成功</option><option value="denied">拒绝</option><option value="failed">失败</option></select></div>
|
||||
<div><small>操作人ID</small><input id="fActor" placeholder="如:1"></div>
|
||||
<div><small>开始时间(RFC3339)</small><input id="fFrom" placeholder="2026-03-09T00:00:00+08:00"></div>
|
||||
<div><small>结束时间(RFC3339)</small><input id="fTo" placeholder="2026-03-10T00:00:00+08:00"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="loadAudit()">查询</button>
|
||||
<button class="secondary" onclick="resetFilters()">重置</button>
|
||||
</div>
|
||||
<div id="list" class="list"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let me=null;
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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':'退出登录',
|
||||
'record.delete.self':'删除本人记录','record.delete.all':'删除全员记录','record.export':'导出记录',
|
||||
'flag.update':'修改高级开关',
|
||||
'channel_update_draft':'更新渠道草稿','channel_publish':'发布渠道草稿','channel_reload':'热加载渠道',
|
||||
'channel_disable_all':'一键关闭全部渠道','channel_enable':'启用渠道','channel_disable':'停用渠道','channel_test':'测试渠道连接'
|
||||
};
|
||||
const targetMap={
|
||||
'transaction':'记账记录','feature_flag':'高级开关','channel':'渠道','user':'用户','system':'系统'
|
||||
};
|
||||
|
||||
function actionLabel(v){return actionMap[v]||v||'-';}
|
||||
function targetLabel(v){return targetMap[v]||v||'-';}
|
||||
function parseResult(note){
|
||||
const m=String(note||'').match(/result=(success|failed|denied)/);
|
||||
return m?m[1]:'';
|
||||
}
|
||||
function resultLabel(r){
|
||||
if(r==='success') return '成功';
|
||||
if(r==='failed') return '失败';
|
||||
if(r==='denied') return '拒绝';
|
||||
return '未标注';
|
||||
}
|
||||
|
||||
function resetFilters(){['fAction','fTarget','fResult','fActor','fFrom','fTo'].forEach(id=>document.getElementById(id).value='');loadAudit();}
|
||||
|
||||
async function loadAudit(){
|
||||
const p=new URLSearchParams();
|
||||
const m={action:qs('fAction'),target_type:qs('fTarget'),result:qs('fResult'),actor_id:qs('fActor'),from:qs('fFrom'),to:qs('fTo')};
|
||||
Object.entries(m).forEach(([k,v])=>{if(v)p.set(k,v)});
|
||||
p.set('limit','200');
|
||||
|
||||
const listEl=document.getElementById('list');
|
||||
listEl.innerHTML='<div class="empty">加载中...</div>';
|
||||
|
||||
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('');
|
||||
}catch(e){
|
||||
listEl.innerHTML='<div class="empty">加载失败:'+esc(e.message||e)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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
templates/cf_settings.html
Normal file
80
templates/cf_settings.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
413
templates/channels.html
Normal file
413
templates/channels.html
Normal file
@@ -0,0 +1,413 @@
|
||||
<!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}
|
||||
.header a:hover { background: rgba(255,255,255,.35); }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 14px; }
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
|
||||
.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: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: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: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; }
|
||||
.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: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; }
|
||||
.header > div:last-child { display:flex; gap:8px; }
|
||||
.wrap { padding:12px; }
|
||||
.toolbar button { flex: 1 1 calc(50% - 4px); }
|
||||
.row label { width:100%; }
|
||||
.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>
|
||||
<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 id="btnDisableAll" class="btn-disable" onclick="disableAll()">一键全部关闭</button>
|
||||
<button id="btnReload" class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
|
||||
</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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>`;
|
||||
}
|
||||
|
||||
function pretty(objStr) {
|
||||
try { return JSON.stringify(JSON.parse(objStr || '{}'), null, 2); } catch { return '{}'; }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const s = (status || 'disabled');
|
||||
const cls = s === 'ok' ? 'ok' : (s === 'error' ? 'error' : 'disabled');
|
||||
return `<span class="badge ${cls}">${s}</span>`;
|
||||
}
|
||||
|
||||
function runtimeState(ch) {
|
||||
if (!ch.enabled) return '<span class="state disabled">已关闭</span>';
|
||||
if ((ch.status || '').toLowerCase() === 'ok') return '<span class="state ok">运行中</span>';
|
||||
if ((ch.status || '').toLowerCase() === 'error') return '<span class="state error">配置异常</span>';
|
||||
return '<span class="state disabled">待检测</span>';
|
||||
}
|
||||
|
||||
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 out = await api('/api/v1/admin/channels');
|
||||
const data = out?.channels;
|
||||
if (!Array.isArray(data)) throw new Error('渠道返回格式异常');
|
||||
return data;
|
||||
}
|
||||
|
||||
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);
|
||||
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="${esc(ch.name||'')}"></div>
|
||||
${fieldsHtml}
|
||||
<div class="btns">
|
||||
<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>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collectChannelForm(platform) {
|
||||
const card = document.querySelector(`[data-platform="${platform}"]`);
|
||||
const name = card.querySelector('.name').value.trim();
|
||||
const enabled = card.querySelector('.enabled').checked;
|
||||
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(cfgText) || {};
|
||||
const secrets = parseJSONSafe(secText) || {};
|
||||
|
||||
if (config === null || secrets === null) {
|
||||
msg.textContent = 'JSON 格式错误,请检查高级 JSON';
|
||||
return null;
|
||||
}
|
||||
|
||||
// 覆盖结构化字段,但保留未知字段
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
const payload = { name, enabled, config };
|
||||
if (hasSecretChange) payload.secrets = secrets;
|
||||
|
||||
return { card, msg, payload };
|
||||
}
|
||||
|
||||
function setCardBusy(card, busy) {
|
||||
if (!card) return;
|
||||
card.querySelectorAll('button').forEach(btn => { btn.disabled = busy; });
|
||||
}
|
||||
|
||||
async function applyNow(platform) {
|
||||
const f = collectChannelForm(platform);
|
||||
if (!f) return;
|
||||
const { card, msg, payload } = f;
|
||||
const applyBtn = card.querySelector('.btn-apply');
|
||||
const oldText = applyBtn ? applyBtn.textContent : '';
|
||||
|
||||
setCardBusy(card, true);
|
||||
if (applyBtn) applyBtn.textContent = '生效中...';
|
||||
|
||||
try {
|
||||
msg.textContent = '保存并生效中...';
|
||||
await api('/api/v1/admin/channels/' + platform + '/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = '已生效';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '失败:' + (e && e.message ? e.message : e);
|
||||
} finally {
|
||||
setCardBusy(card, false);
|
||||
if (applyBtn) applyBtn.textContent = oldText || '保存并立即生效';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft(platform) {
|
||||
const f = collectChannelForm(platform);
|
||||
if (!f) return;
|
||||
const { msg, payload } = f;
|
||||
|
||||
try {
|
||||
await api('/api/v1/admin/channels/' + platform, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = '草稿已保存';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '保存失败:' + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDraft(platform) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
await api('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
|
||||
msg.textContent = '发布成功,建议点“热加载运行参数”';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '发布失败:' + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConn(platform) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
msg.textContent = '正在测试...';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleChannel(platform, enable) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
msg.textContent = enable ? '正在开启...' : '正在关闭...';
|
||||
await api('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
|
||||
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = (enable ? '开启失败:' : '关闭失败:') + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadRuntime() {
|
||||
try {
|
||||
const out = await api('/api/v1/admin/channels/reload', { method: 'POST' });
|
||||
alert('热加载成功:' + (out.detail || 'ok'));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
alert('热加载失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAll() {
|
||||
if (!confirm('确认要关闭所有通道吗?')) return;
|
||||
try {
|
||||
const out = await api('/api/v1/admin/channels/disable-all', { method: 'POST' });
|
||||
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
|
||||
await reload();
|
||||
} catch (e) {
|
||||
alert('批量关闭失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
render(channels);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
renderError('页面加载失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
renderError('前端脚本异常:' + (e && e.message ? e.message : 'unknown'));
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const msg = e && e.reason && e.reason.message ? e.reason.message : String(e.reason || 'unknown');
|
||||
renderError('前端请求异常:' + msg);
|
||||
});
|
||||
|
||||
(async function(){
|
||||
try{ initTheme(); await loadMe(); initPermissionUI(); await reload(); }
|
||||
catch(e){ renderError('初始化失败:'+(e.message||e)); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
207
templates/cpa_settings.html
Normal file
207
templates/cpa_settings.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
257
templates/index.html
Normal file
257
templates/index.html
Normal file
@@ -0,0 +1,257 @@
|
||||
<!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);
|
||||
--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">
|
||||
<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="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="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>
|
||||
let me=null;
|
||||
const state={overview: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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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||{};
|
||||
}
|
||||
|
||||
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('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(){
|
||||
alert('模块开关请进入设置页(后续独立页面/弹窗)');
|
||||
}
|
||||
|
||||
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 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(); initUI(); await loadOverview(); setInterval(loadOverview,15000);}catch(e){
|
||||
document.getElementById('recentJobs').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
templates/login.html
Normal file
142
templates/login.html
Normal file
@@ -0,0 +1,142 @@
|
||||
<!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:#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: 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: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 36px 30px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.35);
|
||||
animation: slideUp .4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-logo .icon { font-size: 48px; }
|
||||
.login-logo h1 {
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.login-logo .subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
outline: none;
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(249,115,22,.2);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity .2s, transform .1s;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.btn-login:hover { opacity: .9; }
|
||||
.btn-login:active { transform: scale(.98); }
|
||||
|
||||
.error-msg {
|
||||
background: rgba(239,68,68,.12);
|
||||
color: #fca5a5;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(239,68,68,.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<div class="icon">🦞</div>
|
||||
<h1>Ops-Assistant</h1>
|
||||
<div class="subtitle">{{.version}}</div>
|
||||
</div>
|
||||
|
||||
{{if .error}}
|
||||
<div class="error-msg">{{.error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button class="btn-login" type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
229
templates/ops.html
Normal file
229
templates/ops.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
templates/ops_targets.html
Normal file
145
templates/ops_targets.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
Reference in New Issue
Block a user