feat: 添加按日期查询用电记录功能,支持切换查看历史用电趋势
1. 新增/api/records-by-date后端接口,支持按日期查询详细用电记录并计算当日用量和费用 2. 前端新增日期选择器,支持切换查看指定日期的用电趋势 3. 更新README文档,补充功能说明、配置项和接口文档 4. 新增.trae目录到gitignore 5. 引入IconPack图标库和dayjs日期处理库
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ dist/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.trae/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
50
README.md
50
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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<ChartLine theme="filled" size="20" fill="#4a90e2" style={{ display: 'flex' }} />
|
||||
今日用电趋势
|
||||
用电趋势
|
||||
<span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 400 }}>(每时段)</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
value={dayjs(chartDate)}
|
||||
onChange={(d) => d && setChartDate(d.format('YYYY-MM-DD'))}
|
||||
allowClear={false}
|
||||
size="small"
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-chart" style={{ position: 'relative', height: 280, background: 'var(--bg-chart)', borderRadius: 8, padding: 20 }}>
|
||||
{(!currentData?.todayRecords || currentData.todayRecords.length === 0) ? (
|
||||
<div className="d-chart-empty" style={{ textAlign: 'center', paddingTop: 110, color: 'var(--text-tertiary)' }}>暂无今日数据</div>
|
||||
{(!isToday && (!dateRecords || dateRecords.length === 0)) || (isToday && (!currentData?.todayRecords || currentData.todayRecords.length === 0)) ? (
|
||||
<div className="d-chart-empty" style={{ textAlign: 'center', paddingTop: 110, color: 'var(--text-tertiary)' }}>暂无数据</div>
|
||||
) : null}
|
||||
<canvas ref={hourlyChartRef} />
|
||||
</div>
|
||||
|
||||
30
server.js
30
server.js
@@ -353,6 +353,36 @@ app.get('/api/current', requireAuth, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/records-by-date', requireAuth, (req, res) => {
|
||||
const dateStr = req.query.date;
|
||||
if (!dateStr) return res.json({ success: false, message: '缺少 date 参数' });
|
||||
|
||||
const dayBefore = new Date(dateStr);
|
||||
dayBefore.setDate(dayBefore.getDate() - 1);
|
||||
const dayBeforeStr = getLocalDateStr(dayBefore);
|
||||
|
||||
const records = getRecordsByDate(dateStr);
|
||||
const dayBeforeRecords = getRecordsByDate(dayBeforeStr);
|
||||
const prevLast = dayBeforeRecords.length > 0 ? dayBeforeRecords[dayBeforeRecords.length - 1] : null;
|
||||
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
let usage = 0;
|
||||
let cost = 0;
|
||||
if (sorted.length >= 1) {
|
||||
const base = prevLast || sorted[0];
|
||||
usage = Math.max(0, Math.round((base.surplus - sorted[sorted.length - 1].surplus) * 100) / 100);
|
||||
cost = Math.max(0, Math.round((base.amount - sorted[sorted.length - 1].amount) * 100) / 100);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
records: sorted,
|
||||
prevLastRecord: prevLast,
|
||||
usage,
|
||||
cost,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/history', requireAuth, (req, res) => {
|
||||
const allRecords = getAllRecords();
|
||||
const { dailyRecords } = calculateDailyUsage(allRecords);
|
||||
|
||||
Reference in New Issue
Block a user