Compare commits
1 Commits
main
...
liangchenx
| Author | SHA1 | Date |
|---|---|---|
|
|
d4b953b636 | 3 months ago |
@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="9c052fa2-877c-4191-94f3-f62ad42d1e2e" name="更改" comment="第五周会议纪要" />
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 0
|
||||
}</component>
|
||||
<component name="ProjectId" id="34H6gyG9l2ZY5ipgc3TKqAY36ls" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "develop",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "D:/软件工程导论/团队项目/WaterManager",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</component>
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-jdk-9823dce3aa75-bf35d07a577b-intellij.indexing.shared.core-IU-252.23892.409" />
|
||||
<option value="bundled-js-predefined-d6986cc7102b-e03c56caf84a-JavaScript-IU-252.23892.409" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="默认任务">
|
||||
<changelist id="9c052fa2-877c-4191-94f3-f62ad42d1e2e" name="更改" comment="" />
|
||||
<created>1760858265187</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1760858265187</updated>
|
||||
<workItem from="1760858266545" duration="750000" />
|
||||
<workItem from="1761132012625" duration="34000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="Vcs.Log.Tabs.Properties">
|
||||
<option name="TAB_STATES">
|
||||
<map>
|
||||
<entry key="MAIN">
|
||||
<value>
|
||||
<State />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="第五周会议纪要" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="第五周会议纪要" />
|
||||
</component>
|
||||
</project>
|
||||
@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Vite
|
||||
.vite
|
||||
@ -0,0 +1,96 @@
|
||||
# 数智水管家 Web端
|
||||
|
||||
一个现代化的水资源管理系统Web端原型界面。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 📊 **仪表盘** - 系统概览和关键指标展示
|
||||
- 🔍 **实时监控** - 实时查看设备状态和用水数据
|
||||
- 📈 **用水统计** - 详细的用水数据分析报告
|
||||
- ⚙️ **设备管理** - 管理所有智能设备的状态和信息
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 18
|
||||
- React Router
|
||||
- Recharts (数据可视化)
|
||||
- Vite (构建工具)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000 查看应用
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
WaterManager_Web/
|
||||
├── src/
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Dashboard.jsx # 仪表盘
|
||||
│ │ ├── Monitoring.jsx # 实时监控
|
||||
│ │ ├── Statistics.jsx # 用水统计
|
||||
│ │ └── Devices.jsx # 设备管理
|
||||
│ ├── App.jsx # 主应用组件
|
||||
│ ├── App.css # 应用样式
|
||||
│ ├── index.css # 全局样式
|
||||
│ └── main.jsx # 入口文件
|
||||
├── index.html
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
## 页面说明
|
||||
|
||||
### 仪表盘
|
||||
- 显示今日用水量、在线设备、本月总用水、告警数量等关键指标
|
||||
- 24小时用水趋势图表
|
||||
- 本周用水统计柱状图
|
||||
- 最近告警列表
|
||||
|
||||
### 实时监控
|
||||
- 实时流量、系统压力、活跃设备等数据
|
||||
- 实时数据趋势折线图
|
||||
- 设备监控列表,显示各设备的实时状态
|
||||
|
||||
### 用水统计
|
||||
- 支持日/月/年统计切换
|
||||
- 月度用水趋势分析
|
||||
- 用水分类占比饼图
|
||||
- 24小时用水分布
|
||||
- 详细统计数据表格
|
||||
|
||||
### 设备管理
|
||||
- 设备总数、在线/离线/告警设备统计
|
||||
- 设备列表展示
|
||||
- 支持按状态筛选和关键词搜索
|
||||
- 显示设备电池电量和信号强度
|
||||
|
||||
## 特性
|
||||
|
||||
- 🎨 现代化UI设计
|
||||
- 📱 响应式布局,支持移动端
|
||||
- 🚀 快速加载和流畅交互
|
||||
- 📊 丰富的数据可视化
|
||||
- 🔔 实时状态监控
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "water-manager-web",
|
||||
"version": "1.0.0",
|
||||
"description": "数智水管家Web端原型界面",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,482 @@
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: linear-gradient(180deg, #1e3a8a 0%, #3b82f6 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border-left-color: white;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
border-left-color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* 页面容器 */
|
||||
.page-container {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 侧边栏退出按钮 */
|
||||
.logout-button {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Monitoring from './pages/Monitoring'
|
||||
import Statistics from './pages/Statistics'
|
||||
import Devices from './pages/Devices'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
import Login from './pages/Login'
|
||||
import DeviceDetails from './pages/DeviceDetails'
|
||||
import Home from './pages/Home'
|
||||
import Orders from './pages/Orders'
|
||||
import WorkOrders from './pages/WorkOrders'
|
||||
import Dispensers from './pages/Dispensers'
|
||||
import FilterDetails from './pages/FilterDetails'
|
||||
import './App.css'
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/home', icon: '🏠', label: '首页' },
|
||||
{ path: '/', icon: '📊', label: '仪表盘' },
|
||||
{ path: '/monitoring', icon: '🔍', label: '实时监控' },
|
||||
{ path: '/statistics', icon: '📈', label: '用水统计' },
|
||||
{ path: '/devices', icon: '⚙️', label: '设备管理' },
|
||||
{ path: '/dispensers', icon: '🚰', label: '滤芯总览' },
|
||||
{ path: '/orders', icon: '🧾', label: '历史订单' },
|
||||
{ path: '/work-orders', icon: '🧰', label: '工单记录' },
|
||||
{ path: '/users', icon: '👥', label: '用户管理' },
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('确定要退出登录吗?')) {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h1>💧 数智水管家</h1>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-item ${location.pathname === item.path || location.pathname.startsWith(item.path + '/') ? 'active' : ''}`}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">👤</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name || '管理员'}</div>
|
||||
<div className="user-role">系统管理员</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="logout-button" onClick={handleLogout}>
|
||||
<span>🚪</span>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/home" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||
<Route path="/monitoring" element={<ProtectedRoute><Monitoring /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||
<Route path="/devices" element={<ProtectedRoute><Devices /></ProtectedRoute>} />
|
||||
<Route path="/devices/:id" element={<ProtectedRoute><DeviceDetails /></ProtectedRoute>} />
|
||||
<Route path="/dispensers" element={<ProtectedRoute><Dispensers /></ProtectedRoute>} />
|
||||
<Route path="/filters/:id" element={<ProtectedRoute><FilterDetails /></ProtectedRoute>} />
|
||||
<Route path="/orders" element={<ProtectedRoute><Orders /></ProtectedRoute>} />
|
||||
<Route path="/work-orders" element={<ProtectedRoute><WorkOrders /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@ -0,0 +1,20 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@ -0,0 +1,230 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-wave {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 45%;
|
||||
animation: wave 15s infinite linear;
|
||||
}
|
||||
|
||||
.login-wave:nth-child(2) {
|
||||
animation-delay: -5s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.login-wave:nth-child(3) {
|
||||
animation-delay: -10s;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.login-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.login-footer .hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 32px 24px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
import React, { useState } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'
|
||||
|
||||
const monthlyData = [
|
||||
{ month: '1月', usage: 8520 },
|
||||
{ month: '2月', usage: 7890 },
|
||||
{ month: '3月', usage: 9210 },
|
||||
{ month: '4月', usage: 8850 },
|
||||
{ month: '5月', usage: 9560 },
|
||||
{ month: '6月', usage: 9120 },
|
||||
]
|
||||
|
||||
const categoryData = [
|
||||
{ name: '生活用水', value: 45680, color: '#3b82f6' },
|
||||
{ name: '工业用水', value: 28950, color: '#10b981' },
|
||||
{ name: '绿化用水', value: 15230, color: '#f59e0b' },
|
||||
{ name: '其他', value: 5820, color: '#8b5cf6' },
|
||||
]
|
||||
|
||||
const hourlyData = [
|
||||
{ hour: 0, usage: 120 },
|
||||
{ hour: 2, usage: 85 },
|
||||
{ hour: 4, usage: 65 },
|
||||
{ hour: 6, usage: 95 },
|
||||
{ hour: 8, usage: 280 },
|
||||
{ hour: 10, usage: 320 },
|
||||
{ hour: 12, usage: 385 },
|
||||
{ hour: 14, usage: 350 },
|
||||
{ hour: 16, usage: 310 },
|
||||
{ hour: 18, usage: 420 },
|
||||
{ hour: 20, usage: 380 },
|
||||
{ hour: 22, usage: 250 },
|
||||
]
|
||||
|
||||
export default function Statistics() {
|
||||
const [timeRange, setTimeRange] = useState('month')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">用水统计</h1>
|
||||
<p className="page-subtitle">查看详细的用水数据分析报告</p>
|
||||
</div>
|
||||
|
||||
{/* 时间范围选择 */}
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
className={`btn ${timeRange === 'day' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTimeRange('day')}
|
||||
>
|
||||
日统计
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${timeRange === 'month' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTimeRange('month')}
|
||||
>
|
||||
月统计
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${timeRange === 'year' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTimeRange('year')}
|
||||
>
|
||||
年统计
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计摘要 */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<div>
|
||||
<div className="stat-label">总用水量</div>
|
||||
<div className="stat-value">95,680 L</div>
|
||||
<div className="stat-change">
|
||||
<span>本月累计</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon">💧</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<div>
|
||||
<div className="stat-label">日均用水</div>
|
||||
<div className="stat-value">3,189 L</div>
|
||||
<div className="stat-change positive">
|
||||
<span>↓</span>
|
||||
<span>较上月减少 8.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon">📊</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<div>
|
||||
<div className="stat-label">峰值时段</div>
|
||||
<div className="stat-value">18:00</div>
|
||||
<div className="stat-change">
|
||||
<span>420 L/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon">⏰</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<div>
|
||||
<div className="stat-label">节省水量</div>
|
||||
<div className="stat-value">8,520 L</div>
|
||||
<div className="stat-change positive">
|
||||
<span>↑</span>
|
||||
<span>较同期节省 8.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon">🌿</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 月度趋势 */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">月度用水趋势</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={monthlyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="usage" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图表网格 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '32px' }}>
|
||||
{/* 用水分类 */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">用水分类占比</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 24小时分布 */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">24小时用水分布</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={hourlyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="usage" stroke="#10b981" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细数据表格 */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">详细统计数据</h2>
|
||||
<button className="btn btn-secondary">导出报表</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>用水量 (L)</th>
|
||||
<th>日均 (L)</th>
|
||||
<th>峰值 (L/h)</th>
|
||||
<th>同比增长</th>
|
||||
<th>环比增长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{monthlyData.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>{item.month}</td>
|
||||
<td>{item.usage.toLocaleString()}</td>
|
||||
<td>{(item.usage / 30).toFixed(0)}</td>
|
||||
<td>{Math.floor(item.usage / 30 * 1.2)}</td>
|
||||
<td className="stat-change positive">+{(Math.random() * 10).toFixed(1)}%</td>
|
||||
<td className={index > 0 ? (item.usage > monthlyData[index - 1].usage ? 'stat-change positive' : 'stat-change negative') : ''}>
|
||||
{index > 0 && (
|
||||
<>
|
||||
{item.usage > monthlyData[index - 1].usage ? '+' : ''}
|
||||
{(((item.usage - monthlyData[index - 1].usage) / monthlyData[index - 1].usage) * 100).toFixed(1)}%
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
const STORAGE_KEY = 'work_orders_extra'
|
||||
|
||||
export function getStoredWorkOrders() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredWorkOrders(list) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list || []))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function addBatchWorkOrders(items) {
|
||||
const existing = getStoredWorkOrders()
|
||||
const now = new Date()
|
||||
const ts = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`
|
||||
let counter = existing.length + 1
|
||||
const batch = items.map(it => ({
|
||||
id: `W${ts}-${String(counter++).padStart(4,'0')}`,
|
||||
assignTo: '待分配',
|
||||
phone: '-',
|
||||
deviceId: it.id,
|
||||
location: it.location,
|
||||
task: `更换 ${it.filterModel}`,
|
||||
status: '待接单',
|
||||
createdAt: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`,
|
||||
acceptedAt: '-',
|
||||
finishedAt: '-',
|
||||
}))
|
||||
const merged = [...batch, ...existing]
|
||||
saveStoredWorkOrders(merged)
|
||||
return batch
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true
|
||||
}
|
||||
})
|
||||
Loading…
Reference in new issue