feat: channels/audit UI unify, apply flow hardening, bump v1.1.12
This commit is contained in:
135
templates/audit.html
Normal file
135
templates/audit.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!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;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{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px}
|
||||
.header a:hover{background:rgba(255,255,255,.35)}
|
||||
|
||||
.wrap{max-width:600px;margin:0 auto;padding:15px}
|
||||
.filters{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05);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 #ddd;border-radius:8px;font-size:13px;background:#fff}
|
||||
small{color:#6b7280}
|
||||
|
||||
.actions{margin:0 0 10px;display:flex;gap:8px;flex-wrap:wrap}
|
||||
button{border:none;border-radius:8px;padding:8px 12px;cursor:pointer;color:#fff;background:#ee5a24;font-size:13px}
|
||||
button:hover{background:#d63031}
|
||||
button.secondary{background:#6b7280}
|
||||
button.secondary:hover{background:#4b5563}
|
||||
|
||||
.list{display:flex;flex-direction:column;gap:10px}
|
||||
.log-card{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05)}
|
||||
.row{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:999px;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:#4b5563;word-break:break-all}
|
||||
.note{font-size:13px;color:#374151;margin-top:6px;white-space:pre-wrap;word-break:break-word}
|
||||
.empty{text-align:center;padding:40px 10px;color:#999}
|
||||
|
||||
@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}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">🧾 审计日志</div>
|
||||
<div class="sub">{{.version}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/">返回首页</a>
|
||||
<a 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>
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function qs(id){return document.getElementById(id).value.trim();}
|
||||
|
||||
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>';
|
||||
|
||||
const r=await fetch('/api/v1/admin/audit?'+p.toString());
|
||||
const data=await r.json().catch(()=>[]);
|
||||
const list=Array.isArray(data)?data:[];
|
||||
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('');
|
||||
}
|
||||
|
||||
loadAudit();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
282
templates/channels.html
Normal file
282
templates/channels.html
Normal file
@@ -0,0 +1,282 @@
|
||||
<!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; 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 { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; }
|
||||
.header a:hover { background: rgba(255,255,255,.35); }
|
||||
.wrap { max-width: 600px; margin: 0 auto; padding: 15px; }
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
|
||||
.tip { color:#6b7280; font-size:12px; margin-bottom:10px; }
|
||||
.card { background:#fff; border-radius:12px; padding:14px; margin-bottom:10px; box-shadow:0 1px 4px rgba(0,0,0,.05); }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
|
||||
.row label { width:110px; color:#4b5563; font-size:13px; }
|
||||
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid #ddd; border-radius:8px; font-size:13px; }
|
||||
.row textarea { min-height:74px; }
|
||||
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
|
||||
button { border:none; border-radius:8px; 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:#ee5a24; }
|
||||
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:#d63031; }
|
||||
.btn-disable { background:#9b2c2c; }
|
||||
.btn-disable:hover { background:#7f1d1d; }
|
||||
.badge { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; }
|
||||
.state { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; margin-left:6px; }
|
||||
.ok { background:#dcfce7; color:#166534; }
|
||||
.error { background:#fee2e2; color:#991b1b; }
|
||||
.disabled { background:#e5e7eb; color:#374151; }
|
||||
small { color:#6b7280; }
|
||||
|
||||
@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); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
|
||||
<div><a href="/">返回首页</a><a href="/logout">退出</a></div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<button class="btn-disable" onclick="disableAll()">一键全部关闭</button>
|
||||
<button class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
|
||||
</div>
|
||||
<div class="tip">默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。</div>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const app = document.getElementById('app');
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
async function fetchChannels() {
|
||||
const r = await fetch('/api/v1/admin/channels');
|
||||
if (!r.ok) {
|
||||
throw new Error('加载渠道失败: HTTP ' + r.status);
|
||||
}
|
||||
const data = await r.json();
|
||||
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);
|
||||
return `<div class="card" data-platform="${ch.platform}">
|
||||
<h3>${ch.name || ch.platform} ${statusBadge(ch.status)} ${runtimeState(ch)} ${ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : ''}</h3>
|
||||
<small>平台:${ch.platform} | 发布:${ch.published_at || '-'} | 最近检测:${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="${(ch.name||'').replace(/"/g,'"')}"></div>
|
||||
<div class="row"><label>草稿 config(JSON)</label><textarea class="config">${draftCfg}</textarea></div>
|
||||
<div class="row"><label>草稿 secrets(JSON)</label><textarea class="secrets">${draftSec}</textarea></div>
|
||||
<div class="btns">
|
||||
<button class="btn-apply" onclick="applyNow('${ch.platform}')">保存并立即生效</button>
|
||||
<button class="btn-save" onclick="saveDraft('${ch.platform}')">保存草稿</button>
|
||||
<button class="btn-publish" onclick="publishDraft('${ch.platform}')">发布草稿</button>
|
||||
<button class="btn-test" onclick="testConn('${ch.platform}')">测试连接</button>
|
||||
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${ch.platform}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${ch.platform}', true)">开启通道</button>`}
|
||||
</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 configText = card.querySelector('.config').value;
|
||||
const secretsText = card.querySelector('.secrets').value;
|
||||
const msg = card.querySelector('.msg');
|
||||
|
||||
const config = parseJSONSafe(configText);
|
||||
const secrets = parseJSONSafe(secretsText);
|
||||
if (!config || !secrets) {
|
||||
msg.textContent = 'JSON 格式错误,请检查 config/secrets';
|
||||
return null;
|
||||
}
|
||||
|
||||
return { card, msg, payload: { name, enabled, config, secrets } };
|
||||
}
|
||||
|
||||
async function apiJSON(url, options = {}) {
|
||||
const r = await fetch(url, options);
|
||||
const out = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(out.error || ('HTTP ' + r.status));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function msgOf(platform) {
|
||||
return document.querySelector(`[data-platform="${platform}"] .msg`);
|
||||
}
|
||||
|
||||
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 apiJSON('/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 apiJSON('/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 apiJSON('/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 apiJSON('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
|
||||
msg.textContent = `测试结果:${out.status} ${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 apiJSON('/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 apiJSON('/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 apiJSON('/api/v1/admin/channels/disable-all', { method: 'POST' });
|
||||
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
|
||||
await reload();
|
||||
} catch (e) {
|
||||
alert('批量关闭失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
reload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,95 +5,91 @@
|
||||
<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; }
|
||||
*{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)}
|
||||
|
||||
.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); }
|
||||
.header h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
.header .subtitle { font-size: 13px; opacity: .85; }
|
||||
.header { position: relative; }
|
||||
.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; transition: background .2s; }
|
||||
.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}
|
||||
|
||||
.stats { display: flex; gap: 10px; padding: 15px; overflow-x: auto; }
|
||||
.stat-card { flex: 1; min-width: 120px; 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}
|
||||
|
||||
.toolbar { display: flex; gap: 10px; padding: 0 15px 10px; align-items: center; }
|
||||
.toolbar a { padding: 8px 16px; background: #ee5a24; color: #fff; border-radius: 8px; text-decoration: none; font-size: 13px; white-space: nowrap; }
|
||||
.toolbar a:hover { background: #d63031; }
|
||||
.toolbar .spacer { flex: 1; }
|
||||
.filter-select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 13px; background: #fff; }
|
||||
#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; }
|
||||
.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; transition: transform .15s; }
|
||||
.tx-card:active { transform: scale(.98); }
|
||||
.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; }
|
||||
.tx-actions { margin-top: 4px; }
|
||||
.btn-del { background: none; border: 1px solid #e74c3c; color: #e74c3c; border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; }
|
||||
.btn-del:hover { background: #e74c3c; color: #fff; }
|
||||
.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; }
|
||||
.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}
|
||||
|
||||
.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}
|
||||
|
||||
.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 h3 { margin-bottom: 12px; }
|
||||
.modal-body .btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.modal-body .btns button { flex: 1; padding: 10px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.modal-body .btn-cancel { background: #f0f0f0; }
|
||||
.modal-body .btn-confirm { background: #e74c3c; color: #fff; }
|
||||
|
||||
@media(min-width:600px) {
|
||||
.tx-list { max-width: 600px; margin: 0 auto; }
|
||||
.stats { max-width: 600px; margin: 0 auto; }
|
||||
.toolbar { max-width: 600px; margin: 0 auto; }
|
||||
@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">Xiaji-Go 记账管理</div>
|
||||
<a href="/logout" class="logout-btn" title="退出登录">退出</a>
|
||||
<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 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="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="/export">📥 导出CSV</a>
|
||||
<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>
|
||||
@@ -103,95 +99,139 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
<h3>确认删除?</h3>
|
||||
<p id="delInfo" style="font-size:13px;color:#666;"></p>
|
||||
<div class="btns">
|
||||
<button class="btn-cancel" onclick="closeModal()">取消</button>
|
||||
<button class="btn-confirm" onclick="confirmDelete()">删除</button>
|
||||
<button onclick="closeModal()">取消</button>
|
||||
<button style="background:#e74c3c;color:#fff" onclick="confirmDelete()">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const catIcons = { '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
|
||||
let allData = [];
|
||||
let deleteId = null;
|
||||
const catIcons={ '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
|
||||
let allData=[],deleteId=null,me=null,flags=[];
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const r = await fetch('/api/records');
|
||||
allData = await r.json();
|
||||
if (!Array.isArray(allData)) allData = [];
|
||||
renderStats();
|
||||
renderList(allData);
|
||||
populateFilter();
|
||||
} catch(e) { console.error(e); }
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const today = new Date().toISOString().slice(0,10);
|
||||
const month = today.slice(0,7);
|
||||
let todaySum = 0, monthSum = 0;
|
||||
allData.forEach(tx => {
|
||||
if (tx.date === today) todaySum += tx.amount;
|
||||
if (tx.date && tx.date.startsWith(month)) monthSum += tx.amount;
|
||||
});
|
||||
document.getElementById('todayTotal').textContent = todaySum.toFixed(2);
|
||||
document.getElementById('monthTotal').textContent = monthSum.toFixed(2);
|
||||
document.getElementById('txCount').textContent = allData.length;
|
||||
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 renderList(data) {
|
||||
const el = document.getElementById('txList');
|
||||
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] || '📦';
|
||||
const catClass = 'cat-' + (tx.category || '其他');
|
||||
return `<div class="tx-card">
|
||||
<div class="tx-icon ${catClass}">${icon}</div>
|
||||
<div class="tx-info">
|
||||
<div class="tx-category">${tx.category || '其他'}</div>
|
||||
<div class="tx-note" title="${esc(tx.note)}">${esc(tx.note)}</div>
|
||||
</div>
|
||||
<div class="tx-right">
|
||||
<div class="tx-amount">-${tx.amount.toFixed(2)}</div>
|
||||
<div class="tx-date">${tx.date}</div>
|
||||
<div class="tx-actions"><button class="btn-del" onclick="showDelete(${tx.id},'${esc(tx.note)}',${tx.amount})">删除</button></div>
|
||||
</div>
|
||||
</div>`;
|
||||
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('');
|
||||
}
|
||||
|
||||
function esc(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
function populateFilter() {
|
||||
const cats = [...new Set(allData.map(t => t.category))].sort();
|
||||
const sel = document.getElementById('catFilter');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">全部分类</option>' + cats.map(c => `<option value="${c}">${c}</option>`).join('');
|
||||
sel.value = cur;
|
||||
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();
|
||||
}
|
||||
|
||||
function filterList() {
|
||||
const cat = document.getElementById('catFilter').value;
|
||||
renderList(cat ? allData.filter(t => t.category === cat) : allData);
|
||||
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 showDelete(id, note, amount) {
|
||||
deleteId = id;
|
||||
document.getElementById('delInfo').textContent = `${note} (${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;
|
||||
await fetch('/delete/' + deleteId, { method: 'POST' });
|
||||
closeModal();
|
||||
loadData();
|
||||
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;
|
||||
}
|
||||
|
||||
loadData();
|
||||
setInterval(loadData, 30000);
|
||||
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 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>
|
||||
|
||||
@@ -103,7 +103,7 @@ body {
|
||||
<div class="login-logo">
|
||||
<div class="icon">🦞</div>
|
||||
<h1>虾记记账</h1>
|
||||
<div class="subtitle">Xiaji-Go 管理后台</div>
|
||||
<div class="subtitle">{{.version}}</div>
|
||||
</div>
|
||||
|
||||
{{if .error}}
|
||||
|
||||
Reference in New Issue
Block a user