feat: 初始化智能电量监控系统项目
该提交完成了完整的电费监控系统项目初始化,包含: 1. 后端Node.js+Express服务,支持定时采集、登录认证、数据API 2. React前端界面,包含登录页和数据仪表盘 3. Docker容器化配置和docker-compose部署文件 4. 环境变量示例和gitignore、dockerignore配置 5. 完整的项目文档README
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
data
|
||||||
|
.env
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 必填 - 小程序的登录 Cookie
|
||||||
|
SHIRO_COOKIE=shiroJID=你的cookie值
|
||||||
|
|
||||||
|
# 登录用户名和密码
|
||||||
|
LOGIN_USERNAME=admin
|
||||||
|
LOGIN_PASSWORD=你的密码
|
||||||
|
|
||||||
|
# 选填 - 企业微信群机器人 Webhook URL
|
||||||
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||||
|
|
||||||
|
# 选填 - Cookie 签名密钥
|
||||||
|
COOKIE_SECRET=
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
client/dist/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
RUN npm ci --only=production && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
FROM node:20-alpine AS client-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY client/package.json ./
|
||||||
|
|
||||||
|
RUN npm install && npm cache clean --force
|
||||||
|
|
||||||
|
COPY client/ ./
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata ca-certificates
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY server.js .
|
||||||
|
COPY --from=client-builder /app/dist ./client/dist
|
||||||
|
COPY data ./data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
131
README.md
131
README.md
@@ -1,2 +1,131 @@
|
|||||||
# electricity-monitor
|
# 智能电量监控系统
|
||||||
|
|
||||||
|
实时监控宿舍/房间电费余额和用电量,通过企业微信推送每日用电报告和异常告警。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 定时采集电费数据(每小时整点)
|
||||||
|
- 可视化展示今日用电趋势和用电量
|
||||||
|
- 历史用电数据统计(7天/15天/30天)
|
||||||
|
- 企业微信机器人通知(每日用电报告 + 采集异常告警)
|
||||||
|
- 登录认证保护
|
||||||
|
- 手动触发数据采集
|
||||||
|
- 响应式布局,支持手机端访问
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 前端 | React 18 + Ant Design 5 + Chart.js |
|
||||||
|
| 后端 | Node.js + Express |
|
||||||
|
| 数据库 | SQLite (better-sqlite3) |
|
||||||
|
| 定时任务 | node-cron |
|
||||||
|
| 容器化 | Docker |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 配置环境变量
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env` 并填写:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 必填 - 小程序的登录 Cookie
|
||||||
|
SHIRO_COOKIE=shiroJID=你的cookie
|
||||||
|
|
||||||
|
# 必填 - 登录用户名和密码
|
||||||
|
LOGIN_USERNAME=admin
|
||||||
|
LOGIN_PASSWORD=你的密码
|
||||||
|
|
||||||
|
# 选填 - 企业微信群机器人 Webhook URL
|
||||||
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||||
|
|
||||||
|
# 选填 - Cookie 签名密钥
|
||||||
|
COOKIE_SECRET=任意随机字符串
|
||||||
|
```
|
||||||
|
|
||||||
|
> **如何获取 SHIRO_COOKIE?**
|
||||||
|
> 1. 打开微信小程序
|
||||||
|
> 2. 进入电费查询页面
|
||||||
|
> 3. 通过抓包工具获取请求中的 Cookie 值
|
||||||
|
|
||||||
|
### 2. 使用 Docker 运行(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:3000`
|
||||||
|
|
||||||
|
### 3. 本地开发
|
||||||
|
|
||||||
|
需要同时启动后端和前端:
|
||||||
|
|
||||||
|
**终端 1 - 启动后端:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
后端运行在 `http://localhost:3000`
|
||||||
|
|
||||||
|
**终端 2 - 启动前端开发服务器:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端运行在 `http://localhost:5173`,Vite 会自动代理 API 请求到后端。
|
||||||
|
|
||||||
|
### 4. 手动采集
|
||||||
|
|
||||||
|
登录系统后,点击右上角 **手动获取** 按钮即可立即采集最新数据。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
electricity-monitor/
|
||||||
|
├── server.js # 后端服务入口
|
||||||
|
├── package.json # 后端依赖
|
||||||
|
├── Dockerfile # Docker 构建
|
||||||
|
├── docker-compose.yml # Docker Compose 配置
|
||||||
|
├── .env # 环境变量(勿提交)
|
||||||
|
├── .env.example # 环境变量示例
|
||||||
|
├── .gitignore
|
||||||
|
├── data/ # SQLite 数据库文件
|
||||||
|
└── client/ # 前端项目
|
||||||
|
├── package.json
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.js
|
||||||
|
└── src/
|
||||||
|
├── main.jsx # 入口
|
||||||
|
├── App.jsx # 根组件(认证状态管理)
|
||||||
|
├── api.js # API 请求模块
|
||||||
|
└── pages/
|
||||||
|
├── Login.jsx # 登录页
|
||||||
|
└── Dashboard.jsx # 仪表盘页
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 | 需登录 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/api/login` | POST | 登录 | 否 |
|
||||||
|
| `/api/logout` | POST | 登出 | 否 |
|
||||||
|
| `/api/check-auth` | GET | 检查登录状态 | 否 |
|
||||||
|
| `/api/current` | GET | 获取当前数据 | 是 |
|
||||||
|
| `/api/history` | GET | 获取历史数据 | 是 |
|
||||||
|
| `/api/trigger-collect` | GET | 手动触发采集 | 是 |
|
||||||
|
|
||||||
|
## 定时任务
|
||||||
|
|
||||||
|
- **每小时整点**:采集电费数据
|
||||||
|
- **每天 00:00**:发送昨日用电报告到企业微信
|
||||||
|
- **采集失败时**:立即发送异常告警到企业微信
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>电费监控系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2917
client/package-lock.json
generated
Normal file
2917
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "electricity-monitor-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.5.0",
|
||||||
|
"@icon-park/react": "^1.4.2",
|
||||||
|
"antd": "^5.22.0",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
client/src/App.jsx
Normal file
23
client/src/App.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import { checkAuth } from './api';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [authed, setAuthed] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth().then(setAuthed).catch(() => setAuthed(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (authed === null) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return authed ? <Dashboard onLogout={() => setAuthed(false)} /> : <Login onLogin={() => setAuthed(true)} />;
|
||||||
|
}
|
||||||
39
client/src/api.js
Normal file
39
client/src/api.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const BASE = '/api';
|
||||||
|
|
||||||
|
export async function checkAuth() {
|
||||||
|
const res = await fetch(`${BASE}/check-auth`);
|
||||||
|
if (res.status === 401) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username, password) {
|
||||||
|
const res = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await fetch(`${BASE}/logout`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCurrent() {
|
||||||
|
const res = await fetch(`${BASE}/current`);
|
||||||
|
if (res.status === 401) throw new Error('未登录');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHistory() {
|
||||||
|
const res = await fetch(`${BASE}/history`);
|
||||||
|
if (res.status === 401) throw new Error('未登录');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerCollect() {
|
||||||
|
const res = await fetch(`${BASE}/trigger-collect`);
|
||||||
|
if (res.status === 401) throw new Error('未登录');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
17
client/src/main.jsx
Normal file
17
client/src/main.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#4a90e2',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
436
client/src/pages/Dashboard.jsx
Normal file
436
client/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Button, Spin, message } from 'antd';
|
||||||
|
import { LogoutOutlined, ReloadOutlined } 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';
|
||||||
|
|
||||||
|
Chart.register(...registerables);
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.d-header { flex-direction: column !important; gap: 12px !important; }
|
||||||
|
.d-stats { grid-template-columns: 1fr !important; gap: 12px !important; }
|
||||||
|
.d-stats-card { padding: 16px !important; }
|
||||||
|
.d-stats-value { font-size: 28px !important; }
|
||||||
|
.d-bottom { grid-template-columns: 1fr !important; gap: 12px !important; }
|
||||||
|
.d-chart { height: 200px !important; padding: 12px !important; }
|
||||||
|
.d-chart-empty { padding-top: 75px !important; }
|
||||||
|
.d-section-gap { margin-bottom: 24px !important; }
|
||||||
|
.d-container { padding: 16px !important; }
|
||||||
|
.d-body { padding: 12px !important; }
|
||||||
|
.d-trend-header { flex-direction: column !important; gap: 8px !important; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard({ onLogout }) {
|
||||||
|
const [currentData, setCurrentData] = useState(null);
|
||||||
|
const [historyData, setHistoryData] = useState(null);
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [collecting, setCollecting] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const hourlyChartRef = useRef(null);
|
||||||
|
const trendChartRef = useRef(null);
|
||||||
|
const hourlyInstance = useRef(null);
|
||||||
|
const trendInstance = useRef(null);
|
||||||
|
const intervalRef = useRef(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [c, h] = await Promise.all([fetchCurrent(), fetchHistory()]);
|
||||||
|
if (c.success) setCurrentData(c);
|
||||||
|
if (h.success) setHistoryData(h);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
intervalRef.current = setInterval(loadData, 60000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
hourlyInstance.current?.destroy();
|
||||||
|
trendInstance.current?.destroy();
|
||||||
|
};
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleCollect = async () => {
|
||||||
|
setCollecting(true);
|
||||||
|
try {
|
||||||
|
await triggerCollect();
|
||||||
|
await loadData();
|
||||||
|
message.success('获取成功');
|
||||||
|
} catch {
|
||||||
|
message.error('获取失败');
|
||||||
|
} finally {
|
||||||
|
setCollecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
onLogout();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentData?.todayRecords || currentData.todayRecords.length === 0) return;
|
||||||
|
hourlyInstance.current?.destroy();
|
||||||
|
|
||||||
|
const sorted = [...currentData.todayRecords].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const prev = currentData.yesterdayLastRecord;
|
||||||
|
const labels = sorted.map(r => formatTime(r.timestamp));
|
||||||
|
const values = sorted.map((r, i) => {
|
||||||
|
const prevRecord = i === 0 ? prev : sorted[i - 1];
|
||||||
|
if (!prevRecord) return 0;
|
||||||
|
return Math.round(Math.max(0, prevRecord.surplus - r.surplus) * 100) / 100;
|
||||||
|
});
|
||||||
|
const costValues = sorted.map((r, i) => {
|
||||||
|
const prevRecord = i === 0 ? prev : sorted[i - 1];
|
||||||
|
if (!prevRecord) return 0;
|
||||||
|
return Math.round(Math.max(0, prevRecord.amount - r.amount) * 100) / 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = hourlyChartRef.current?.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
hourlyInstance.current = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: '用电量 (kWh)',
|
||||||
|
data: values,
|
||||||
|
borderColor: '#4a90e2',
|
||||||
|
backgroundColor: 'rgba(74, 144, 226, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: '#4a90e2',
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => `用电量: ${context.parsed.y.toFixed(2)} kWh`,
|
||||||
|
afterLabel: (context) => {
|
||||||
|
const idx = context.dataIndex;
|
||||||
|
return `电费: ¥${costValues[idx].toFixed(2)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { maxRotation: 45, minRotation: 45, font: { size: 11 }, color: '#999' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: '#e8e8e8' },
|
||||||
|
ticks: { font: { size: 11 }, color: '#999' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [currentData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyData?.dailyRecords || historyData.dailyRecords.length === 0) return;
|
||||||
|
trendInstance.current?.destroy();
|
||||||
|
|
||||||
|
const display = historyData.dailyRecords.slice(-days);
|
||||||
|
const labels = display.map(r => r.date.slice(5));
|
||||||
|
const usageData = display.map(r => r.usage);
|
||||||
|
const surplusData = display.map(r => r.lastSurplus);
|
||||||
|
|
||||||
|
const maxUsage = Math.max(...usageData, 1);
|
||||||
|
const maxSurplus = Math.max(...surplusData, 1);
|
||||||
|
|
||||||
|
const ctx = trendChartRef.current?.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
trendInstance.current = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '日用电量 (kWh)',
|
||||||
|
data: usageData,
|
||||||
|
borderColor: '#f39c12',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tension: 0.4,
|
||||||
|
yAxisID: 'y',
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#fff',
|
||||||
|
pointBorderColor: '#f39c12',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '剩余电量 (kWh)',
|
||||||
|
data: surplusData,
|
||||||
|
borderColor: '#4a90e2',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.4,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#fff',
|
||||||
|
pointBorderColor: '#4a90e2',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'top',
|
||||||
|
align: 'center',
|
||||||
|
labels: { usePointStyle: true, padding: 20, font: { size: 13 } },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => `${context.dataset.label}: ${context.parsed.y}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { font: { size: 11 }, color: '#999' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
beginAtZero: true,
|
||||||
|
max: Math.ceil(maxUsage * 1.2),
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '用电量 (kWh)',
|
||||||
|
color: '#f39c12',
|
||||||
|
font: { size: 12 },
|
||||||
|
},
|
||||||
|
grid: { color: '#e8e8e8' },
|
||||||
|
ticks: { color: '#f39c12', font: { size: 11 } },
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
beginAtZero: true,
|
||||||
|
max: Math.ceil(maxSurplus * 1.1),
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '剩余电量 (kWh)',
|
||||||
|
color: '#4a90e2',
|
||||||
|
font: { size: 12 },
|
||||||
|
},
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
ticks: { color: '#4a90e2', font: { size: 11 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [historyData, days]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: '#f5f7fa' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = currentData?.current;
|
||||||
|
const stats = historyData?.stats || {};
|
||||||
|
const statKey = days === 7 ? '7' : days === 15 ? '15' : '30';
|
||||||
|
const avgDaily = stats[`avgDaily${statKey}`];
|
||||||
|
const estimatedDays = stats[`estimatedDays${statKey}`];
|
||||||
|
|
||||||
|
const todayUsage = currentData?.todayUsage ?? 0;
|
||||||
|
const todayAvgPower = currentData?.todayAvgPower ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="d-body" style={{ background: '#f5f7fa', minHeight: '100vh', padding: 20 }}>
|
||||||
|
<style>{`${styles}
|
||||||
|
body { margin: 0; }
|
||||||
|
.d-container { border-radius: 12px !important; box-shadow: none !important; }
|
||||||
|
`}</style>
|
||||||
|
<div className="d-container" style={{ maxWidth: 1200, margin: '0 auto', background: 'white', padding: 32, borderRadius: 12 }}>
|
||||||
|
<div className="d-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 24, fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
|
<Lightning theme="filled" size="28" fill="#4a90e2" style={{ display: 'flex' }} />
|
||||||
|
智能电量监控
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleCollect}
|
||||||
|
loading={collecting}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
borderRadius: 20,
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
手动获取
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<LogoutOutlined />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
borderRadius: 20,
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24, marginBottom: 32 }}>
|
||||||
|
<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', display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||||
|
{current?.surplus?.toFixed(1) ?? '--'}
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 400, color: '#999' }}>kWh</span>
|
||||||
|
</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' }}>
|
||||||
|
¥{current?.amount?.toFixed(2) ?? '--'}
|
||||||
|
</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', display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||||
|
{todayUsage.toFixed(1)}
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 400, color: '#999' }}>kWh</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-section-gap" style={{ marginBottom: 32 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
|
<ChartLine theme="filled" size="20" fill="#4a90e2" style={{ display: 'flex' }} />
|
||||||
|
今日用电趋势
|
||||||
|
<span style={{ fontSize: 13, color: '#999', fontWeight: 400 }}>(每时段)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-chart" style={{ position: 'relative', height: 280, background: '#fafbfc', borderRadius: 8, padding: 20 }}>
|
||||||
|
{(!currentData?.todayRecords || currentData.todayRecords.length === 0) ? (
|
||||||
|
<div className="d-chart-empty" style={{ textAlign: 'center', paddingTop: 110, color: '#999' }}>暂无今日数据</div>
|
||||||
|
) : null}
|
||||||
|
<canvas ref={hourlyChartRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-section-gap" style={{ marginBottom: 32 }}>
|
||||||
|
<div className="d-trend-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
|
<ChartHistogram theme="filled" size="20" fill="#4a90e2" style={{ display: 'flex' }} />
|
||||||
|
用电 & 剩余电量趋势
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{[7, 15, 30].map(d => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setDays(d)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
border: 'none',
|
||||||
|
background: days === d ? '#333' : '#f0f2f5',
|
||||||
|
color: days === d ? 'white' : '#666',
|
||||||
|
borderRadius: 20,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d}天
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-chart" style={{ position: 'relative', height: 280, background: '#fafbfc', borderRadius: 8, padding: 20 }}>
|
||||||
|
{(!historyData?.dailyRecords || historyData.dailyRecords.length === 0) ? (
|
||||||
|
<div className="d-chart-empty" style={{ textAlign: 'center', paddingTop: 110, color: '#999' }}>暂无历史数据</div>
|
||||||
|
) : null}
|
||||||
|
<canvas ref={trendChartRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-bottom" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8f9fa', borderRadius: 8 }}>
|
||||||
|
<div style={{ width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: '#e8f4fd' }}>
|
||||||
|
<ChartHistogram theme="filled" size="22" fill="#4a90e2" style={{ display: 'flex' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 2 }}>日均耗电</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
|
{avgDaily != null ? `${avgDaily.toFixed(1)} kWh` : '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8f9fa', borderRadius: 8 }}>
|
||||||
|
<div style={{ width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: '#f0e8fd' }}>
|
||||||
|
<Lightning theme="filled" size="22" fill="#9b59b6" style={{ display: 'flex' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 2 }}>平均功率</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
|
{todayAvgPower != null ? `${(todayAvgPower * 1000).toFixed(0)} W` : '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8f9fa', borderRadius: 8 }}>
|
||||||
|
<div style={{ width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: '#e8fdf4' }}>
|
||||||
|
<Timer theme="filled" size="22" fill="#27ae60" style={{ display: 'flex' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 2 }}>预计可用</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
|
{estimatedDays != null ? `${estimatedDays.toFixed(0)} 天` : '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
client/src/pages/Login.jsx
Normal file
106
client/src/pages/Login.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, Form, Input, Button, message } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { Lightning } from '@icon-park/react';
|
||||||
|
import { login } from '../api';
|
||||||
|
|
||||||
|
export default function Login({ onLogin }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await login(values.username, values.password);
|
||||||
|
if (data.success) {
|
||||||
|
onLogin();
|
||||||
|
} else {
|
||||||
|
message.error(data.message || '用户名或密码错误');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('网络错误,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: '#f5f7fa',
|
||||||
|
padding: 0,
|
||||||
|
}}>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '48px 40px', textAlign: 'center' } }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 32 }}>
|
||||||
|
<Lightning theme="filled" size="40" fill="#4a90e2" style={{ display: 'flex', justifyContent: 'center', marginBottom: 8 }} />
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1a1a1a',
|
||||||
|
}}>
|
||||||
|
智能电量监控
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#999', fontSize: 14, marginTop: 8, marginBottom: 0 }}>
|
||||||
|
请登录后查看用电数据
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined style={{ color: '#999' }} />}
|
||||||
|
placeholder="用户名"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#999' }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginTop: 32, marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
style={{
|
||||||
|
height: 44,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
client/vite.config.js
Normal file
19
client/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
electricity-monitor:
|
||||||
|
build: .
|
||||||
|
container_name: electricity-monitor
|
||||||
|
user: root
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
1305
package-lock.json
generated
Normal file
1305
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "electricity-bill-monitor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "电费监控网站",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
415
server.js
Normal file
415
server.js
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`企业微信通知发送失败: ${res.status}`);
|
||||||
|
}
|
||||||
|
} 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, 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();
|
||||||
|
|
||||||
|
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 usage = Math.round(Math.max(0, firstSurplus - 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,
|
||||||
|
lastSurplus,
|
||||||
|
recordCount: dayRecords.length,
|
||||||
|
hoursSpan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = getLocalDateStr(new Date());
|
||||||
|
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');
|
||||||
|
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 { dailyUsage } = calculateDailyUsage(allRecords);
|
||||||
|
const todayRecords = getTodayRecords();
|
||||||
|
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayRecords = getRecordsByDate(getLocalDateStr(yesterday));
|
||||||
|
const yesterdayLast = yesterdayRecords.length > 0 ? yesterdayRecords[yesterdayRecords.length - 1] : null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
current: latest,
|
||||||
|
todayUsage: dailyUsage,
|
||||||
|
todayAvgPower: dailyUsage > 0 ? Math.round((dailyUsage / Math.max(1, new Date().getHours())) * 1000) / 1000 : 0,
|
||||||
|
todayRecords,
|
||||||
|
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 totalUsageLast7Days = dailyRecords.slice(-7).reduce((sum, d) => sum + d.usage, 0);
|
||||||
|
const totalUsageLast15Days = dailyRecords.slice(-15).reduce((sum, d) => sum + d.usage, 0);
|
||||||
|
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 totalHours15 = dailyRecords.slice(-15).reduce((sum, d) => sum + d.hoursSpan, 0);
|
||||||
|
const totalHours30 = dailyRecords.slice(-30).reduce((sum, d) => sum + d.hoursSpan, 0);
|
||||||
|
|
||||||
|
const days7 = Math.min(7, dailyRecords.length);
|
||||||
|
const days15 = Math.min(15, dailyRecords.length);
|
||||||
|
const days30 = Math.min(30, dailyRecords.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 < 2) {
|
||||||
|
await sendWecomNotification(
|
||||||
|
'## 电费日报\n\n' +
|
||||||
|
`> 日期:${dateStr}\n\n` +
|
||||||
|
`数据不足,无法生成昨日用电报告(仅 ${records.length} 条记录)`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = records[0];
|
||||||
|
const last = records[records.length - 1];
|
||||||
|
const usage = Math.round(Math.max(0, first.surplus - last.surplus) * 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 += `**平均功率**:${avgPower.toFixed(3)} kW\n`;
|
||||||
|
content += `**数据记录**:${records.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) {
|
||||||
|
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('*', (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(`========================================`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user