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:
+226
-204
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,"'")}',${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>
|
||||
|
||||
Reference in New Issue
Block a user