init: ops-assistant codebase
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user