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;
+}