Files
ops-assistant/templates/audit.html
2026-03-19 21:23:28 +08:00

212 lines
9.2 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function qs(id){return document.getElementById(id).value.trim();}
async function api(url,opt={}){const r=await fetch(url,opt);const out=await r.json().catch(()=>({}));if(!r.ok) throw new Error(out.message||('HTTP '+r.status));return out?.data||{};}
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
const actionMap={
'auth.login.success':'登录成功','auth.login.failed':'登录失败','auth.logout':'退出登录',
'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>