- Telegram Bot + QQ Bot (WebSocket) 双平台支持 - 150+ 预设分类关键词,jieba 智能分词 - Web 管理后台(记录查看/删除/CSV导出) - 金额精确存储(分/int64) - 版本信息嵌入(编译时注入) - Docker 支持 - 优雅关闭(context + signal)
194 lines
7.7 KiB
HTML
194 lines
7.7 KiB
HTML
<!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); }
|
|
.header h1 { font-size: 24px; margin-bottom: 4px; }
|
|
.header .subtitle { font-size: 13px; opacity: .85; }
|
|
|
|
.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: 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; }
|
|
|
|
.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; }
|
|
|
|
.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 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; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<h1>🦞 虾记记账</h1>
|
|
<div class="subtitle">Xiaji-Go 记账管理</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>
|
|
<div class="spacer"></div>
|
|
<a href="/export">📥 导出CSV</a>
|
|
</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 class="btn-cancel" onclick="closeModal()">取消</button>
|
|
<button class="btn-confirm" onclick="confirmDelete()">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const catIcons = { '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
|
|
let allData = [];
|
|
let deleteId = null;
|
|
|
|
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); }
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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>`;
|
|
}).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;
|
|
}
|
|
|
|
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} (${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();
|
|
}
|
|
|
|
loadData();
|
|
setInterval(loadData, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|