diff --git a/.gitignore b/.gitignore index dfa0600..34e61bf 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist/ *.swp *.swo *~ +.trae/ # OS .DS_Store diff --git a/README.md b/README.md index 5d795bf..56ca74f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # 智能电量监控系统 -实时监控宿舍/房间电费余额和用电量,通过企业微信推送每日用电报告和异常告警。 +实时监控宿舍/房间电费余额和用电量,支持企业微信和 Telegram 推送每日用电报告、余额预警和异常告警。 ## 功能 - 定时采集电费数据(每小时整点) -- 可视化展示今日用电趋势和用电量 -- 历史用电数据统计(7天/15天/30天) -- 企业微信机器人通知(每日用电报告 + 采集异常告警) +- 可视化展示每日用电趋势(支持日期切换查看历史) +- 历史用电数据统计(7天/15天/30天),今日数据自动归一化 +- 通知推送:企业微信 + Telegram 双通道 +- 余额预警:低于阈值时自动推送通知 +- 每日报告:每晚 23:30 推送今日用电汇总 +- 深色模式:自动跟随系统主题 - 登录认证保护 - 手动触发数据采集 - 响应式布局,支持手机端访问 @@ -16,7 +19,7 @@ | 层级 | 技术 | |---|---| -| 前端 | React 18 + Ant Design 5 + Chart.js | +| 前端 | React 18 + Ant Design 5 + Chart.js + IconPark | | 后端 | Node.js + Express | | 数据库 | SQLite (better-sqlite3) | | 定时任务 | node-cron | @@ -39,6 +42,15 @@ LOGIN_PASSWORD=你的密码 # 选填 - 企业微信群机器人 Webhook URL WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key +# 选填 - Telegram 机器人 Token(从 @BotFather 获取) +TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklmNOPqrstUVwxyz + +# 选填 - 接收通知的 Telegram 用户 ID(从 @userinfobot 获取) +TELEGRAM_CHAT_ID=123456789 + +# 选填 - 电费余额预警阈值(低于此值时发送通知,设为0或留空关闭) +ALERT_THRESHOLD=20 + # 选填 - Cookie 签名密钥 COOKIE_SECRET=任意随机字符串 ``` @@ -100,9 +112,11 @@ electricity-monitor/ ├── package.json ├── index.html ├── vite.config.js + ├── public/ + │ └── lightning.svg # Favicon └── src/ - ├── main.jsx # 入口 - ├── App.jsx # 根组件(认证状态管理) + ├── main.jsx # 入口(主题检测) + ├── App.jsx # 根组件(CSS 变量 & 认证管理) ├── api.js # API 请求模块 └── pages/ ├── Login.jsx # 登录页 @@ -117,15 +131,27 @@ electricity-monitor/ | `/api/logout` | POST | 登出 | 否 | | `/api/check-auth` | GET | 检查登录状态 | 否 | | `/api/current` | GET | 获取当前数据 | 是 | -| `/api/history` | GET | 获取历史数据 | 是 | +| `/api/history` | GET | 获取历史统计数据(7/15/30天) | 是 | +| `/api/records-by-date` | GET | 按日期查询详细记录 | 是 | | `/api/trigger-collect` | GET | 手动触发采集 | 是 | +| `/api/test-notify` | GET | 发送测试通知 | 是 | +| `/api/send-report` | GET | 发送今日日报 | 是 | ## 定时任务 -- **每小时整点**:采集电费数据 -- **每天 00:00**:发送昨日用电报告到企业微信 -- **采集失败时**:立即发送异常告警到企业微信 +| 时间 | 任务 | 说明 | +|---|---|---| +| 每小时整点 | `collectData` | 采集电费数据 | +| 每晚 23:30 | `sendDailyReport` | 发送今日用电报告 | +| 余额低于阈值 | `checkThresholdAndAlert` | 发送余额预警通知 | + +## 通知渠道 + +| 渠道 | 配置变量 | 说明 | +|---|---|---| +| 企业微信 | `WECOM_WEBHOOK_URL` | 群机器人 Webhook | +| Telegram | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` | 机器人私聊推送 | ## 许可 -MIT +[MIT](LICENSE) diff --git a/client/src/api.js b/client/src/api.js index d9fc49e..eb3cce0 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -37,3 +37,9 @@ export async function triggerCollect() { if (res.status === 401) throw new Error('未登录'); return res.json(); } + +export async function fetchRecordsByDate(dateStr) { + const res = await fetch(`${BASE}/records-by-date?date=${dateStr}`); + if (res.status === 401) throw new Error('未登录'); + return res.json(); +} diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index c5f8ffb..4f2543e 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -1,9 +1,10 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { Button, Spin, message } from 'antd'; +import { Button, Spin, message, DatePicker } from 'antd'; import { LogoutOutlined, ReloadOutlined, BugOutlined } from '@ant-design/icons'; import { Lightning, ChartLine, ChartHistogram, Timer } from '@icon-park/react'; import { Chart, registerables } from 'chart.js'; -import { fetchCurrent, fetchHistory, triggerCollect, logout } from '../api'; +import dayjs from 'dayjs'; +import { fetchCurrent, fetchHistory, triggerCollect, logout, fetchRecordsByDate } from '../api'; Chart.register(...registerables); @@ -34,6 +35,10 @@ export default function Dashboard({ onLogout }) { const [days, setDays] = useState(30); const [collecting, setCollecting] = useState(false); const [loading, setLoading] = useState(true); + const [chartDate, setChartDate] = useState(dayjs().format('YYYY-MM-DD')); + const [dateRecords, setDateRecords] = useState(null); + const [datePrevLast, setDatePrevLast] = useState(null); + const [isToday, setIsToday] = useState(true); const hourlyChartRef = useRef(null); const trendChartRef = useRef(null); @@ -63,6 +68,17 @@ export default function Dashboard({ onLogout }) { }; }, [loadData]); + useEffect(() => { + const today = dayjs().format('YYYY-MM-DD'); + setIsToday(chartDate === today); + fetchRecordsByDate(chartDate).then(data => { + if (data.success) { + setDateRecords(data.records); + setDatePrevLast(data.prevLastRecord); + } + }); + }, [chartDate]); + const handleCollect = async () => { setCollecting(true); try { @@ -110,11 +126,12 @@ export default function Dashboard({ onLogout }) { }; useEffect(() => { - if (!currentData?.todayRecords || currentData.todayRecords.length === 0) return; + const records = isToday ? currentData?.todayRecords : dateRecords; + const prev = isToday ? currentData?.yesterdayLastRecord : datePrevLast; + if (!records || records.length === 0) return; hourlyInstance.current?.destroy(); - const sorted = [...currentData.todayRecords].sort((a, b) => a.timestamp - b.timestamp); - const prev = currentData.yesterdayLastRecord; + const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp); const labels = sorted.map(r => formatTime(r.timestamp)); const values = sorted.map((r, i) => { const prevRecord = i === 0 ? prev : sorted[i - 1]; @@ -179,7 +196,7 @@ export default function Dashboard({ onLogout }) { interaction: { intersect: false, mode: 'index' }, }, }); - }, [currentData]); + }, [currentData, dateRecords, datePrevLast, isToday]); useEffect(() => { if (!historyData?.dailyRecords || historyData.dailyRecords.length === 0) return; @@ -408,13 +425,20 @@ body { margin: 0; }