feat: add today cost display and test notification function, optimize stats layout and logic
1. 新增测试通知接口和前端按钮,支持快速验证企业微信通知 2. 增加今日电费统计展示,优化用电计算逻辑 3. 调整统计卡片布局为4列,修复移动端样式缩进 4. 重构用电统计逻辑,修复昨日数据计算误差 5. 完善日报功能,新增昨日电费统计和更准确的区间计算
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Button, Spin, message } from 'antd';
|
import { Button, Spin, message } from 'antd';
|
||||||
import { LogoutOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { LogoutOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { 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 { fetchCurrent, fetchHistory, triggerCollect, logout } from '../api';
|
||||||
@@ -81,6 +82,20 @@ export default function Dashboard({ onLogout }) {
|
|||||||
onLogout();
|
onLogout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTestNotify = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/test-notify');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
message.success('测试消息已发送,请查看企业微信');
|
||||||
|
} else {
|
||||||
|
message.error('发送失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('发送失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentData?.todayRecords || currentData.todayRecords.length === 0) return;
|
if (!currentData?.todayRecords || currentData.todayRecords.length === 0) return;
|
||||||
hourlyInstance.current?.destroy();
|
hourlyInstance.current?.destroy();
|
||||||
@@ -276,6 +291,7 @@ export default function Dashboard({ onLogout }) {
|
|||||||
const estimatedDays = stats[`estimatedDays${statKey}`];
|
const estimatedDays = stats[`estimatedDays${statKey}`];
|
||||||
|
|
||||||
const todayUsage = currentData?.todayUsage ?? 0;
|
const todayUsage = currentData?.todayUsage ?? 0;
|
||||||
|
const todayCost = currentData?.todayCost ?? 0;
|
||||||
const todayAvgPower = currentData?.todayAvgPower ?? null;
|
const todayAvgPower = currentData?.todayAvgPower ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,6 +321,19 @@ body { margin: 0; }
|
|||||||
>
|
>
|
||||||
手动获取
|
手动获取
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<BugOutlined />}
|
||||||
|
onClick={handleTestNotify}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
borderRadius: 20,
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
测试通知
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<LogoutOutlined />}
|
icon={<LogoutOutlined />}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -321,7 +350,7 @@ body { margin: 0; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24, marginBottom: 32 }}>
|
<div className="d-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24, marginBottom: 32 }}>
|
||||||
<div className="d-stats-card" style={{ padding: 20 }}>
|
<div className="d-stats-card" style={{ padding: 20 }}>
|
||||||
<div style={{ fontSize: 14, color: '#999', marginBottom: 8 }}>剩余电量</div>
|
<div style={{ fontSize: 14, color: '#999', marginBottom: 8 }}>剩余电量</div>
|
||||||
<div className="d-stats-value" style={{ fontSize: 36, fontWeight: 700, color: '#1a1a1a', display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
<div className="d-stats-value" style={{ fontSize: 36, fontWeight: 700, color: '#1a1a1a', display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||||
@@ -342,6 +371,12 @@ body { margin: 0; }
|
|||||||
<span style={{ fontSize: 16, fontWeight: 400, color: '#999' }}>kWh</span>
|
<span style={{ fontSize: 16, fontWeight: 400, color: '#999' }}>kWh</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-stats-card" style={{ padding: 20 }}>
|
||||||
|
<div style={{ fontSize: 14, color: '#999', marginBottom: 8 }}>今日电费</div>
|
||||||
|
<div className="d-stats-value" style={{ fontSize: 36, fontWeight: 700, color: '#1a1a1a' }}>
|
||||||
|
¥{todayCost.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-section-gap" style={{ marginBottom: 32 }}>
|
<div className="d-section-gap" style={{ marginBottom: 32 }}>
|
||||||
|
|||||||
111
server.js
111
server.js
@@ -71,14 +71,6 @@ function getAllRecords() {
|
|||||||
return db.prepare('SELECT * FROM records ORDER BY timestamp ASC').all();
|
return db.prepare('SELECT * FROM records ORDER BY timestamp ASC').all();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTodayRecords() {
|
|
||||||
const todayStart = new Date();
|
|
||||||
todayStart.setHours(0, 0, 0, 0);
|
|
||||||
return db.prepare(
|
|
||||||
'SELECT * FROM records WHERE timestamp >= ? ORDER BY timestamp ASC'
|
|
||||||
).all(todayStart.getTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendWecomNotification(content) {
|
async function sendWecomNotification(content) {
|
||||||
if (!WECOM_WEBHOOK_URL) return;
|
if (!WECOM_WEBHOOK_URL) return;
|
||||||
try {
|
try {
|
||||||
@@ -90,8 +82,9 @@ async function sendWecomNotification(content) {
|
|||||||
markdown: { content }
|
markdown: { content }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
const body = await res.json();
|
||||||
console.error(`企业微信通知发送失败: ${res.status}`);
|
if (body.errcode !== 0) {
|
||||||
|
console.error(`企业微信通知发送失败: errcode=${body.errcode}, errmsg=${body.errmsg}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('企业微信通知发送失败:', err.message);
|
console.error('企业微信通知发送失败:', err.message);
|
||||||
@@ -179,7 +172,7 @@ async function collectData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calculateDailyUsage(records) {
|
function calculateDailyUsage(records) {
|
||||||
if (records.length < 2) return { dailyUsage: 0, todayUsage: 0, dailyRecords: [] };
|
if (records.length < 2) return { dailyUsage: 0, todayUsage: 0, todayCost: 0, dailyRecords: [] };
|
||||||
|
|
||||||
const dailyMap = {};
|
const dailyMap = {};
|
||||||
|
|
||||||
@@ -193,13 +186,15 @@ function calculateDailyUsage(records) {
|
|||||||
|
|
||||||
const dailyRecords = [];
|
const dailyRecords = [];
|
||||||
const sortedDates = Object.keys(dailyMap).sort();
|
const sortedDates = Object.keys(dailyMap).sort();
|
||||||
|
let prevLastSurplus = null;
|
||||||
|
|
||||||
for (let i = 0; i < sortedDates.length; i++) {
|
for (let i = 0; i < sortedDates.length; i++) {
|
||||||
const date = sortedDates[i];
|
const date = sortedDates[i];
|
||||||
const dayRecords = dailyMap[date].sort((a, b) => a.timestamp - b.timestamp);
|
const dayRecords = dailyMap[date].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
const firstSurplus = dayRecords[0].surplus;
|
const firstSurplus = dayRecords[0].surplus;
|
||||||
const lastSurplus = dayRecords[dayRecords.length - 1].surplus;
|
const lastSurplus = dayRecords[dayRecords.length - 1].surplus;
|
||||||
const usage = Math.round(Math.max(0, firstSurplus - lastSurplus) * 100) / 100;
|
const base = prevLastSurplus !== null ? prevLastSurplus : firstSurplus;
|
||||||
|
const usage = Math.round(Math.max(0, base - lastSurplus) * 100) / 100;
|
||||||
|
|
||||||
const hoursSpan = (dayRecords[dayRecords.length - 1].timestamp - dayRecords[0].timestamp) / (1000 * 60 * 60);
|
const hoursSpan = (dayRecords[dayRecords.length - 1].timestamp - dayRecords[0].timestamp) / (1000 * 60 * 60);
|
||||||
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
|
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
|
||||||
@@ -208,22 +203,16 @@ function calculateDailyUsage(records) {
|
|||||||
date,
|
date,
|
||||||
usage,
|
usage,
|
||||||
avgPower,
|
avgPower,
|
||||||
firstSurplus,
|
firstSurplus: base,
|
||||||
lastSurplus,
|
lastSurplus,
|
||||||
recordCount: dayRecords.length,
|
recordCount: dayRecords.length,
|
||||||
hoursSpan,
|
hoursSpan,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
prevLastSurplus = lastSurplus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = getLocalDateStr(new Date());
|
return { dailyUsage: 0, todayUsage: 0, todayCost: 0, dailyRecords };
|
||||||
const todayRecords = dailyMap[today];
|
|
||||||
let todayUsage = 0;
|
|
||||||
if (todayRecords && todayRecords.length >= 2) {
|
|
||||||
const sorted = todayRecords.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
todayUsage = Math.max(0, Math.round((sorted[0].surplus - sorted[sorted.length - 1].surplus) * 100) / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { dailyUsage: todayUsage, todayUsage, dailyRecords };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIENT_DIR = path.join(__dirname, 'client', 'dist');
|
const CLIENT_DIR = path.join(__dirname, 'client', 'dist');
|
||||||
@@ -263,20 +252,32 @@ app.get('/api/current', requireAuth, (req, res) => {
|
|||||||
return res.json({ success: false, message: '暂无数据' });
|
return res.json({ success: false, message: '暂无数据' });
|
||||||
}
|
}
|
||||||
const latest = allRecords[allRecords.length - 1];
|
const latest = allRecords[allRecords.length - 1];
|
||||||
const { dailyUsage } = calculateDailyUsage(allRecords);
|
|
||||||
const todayRecords = getTodayRecords();
|
|
||||||
|
|
||||||
|
const todayStr = getLocalDateStr(new Date());
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
const yesterdayRecords = getRecordsByDate(getLocalDateStr(yesterday));
|
const yesterdayStr = getLocalDateStr(yesterday);
|
||||||
|
|
||||||
|
const todayRecordsDB = getRecordsByDate(todayStr);
|
||||||
|
const yesterdayRecords = getRecordsByDate(yesterdayStr);
|
||||||
const yesterdayLast = yesterdayRecords.length > 0 ? yesterdayRecords[yesterdayRecords.length - 1] : null;
|
const yesterdayLast = yesterdayRecords.length > 0 ? yesterdayRecords[yesterdayRecords.length - 1] : null;
|
||||||
|
const sortedToday = [...todayRecordsDB].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
let todayUsage = 0;
|
||||||
|
let todayCost = 0;
|
||||||
|
if (sortedToday.length >= 1) {
|
||||||
|
const base = yesterdayLast || sortedToday[0];
|
||||||
|
todayUsage = Math.max(0, Math.round((base.surplus - sortedToday[sortedToday.length - 1].surplus) * 100) / 100);
|
||||||
|
todayCost = Math.max(0, Math.round((base.amount - sortedToday[sortedToday.length - 1].amount) * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
current: latest,
|
current: latest,
|
||||||
todayUsage: dailyUsage,
|
todayUsage,
|
||||||
todayAvgPower: dailyUsage > 0 ? Math.round((dailyUsage / Math.max(1, new Date().getHours())) * 1000) / 1000 : 0,
|
todayCost,
|
||||||
todayRecords,
|
todayAvgPower: todayUsage > 0 ? Math.round((todayUsage / Math.max(1, new Date().getHours())) * 1000) / 1000 : 0,
|
||||||
|
todayRecords: sortedToday,
|
||||||
yesterdayLastRecord: yesterdayLast,
|
yesterdayLastRecord: yesterdayLast,
|
||||||
totalRecords: allRecords.length
|
totalRecords: allRecords.length
|
||||||
});
|
});
|
||||||
@@ -287,17 +288,20 @@ app.get('/api/history', requireAuth, (req, res) => {
|
|||||||
const { dailyRecords } = calculateDailyUsage(allRecords);
|
const { dailyRecords } = calculateDailyUsage(allRecords);
|
||||||
const latest = allRecords.length > 0 ? allRecords[allRecords.length - 1] : null;
|
const latest = allRecords.length > 0 ? allRecords[allRecords.length - 1] : null;
|
||||||
|
|
||||||
const totalUsageLast7Days = dailyRecords.slice(-7).reduce((sum, d) => sum + d.usage, 0);
|
const todayStr = getLocalDateStr(new Date());
|
||||||
const totalUsageLast15Days = dailyRecords.slice(-15).reduce((sum, d) => sum + d.usage, 0);
|
const historyDailyRecords = dailyRecords.filter(d => d.date !== todayStr);
|
||||||
const totalUsageLast30Days = dailyRecords.slice(-30).reduce((sum, d) => sum + d.usage, 0);
|
|
||||||
|
|
||||||
const totalHours7 = dailyRecords.slice(-7).reduce((sum, d) => sum + d.hoursSpan, 0);
|
const totalUsageLast7Days = historyDailyRecords.slice(-7).reduce((sum, d) => sum + d.usage, 0);
|
||||||
const totalHours15 = dailyRecords.slice(-15).reduce((sum, d) => sum + d.hoursSpan, 0);
|
const totalUsageLast15Days = historyDailyRecords.slice(-15).reduce((sum, d) => sum + d.usage, 0);
|
||||||
const totalHours30 = dailyRecords.slice(-30).reduce((sum, d) => sum + d.hoursSpan, 0);
|
const totalUsageLast30Days = historyDailyRecords.slice(-30).reduce((sum, d) => sum + d.usage, 0);
|
||||||
|
|
||||||
const days7 = Math.min(7, dailyRecords.length);
|
const totalHours7 = historyDailyRecords.slice(-7).reduce((sum, d) => sum + d.hoursSpan, 0);
|
||||||
const days15 = Math.min(15, dailyRecords.length);
|
const totalHours15 = historyDailyRecords.slice(-15).reduce((sum, d) => sum + d.hoursSpan, 0);
|
||||||
const days30 = Math.min(30, dailyRecords.length);
|
const totalHours30 = historyDailyRecords.slice(-30).reduce((sum, d) => sum + d.hoursSpan, 0);
|
||||||
|
|
||||||
|
const days7 = Math.min(7, historyDailyRecords.length);
|
||||||
|
const days15 = Math.min(15, historyDailyRecords.length);
|
||||||
|
const days30 = Math.min(30, historyDailyRecords.length);
|
||||||
|
|
||||||
const avgDaily7 = days7 >= 1 ? Math.round((totalUsageLast7Days / days7) * 100) / 100 : null;
|
const avgDaily7 = days7 >= 1 ? Math.round((totalUsageLast7Days / days7) * 100) / 100 : null;
|
||||||
const avgDaily15 = days15 >= 1 ? Math.round((totalUsageLast15Days / days15) * 100) / 100 : null;
|
const avgDaily15 = days15 >= 1 ? Math.round((totalUsageLast15Days / days15) * 100) / 100 : null;
|
||||||
@@ -356,18 +360,28 @@ async function sendDailyReport() {
|
|||||||
const dateStr = getLocalDateStr(yesterday);
|
const dateStr = getLocalDateStr(yesterday);
|
||||||
|
|
||||||
const records = getRecordsByDate(dateStr);
|
const records = getRecordsByDate(dateStr);
|
||||||
if (records.length < 2) {
|
if (records.length < 1) {
|
||||||
await sendWecomNotification(
|
await sendWecomNotification(
|
||||||
'## 电费日报\n\n' +
|
'## 电费日报\n\n' +
|
||||||
`> 日期:${dateStr}\n\n` +
|
`> 日期:${dateStr}\n\n` +
|
||||||
`数据不足,无法生成昨日用电报告(仅 ${records.length} 条记录)`
|
`昨日无数据记录`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = records[0];
|
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
const last = records[records.length - 1];
|
const first = sorted[0];
|
||||||
const usage = Math.round(Math.max(0, first.surplus - last.surplus) * 100) / 100;
|
const last = sorted[sorted.length - 1];
|
||||||
|
|
||||||
|
const dayBefore = new Date(yesterday);
|
||||||
|
dayBefore.setDate(dayBefore.getDate() - 1);
|
||||||
|
const dayBeforeRecords = getRecordsByDate(getLocalDateStr(dayBefore));
|
||||||
|
const dayBeforeLast = dayBeforeRecords.length > 0 ? dayBeforeRecords[dayBeforeRecords.length - 1] : null;
|
||||||
|
const baseSurplus = dayBeforeLast ? dayBeforeLast.surplus : first.surplus;
|
||||||
|
const baseAmount = dayBeforeLast ? dayBeforeLast.amount : first.amount;
|
||||||
|
|
||||||
|
const usage = Math.round(Math.max(0, baseSurplus - last.surplus) * 100) / 100;
|
||||||
|
const cost = Math.round(Math.max(0, baseAmount - last.amount) * 100) / 100;
|
||||||
|
|
||||||
const hoursSpan = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
|
const hoursSpan = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
|
||||||
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
|
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
|
||||||
@@ -377,9 +391,9 @@ async function sendDailyReport() {
|
|||||||
let content = '## 电费日报\n\n';
|
let content = '## 电费日报\n\n';
|
||||||
content += `> 日期:${dateStr}\n\n`;
|
content += `> 日期:${dateStr}\n\n`;
|
||||||
content += `**昨日用电**:${usage.toFixed(2)} 度\n`;
|
content += `**昨日用电**:${usage.toFixed(2)} 度\n`;
|
||||||
|
content += `**昨日电费**:¥${cost.toFixed(2)}\n`;
|
||||||
content += `**平均功率**:${avgPower.toFixed(3)} kW\n`;
|
content += `**平均功率**:${avgPower.toFixed(3)} kW\n`;
|
||||||
content += `**数据记录**:${records.length} 条\n`;
|
content += `**数据记录**:${sorted.length} 条\n`;
|
||||||
content += `**记录区间**:${new Date(first.timestamp).toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' })} ~ ${new Date(last.timestamp).toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' })}\n`;
|
|
||||||
if (latest) {
|
if (latest) {
|
||||||
content += `\n**当前剩余电量**:${latest.surplus.toFixed(2)} 度\n`;
|
content += `\n**当前剩余电量**:${latest.surplus.toFixed(2)} 度\n`;
|
||||||
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
|
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
|
||||||
@@ -398,6 +412,15 @@ app.get('/api/trigger-collect', requireAuth, async (req, res) => {
|
|||||||
res.json({ success: true, message: '采集完成' });
|
res.json({ success: true, message: '采集完成' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/test-notify', requireAuth, async (req, res) => {
|
||||||
|
await sendWecomNotification(
|
||||||
|
'## 电费监控测试消息\n\n' +
|
||||||
|
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
|
||||||
|
'如果收到此消息,说明企业微信通知配置正常。'
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '测试消息已发送,请查看企业微信' });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
if (fs.existsSync(CLIENT_DIR)) {
|
if (fs.existsSync(CLIENT_DIR)) {
|
||||||
res.sendFile(path.join(CLIENT_DIR, 'index.html'));
|
res.sendFile(path.join(CLIENT_DIR, 'index.html'));
|
||||||
|
|||||||
Reference in New Issue
Block a user