diff --git a/go/static/app.js b/go/static/app.js new file mode 100644 index 0000000..93cb5cf --- /dev/null +++ b/go/static/app.js @@ -0,0 +1,214 @@ +// 宿舍电费监控 - 前端逻辑 +const POLL_INTERVAL = 30000; // 自动刷新间隔 30s +let usageChart = null; +let availableMonths = []; // 后端返回的可用月份列表 +let currentMonth = ""; // 当前选中的月份 (YYYY-MM) + +const $ = (id) => document.getElementById(id); + +// ---------- 工具 ---------- +function formatTime(ts) { + if (!ts) return "--"; + const d = new Date(ts * 1000); + return d.toLocaleString("zh-CN", { hour12: false }); +} + +function formatDateStr(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); +} + +function setBadge(state, text) { + const el = $("statusBadge"); + el.className = "status-badge " + state; + el.textContent = text; +} + +// ---------- 当前电费 ---------- +async function fetchCurrent() { + setBadge("loading", "查询中..."); + try { + const resp = await fetch("/api/electric"); + const data = await resp.json(); + if (data.ok) { + $("surplusValue").textContent = data.surplus.toFixed(2); + $("amountValue").textContent = data.amount.toFixed(2); + $("updateTime").textContent = formatTime(data.time); + $("roomInfo").textContent = "🏠 " + data.room; + setBadge("ok", "实时"); + } else { + setBadge("err", "查询失败"); + $("roomInfo").textContent = "⚠️ " + (data.error || "查询失败"); + } + } catch (e) { + setBadge("err", "网络错误"); + } +} + +// ---------- 月度汇总 ---------- +function renderSummary(s) { + if (!s || !s.record_count) { + const empty = "--"; + ["sRecordCount", "sTotalUsage", "sAvgUsage", "sRecharge", "sStartSurplus", "sEndSurplus"].forEach((id) => { + $(id).textContent = empty; + }); + return; + } + $("sRecordCount").textContent = s.record_count + " 天"; + $("sTotalUsage").textContent = s.total_usage != null ? s.total_usage.toFixed(2) : "--"; + $("sAvgUsage").textContent = s.avg_usage != null ? s.avg_usage.toFixed(2) : "--"; + $("sRecharge").textContent = s.recharge_days; + $("sStartSurplus").textContent = s.start_surplus != null ? s.start_surplus.toFixed(2) : "--"; + $("sEndSurplus").textContent = s.end_surplus != null ? s.end_surplus.toFixed(2) : "--"; +} + +// ---------- 历史记录 ---------- +function renderChart(daily) { + $("chartEmpty").style.display = daily.length ? "none" : "block"; + if (!daily.length) return; + + const labels = daily.map((d) => formatDateStr(d.date)); + const usage = daily.map((d) => d.usage); + const surplus = daily.map((d) => d.surplus); + + const ctx = $("usageChart").getContext("2d"); + if (usageChart) usageChart.destroy(); + usageChart = new Chart(ctx, { + data: { + labels, + datasets: [ + { + type: "bar", + label: "日耗电(度)", + data: usage, + backgroundColor: "rgba(255, 167, 38, 0.75)", + borderRadius: 6, + yAxisID: "y", + }, + { + type: "line", + label: "剩余电量(度)", + data: surplus, + borderColor: "#4fc3f7", + backgroundColor: "rgba(79, 195, 247, 0.15)", + tension: 0.3, + pointRadius: 4, + yAxisID: "y1", + }, + ], + }, + options: { + responsive: true, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { + labels: { color: "#e8edf2", usePointStyle: true }, + }, + }, + scales: { + x: { ticks: { color: "#9fb3c8" }, grid: { color: "rgba(255,255,255,0.05)" } }, + y: { + position: "left", + title: { display: true, text: "耗电(度)", color: "#ffa726" }, + ticks: { color: "#9fb3c8" }, + grid: { color: "rgba(255,255,255,0.05)" }, + }, + y1: { + position: "right", + title: { display: true, text: "剩余(度)", color: "#4fc3f7" }, + ticks: { color: "#9fb3c8" }, + grid: { drawOnChartArea: false }, + }, + }, + }, + }); +} + +function renderHistory(daily) { + const body = $("historyBody"); + if (!daily.length) { + body.innerHTML = '暂无记录'; + return; + } + // daily 是升序,表格显示降序(新→旧) + const rows = [...daily].reverse(); + body.innerHTML = rows + .map((d) => { + const usageTxt = d.usage != null && d.usage >= 0 ? d.usage.toFixed(2) : "--"; + return ` + ${formatDateStr(d.date)} + ${d.surplus.toFixed(2)} + ${usageTxt} + 自动 + `; + }) + .join(""); +} + +async function fetchHistory() { + try { + const params = currentMonth ? "?month=" + encodeURIComponent(currentMonth) : ""; + const resp = await fetch("/api/history" + params); + const data = await resp.json(); + + // 初始化可用月份与选择器 + if (data.months && data.months.length) { + availableMonths = data.months; + if (!currentMonth || !availableMonths.includes(currentMonth)) { + currentMonth = availableMonths[0]; + $("monthPicker").value = currentMonth; + } + } + if (currentMonth) $("monthPicker").value = currentMonth; + + renderSummary(data.summary); + renderChart(data.daily || []); + renderHistory(data.daily || []); + } catch (e) { + console.error("history fetch error", e); + } +} + +// ---------- 月份导航 ---------- +function setMonth(month) { + if (!month) return; + currentMonth = month; + $("monthPicker").value = month; + fetchHistory(); +} + +function monthOffset(month, delta) { + const [y, m] = month.split("-").map(Number); + const d = new Date(y, m - 1 + delta, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +$("monthPicker").addEventListener("change", (e) => { + if (e.target.value) { + currentMonth = e.target.value; + fetchHistory(); + } +}); + +$("prevMonthBtn").addEventListener("click", () => { + setMonth(monthOffset(currentMonth || new Date().toISOString().slice(0, 7), -1)); +}); + +$("nextMonthBtn").addEventListener("click", () => { + setMonth(monthOffset(currentMonth || new Date().toISOString().slice(0, 7), 1)); +}); + +// ---------- 主流程 ---------- +async function refreshAll() { + await Promise.all([fetchCurrent(), fetchHistory()]); +} + +$("refreshBtn").addEventListener("click", () => { + $("refreshBtn").disabled = true; + refreshAll().finally(() => { + $("refreshBtn").disabled = false; + }); +}); + +refreshAll(); +setInterval(refreshAll, POLL_INTERVAL); diff --git a/go/static/index.html b/go/static/index.html new file mode 100644 index 0000000..52239fa --- /dev/null +++ b/go/static/index.html @@ -0,0 +1,76 @@ + + + + + + 宿舍电费监控 + + + +
+
+

⚡ 宿舍电费监控

+
加载中...
+
+ +
+
+ 当前剩余电量 + -- +
+
+ -- + +
+
+ 预估金额: ¥-- + 更新于: -- +
+ +
+ +
+
+ 耗电量统计 +
+ + + +
+
+
+
--
记录天数
+
--
月总耗电(度)
+
--
日均耗电(度)
+
--
疑似充值(天)
+
--
月初电量(度)
+
--
月末电量(度)
+
+ +
暂无历史数据
+
+ +
+
+ 每日电费记录 +
+ + + + + + + + + + + + +
日期剩余电量(度)日耗电(度)记录方式
加载中...
+
+
+ + + + + diff --git a/go/static/style.css b/go/static/style.css new file mode 100644 index 0000000..2cf74ae --- /dev/null +++ b/go/static/style.css @@ -0,0 +1,270 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", sans-serif; + background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%); + min-height: 100vh; + color: #e8edf2; +} + +.container { + max-width: 720px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +header { + text-align: center; + margin-bottom: 24px; +} + +header h1 { + font-size: 26px; + font-weight: 700; + letter-spacing: 1px; + background: linear-gradient(90deg, #ffd700, #ffaa00); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.room-info { + margin-top: 8px; + font-size: 14px; + color: #9fb3c8; +} + +.card { + background: rgba(255, 255, 255, 0.06); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.label { + font-size: 14px; + color: #9fb3c8; + font-weight: 500; +} + +.status-badge { + padding: 3px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.status-badge.ok { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; +} + +.status-badge.err { + background: rgba(231, 76, 60, 0.2); + color: #e74c3c; +} + +.status-badge.loading { + background: rgba(241, 196, 15, 0.2); + color: #f1c40f; +} + +.surplus-display { + display: flex; + align-items: baseline; + justify-content: center; + padding: 16px 0 8px; +} + +.surplus-value { + font-size: 64px; + font-weight: 800; + background: linear-gradient(90deg, #4fc3f7, #00e5ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.surplus-unit { + font-size: 20px; + color: #9fb3c8; + margin-left: 8px; +} + +.sub-info { + display: flex; + justify-content: center; + gap: 24px; + font-size: 13px; + color: #9fb3c8; + margin-bottom: 16px; +} + +.refresh-btn { + display: block; + width: 100%; + padding: 12px; + border: none; + border-radius: 10px; + background: linear-gradient(90deg, #00b4d8, #0096c7); + color: #fff; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s, opacity 0.15s; +} + +.refresh-btn:hover { + transform: translateY(-1px); + opacity: 0.9; +} + +.refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chart-wrap { + position: relative; +} + +.chart-empty { + display: none; + text-align: center; + padding: 40px 0; + color: #9fb3c8; + font-size: 14px; +} + +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.history-table th, +.history-table td { + padding: 10px 8px; + text-align: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.history-table th { + color: #9fb3c8; + font-weight: 500; + font-size: 12px; +} + +.history-table td:first-child { + text-align: left; +} + +.empty-td { + color: #9fb3c8; + text-align: center !important; + padding: 24px 0 !important; +} + +.tag { + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; +} + +.tag.auto { + background: rgba(0, 180, 216, 0.2); + color: #4fc3f7; +} + +.tag.manual { + background: rgba(241, 196, 15, 0.2); + color: #f1c40f; +} + +.tag.cache { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; +} + +/* 月份选择器 */ +.month-selector { + display: flex; + align-items: center; + gap: 6px; +} + +.month-selector input[type="month"] { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #e8edf2; + border-radius: 8px; + padding: 5px 8px; + font-size: 13px; + color-scheme: dark; +} + +.month-selector input[type="month"]::-webkit-calendar-picker-indicator { + filter: invert(0.8); +} + +.mini-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #e8edf2; + border-radius: 8px; + width: 30px; + height: 30px; + cursor: pointer; + font-size: 13px; + transition: background 0.15s; +} + +.mini-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* 月度汇总 */ +.summary-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +@media (max-width: 480px) { + .summary-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.summary-item { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + padding: 10px 8px; + text-align: center; +} + +.summary-value { + font-size: 20px; + font-weight: 700; + color: #ffd700; +} + +.summary-label { + font-size: 11px; + color: #9fb3c8; + margin-top: 2px; +}