Files
electricity-monitor/server.js
EchoZenith 3b43ddad2c feat: add today cost display and test notification function, optimize stats layout and logic
1. 新增测试通知接口和前端按钮,支持快速验证企业微信通知
2. 增加今日电费统计展示,优化用电计算逻辑
3. 调整统计卡片布局为4列,修复移动端样式缩进
4. 重构用电统计逻辑,修复昨日数据计算误差
5. 完善日报功能,新增昨日电费统计和更准确的区间计算
2026-05-23 00:41:07 +08:00

439 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config();
const express = require('express');
const path = require('path');
const fs = require('fs');
const cron = require('node-cron');
const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(cookieParser(process.env.COOKIE_SECRET || 'electricity-monitor-secret'));
const DB_PATH = path.join(__dirname, 'data', 'electricity.db');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
surplus REAL NOT NULL,
amount REAL NOT NULL,
timestamp INTEGER NOT NULL,
room_name TEXT DEFAULT ''
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records(timestamp)
`);
const insertStmt = db.prepare(
'INSERT INTO records (surplus, amount, timestamp, room_name) VALUES (?, ?, ?, ?)'
);
const updateStmt = db.prepare(
'UPDATE records SET surplus = ?, amount = ?, timestamp = ? WHERE id = ?'
);
const API_URL = 'https://application.xiaofubao.com/app/electric/queryRoomSurplus';
const API_BODY = 'areaId=2105355156363427841&buildingCode=34&floorCode=54&roomCode=12828&platform=YUNMA_WXAPP_CHONGD';
const COOKIE = process.env.SHIRO_COOKIE;
if (!COOKIE) {
console.error('错误: 环境变量 SHIRO_COOKIE 未设置,请在 .env 文件中配置');
process.exit(1);
}
const LOGIN_USERNAME = process.env.LOGIN_USERNAME || 'admin';
const LOGIN_PASSWORD = process.env.LOGIN_PASSWORD;
const WECOM_WEBHOOK_URL = process.env.WECOM_WEBHOOK_URL;
if (!WECOM_WEBHOOK_URL) {
console.warn('警告: 环境变量 WECOM_WEBHOOK_URL 未设置,企业微信通知功能将不可用');
}
function requireAuth(req, res, next) {
const auth = req.signedCookies.auth;
if (auth === LOGIN_USERNAME) {
return next();
}
res.status(401).json({ success: false, message: '未登录' });
}
function getLatestRecord() {
const row = db.prepare('SELECT * FROM records ORDER BY timestamp DESC LIMIT 1').get();
return row || null;
}
function getAllRecords() {
return db.prepare('SELECT * FROM records ORDER BY timestamp ASC').all();
}
async function sendWecomNotification(content) {
if (!WECOM_WEBHOOK_URL) return;
try {
const res = await fetch(WECOM_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: { content }
})
});
const body = await res.json();
if (body.errcode !== 0) {
console.error(`企业微信通知发送失败: errcode=${body.errcode}, errmsg=${body.errmsg}`);
}
} catch (err) {
console.error('企业微信通知发送失败:', err.message);
}
}
function getLocalDateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function getRecordsByDate(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number);
const startOfDay = new Date(year, month - 1, day);
const endOfDay = new Date(year, month - 1, day, 23, 59, 59, 999);
return db.prepare(
'SELECT * FROM records WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC'
).all(startOfDay.getTime(), endOfDay.getTime());
}
async function fetchData() {
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Cookie': COOKIE,
'x-requested-with': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) wxwork/5.0.8 MicroMessenger/7.0.0(0x17000000) MacWechat/5.0.8(0x15000800) MiniProgramEnv/Mac MiniProgram/',
'Referer': 'https://application.xiaofubao.com/',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
'Origin': 'https://application.xiaofubao.com'
},
body: API_BODY
});
if (!response.ok) {
console.error(`请求失败: ${response.status}`);
return null;
}
const text = await response.text();
const json = JSON.parse(text);
if (json.success && json.data) {
return {
surplus: json.data.surplus,
amount: json.data.amount,
timestamp: Date.now(),
roomName: json.data.displayRoomName || ''
};
}
return null;
} catch (err) {
console.error('请求出错:', err.message);
return null;
}
}
async function collectData() {
const data = await fetchData();
if (!data) {
console.log(`[${new Date().toLocaleString()}] 采集失败: 接口请求异常`);
await sendWecomNotification(
'## 电费采集异常\n\n' +
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
'电费接口请求失败,请检查网络或 Cookie 是否过期。'
);
return;
}
const latest = getLatestRecord();
if (latest) {
const sameHour = new Date(latest.timestamp).getHours() === new Date(data.timestamp).getHours();
const sameDay = new Date(latest.timestamp).toDateString() === new Date(data.timestamp).toDateString();
if (sameDay && sameHour) {
updateStmt.run(data.surplus, data.amount, data.timestamp, latest.id);
console.log(`[${new Date().toLocaleString()}] 更新本小时记录: ${data.surplus}度, ¥${data.amount}`);
return;
}
}
insertStmt.run(data.surplus, data.amount, data.timestamp, data.roomName);
console.log(`[${new Date().toLocaleString()}] 新增记录: ${data.surplus}度, ¥${data.amount}`);
}
function calculateDailyUsage(records) {
if (records.length < 2) return { dailyUsage: 0, todayUsage: 0, todayCost: 0, dailyRecords: [] };
const dailyMap = {};
records.forEach(record => {
const dateStr = getLocalDateStr(new Date(record.timestamp));
if (!dailyMap[dateStr]) {
dailyMap[dateStr] = [];
}
dailyMap[dateStr].push(record);
});
const dailyRecords = [];
const sortedDates = Object.keys(dailyMap).sort();
let prevLastSurplus = null;
for (let i = 0; i < sortedDates.length; i++) {
const date = sortedDates[i];
const dayRecords = dailyMap[date].sort((a, b) => a.timestamp - b.timestamp);
const firstSurplus = dayRecords[0].surplus;
const lastSurplus = dayRecords[dayRecords.length - 1].surplus;
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 avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
dailyRecords.push({
date,
usage,
avgPower,
firstSurplus: base,
lastSurplus,
recordCount: dayRecords.length,
hoursSpan,
});
prevLastSurplus = lastSurplus;
}
return { dailyUsage: 0, todayUsage: 0, todayCost: 0, dailyRecords };
}
const CLIENT_DIR = path.join(__dirname, 'client', 'dist');
if (fs.existsSync(CLIENT_DIR)) {
app.use(express.static(CLIENT_DIR));
}
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {};
if (username === LOGIN_USERNAME && password === LOGIN_PASSWORD) {
res.cookie('auth', LOGIN_USERNAME, {
signed: true,
maxAge: 30 * 24 * 60 * 60 * 1000,
httpOnly: true
});
return res.json({ success: true });
}
res.status(401).json({ success: false, message: '用户名或密码错误' });
});
app.post('/api/logout', (req, res) => {
res.clearCookie('auth');
res.json({ success: true });
});
app.get('/api/check-auth', (req, res) => {
const auth = req.signedCookies.auth;
if (auth === LOGIN_USERNAME) {
return res.json({ success: true });
}
res.status(401).json({ success: false, message: '未登录' });
});
app.get('/api/current', requireAuth, (req, res) => {
const allRecords = getAllRecords();
if (allRecords.length === 0) {
return res.json({ success: false, message: '暂无数据' });
}
const latest = allRecords[allRecords.length - 1];
const todayStr = getLocalDateStr(new Date());
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = getLocalDateStr(yesterday);
const todayRecordsDB = getRecordsByDate(todayStr);
const yesterdayRecords = getRecordsByDate(yesterdayStr);
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({
success: true,
current: latest,
todayUsage,
todayCost,
todayAvgPower: todayUsage > 0 ? Math.round((todayUsage / Math.max(1, new Date().getHours())) * 1000) / 1000 : 0,
todayRecords: sortedToday,
yesterdayLastRecord: yesterdayLast,
totalRecords: allRecords.length
});
});
app.get('/api/history', requireAuth, (req, res) => {
const allRecords = getAllRecords();
const { dailyRecords } = calculateDailyUsage(allRecords);
const latest = allRecords.length > 0 ? allRecords[allRecords.length - 1] : null;
const todayStr = getLocalDateStr(new Date());
const historyDailyRecords = dailyRecords.filter(d => d.date !== todayStr);
const totalUsageLast7Days = historyDailyRecords.slice(-7).reduce((sum, d) => sum + d.usage, 0);
const totalUsageLast15Days = historyDailyRecords.slice(-15).reduce((sum, d) => sum + d.usage, 0);
const totalUsageLast30Days = historyDailyRecords.slice(-30).reduce((sum, d) => sum + d.usage, 0);
const totalHours7 = historyDailyRecords.slice(-7).reduce((sum, d) => sum + d.hoursSpan, 0);
const totalHours15 = historyDailyRecords.slice(-15).reduce((sum, d) => sum + d.hoursSpan, 0);
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 avgDaily15 = days15 >= 1 ? Math.round((totalUsageLast15Days / days15) * 100) / 100 : null;
const avgDaily30 = days30 >= 1 ? Math.round((totalUsageLast30Days / days30) * 100) / 100 : null;
const avgPower7 = totalHours7 >= 1
? Math.round((totalUsageLast7Days / totalHours7) * 1000) / 1000
: null;
const avgPower15 = totalHours15 >= 1
? Math.round((totalUsageLast15Days / totalHours15) * 1000) / 1000
: null;
const avgPower30 = totalHours30 >= 1
? Math.round((totalUsageLast30Days / totalHours30) * 1000) / 1000
: null;
const estimatedDays7 = avgDaily7 && avgDaily7 > 0 && latest
? Math.round(latest.surplus / avgDaily7 * 10) / 10
: null;
const estimatedDays15 = avgDaily15 && avgDaily15 > 0 && latest
? Math.round(latest.surplus / avgDaily15 * 10) / 10
: null;
const estimatedDays30 = avgDaily30 && avgDaily30 > 0 && latest
? Math.round(latest.surplus / avgDaily30 * 10) / 10
: null;
res.json({
success: true,
current: latest,
dailyRecords: dailyRecords.slice(-30),
stats: {
avgDaily7,
avgDaily15,
avgDaily30,
avgPower7,
avgPower15,
avgPower30,
estimatedDays7,
estimatedDays15,
estimatedDays30
}
});
});
cron.schedule('0 * * * *', async () => {
console.log(`[定时任务] 开始采集数据...`);
await collectData();
});
async function sendDailyReport() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = getLocalDateStr(yesterday);
const records = getRecordsByDate(dateStr);
if (records.length < 1) {
await sendWecomNotification(
'## 电费日报\n\n' +
`> 日期:${dateStr}\n\n` +
`昨日无数据记录`
);
return;
}
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
const first = sorted[0];
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 avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
const latest = getLatestRecord();
let content = '## 电费日报\n\n';
content += `> 日期:${dateStr}\n\n`;
content += `**昨日用电**${usage.toFixed(2)}\n`;
content += `**昨日电费**:¥${cost.toFixed(2)}\n`;
content += `**平均功率**${avgPower.toFixed(3)} kW\n`;
content += `**数据记录**${sorted.length}\n`;
if (latest) {
content += `\n**当前剩余电量**${latest.surplus.toFixed(2)}\n`;
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
}
await sendWecomNotification(content);
}
cron.schedule('0 0 * * *', async () => {
console.log(`[定时任务] 发送昨日用电报告...`);
await sendDailyReport();
});
app.get('/api/trigger-collect', requireAuth, async (req, res) => {
await collectData();
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) => {
if (fs.existsSync(CLIENT_DIR)) {
res.sendFile(path.join(CLIENT_DIR, 'index.html'));
} else {
res.status(200).json({ message: 'API 服务运行中,前端请通过 Vite 开发服务器访问 (port 5173)' });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`========================================`);
console.log(` 电费监控系统已启动`);
console.log(` 访问地址: http://localhost:${PORT}`);
console.log(` 数据库: ${DB_PATH}`);
console.log(`========================================`);
});