feat: 初始化智能电量监控系统项目
该提交完成了完整的电费监控系统项目初始化,包含: 1. 后端Node.js+Express服务,支持定时采集、登录认证、数据API 2. React前端界面,包含登录页和数据仪表盘 3. Docker容器化配置和docker-compose部署文件 4. 环境变量示例和gitignore、dockerignore配置 5. 完整的项目文档README
This commit is contained in:
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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user