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
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
.trae/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.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 |
|
| 后端 | Node.js + Express |
|
||||||
| 数据库 | SQLite (better-sqlite3) |
|
| 数据库 | SQLite (better-sqlite3) |
|
||||||
| 定时任务 | node-cron |
|
| 定时任务 | node-cron |
|
||||||
@@ -39,6 +42,15 @@ LOGIN_PASSWORD=你的密码
|
|||||||
# 选填 - 企业微信群机器人 Webhook URL
|
# 选填 - 企业微信群机器人 Webhook URL
|
||||||
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
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 签名密钥
|
||||||
COOKIE_SECRET=任意随机字符串
|
COOKIE_SECRET=任意随机字符串
|
||||||
```
|
```
|
||||||
@@ -100,9 +112,11 @@ electricity-monitor/
|
|||||||
├── package.json
|
├── package.json
|
||||||
├── index.html
|
├── index.html
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
|
├── public/
|
||||||
|
│ └── lightning.svg # Favicon
|
||||||
└── src/
|
└── src/
|
||||||
├── main.jsx # 入口
|
├── main.jsx # 入口(主题检测)
|
||||||
├── App.jsx # 根组件(认证状态管理)
|
├── App.jsx # 根组件(CSS 变量 & 认证管理)
|
||||||
├── api.js # API 请求模块
|
├── api.js # API 请求模块
|
||||||
└── pages/
|
└── pages/
|
||||||
├── Login.jsx # 登录页
|
├── Login.jsx # 登录页
|
||||||
@@ -117,15 +131,27 @@ electricity-monitor/
|
|||||||
| `/api/logout` | POST | 登出 | 否 |
|
| `/api/logout` | POST | 登出 | 否 |
|
||||||
| `/api/check-auth` | GET | 检查登录状态 | 否 |
|
| `/api/check-auth` | GET | 检查登录状态 | 否 |
|
||||||
| `/api/current` | GET | 获取当前数据 | 是 |
|
| `/api/current` | GET | 获取当前数据 | 是 |
|
||||||
| `/api/history` | GET | 获取历史数据 | 是 |
|
| `/api/history` | GET | 获取历史统计数据(7/15/30天) | 是 |
|
||||||
|
| `/api/records-by-date` | GET | 按日期查询详细记录 | 是 |
|
||||||
| `/api/trigger-collect` | 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('未登录');
|
if (res.status === 401) throw new Error('未登录');
|
||||||
return res.json();
|
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 { 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 { LogoutOutlined, ReloadOutlined, BugOutlined } from '@ant-design/icons';
|
||||||
import { Lightning, ChartLine, ChartHistogram, Timer } from '@icon-park/react';
|
import { Lightning, ChartLine, ChartHistogram, Timer } from '@icon-park/react';
|
||||||
import { Chart, registerables } from 'chart.js';
|
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);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ export default function Dashboard({ onLogout }) {
|
|||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
const [collecting, setCollecting] = useState(false);
|
const [collecting, setCollecting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
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 hourlyChartRef = useRef(null);
|
||||||
const trendChartRef = useRef(null);
|
const trendChartRef = useRef(null);
|
||||||
@@ -63,6 +68,17 @@ export default function Dashboard({ onLogout }) {
|
|||||||
};
|
};
|
||||||
}, [loadData]);
|
}, [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 () => {
|
const handleCollect = async () => {
|
||||||
setCollecting(true);
|
setCollecting(true);
|
||||||
try {
|
try {
|
||||||
@@ -110,11 +126,12 @@ export default function Dashboard({ onLogout }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
hourlyInstance.current?.destroy();
|
||||||
|
|
||||||
const sorted = [...currentData.todayRecords].sort((a, b) => a.timestamp - b.timestamp);
|
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
const prev = currentData.yesterdayLastRecord;
|
|
||||||
const labels = sorted.map(r => formatTime(r.timestamp));
|
const labels = sorted.map(r => formatTime(r.timestamp));
|
||||||
const values = sorted.map((r, i) => {
|
const values = sorted.map((r, i) => {
|
||||||
const prevRecord = i === 0 ? prev : sorted[i - 1];
|
const prevRecord = i === 0 ? prev : sorted[i - 1];
|
||||||
@@ -179,7 +196,7 @@ export default function Dashboard({ onLogout }) {
|
|||||||
interaction: { intersect: false, mode: 'index' },
|
interaction: { intersect: false, mode: 'index' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [currentData]);
|
}, [currentData, dateRecords, datePrevLast, isToday]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyData?.dailyRecords || historyData.dailyRecords.length === 0) return;
|
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', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>
|
<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' }} />
|
<ChartLine theme="filled" size="20" fill="#4a90e2" style={{ display: 'flex' }} />
|
||||||
今日用电趋势
|
用电趋势
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 400 }}>(每时段)</span>
|
<span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 400 }}>(每时段)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<DatePicker
|
||||||
|
value={dayjs(chartDate)}
|
||||||
|
onChange={(d) => d && setChartDate(d.format('YYYY-MM-DD'))}
|
||||||
|
allowClear={false}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-chart" style={{ position: 'relative', height: 280, background: 'var(--bg-chart)', borderRadius: 8, padding: 20 }}>
|
<div className="d-chart" style={{ position: 'relative', height: 280, background: 'var(--bg-chart)', borderRadius: 8, padding: 20 }}>
|
||||||
{(!currentData?.todayRecords || currentData.todayRecords.length === 0) ? (
|
{(!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>
|
<div className="d-chart-empty" style={{ textAlign: 'center', paddingTop: 110, color: 'var(--text-tertiary)' }}>暂无数据</div>
|
||||||
) : null}
|
) : null}
|
||||||
<canvas ref={hourlyChartRef} />
|
<canvas ref={hourlyChartRef} />
|
||||||
</div>
|
</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) => {
|
app.get('/api/history', requireAuth, (req, res) => {
|
||||||
const allRecords = getAllRecords();
|
const allRecords = getAllRecords();
|
||||||
const { dailyRecords } = calculateDailyUsage(allRecords);
|
const { dailyRecords } = calculateDailyUsage(allRecords);
|
||||||
|
|||||||
Reference in New Issue
Block a user