Files
Xiaji-go/templates/index.html

238 lines
12 KiB
HTML
Raw 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>🦞 虾记记账</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}
}
</style>
</head>
<body>
<div class="header">
<h1>🦞 虾记记账</h1>
<div class="subtitle">{{.version}}</div>
<a href="/logout" class="logout-btn">退出</a>
</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>
</div>
</div>
<script>
const catIcons={ '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
let allData=[],deleteId=null,me=null,flags=[];
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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})
});
const out=await r.json().catch(()=>({}));
if(!r.ok){ alert(out.error||'修改失败'); }
await loadMe(); await loadFlags(); renderFlags();
}
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 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 renderList(data){
const el=document.getElementById('txList');
const caps=(me&&me.effective_capabilities)||{};
if(!data.length){ el.innerHTML='<div class="empty"><div class="icon">📭</div>暂无记录<br><small>通过 Telegram/QQ 发送消息记账</small></div>'; return; }
el.innerHTML=data.map(tx=>{
const icon=catIcons[tx.category]||'📦', cls='cat-'+(tx.category||'其他');
const amount=Number(tx.amount||0), note=esc(tx.note), canDelete=caps.can_delete_self||caps.can_delete_all;
const delBtn=canDelete ? `<button class="btn-del" onclick="showDelete(${tx.id},'${note.replace(/'/g,"&#39;")}',${amount})">删除</button>` : `<button class="btn-del" style="opacity:.45;cursor:not-allowed;" onclick="alert('无删除权限')">删除</button>`;
return `<div class="tx-card"><div class="tx-icon ${cls}">${icon}</div><div class="tx-info"><div class="tx-category">${esc(tx.category||'其他')}</div><div class="tx-note" title="${note}">${note}</div></div><div class="tx-right"><div class="tx-amount">-${amount.toFixed(2)}</div><div class="tx-date">${esc(tx.date||'')}</div>${delBtn}</div></div>`;
}).join('');
}
function 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();
}
(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>'; }
})();
</script>
</body>
</html>