Initial commit
This commit is contained in:
commit
fd6d468797
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 开发环境配置
|
||||||
|
# 服务器域名
|
||||||
|
VITE_DOMAIN=localhost
|
||||||
|
|
||||||
|
# 服务器端口
|
||||||
|
VITE_PORT=8888
|
||||||
|
|
||||||
|
# 应用部署路径前缀(必须以/开头和结尾)
|
||||||
|
VITE_BASE_PATH=/demo/lot-demo/
|
||||||
|
|
||||||
|
# API请求路径前缀
|
||||||
|
VITE_API_PREFIX=/demo/lot-demo/api
|
||||||
|
|
||||||
|
# 是否使用HTTPS (true/false)
|
||||||
|
VITE_USE_HTTPS=false
|
||||||
|
|
||||||
|
# 请求超时时间(毫秒)
|
||||||
|
VITE_TIMEOUT=30000
|
||||||
|
|
||||||
|
# 应用程序标题(未设置时使用默认值)
|
||||||
|
VITE_APP_TITLE=loT Smart Control - 智能控制系统
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 本地开发环境配置
|
||||||
|
# 服务器域名
|
||||||
|
VITE_DOMAIN=localhost
|
||||||
|
|
||||||
|
# 服务器端口
|
||||||
|
VITE_PORT=8888
|
||||||
|
|
||||||
|
# 应用部署路径前缀(开发环境通常为/)
|
||||||
|
VITE_BASE_PATH=/demo/lot-demo/
|
||||||
|
|
||||||
|
# API请求路径前缀
|
||||||
|
VITE_API_PREFIX=/demo/lot-demo/api
|
||||||
|
|
||||||
|
# 是否使用HTTPS (true/false)
|
||||||
|
VITE_USE_HTTPS=false
|
||||||
|
|
||||||
|
# 请求超时时间(毫秒)
|
||||||
|
VITE_TIMEOUT=30000
|
||||||
|
|
||||||
|
# 应用程序标题(未设置时使用默认值)
|
||||||
|
VITE_APP_TITLE=智能楼宇Demo-1.0
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 生产环境配置
|
||||||
|
# 服务器域名
|
||||||
|
# VITE_DOMAIN=ashai.com.cn
|
||||||
|
VITE_DOMAIN=localhost
|
||||||
|
|
||||||
|
# 服务器端口
|
||||||
|
VITE_PORT=8888
|
||||||
|
|
||||||
|
# 应用部署路径前缀(必须以/开头和结尾)
|
||||||
|
VITE_BASE_PATH=/demo/lot-demo/
|
||||||
|
|
||||||
|
# API请求路径前缀
|
||||||
|
VITE_API_PREFIX=/demo/lot-demo/api
|
||||||
|
|
||||||
|
# 是否使用HTTPS (true/false)
|
||||||
|
VITE_USE_HTTPS=false
|
||||||
|
|
||||||
|
# 请求超时时间(毫秒)
|
||||||
|
VITE_TIMEOUT=30000
|
||||||
|
|
||||||
|
# 应用程序标题(未设置时使用默认值)
|
||||||
|
VITE_APP_TITLE=智能楼宇Demo-1.0
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Production
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
dist.zip
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local env files (包含敏感信息,不提交)
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Trae
|
||||||
|
.trae
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
# IoT Smart Control - 智能家居控制系统
|
||||||
|
|
||||||
|
一个现代化的智能家居控制前端演示项目,支持空调控制、照明控制、能源监控和设备状态管理。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 🏠 仪表盘
|
||||||
|
- 实时显示设备在线状态统计
|
||||||
|
- 能耗趋势图表展示
|
||||||
|
- 快捷控制面板
|
||||||
|
- 设备状态概览
|
||||||
|
|
||||||
|
### ❄️ 空调控制
|
||||||
|
- 多设备管理
|
||||||
|
- 温度调节(16°C - 30°C)
|
||||||
|
- 运行模式切换(制冷/制热/自动/送风/除湿)
|
||||||
|
- 风速控制(低/中/高/自动)
|
||||||
|
- 摆风开关
|
||||||
|
- 定时功能
|
||||||
|
- 能耗信息展示
|
||||||
|
|
||||||
|
### 💡 照明控制
|
||||||
|
- 多房间灯光管理
|
||||||
|
- 亮度调节
|
||||||
|
- 色温调节
|
||||||
|
- 颜色选择
|
||||||
|
- 场景模式(日常/阅读/观影/睡眠/派对)
|
||||||
|
- 一键开关所有灯光
|
||||||
|
|
||||||
|
### ⚡ 能源监控
|
||||||
|
- 实时能耗数据
|
||||||
|
- 时/日/月能耗趋势图表
|
||||||
|
- 设备能耗分布饼图
|
||||||
|
- 节能提醒通知
|
||||||
|
- 费用概览
|
||||||
|
|
||||||
|
### 📱 设备状态
|
||||||
|
- 设备在线/离线/警告状态
|
||||||
|
- 房间筛选
|
||||||
|
- 设备类型筛选
|
||||||
|
- 信号强度显示
|
||||||
|
- 电池电量监控
|
||||||
|
- 设备详情弹窗
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **React 18** - 前端框架
|
||||||
|
- **TypeScript** - 类型安全
|
||||||
|
- **Vite** - 构建工具
|
||||||
|
- **Redux Toolkit** - 状态管理
|
||||||
|
- **Ant Design** - UI组件库
|
||||||
|
- **ECharts** - 数据可视化
|
||||||
|
- **Tailwind CSS** - 样式框架
|
||||||
|
- **Framer Motion** - 动画库
|
||||||
|
- **React Router** - 路由管理
|
||||||
|
- **Axios** - HTTP请求库
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API接口定义
|
||||||
|
│ └── index.ts
|
||||||
|
├── components/ # 可复用组件
|
||||||
|
│ ├── Charts/ # 图表组件
|
||||||
|
│ ├── Control/ # 控制组件
|
||||||
|
│ ├── Device/ # 设备组件
|
||||||
|
│ └── Layout/ # 布局组件
|
||||||
|
├── config/ # 配置文件
|
||||||
|
│ └── index.ts
|
||||||
|
├── hooks/ # 自定义Hooks
|
||||||
|
├── pages/ # 页面组件
|
||||||
|
├── store/ # Redux状态管理
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ └── request.ts # Axios封装
|
||||||
|
├── App.tsx
|
||||||
|
├── main.tsx
|
||||||
|
└── index.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:3000 查看应用
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预览生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 服务器配置说明
|
||||||
|
|
||||||
|
### 配置文件位置
|
||||||
|
|
||||||
|
项目使用环境变量文件进行配置,配置文件位于项目根目录:
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `.env` | 默认配置 |
|
||||||
|
| `.env.development` | 开发环境配置 |
|
||||||
|
| `.env.production` | 生产环境配置 |
|
||||||
|
|
||||||
|
### 配置项说明
|
||||||
|
|
||||||
|
打开 `.env.production` 文件,可以看到以下配置项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务器域名
|
||||||
|
VITE_DOMAIN=ashai.com.cn
|
||||||
|
|
||||||
|
# 服务器端口
|
||||||
|
VITE_PORT=8888
|
||||||
|
|
||||||
|
# 应用部署路径前缀(必须以/开头和结尾)
|
||||||
|
VITE_BASE_PATH=/demo/
|
||||||
|
|
||||||
|
# API请求路径前缀
|
||||||
|
VITE_API_PREFIX=/demo/api
|
||||||
|
|
||||||
|
# 是否使用HTTPS (true/false)
|
||||||
|
VITE_USE_HTTPS=false
|
||||||
|
|
||||||
|
# 请求超时时间(毫秒)
|
||||||
|
VITE_TIMEOUT=30000
|
||||||
|
|
||||||
|
# 应用程序标题(未设置时使用默认值"loT Smart Control - 智能控制系统")
|
||||||
|
VITE_APP_TITLE=loT Smart Control - 智能控制系统
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如何修改域名和端口
|
||||||
|
|
||||||
|
#### 方法一:修改环境变量文件(推荐)
|
||||||
|
|
||||||
|
1. 打开 `.env.production` 文件
|
||||||
|
2. 修改 `VITE_DOMAIN` 为新的域名
|
||||||
|
3. 修改 `VITE_PORT` 为新的端口
|
||||||
|
4. 修改 `VITE_BASE_PATH` 为新的部署路径
|
||||||
|
5. 重新执行 `npm run build` 打包
|
||||||
|
|
||||||
|
**示例:修改为新的服务器地址**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 修改前
|
||||||
|
VITE_DOMAIN=ashai.com.cn
|
||||||
|
VITE_PORT=8888
|
||||||
|
VITE_BASE_PATH=/demo/
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
VITE_DOMAIN=myserver.example.com
|
||||||
|
VITE_PORT=9000
|
||||||
|
VITE_BASE_PATH=/iot-app/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法二:修改代码配置文件
|
||||||
|
|
||||||
|
如果需要更灵活的配置,可以修改 `src/config/index.ts` 文件中的默认值:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getConfig = (): AppConfig => {
|
||||||
|
if (typeof __APP_CONFIG__ !== 'undefined') {
|
||||||
|
return __APP_CONFIG__
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改这里的默认值
|
||||||
|
return {
|
||||||
|
domain: 'ashai.com.cn', // 修改域名
|
||||||
|
port: '8888', // 修改端口
|
||||||
|
basePath: '/demo/', // 修改部署路径
|
||||||
|
apiPrefix: '/demo/api', // 修改API前缀
|
||||||
|
useHttps: false, // 是否使用HTTPS
|
||||||
|
timeout: 30000, // 超时时间
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置项详细说明
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 示例值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `VITE_DOMAIN` | 服务器域名,不含协议和端口 | `ashai.com.cn` |
|
||||||
|
| `VITE_PORT` | 服务器端口号 | `8888` |
|
||||||
|
| `VITE_BASE_PATH` | 应用部署的基础路径,必须以 `/` 开头和结尾 | `/demo/` |
|
||||||
|
| `VITE_API_PREFIX` | API请求的路径前缀 | `/demo/api` |
|
||||||
|
| `VITE_USE_HTTPS` | 是否使用HTTPS协议 | `true` 或 `false` |
|
||||||
|
| `VITE_TIMEOUT` | HTTP请求超时时间(毫秒) | `30000` |
|
||||||
|
| `VITE_APP_TITLE` | 应用程序标题,显示在浏览器标签页,未设置时使用默认值 | `loT Smart Control - 智能控制系统` |
|
||||||
|
|
||||||
|
### 应用程序标题配置
|
||||||
|
|
||||||
|
`VITE_APP_TITLE` 用于自定义浏览器标签页显示的应用程序标题。
|
||||||
|
|
||||||
|
#### 配置方法
|
||||||
|
|
||||||
|
在 `.env.development` 或 `.env.production` 文件中设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置自定义标题
|
||||||
|
VITE_APP_TITLE=IoT Smart Control - 智能控制系统
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 默认值机制
|
||||||
|
|
||||||
|
如果未配置 `VITE_APP_TITLE` 或将其留空,应用程序将使用默认标题 **"loT Smart Control - 智能控制系统"**。
|
||||||
|
|
||||||
|
#### 适用场景
|
||||||
|
|
||||||
|
| 场景 | 建议配置 |
|
||||||
|
|------|----------|
|
||||||
|
| 开发环境 | 可设置为项目名称,便于开发者识别 |
|
||||||
|
| 测试环境 | 可设置为"测试环境 - xxx"以区分 |
|
||||||
|
| 生产环境 | 设置为正式的产品名称 |
|
||||||
|
| 多租户部署 | 可根据不同客户动态设置不同标题 |
|
||||||
|
|
||||||
|
#### 注意事项
|
||||||
|
|
||||||
|
1. **环境区分**:开发环境使用 `.env.development`,生产环境使用 `.env.production`
|
||||||
|
2. **重新启动**:修改环境变量后需要重启开发服务器或重新构建
|
||||||
|
3. **编码问题**:标题支持中文字符,无需额外编码处理
|
||||||
|
4. **HTML 预设**:`index.html` 中设置了默认标题作为初始值,JavaScript 加载后会覆盖
|
||||||
|
|
||||||
|
### 访问路径示例
|
||||||
|
|
||||||
|
根据默认配置,构建后的访问路径为:
|
||||||
|
|
||||||
|
| 类型 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| 应用首页 | `http://ashai.com.cn:8888/demo/` |
|
||||||
|
| API基础路径 | `http://ashai.com.cn:8888/demo/api` |
|
||||||
|
|
||||||
|
### 使用配置的API
|
||||||
|
|
||||||
|
在代码中使用配置好的API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getApiBaseUrl, getServerBaseUrl, getBasePath } from '@/config'
|
||||||
|
|
||||||
|
// 获取API基础URL
|
||||||
|
console.log(getApiBaseUrl()) // http://ashai.com.cn:8888/demo/api
|
||||||
|
|
||||||
|
// 获取服务器基础URL
|
||||||
|
console.log(getServerBaseUrl()) // http://ashai.com.cn:8888
|
||||||
|
|
||||||
|
// 获取应用部署路径
|
||||||
|
console.log(getBasePath()) // /demo/
|
||||||
|
```
|
||||||
|
|
||||||
|
### API请求示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { deviceApi, airConditioningApi, lightingApi, energyApi } from '@/api'
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
const devices = await deviceApi.getList()
|
||||||
|
|
||||||
|
// 设置空调温度
|
||||||
|
await airConditioningApi.setTemperature('ac-1', 24)
|
||||||
|
|
||||||
|
// 设置灯光亮度
|
||||||
|
await lightingApi.setBrightness('light-1', 80)
|
||||||
|
|
||||||
|
// 获取能耗数据
|
||||||
|
const energyData = await energyApi.getDailyData()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署注意事项
|
||||||
|
|
||||||
|
1. **路径匹配**:确保 `VITE_BASE_PATH` 与服务器上的部署路径一致
|
||||||
|
2. **跨域配置**:如果前后端分离部署,需要配置CORS
|
||||||
|
3. **HTTPS**:生产环境建议启用HTTPS,设置 `VITE_USE_HTTPS=true`
|
||||||
|
4. **重新打包**:每次修改配置后都需要重新执行 `npm run build`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 响应式设计
|
||||||
|
|
||||||
|
项目支持多种屏幕尺寸:
|
||||||
|
|
||||||
|
- **桌面端** (>1200px): 完整侧边栏,多列布局
|
||||||
|
- **平板端** (768px - 1200px): 可折叠侧边栏,双列布局
|
||||||
|
- **移动端** (<768px): 抽屉式菜单,单列布局
|
||||||
|
|
||||||
|
## Mock数据
|
||||||
|
|
||||||
|
所有数据均为模拟数据,包括:
|
||||||
|
|
||||||
|
- 空调设备列表和状态
|
||||||
|
- 照明设备列表和状态
|
||||||
|
- 能耗历史数据
|
||||||
|
- 设备状态信息
|
||||||
|
|
||||||
|
## 演示说明
|
||||||
|
|
||||||
|
1. 点击侧边栏导航切换不同功能模块
|
||||||
|
2. 在空调控制页面可以调节温度、切换模式等
|
||||||
|
3. 在照明控制页面可以调节亮度、色温、颜色等
|
||||||
|
4. 在能源监控页面可以查看能耗趋势和分布
|
||||||
|
5. 在设备状态页面可以查看所有设备状态
|
||||||
|
|
||||||
|
## 浏览器支持
|
||||||
|
|
||||||
|
- Chrome (推荐)
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
- Edge
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!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>loT Smart Control - 智能控制系统</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "iot-smart-control",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.2.1",
|
||||||
|
"antd": "^5.15.3",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"echarts": "^5.5.0",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"framer-motion": "^11.0.8",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^5.0.1",
|
||||||
|
"react-redux": "^9.1.0",
|
||||||
|
"react-router-dom": "^6.22.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.64",
|
||||||
|
"@types/react-dom": "^18.2.21",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||||
|
"@typescript-eslint/parser": "^7.1.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#40a9ff;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||||
|
<circle cx="50" cy="35" r="15" fill="white" opacity="0.9"/>
|
||||||
|
<rect x="35" y="55" width="30" height="25" rx="5" fill="white" opacity="0.9"/>
|
||||||
|
<line x1="25" y1="85" x2="75" y2="85" stroke="white" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 633 B |
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import MainLayout from './components/Layout/MainLayout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import AirConditioning from './pages/AirConditioning'
|
||||||
|
import Lighting from './pages/Lighting'
|
||||||
|
import EnergyMonitor from './pages/EnergyMonitor'
|
||||||
|
import DeviceStatus from './pages/DeviceStatus'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PageWrapper><Dashboard /></PageWrapper>} />
|
||||||
|
<Route path="/air-conditioning" element={<PageWrapper><AirConditioning /></PageWrapper>} />
|
||||||
|
<Route path="/lighting" element={<PageWrapper><Lighting /></PageWrapper>} />
|
||||||
|
<Route path="/energy" element={<PageWrapper><EnergyMonitor /></PageWrapper>} />
|
||||||
|
<Route path="/devices" element={<PageWrapper><DeviceStatus /></PageWrapper>} />
|
||||||
|
</Routes>
|
||||||
|
</AnimatePresence>
|
||||||
|
</MainLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { request } from '../utils/request'
|
||||||
|
import { getServerBaseUrl } from '../config'
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
room: string
|
||||||
|
status: 'online' | 'offline' | 'warning'
|
||||||
|
isOn: boolean
|
||||||
|
temperature?: number
|
||||||
|
brightness?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnergyData {
|
||||||
|
timestamp: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceApi = {
|
||||||
|
getList: () => request.get<Device[]>('/devices'),
|
||||||
|
|
||||||
|
getById: (id: string) => request.get<Device>(`/devices/${id}`),
|
||||||
|
|
||||||
|
toggle: (id: string, isOn: boolean) =>
|
||||||
|
request.post<Device>(`/devices/${id}/toggle`, { isOn }),
|
||||||
|
|
||||||
|
updateStatus: (id: string, status: string) =>
|
||||||
|
request.post<Device>(`/devices/${id}/status`, { status }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const airConditioningApi = {
|
||||||
|
getList: () => request.get<Device[]>('/air-conditioning'),
|
||||||
|
|
||||||
|
setTemperature: (id: string, temperature: number) =>
|
||||||
|
request.post(`/air-conditioning/${id}/temperature`, { temperature }),
|
||||||
|
|
||||||
|
setMode: (id: string, mode: string) =>
|
||||||
|
request.post(`/air-conditioning/${id}/mode`, { mode }),
|
||||||
|
|
||||||
|
setFanSpeed: (id: string, speed: string) =>
|
||||||
|
request.post(`/air-conditioning/${id}/fan-speed`, { speed }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lightingApi = {
|
||||||
|
getList: () => request.get<Device[]>('/lighting'),
|
||||||
|
|
||||||
|
setBrightness: (id: string, brightness: number) =>
|
||||||
|
request.post(`/lighting/${id}/brightness`, { brightness }),
|
||||||
|
|
||||||
|
setColorTemp: (id: string, colorTemp: number) =>
|
||||||
|
request.post(`/lighting/${id}/color-temp`, { colorTemp }),
|
||||||
|
|
||||||
|
setColor: (id: string, color: string) =>
|
||||||
|
request.post(`/lighting/${id}/color`, { color }),
|
||||||
|
|
||||||
|
setScene: (id: string, scene: string) =>
|
||||||
|
request.post(`/lighting/${id}/scene`, { scene }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const energyApi = {
|
||||||
|
getHourlyData: () => request.get<EnergyData[]>('/energy/hourly'),
|
||||||
|
|
||||||
|
getDailyData: () => request.get<EnergyData[]>('/energy/daily'),
|
||||||
|
|
||||||
|
getMonthlyData: () => request.get<EnergyData[]>('/energy/monthly'),
|
||||||
|
|
||||||
|
getSummary: () => request.get('/energy/summary'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
request.post('/auth/login', { username, password }),
|
||||||
|
|
||||||
|
logout: () => request.post('/auth/logout'),
|
||||||
|
|
||||||
|
refreshToken: () => request.post('/auth/refresh'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const healthCheck = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const serverUrl = getServerBaseUrl()
|
||||||
|
const response = await fetch(`${serverUrl}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Health check failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
|
||||||
|
interface EnergyChartProps {
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnergyChart: React.FC<EnergyChartProps> = ({ height = 250 }) => {
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => `${i}:00`)
|
||||||
|
const data = hours.map(() => Math.random() * 2 + 0.5)
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
grid: {
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 30,
|
||||||
|
left: 50,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: hours,
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 10,
|
||||||
|
interval: 3,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: 'kWh',
|
||||||
|
nameTextStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: data,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: {
|
||||||
|
color: '#1890ff',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(24, 144, 255, 0)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(26, 31, 46, 0.9)',
|
||||||
|
borderColor: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const point = params[0]
|
||||||
|
return `<div class="text-sm">
|
||||||
|
<div class="text-gray-400">${point.axisValue}</div>
|
||||||
|
<div class="text-white font-medium">${point.value.toFixed(2)} kWh</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height }}
|
||||||
|
opts={{ renderer: 'svg' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnergyChart
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
|
||||||
|
interface DataPoint {
|
||||||
|
timestamp: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnergyLineChartProps {
|
||||||
|
data: DataPoint[]
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnergyLineChart: React.FC<EnergyLineChartProps> = ({ data, height = 300 }) => {
|
||||||
|
const option = {
|
||||||
|
grid: {
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 40,
|
||||||
|
left: 60,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.map(d => d.timestamp),
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 11,
|
||||||
|
rotate: data.length > 15 ? 45 : 0,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: 'kWh',
|
||||||
|
nameTextStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: [0, 40, 0, 0],
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: data.map(d => d.value),
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#1890ff',
|
||||||
|
width: 3,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: '#1890ff',
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(24, 144, 255, 0.4)' },
|
||||||
|
{ offset: 1, color: 'rgba(24, 144, 255, 0)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(26, 31, 46, 0.95)',
|
||||||
|
borderColor: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const point = params[0]
|
||||||
|
return `<div style="padding: 4px 8px;">
|
||||||
|
<div style="color: rgba(255,255,255,0.5); font-size: 12px;">${point.axisValue}</div>
|
||||||
|
<div style="color: #fff; font-size: 14px; font-weight: 500;">${point.value.toFixed(2)} kWh</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height }}
|
||||||
|
opts={{ renderer: 'svg' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnergyLineChart
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
|
||||||
|
interface DeviceEnergy {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
consumption: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnergyPieChartProps {
|
||||||
|
data: DeviceEnergy[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnergyPieChart: React.FC<EnergyPieChartProps> = ({ data }) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
air: '#3b82f6',
|
||||||
|
light: '#eab308',
|
||||||
|
other: '#a855f7',
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
backgroundColor: 'rgba(26, 31, 46, 0.95)',
|
||||||
|
borderColor: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
formatter: (params: any) => {
|
||||||
|
return `<div style="padding: 4px 8px;">
|
||||||
|
<div style="color: #fff; font-weight: 500;">${params.name}</div>
|
||||||
|
<div style="color: rgba(255,255,255,0.7); font-size: 12px;">${params.value.toFixed(1)} kWh (${params.percent}%)</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['50%', '80%'],
|
||||||
|
center: ['50%', '50%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 8,
|
||||||
|
borderColor: 'rgba(26, 31, 46, 1)',
|
||||||
|
borderWidth: 3,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
data: data.map(d => ({
|
||||||
|
value: d.consumption,
|
||||||
|
name: d.name,
|
||||||
|
itemStyle: {
|
||||||
|
color: colors[d.type] || '#6b7280',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height: 200 }}
|
||||||
|
opts={{ renderer: 'svg' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnergyPieChart
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineSparkles,
|
||||||
|
HiOutlineLightBulb,
|
||||||
|
HiOutlineHome,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { Switch, message } from 'antd'
|
||||||
|
import { useAppSelector, useAppDispatch } from '../../hooks/useRedux'
|
||||||
|
import { toggleDevice } from '../../store/slices/airConditioningSlice'
|
||||||
|
import { toggleLight } from '../../store/slices/lightingSlice'
|
||||||
|
|
||||||
|
const QuickControl: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { devices: acDevices } = useAppSelector(state => state.airConditioning)
|
||||||
|
const { devices: lightDevices } = useAppSelector(state => state.lighting)
|
||||||
|
|
||||||
|
const handleAcToggle = (id: string, isOn: boolean) => {
|
||||||
|
dispatch(toggleDevice(id))
|
||||||
|
message.success(isOn ? '空调已关闭' : '空调已开启')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightToggle = (id: string, isOn: boolean) => {
|
||||||
|
dispatch(toggleLight(id))
|
||||||
|
message.success(isOn ? '灯光已关闭' : '灯光已开启')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Air Conditioning */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HiOutlineSparkles className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-gray-400">空调</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{acDevices.slice(0, 3).map(device => (
|
||||||
|
<motion.div
|
||||||
|
key={device.id}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="flex items-center justify-between p-3 bg-white/5 rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${device.isOn ? 'bg-green-500' : 'bg-gray-500'}`} />
|
||||||
|
<span className="text-gray-300 text-sm">{device.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{device.isOn && (
|
||||||
|
<span className="text-xs text-gray-500">{device.targetTemperature}°C</span>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={device.isOn}
|
||||||
|
onChange={() => handleAcToggle(device.id, device.isOn)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lighting */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HiOutlineLightBulb className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span className="text-sm text-gray-400">照明</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lightDevices.slice(0, 3).map(device => (
|
||||||
|
<motion.div
|
||||||
|
key={device.id}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="flex items-center justify-between p-3 bg-white/5 rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${device.isOn ? 'bg-green-500' : 'bg-gray-500'}`} />
|
||||||
|
<span className="text-gray-300 text-sm">{device.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{device.isOn && (
|
||||||
|
<span className="text-xs text-gray-500">{device.brightness}%</span>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={device.isOn}
|
||||||
|
onChange={() => handleLightToggle(device.id, device.isOn)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scene Buttons */}
|
||||||
|
<div className="pt-4 border-t border-white/5">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HiOutlineHome className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-sm text-gray-400">快捷场景</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button className="p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-xl text-blue-400 text-sm hover:from-blue-500/30 hover:to-cyan-500/30 transition-all">
|
||||||
|
回家模式
|
||||||
|
</button>
|
||||||
|
<button className="p-3 bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-xl text-purple-400 text-sm hover:from-purple-500/30 hover:to-pink-500/30 transition-all">
|
||||||
|
离家模式
|
||||||
|
</button>
|
||||||
|
<button className="p-3 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-xl text-green-400 text-sm hover:from-green-500/30 hover:to-emerald-500/30 transition-all">
|
||||||
|
睡眠模式
|
||||||
|
</button>
|
||||||
|
<button className="p-3 bg-gradient-to-br from-orange-500/20 to-yellow-500/20 rounded-xl text-orange-400 text-sm hover:from-orange-500/30 hover:to-yellow-500/30 transition-all">
|
||||||
|
影院模式
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickControl
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineChip,
|
||||||
|
HiOutlineWifi,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { BsBatteryHalf } from 'react-icons/bs'
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
room: string
|
||||||
|
status: 'online' | 'offline' | 'warning'
|
||||||
|
isOn: boolean
|
||||||
|
battery?: number
|
||||||
|
signalStrength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceStatusCardProps {
|
||||||
|
device: Device
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceStatusCard: React.FC<DeviceStatusCardProps> = ({ device }) => {
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (device.status) {
|
||||||
|
case 'online':
|
||||||
|
return 'bg-green-500'
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-500'
|
||||||
|
case 'offline':
|
||||||
|
return 'bg-red-500'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSignalBars = () => {
|
||||||
|
if (device.signalStrength >= 80) return 3
|
||||||
|
if (device.signalStrength >= 50) return 2
|
||||||
|
if (device.signalStrength >= 20) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="glass-card-dark p-4 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0 left-0 w-full h-1 ${getStatusColor()}`} />
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||||
|
device.isOn ? 'bg-primary-500/20' : 'bg-white/5'
|
||||||
|
}`}>
|
||||||
|
<HiOutlineChip className={`w-5 h-5 ${device.isOn ? 'text-primary-400' : 'text-gray-500'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium text-sm">{device.name}</h3>
|
||||||
|
<p className="text-xs text-gray-400">{device.room}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${getStatusColor()} ${device.status === 'online' ? 'animate-pulse' : ''}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiOutlineWifi className={`w-3 h-3 ${getSignalBars() > 0 ? 'text-green-400' : 'text-gray-500'}`} />
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3].map(bar => (
|
||||||
|
<div
|
||||||
|
key={bar}
|
||||||
|
className={`w-1 rounded-sm ${
|
||||||
|
bar <= getSignalBars() ? 'bg-green-400' : 'bg-gray-600'
|
||||||
|
}`}
|
||||||
|
style={{ height: `${bar * 3 + 3}px` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device.battery !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<BsBatteryHalf className={`w-3 h-3 ${device.battery > 20 ? 'text-green-400' : 'text-red-400'}`} />
|
||||||
|
<span className={device.battery > 20 ? 'text-gray-400' : 'text-red-400'}>
|
||||||
|
{device.battery}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||||
|
device.isOn
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-gray-500/20 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{device.isOn ? '运行中' : '已关闭'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceStatusCard
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineMenu,
|
||||||
|
HiOutlineBell,
|
||||||
|
HiOutlineMoon,
|
||||||
|
HiOutlineSun,
|
||||||
|
HiOutlineSearch,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { Badge, Input, Tooltip } from 'antd'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useRedux'
|
||||||
|
import { toggleDarkMode } from '../../store/slices/uiSlice'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onMenuClick: () => void
|
||||||
|
isMobile: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({ onMenuClick, isMobile }) => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { darkMode } = useAppSelector(state => state.ui)
|
||||||
|
const { devices } = useAppSelector(state => state.devices)
|
||||||
|
|
||||||
|
const onlineDevices = devices.filter(d => d.status === 'online').length
|
||||||
|
const warningDevices = devices.filter(d => d.status === 'warning').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
className="fixed top-0 right-0 left-0 md:left-auto h-16 md:h-20 bg-dark-bg/80 backdrop-blur-xl border-b border-dark-border z-20"
|
||||||
|
>
|
||||||
|
<div className="h-full px-4 md:px-6 flex items-center justify-between gap-4">
|
||||||
|
{/* Left Section */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HiOutlineMenu className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="relative">
|
||||||
|
<HiOutlineSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索设备、房间..."
|
||||||
|
className="w-64 lg:w-80 pl-10 bg-white/5 border-white/10 text-white placeholder-gray-500 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page Title - Mobile */}
|
||||||
|
{isMobile && (
|
||||||
|
<h1 className="font-display font-bold text-lg text-white">IoT Control</h1>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section */}
|
||||||
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
|
{/* Stats */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="hidden lg:flex items-center gap-6 mr-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
在线设备: <span className="text-white font-medium">{onlineDevices}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{warningDevices > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
警告: <span className="text-yellow-400 font-medium">{warningDevices}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<Tooltip title={darkMode ? '切换到亮色模式' : '切换到暗色模式'}>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(toggleDarkMode())}
|
||||||
|
className="p-2 md:p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ rotate: darkMode ? 0 : 180 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{darkMode ? (
|
||||||
|
<HiOutlineMoon className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineSun className="w-5 h-5 text-yellow-400" />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Badge count={3} size="small" offset={[-2, 2]}>
|
||||||
|
<button className="p-2 md:p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors relative">
|
||||||
|
<HiOutlineBell className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* User Avatar - Desktop */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform">
|
||||||
|
<span className="text-white font-semibold text-sm">U</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import Header from './Header'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useRedux'
|
||||||
|
import { setIsMobile } from '../../store/slices/uiSlice'
|
||||||
|
|
||||||
|
interface MainLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { sidebarCollapsed, isMobile } = useAppSelector(state => state.ui)
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
const mobile = window.innerWidth < 768
|
||||||
|
dispatch(setIsMobile(mobile))
|
||||||
|
if (!mobile) {
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
return () => window.removeEventListener('resize', checkMobile)
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-gradient-to-br from-dark-bg via-dark-card to-dark-bg">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<motion.aside
|
||||||
|
initial={{ width: 280 }}
|
||||||
|
animate={{ width: sidebarCollapsed ? 80 : 280 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
className="fixed left-0 top-0 h-full z-30"
|
||||||
|
>
|
||||||
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
|
</motion.aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Sidebar Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobile && mobileMenuOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: -300 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: -300 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed left-0 top-0 h-full z-50 w-72"
|
||||||
|
>
|
||||||
|
<Sidebar collapsed={false} mobile onClose={() => setMobileMenuOpen(false)} />
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<motion.main
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
marginLeft: isMobile ? 0 : (sidebarCollapsed ? 80 : 280),
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
className="flex-1 min-h-screen"
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
onMenuClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<div className="p-4 md:p-6 lg:p-8 pt-20 md:pt-24">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainLayout
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineHome,
|
||||||
|
HiOutlineSparkles,
|
||||||
|
HiOutlineLightBulb,
|
||||||
|
HiOutlineChartBar,
|
||||||
|
HiOutlineChip,
|
||||||
|
HiOutlineChevronLeft,
|
||||||
|
HiOutlineX,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { useAppDispatch } from '../../hooks/useRedux'
|
||||||
|
import { toggleSidebar } from '../../store/slices/uiSlice'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
collapsed: boolean
|
||||||
|
mobile?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/', icon: HiOutlineHome, label: '仪表盘' },
|
||||||
|
{ path: '/air-conditioning', icon: HiOutlineSparkles, label: '空调控制' },
|
||||||
|
{ path: '/lighting', icon: HiOutlineLightBulb, label: '照明控制' },
|
||||||
|
{ path: '/energy', icon: HiOutlineChartBar, label: '能源监控' },
|
||||||
|
{ path: '/devices', icon: HiOutlineChip, label: '设备状态' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ collapsed, mobile, onClose }) => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-dark-card/90 backdrop-blur-xl border-r border-dark-border flex flex-col">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="h-16 md:h-20 flex items-center justify-between px-4 md:px-6 border-b border-dark-border">
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ opacity: collapsed ? 0 : 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={`flex items-center gap-3 ${collapsed ? 'hidden' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg shadow-primary-500/30">
|
||||||
|
<HiOutlineChip className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display font-bold text-lg text-white">IoT Control</h1>
|
||||||
|
<p className="text-xs text-gray-400">智能控制系统</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{collapsed && !mobile && (
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center mx-auto shadow-lg shadow-primary-500/30">
|
||||||
|
<HiOutlineChip className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobile && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HiOutlineX className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 py-4 px-3 overflow-y-auto scrollbar-hide">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.path}>
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
onClick={mobile ? onClose : undefined}
|
||||||
|
className={`relative flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-primary-500/20 to-primary-600/10 text-primary-400'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeIndicator"
|
||||||
|
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary-500 rounded-r-full"
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className={`w-6 h-6 flex-shrink-0 ${isActive ? 'text-primary-400' : ''}`} />
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
)}
|
||||||
|
{isActive && !collapsed && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="ml-auto w-2 h-2 rounded-full bg-primary-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Collapse Button (Desktop only) */}
|
||||||
|
{!mobile && (
|
||||||
|
<div className="p-4 border-t border-dark-border">
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(toggleSidebar())}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: collapsed ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<HiOutlineChevronLeft className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
{!collapsed && <span className="text-sm">收起菜单</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="p-4 border-t border-dark-border">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
|
||||||
|
<span className="text-white font-semibold">U</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">用户</p>
|
||||||
|
<p className="text-xs text-gray-400 truncate">user@example.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* 应用配置类型定义
|
||||||
|
*/
|
||||||
|
interface AppConfig {
|
||||||
|
domain: string
|
||||||
|
port: string
|
||||||
|
basePath: string
|
||||||
|
apiPrefix: string
|
||||||
|
useHttps: boolean
|
||||||
|
timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局配置对象(由Vite在构建时注入)
|
||||||
|
*/
|
||||||
|
declare const __APP_CONFIG__: AppConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取运行时配置
|
||||||
|
* 优先使用Vite注入的环境变量,其次使用默认配置
|
||||||
|
*/
|
||||||
|
const getConfig = (): AppConfig => {
|
||||||
|
if (typeof __APP_CONFIG__ !== 'undefined') {
|
||||||
|
return __APP_CONFIG__
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: 'ashai.com.cn',
|
||||||
|
port: '8888',
|
||||||
|
basePath: '/demo/',
|
||||||
|
apiPrefix: '/demo/api',
|
||||||
|
useHttps: false,
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整的API基础URL
|
||||||
|
* @returns 完整的API基础URL
|
||||||
|
* @example
|
||||||
|
* // 返回: http://ashai.com.cn:8888/demo/api
|
||||||
|
* getApiBaseUrl()
|
||||||
|
*/
|
||||||
|
export const getApiBaseUrl = (): string => {
|
||||||
|
const protocol = config.useHttps ? 'https' : 'http'
|
||||||
|
return `${protocol}://${config.domain}:${config.port}${config.apiPrefix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整的服务器基础URL
|
||||||
|
* @returns 完整的服务器基础URL
|
||||||
|
* @example
|
||||||
|
* // 返回: http://ashai.com.cn:8888
|
||||||
|
* getServerBaseUrl()
|
||||||
|
*/
|
||||||
|
export const getServerBaseUrl = (): string => {
|
||||||
|
const protocol = config.useHttps ? 'https' : 'http'
|
||||||
|
return `${protocol}://${config.domain}:${config.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取应用部署路径
|
||||||
|
* @returns 应用部署路径
|
||||||
|
* @example
|
||||||
|
* // 返回: /demo/
|
||||||
|
* getBasePath()
|
||||||
|
*/
|
||||||
|
export const getBasePath = (): string => {
|
||||||
|
return config.basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求超时时间
|
||||||
|
* @returns 超时时间(毫秒)
|
||||||
|
*/
|
||||||
|
export const getTimeout = (): number => {
|
||||||
|
return config.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器域名
|
||||||
|
* @returns 服务器域名
|
||||||
|
*/
|
||||||
|
export const getDomain = (): string => {
|
||||||
|
return config.domain
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器端口
|
||||||
|
* @returns 服务器端口
|
||||||
|
*/
|
||||||
|
export const getPort = (): string => {
|
||||||
|
return config.port
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整配置对象
|
||||||
|
* @returns 配置对象
|
||||||
|
*/
|
||||||
|
export const getConfigObject = (): AppConfig => {
|
||||||
|
return { ...config }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
||||||
|
import type { RootState, AppDispatch } from '../store'
|
||||||
|
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>()
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
--success-gradient: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||||
|
--warning-gradient: linear-gradient(135deg, #f2994a 0%, #f2c94c 100%);
|
||||||
|
--danger-gradient: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
|
||||||
|
--info-gradient: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background: linear-gradient(135deg, #0f1419 0%, #1a1f2e 50%, #0f1419 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background: linear-gradient(135deg, #0f1419 0%, #1a1f2e 50%, #0f1419 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card-dark {
|
||||||
|
@apply bg-dark-card/80 backdrop-blur-xl border border-dark-border rounded-2xl shadow-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary-400 to-primary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-effect {
|
||||||
|
box-shadow: 0 0 20px rgba(24, 144, 255, 0.3),
|
||||||
|
0 0 40px rgba(24, 144, 255, 0.2),
|
||||||
|
0 0 60px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
@apply bg-green-500;
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
@apply bg-red-500;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
@apply bg-yellow-500;
|
||||||
|
box-shadow: 0 0 10px rgba(234, 179, 8, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
@apply px-6 py-3 rounded-xl font-semibold transition-all duration-300 ease-out;
|
||||||
|
@apply hover:scale-105 hover:shadow-lg active:scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn-primary {
|
||||||
|
@apply control-btn bg-gradient-to-r from-primary-500 to-primary-600 text-white;
|
||||||
|
@apply hover:from-primary-600 hover:to-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn-secondary {
|
||||||
|
@apply control-btn bg-white/10 text-white border border-white/20;
|
||||||
|
@apply hover:bg-white/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
@apply h-2 rounded-full bg-white/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb {
|
||||||
|
@apply w-5 h-5 rounded-full bg-gradient-to-r from-primary-400 to-primary-600 cursor-pointer;
|
||||||
|
box-shadow: 0 0 10px rgba(24, 144, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
@apply glass-card-dark p-6 transition-all duration-300;
|
||||||
|
@apply hover:scale-[1.02] hover:border-primary-500/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
@apply glass-card-dark p-4 transition-all duration-300;
|
||||||
|
@apply hover:scale-[1.02] hover:border-primary-500/30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-shadow-glow {
|
||||||
|
text-shadow: 0 0 10px rgba(24, 144, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.glass-card, .glass-card-dark {
|
||||||
|
@apply rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card, .device-card {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { ConfigProvider, theme } from 'antd'
|
||||||
|
import App from './App'
|
||||||
|
import { store } from './store'
|
||||||
|
import { getBasePath } from './config'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const basePath = getBasePath().replace(/\/$/, '')
|
||||||
|
|
||||||
|
const appTitle = import.meta.env.VITE_APP_TITLE || 'loT Smart Control - 智能控制系统'
|
||||||
|
document.title = appTitle
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter basename={basePath}>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1890ff',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
colorBgContainer: 'rgba(26, 31, 46, 0.8)',
|
||||||
|
colorBorder: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Slider: {
|
||||||
|
trackBg: 'rgba(24, 144, 255, 0.3)',
|
||||||
|
trackHoverBg: 'rgba(24, 144, 255, 0.5)',
|
||||||
|
handleColor: '#1890ff',
|
||||||
|
handleActiveColor: '#40a9ff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineSparkles,
|
||||||
|
HiOutlinePlus,
|
||||||
|
HiOutlineMinus,
|
||||||
|
HiOutlineClock,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { Slider, Switch, Select, Button, Tooltip, Modal, InputNumber } from 'antd'
|
||||||
|
import { useAppSelector, useAppDispatch } from '../hooks/useRedux'
|
||||||
|
import {
|
||||||
|
toggleDevice,
|
||||||
|
setTargetTemperature,
|
||||||
|
setMode,
|
||||||
|
setFanSpeed,
|
||||||
|
toggleSwing,
|
||||||
|
setTimer,
|
||||||
|
selectDevice,
|
||||||
|
} from '../store/slices/airConditioningSlice'
|
||||||
|
|
||||||
|
const modeOptions = [
|
||||||
|
{ value: 'cool', label: '制冷', icon: '❄️', color: 'from-blue-400 to-cyan-500' },
|
||||||
|
{ value: 'heat', label: '制热', icon: '🔥', color: 'from-orange-400 to-red-500' },
|
||||||
|
{ value: 'auto', label: '自动', icon: '🔄', color: 'from-green-400 to-emerald-500' },
|
||||||
|
{ value: 'fan', label: '送风', icon: '💨', color: 'from-gray-400 to-slate-500' },
|
||||||
|
{ value: 'dry', label: '除湿', icon: '💧', color: 'from-teal-400 to-cyan-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fanSpeedOptions = [
|
||||||
|
{ value: 'low', label: '低速' },
|
||||||
|
{ value: 'medium', label: '中速' },
|
||||||
|
{ value: 'high', label: '高速' },
|
||||||
|
{ value: 'auto', label: '自动' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const AirConditioning: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { devices, selectedDevice } = useAppSelector(state => state.airConditioning)
|
||||||
|
const [timerModal, setTimerModal] = useState(false)
|
||||||
|
const [timerValue, setTimerValue] = useState(60)
|
||||||
|
|
||||||
|
const currentDevice = devices.find(d => d.id === selectedDevice) || devices[0]
|
||||||
|
|
||||||
|
const handleTemperatureChange = (value: number) => {
|
||||||
|
dispatch(setTargetTemperature({ id: currentDevice.id, temp: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModeChange = (mode: string) => {
|
||||||
|
dispatch(setMode({ id: currentDevice.id, mode: mode as any }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFanSpeedChange = (speed: string) => {
|
||||||
|
dispatch(setFanSpeed({ id: currentDevice.id, speed: speed as any }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimerSet = () => {
|
||||||
|
dispatch(setTimer({ id: currentDevice.id, minutes: timerValue }))
|
||||||
|
setTimerModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-display font-bold text-white mb-2">
|
||||||
|
空调控制
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">管理所有空调设备</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Device Selector */}
|
||||||
|
<motion.div variants={itemVariants} className="flex flex-wrap gap-3">
|
||||||
|
{devices.map(device => (
|
||||||
|
<button
|
||||||
|
key={device.id}
|
||||||
|
onClick={() => dispatch(selectDevice(device.id))}
|
||||||
|
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${
|
||||||
|
selectedDevice === device.id
|
||||||
|
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${device.isOn ? 'bg-green-500' : 'bg-gray-500'}`} />
|
||||||
|
<span>{device.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Control Panel */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="lg:col-span-2 glass-card-dark p-6 md:p-8"
|
||||||
|
>
|
||||||
|
{/* Device Info & Power */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${
|
||||||
|
currentDevice.isOn ? 'from-blue-500 to-cyan-600' : 'from-gray-600 to-gray-700'
|
||||||
|
} flex items-center justify-center shadow-lg`}>
|
||||||
|
<HiOutlineSparkles className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-white">{currentDevice.name}</h2>
|
||||||
|
<p className="text-sm text-gray-400">{currentDevice.room}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={currentDevice.isOn}
|
||||||
|
onChange={() => dispatch(toggleDevice(currentDevice.id))}
|
||||||
|
className="!bg-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temperature Control */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-gray-400">目标温度</span>
|
||||||
|
<span className="text-3xl font-bold text-white">{currentDevice.targetTemperature}°C</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTemperatureChange(Math.max(16, currentDevice.targetTemperature - 1))}
|
||||||
|
className="w-12 h-12 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<HiOutlineMinus className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
<Slider
|
||||||
|
value={currentDevice.targetTemperature}
|
||||||
|
onChange={handleTemperatureChange}
|
||||||
|
min={16}
|
||||||
|
max={30}
|
||||||
|
className="flex-1"
|
||||||
|
tooltip={{ formatter: (value) => `${value}°C` }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTemperatureChange(Math.min(30, currentDevice.targetTemperature + 1))}
|
||||||
|
className="w-12 h-12 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<HiOutlinePlus className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-2">
|
||||||
|
<span>16°C</span>
|
||||||
|
<span>当前: {currentDevice.temperature.toFixed(1)}°C</span>
|
||||||
|
<span>30°C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<span className="text-gray-400 text-sm mb-3 block">运行模式</span>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{modeOptions.map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode.value}
|
||||||
|
onClick={() => handleModeChange(mode.value)}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
className={`p-3 rounded-xl transition-all duration-200 flex flex-col items-center gap-1 ${
|
||||||
|
currentDevice.mode === mode.value
|
||||||
|
? `bg-gradient-to-br ${mode.color} text-white shadow-lg`
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
} ${!currentDevice.isOn && 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{mode.icon}</span>
|
||||||
|
<span className="text-xs">{mode.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fan Speed & Swing */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 text-sm mb-2 block">风速</span>
|
||||||
|
<Select
|
||||||
|
value={currentDevice.fanSpeed}
|
||||||
|
onChange={handleFanSpeedChange}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
className="w-full"
|
||||||
|
options={fanSpeedOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 text-sm mb-2 block">摆风</span>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(toggleSwing(currentDevice.id))}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
className={`w-full py-2 px-4 rounded-xl transition-all ${
|
||||||
|
currentDevice.swing
|
||||||
|
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||||
|
: 'bg-white/5 text-gray-400 border border-white/10'
|
||||||
|
} ${!currentDevice.isOn && 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
{currentDevice.swing ? '开启' : '关闭'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Side Panel */}
|
||||||
|
<motion.div variants={itemVariants} className="space-y-6">
|
||||||
|
{/* Timer */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">定时设置</h3>
|
||||||
|
<Tooltip title="设置定时开关机">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
onClick={() => setTimerModal(true)}
|
||||||
|
icon={<HiOutlineClock className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
设置
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{currentDevice.timer ? (
|
||||||
|
<div className="bg-white/5 rounded-xl p-4">
|
||||||
|
<p className="text-sm text-gray-400">将在 {currentDevice.timer} 分钟后自动关闭</p>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="p-0 mt-2"
|
||||||
|
onClick={() => dispatch(setTimer({ id: currentDevice.id, minutes: null }))}
|
||||||
|
>
|
||||||
|
取消定时
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">未设置定时</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Power Consumption */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">能耗信息</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-400">当前功率</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{currentDevice.isOn ? `${(currentDevice.powerConsumption * 1000).toFixed(0)}W` : '0W'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-400">今日耗电</span>
|
||||||
|
<span className="text-white font-medium">{currentDevice.powerConsumption.toFixed(2)} kWh</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-400">运行状态</span>
|
||||||
|
<span className={`font-medium ${currentDevice.isOn ? 'text-green-400' : 'text-gray-400'}`}>
|
||||||
|
{currentDevice.isOn ? '运行中' : '已关闭'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Devices Overview */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">设备概览</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{devices.map(device => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-white/5 rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${device.isOn ? 'bg-green-500' : 'bg-gray-500'}`} />
|
||||||
|
<span className="text-gray-300">{device.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{device.isOn ? `${device.targetTemperature}°C` : '关闭'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer Modal */}
|
||||||
|
<Modal
|
||||||
|
title="设置定时"
|
||||||
|
open={timerModal}
|
||||||
|
onCancel={() => setTimerModal(false)}
|
||||||
|
onOk={handleTimerSet}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-gray-400 mb-4">设置设备自动关闭时间(分钟)</p>
|
||||||
|
<InputNumber
|
||||||
|
value={timerValue}
|
||||||
|
onChange={(value) => setTimerValue(value || 60)}
|
||||||
|
min={1}
|
||||||
|
max={480}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AirConditioning
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineSparkles,
|
||||||
|
HiOutlineLightBulb,
|
||||||
|
HiOutlineChartBar,
|
||||||
|
HiOutlineChip,
|
||||||
|
HiOutlineArrowUp,
|
||||||
|
HiOutlineArrowDown,
|
||||||
|
HiOutlineTrendingUp,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { useAppSelector } from '../hooks/useRedux'
|
||||||
|
import EnergyChart from '../components/Charts/EnergyChart'
|
||||||
|
import DeviceStatusCard from '../components/Device/DeviceStatusCard'
|
||||||
|
import QuickControl from '../components/Control/QuickControl'
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const { devices: acDevices } = useAppSelector(state => state.airConditioning)
|
||||||
|
const { devices: lightDevices } = useAppSelector(state => state.lighting)
|
||||||
|
const { dailyConsumption, deviceBreakdown } = useAppSelector(state => state.energy)
|
||||||
|
const { devices } = useAppSelector(state => state.devices)
|
||||||
|
|
||||||
|
const onlineDevices = devices.filter(d => d.status === 'online').length
|
||||||
|
const activeAc = acDevices.filter(d => d.isOn).length
|
||||||
|
const activeLights = lightDevices.filter(d => d.isOn).length
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: '在线设备',
|
||||||
|
value: onlineDevices,
|
||||||
|
total: devices.length,
|
||||||
|
icon: HiOutlineChip,
|
||||||
|
color: 'from-green-500 to-emerald-600',
|
||||||
|
trend: '+2',
|
||||||
|
trendUp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '运行中空调',
|
||||||
|
value: activeAc,
|
||||||
|
total: acDevices.length,
|
||||||
|
icon: HiOutlineSparkles,
|
||||||
|
color: 'from-blue-500 to-cyan-600',
|
||||||
|
trend: '-1',
|
||||||
|
trendUp: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '开启灯光',
|
||||||
|
value: activeLights,
|
||||||
|
total: lightDevices.length,
|
||||||
|
icon: HiOutlineLightBulb,
|
||||||
|
color: 'from-yellow-500 to-orange-600',
|
||||||
|
trend: '+3',
|
||||||
|
trendUp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '今日能耗',
|
||||||
|
value: dailyConsumption.toFixed(1),
|
||||||
|
unit: 'kWh',
|
||||||
|
icon: HiOutlineChartBar,
|
||||||
|
color: 'from-purple-500 to-pink-600',
|
||||||
|
trend: '-5%',
|
||||||
|
trendUp: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Page Header */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-display font-bold text-white mb-2">
|
||||||
|
智能家居控制中心
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
实时监控和控制您的智能设备
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6"
|
||||||
|
>
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={stat.title}
|
||||||
|
variants={itemVariants}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="glass-card-dark p-4 md:p-6 relative overflow-hidden group"
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0 right-0 w-24 h-24 bg-gradient-to-br ${stat.color} opacity-10 rounded-full -translate-y-8 translate-x-8 group-hover:scale-150 transition-transform duration-500`} />
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`w-10 h-10 md:w-12 md:h-12 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center mb-3 md:mb-4 shadow-lg`}>
|
||||||
|
<Icon className="w-5 h-5 md:w-6 md:h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs md:text-sm text-gray-400 mb-1">{stat.title}</p>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<span className="text-xl md:text-2xl font-bold text-white">
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
{stat.total && (
|
||||||
|
<span className="text-sm text-gray-500 mb-1">/ {stat.total}</span>
|
||||||
|
)}
|
||||||
|
{stat.unit && (
|
||||||
|
<span className="text-sm text-gray-500 mb-1">{stat.unit}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 mt-2 text-xs ${
|
||||||
|
stat.trendUp ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{stat.trendUp ? (
|
||||||
|
<HiOutlineArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span>{stat.trend}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Energy Chart */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="lg:col-span-2 glass-card-dark p-4 md:p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4 md:mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">能耗趋势</h2>
|
||||||
|
<p className="text-sm text-gray-400">过去24小时能耗数据</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-green-400 text-sm">
|
||||||
|
<HiOutlineTrendingUp className="w-4 h-4" />
|
||||||
|
<span>较昨日下降 8%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EnergyChart />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Quick Control */}
|
||||||
|
<motion.div variants={itemVariants} className="glass-card-dark p-4 md:p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">快捷控制</h2>
|
||||||
|
<QuickControl />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Status */}
|
||||||
|
<motion.div variants={itemVariants} className="glass-card-dark p-4 md:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4 md:mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">设备状态</h2>
|
||||||
|
<p className="text-sm text-gray-400">实时监控所有设备运行状态</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{deviceBreakdown.map(device => (
|
||||||
|
<div key={device.id} className="text-right">
|
||||||
|
<p className="text-xs text-gray-400">{device.name}</p>
|
||||||
|
<p className="text-sm font-medium text-white">{device.consumption.toFixed(1)} kWh</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{devices.slice(0, 4).map(device => (
|
||||||
|
<DeviceStatusCard key={device.id} device={device} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineChip,
|
||||||
|
HiOutlineWifi,
|
||||||
|
HiOutlineExclamation,
|
||||||
|
HiOutlineRefresh,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { BsBatteryHalf } from 'react-icons/bs'
|
||||||
|
import { Tag, Button, Progress, Tooltip, Modal, Descriptions, Badge } from 'antd'
|
||||||
|
import { useAppSelector, useAppDispatch } from '../hooks/useRedux'
|
||||||
|
import { selectRoom, setFilterType, toggleDevice, updateDeviceStatus } from '../store/slices/deviceSlice'
|
||||||
|
|
||||||
|
const deviceTypeLabels: Record<string, string> = {
|
||||||
|
air: '空调',
|
||||||
|
light: '照明',
|
||||||
|
sensor: '传感器',
|
||||||
|
camera: '摄像头',
|
||||||
|
lock: '门锁',
|
||||||
|
other: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceStatus: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { devices, rooms, selectedRoom, filterType } = useAppSelector(state => state.devices)
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<string | null>(null)
|
||||||
|
const [detailModal, setDetailModal] = useState(false)
|
||||||
|
|
||||||
|
const filteredDevices = devices.filter(d => {
|
||||||
|
const roomMatch = selectedRoom === '全部' || d.room === selectedRoom
|
||||||
|
const typeMatch = !filterType || d.type === filterType
|
||||||
|
return roomMatch && typeMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
const onlineCount = devices.filter(d => d.status === 'online').length
|
||||||
|
const warningCount = devices.filter(d => d.status === 'warning').length
|
||||||
|
const offlineCount = devices.filter(d => d.status === 'offline').length
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return 'green'
|
||||||
|
case 'warning':
|
||||||
|
return 'yellow'
|
||||||
|
case 'offline':
|
||||||
|
return 'red'
|
||||||
|
default:
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSignalIcon = (strength: number) => {
|
||||||
|
if (strength >= 80) return '●●●'
|
||||||
|
if (strength >= 50) return '●●○'
|
||||||
|
if (strength >= 20) return '●○○'
|
||||||
|
return '○○○'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDevice = devices.find(d => d.id === selectedDevice)
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.05 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div variants={itemVariants} className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-display font-bold text-white mb-2">
|
||||||
|
设备状态
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">监控所有设备运行状态</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<HiOutlineRefresh className="w-4 h-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
devices.forEach(d => {
|
||||||
|
const randomStatus = Math.random() > 0.9 ? 'warning' : 'online'
|
||||||
|
dispatch(updateDeviceStatus({ id: d.id, status: randomStatus }))
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="!rounded-xl"
|
||||||
|
>
|
||||||
|
刷新状态
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Status Overview */}
|
||||||
|
<motion.div variants={itemVariants} className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="glass-card-dark p-4 text-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500 mx-auto mb-2 animate-pulse" />
|
||||||
|
<p className="text-2xl font-bold text-white">{onlineCount}</p>
|
||||||
|
<p className="text-sm text-gray-400">在线</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-dark p-4 text-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500 mx-auto mb-2 animate-pulse" />
|
||||||
|
<p className="text-2xl font-bold text-white">{warningCount}</p>
|
||||||
|
<p className="text-sm text-gray-400">警告</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-dark p-4 text-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500 mx-auto mb-2" />
|
||||||
|
<p className="text-2xl font-bold text-white">{offlineCount}</p>
|
||||||
|
<p className="text-sm text-gray-400">离线</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<motion.div variants={itemVariants} className="flex flex-wrap gap-3">
|
||||||
|
{/* Room Filter */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{rooms.map(room => (
|
||||||
|
<button
|
||||||
|
key={room}
|
||||||
|
onClick={() => dispatch(selectRoom(room))}
|
||||||
|
className={`px-4 py-2 rounded-xl transition-all duration-200 ${
|
||||||
|
selectedRoom === room
|
||||||
|
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{room}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<div className="flex flex-wrap gap-2 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(setFilterType(null))}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||||
|
!filterType
|
||||||
|
? 'bg-primary-500/20 text-primary-400'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部类型
|
||||||
|
</button>
|
||||||
|
{Object.entries(deviceTypeLabels).map(([type, label]) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => dispatch(setFilterType(type))}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||||
|
filterType === type
|
||||||
|
? 'bg-primary-500/20 text-primary-400'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Device Grid */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
|
{filteredDevices.map(device => (
|
||||||
|
<motion.div
|
||||||
|
key={device.id}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="glass-card-dark p-4 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDevice(device.id)
|
||||||
|
setDetailModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||||
|
device.status === 'online'
|
||||||
|
? 'bg-green-500/20'
|
||||||
|
: device.status === 'warning'
|
||||||
|
? 'bg-yellow-500/20'
|
||||||
|
: 'bg-red-500/20'
|
||||||
|
}`}>
|
||||||
|
<HiOutlineChip className={`w-5 h-5 ${
|
||||||
|
device.status === 'online'
|
||||||
|
? 'text-green-400'
|
||||||
|
: device.status === 'warning'
|
||||||
|
? 'text-yellow-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">{device.name}</h3>
|
||||||
|
<p className="text-xs text-gray-400">{device.room}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge color={getStatusColor(device.status)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500">{deviceTypeLabels[device.type]}</span>
|
||||||
|
<span className={`${
|
||||||
|
device.status === 'online' ? 'text-green-400' :
|
||||||
|
device.status === 'warning' ? 'text-yellow-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{device.status === 'online' ? '在线' :
|
||||||
|
device.status === 'warning' ? '警告' : '离线'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-white/5 flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1 text-gray-400">
|
||||||
|
<HiOutlineWifi className="w-3 h-3" />
|
||||||
|
<span className={device.signalStrength > 50 ? 'text-green-400' : 'text-yellow-400'}>
|
||||||
|
{getSignalIcon(device.signalStrength)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{device.battery !== undefined && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-400">
|
||||||
|
<BsBatteryHalf className="w-3 h-3" />
|
||||||
|
<span className={device.battery < 20 ? 'text-red-400' : ''}>
|
||||||
|
{device.battery}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.battery !== undefined && device.battery < 20 && (
|
||||||
|
<Tooltip title="电量过低">
|
||||||
|
<HiOutlineExclamation className="w-4 h-4 text-red-400" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Device Detail Modal */}
|
||||||
|
<Modal
|
||||||
|
title={currentDevice?.name}
|
||||||
|
open={detailModal}
|
||||||
|
onCancel={() => setDetailModal(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="close" onClick={() => setDetailModal(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="toggle"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentDevice) {
|
||||||
|
dispatch(toggleDevice(currentDevice.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentDevice?.isOn ? '关闭设备' : '开启设备'}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{currentDevice && (
|
||||||
|
<div className="py-4">
|
||||||
|
<Descriptions column={2} labelStyle={{ color: '#9ca3af' }} contentStyle={{ color: '#fff' }}>
|
||||||
|
<Descriptions.Item label="设备类型">{deviceTypeLabels[currentDevice.type]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="所在房间">{currentDevice.room}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="设备状态">
|
||||||
|
<Tag color={getStatusColor(currentDevice.status)}>
|
||||||
|
{currentDevice.status === 'online' ? '在线' :
|
||||||
|
currentDevice.status === 'warning' ? '警告' : '离线'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运行状态">
|
||||||
|
<Tag color={currentDevice.isOn ? 'green' : 'default'}>
|
||||||
|
{currentDevice.isOn ? '运行中' : '已关闭'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="信号强度">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
percent={currentDevice.signalStrength}
|
||||||
|
size="small"
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={currentDevice.signalStrength > 50 ? '#22c55e' : '#eab308'}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<span>{currentDevice.signalStrength}%</span>
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{currentDevice.battery !== undefined && (
|
||||||
|
<Descriptions.Item label="电池电量">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
percent={currentDevice.battery}
|
||||||
|
size="small"
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={currentDevice.battery > 20 ? '#22c55e' : '#ef4444'}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<span>{currentDevice.battery}%</span>
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="固件版本">{currentDevice.firmware}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后活动">
|
||||||
|
{new Date(currentDevice.lastActive).toLocaleString('zh-CN')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{currentDevice.ip && (
|
||||||
|
<Descriptions.Item label="IP地址">{currentDevice.ip}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{currentDevice.mac && (
|
||||||
|
<Descriptions.Item label="MAC地址">{currentDevice.mac}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceStatus
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineChartBar,
|
||||||
|
HiOutlineTrendingUp,
|
||||||
|
HiOutlineTrendingDown,
|
||||||
|
HiOutlineLightningBolt,
|
||||||
|
HiOutlineExclamation,
|
||||||
|
HiOutlineInformationCircle,
|
||||||
|
HiOutlineCheckCircle,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { Progress, Button } from 'antd'
|
||||||
|
import { useAppSelector, useAppDispatch } from '../hooks/useRedux'
|
||||||
|
import { dismissAlert, refreshData } from '../store/slices/energySlice'
|
||||||
|
import EnergyLineChart from '../components/Charts/EnergyLineChart'
|
||||||
|
import EnergyPieChart from '../components/Charts/EnergyPieChart'
|
||||||
|
|
||||||
|
const EnergyMonitor: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const {
|
||||||
|
totalConsumption,
|
||||||
|
dailyConsumption,
|
||||||
|
monthlyConsumption,
|
||||||
|
hourlyData,
|
||||||
|
dailyData,
|
||||||
|
monthlyData,
|
||||||
|
deviceBreakdown,
|
||||||
|
costPerKwh,
|
||||||
|
alerts,
|
||||||
|
} = useAppSelector(state => state.energy)
|
||||||
|
|
||||||
|
const [timeRange, setTimeRange] = useState<'hour' | 'day' | 'month'>('day')
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: '今日用电',
|
||||||
|
value: dailyConsumption.toFixed(1),
|
||||||
|
unit: 'kWh',
|
||||||
|
cost: (dailyConsumption * costPerKwh).toFixed(2),
|
||||||
|
trend: -8,
|
||||||
|
icon: HiOutlineLightningBolt,
|
||||||
|
color: 'from-blue-500 to-cyan-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本月用电',
|
||||||
|
value: monthlyConsumption.toFixed(1),
|
||||||
|
unit: 'kWh',
|
||||||
|
cost: (monthlyConsumption * costPerKwh).toFixed(2),
|
||||||
|
trend: 5,
|
||||||
|
icon: HiOutlineChartBar,
|
||||||
|
color: 'from-purple-500 to-pink-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '总用电量',
|
||||||
|
value: totalConsumption.toFixed(1),
|
||||||
|
unit: 'kWh',
|
||||||
|
cost: (totalConsumption * costPerKwh).toFixed(2),
|
||||||
|
trend: 0,
|
||||||
|
icon: HiOutlineTrendingUp,
|
||||||
|
color: 'from-green-500 to-emerald-500',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const getAlertIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'warning':
|
||||||
|
return <HiOutlineExclamation className="w-5 h-5 text-yellow-400" />
|
||||||
|
case 'info':
|
||||||
|
return <HiOutlineInformationCircle className="w-5 h-5 text-blue-400" />
|
||||||
|
case 'success':
|
||||||
|
return <HiOutlineCheckCircle className="w-5 h-5 text-green-400" />
|
||||||
|
default:
|
||||||
|
return <HiOutlineInformationCircle className="w-5 h-5 text-gray-400" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div variants={itemVariants} className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-display font-bold text-white mb-2">
|
||||||
|
能源监控
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">实时监控家庭能源消耗</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => dispatch(refreshData())}
|
||||||
|
className="!rounded-xl"
|
||||||
|
>
|
||||||
|
刷新数据
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={stat.title}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="glass-card-dark p-6 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0 right-0 w-32 h-32 bg-gradient-to-br ${stat.color} opacity-10 rounded-full -translate-y-12 translate-x-12`} />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center shadow-lg`}>
|
||||||
|
<Icon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
{stat.trend !== 0 && (
|
||||||
|
<div className={`flex items-center gap-1 text-sm ${stat.trend < 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{stat.trend < 0 ? (
|
||||||
|
<HiOutlineTrendingDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineTrendingUp className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{Math.abs(stat.trend)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm mb-1">{stat.title}</p>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<span className="text-3xl font-bold text-white">{stat.value}</span>
|
||||||
|
<span className="text-gray-400 mb-1">{stat.unit}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
约 ¥{stat.cost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<motion.div variants={itemVariants} className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Chart */}
|
||||||
|
<div className="lg:col-span-2 glass-card-dark p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white">能耗趋势</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['hour', 'day', 'month'] as const).map(range => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||||
|
timeRange === range
|
||||||
|
? 'bg-primary-500/20 text-primary-400'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range === 'hour' ? '时' : range === 'day' ? '日' : '月'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EnergyLineChart
|
||||||
|
data={
|
||||||
|
timeRange === 'hour'
|
||||||
|
? hourlyData
|
||||||
|
: timeRange === 'day'
|
||||||
|
? dailyData
|
||||||
|
: monthlyData
|
||||||
|
}
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pie Chart */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-6">能耗分布</h2>
|
||||||
|
<EnergyPieChart data={deviceBreakdown} />
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{deviceBreakdown.map(device => (
|
||||||
|
<div key={device.id} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full ${
|
||||||
|
device.type === 'air'
|
||||||
|
? 'bg-blue-500'
|
||||||
|
: device.type === 'light'
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-purple-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400 text-sm">{device.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white text-sm">{device.percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Device Breakdown & Alerts */}
|
||||||
|
<motion.div variants={itemVariants} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Device Consumption */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-6">设备能耗排行</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{deviceBreakdown.map((device, index) => (
|
||||||
|
<div key={device.id}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-gray-500 text-sm w-4">{index + 1}</span>
|
||||||
|
<span className="text-white">{device.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">{device.consumption.toFixed(1)} kWh</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={device.percentage}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={
|
||||||
|
device.type === 'air'
|
||||||
|
? '#3b82f6'
|
||||||
|
: device.type === 'light'
|
||||||
|
? '#eab308'
|
||||||
|
: '#a855f7'
|
||||||
|
}
|
||||||
|
trailColor="rgba(255,255,255,0.1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-6">节能提醒</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{alerts.map(alert => (
|
||||||
|
<motion.div
|
||||||
|
key={alert.id}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className={`p-4 rounded-xl flex items-start gap-3 ${
|
||||||
|
alert.type === 'warning'
|
||||||
|
? 'bg-yellow-500/10 border border-yellow-500/20'
|
||||||
|
: alert.type === 'info'
|
||||||
|
? 'bg-blue-500/10 border border-blue-500/20'
|
||||||
|
: 'bg-green-500/10 border border-green-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getAlertIcon(alert.type)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-white text-sm">{alert.message}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-1">
|
||||||
|
{new Date(alert.timestamp).toLocaleString('zh-CN')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(dismissAlert(alert.id))}
|
||||||
|
className="text-gray-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Cost Summary */}
|
||||||
|
<motion.div variants={itemVariants} className="glass-card-dark p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-6">费用概览</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-white/5 rounded-xl">
|
||||||
|
<p className="text-gray-400 text-sm mb-2">今日费用</p>
|
||||||
|
<p className="text-2xl font-bold text-white">¥{(dailyConsumption * costPerKwh).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white/5 rounded-xl">
|
||||||
|
<p className="text-gray-400 text-sm mb-2">本月费用</p>
|
||||||
|
<p className="text-2xl font-bold text-white">¥{(monthlyConsumption * costPerKwh).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white/5 rounded-xl">
|
||||||
|
<p className="text-gray-400 text-sm mb-2">电价</p>
|
||||||
|
<p className="text-2xl font-bold text-white">¥{costPerKwh}/kWh</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-white/5 rounded-xl">
|
||||||
|
<p className="text-gray-400 text-sm mb-2">预计月费用</p>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
¥{(monthlyConsumption * costPerKwh * 1.1).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnergyMonitor
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
HiOutlineLightBulb,
|
||||||
|
HiOutlineSun,
|
||||||
|
HiOutlineMoon,
|
||||||
|
HiOutlineBookOpen,
|
||||||
|
HiOutlineFilm,
|
||||||
|
HiOutlineMusicNote,
|
||||||
|
} from 'react-icons/hi'
|
||||||
|
import { Slider, Switch, Button, ColorPicker, message } from 'antd'
|
||||||
|
import type { Color } from 'antd/es/color-picker'
|
||||||
|
import { useAppSelector, useAppDispatch } from '../hooks/useRedux'
|
||||||
|
import {
|
||||||
|
toggleLight,
|
||||||
|
setBrightness,
|
||||||
|
setColorTemp,
|
||||||
|
setColor,
|
||||||
|
setScene,
|
||||||
|
selectDevice,
|
||||||
|
toggleAllLights,
|
||||||
|
} from '../store/slices/lightingSlice'
|
||||||
|
|
||||||
|
const sceneIcons = {
|
||||||
|
normal: HiOutlineSun,
|
||||||
|
reading: HiOutlineBookOpen,
|
||||||
|
movie: HiOutlineFilm,
|
||||||
|
sleep: HiOutlineMoon,
|
||||||
|
party: HiOutlineMusicNote,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneColors = {
|
||||||
|
normal: 'from-yellow-400 to-orange-500',
|
||||||
|
reading: 'from-blue-400 to-cyan-500',
|
||||||
|
movie: 'from-purple-400 to-pink-500',
|
||||||
|
sleep: 'from-indigo-400 to-purple-500',
|
||||||
|
party: 'from-pink-400 to-rose-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Lighting: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { devices, selectedDevice, scenes } = useAppSelector(state => state.lighting)
|
||||||
|
const [selectedRoom, setSelectedRoom] = useState<string>('全部')
|
||||||
|
|
||||||
|
const currentDevice = devices.find(d => d.id === selectedDevice) || devices[0]
|
||||||
|
|
||||||
|
const rooms = ['全部', ...new Set(devices.map(d => d.room))]
|
||||||
|
|
||||||
|
const filteredDevices = selectedRoom === '全部'
|
||||||
|
? devices
|
||||||
|
: devices.filter(d => d.room === selectedRoom)
|
||||||
|
|
||||||
|
const handleBrightnessChange = (value: number) => {
|
||||||
|
dispatch(setBrightness({ id: currentDevice.id, brightness: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColorTempChange = (value: number) => {
|
||||||
|
dispatch(setColorTemp({ id: currentDevice.id, colorTemp: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColorChange = (color: Color) => {
|
||||||
|
dispatch(setColor({ id: currentDevice.id, color: color.toHexString() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSceneChange = (scene: string) => {
|
||||||
|
dispatch(setScene({ id: currentDevice.id, scene: scene as any }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAllLightsToggle = () => {
|
||||||
|
const allOn = devices.every(d => d.isOn)
|
||||||
|
dispatch(toggleAllLights(!allOn))
|
||||||
|
message.success(allOn ? '已关闭所有灯光' : '已开启所有灯光')
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div variants={itemVariants} className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-display font-bold text-white mb-2">
|
||||||
|
照明控制
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">管理所有照明设备</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={handleAllLightsToggle}
|
||||||
|
className="!rounded-xl !h-12"
|
||||||
|
>
|
||||||
|
{devices.every(d => d.isOn) ? '关闭全部' : '开启全部'}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Room Filter */}
|
||||||
|
<motion.div variants={itemVariants} className="flex flex-wrap gap-3">
|
||||||
|
{rooms.map(room => (
|
||||||
|
<button
|
||||||
|
key={room}
|
||||||
|
onClick={() => setSelectedRoom(room)}
|
||||||
|
className={`px-4 py-2 rounded-xl transition-all duration-200 ${
|
||||||
|
selectedRoom === room
|
||||||
|
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{room}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Light Cards Grid */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
{filteredDevices.map(device => (
|
||||||
|
<motion.div
|
||||||
|
key={device.id}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
onClick={() => dispatch(selectDevice(device.id))}
|
||||||
|
className={`glass-card-dark p-4 cursor-pointer transition-all duration-200 ${
|
||||||
|
selectedDevice === device.id ? 'ring-2 ring-primary-500/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 ${
|
||||||
|
device.isOn
|
||||||
|
? 'bg-gradient-to-br from-yellow-400 to-orange-500 shadow-lg shadow-yellow-500/30'
|
||||||
|
: 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
style={device.isOn ? { backgroundColor: device.color } : undefined}
|
||||||
|
>
|
||||||
|
<HiOutlineLightBulb className={`w-5 h-5 ${device.isOn ? 'text-white' : 'text-gray-500'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">{device.name}</h3>
|
||||||
|
<p className="text-xs text-gray-400">{device.room}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={device.isOn}
|
||||||
|
onChange={() => dispatch(toggleLight(device.id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device.isOn && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||||
|
<span>亮度</span>
|
||||||
|
<span>{device.brightness}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${device.brightness}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Control Panel */}
|
||||||
|
<motion.div variants={itemVariants} className="space-y-6">
|
||||||
|
{/* Selected Device Control */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: currentDevice.isOn ? currentDevice.color : '#374151' }}
|
||||||
|
>
|
||||||
|
<HiOutlineLightBulb className={`w-6 h-6 ${currentDevice.isOn ? 'text-white' : 'text-gray-500'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">{currentDevice.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{currentDevice.room}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brightness */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-gray-400">亮度</span>
|
||||||
|
<span className="text-white">{currentDevice.brightness}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={currentDevice.brightness}
|
||||||
|
onChange={handleBrightnessChange}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
tooltip={{ formatter: (value) => `${value}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Temperature */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-gray-400">色温</span>
|
||||||
|
<span className="text-white">{currentDevice.colorTemp}K</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={currentDevice.colorTemp}
|
||||||
|
onChange={handleColorTempChange}
|
||||||
|
min={2200}
|
||||||
|
max={6500}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
tooltip={{ formatter: (value) => `${value}K` }}
|
||||||
|
trackStyle={{
|
||||||
|
background: `linear-gradient(to right, #ff9f43, #ffffff, #dfe6e9)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-gray-400 text-sm block mb-2">颜色</span>
|
||||||
|
<ColorPicker
|
||||||
|
value={currentDevice.color}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
showText
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scene Selection */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">场景模式</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{scenes.map(scene => {
|
||||||
|
const Icon = sceneIcons[scene.id.replace('scene-', '') as keyof typeof sceneIcons] || HiOutlineSun
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scene.id}
|
||||||
|
onClick={() => handleSceneChange(scene.id.replace('scene-', ''))}
|
||||||
|
disabled={!currentDevice.isOn}
|
||||||
|
className={`p-3 rounded-xl transition-all duration-200 flex flex-col items-center gap-2 ${
|
||||||
|
currentDevice.scene === scene.id.replace('scene-', '')
|
||||||
|
? `bg-gradient-to-br ${sceneColors[scene.id.replace('scene-', '') as keyof typeof sceneColors]} text-white shadow-lg`
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
} ${!currentDevice.isOn && 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
<span className="text-sm">{scene.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Power Stats */}
|
||||||
|
<div className="glass-card-dark p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">能耗统计</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">当前功率</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{(filteredDevices.reduce((sum, d) => sum + (d.isOn ? d.powerConsumption : 0), 0) * 1000).toFixed(0)}W
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">开启数量</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{filteredDevices.filter(d => d.isOn).length} / {filteredDevices.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lighting
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import airConditioningReducer from './slices/airConditioningSlice'
|
||||||
|
import lightingReducer from './slices/lightingSlice'
|
||||||
|
import energyReducer from './slices/energySlice'
|
||||||
|
import deviceReducer from './slices/deviceSlice'
|
||||||
|
import uiReducer from './slices/uiSlice'
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
airConditioning: airConditioningReducer,
|
||||||
|
lighting: lightingReducer,
|
||||||
|
energy: energyReducer,
|
||||||
|
devices: deviceReducer,
|
||||||
|
ui: uiReducer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
|
export type AppDispatch = typeof store.dispatch
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
interface AirConditioningState {
|
||||||
|
devices: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
room: string
|
||||||
|
isOn: boolean
|
||||||
|
temperature: number
|
||||||
|
targetTemperature: number
|
||||||
|
mode: 'cool' | 'heat' | 'auto' | 'fan' | 'dry'
|
||||||
|
fanSpeed: 'low' | 'medium' | 'high' | 'auto'
|
||||||
|
swing: boolean
|
||||||
|
timer: number | null
|
||||||
|
powerConsumption: number
|
||||||
|
status: 'online' | 'offline' | 'warning'
|
||||||
|
}[]
|
||||||
|
selectedDevice: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AirConditioningState = {
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: 'ac-1',
|
||||||
|
name: '客厅空调',
|
||||||
|
room: '客厅',
|
||||||
|
isOn: true,
|
||||||
|
temperature: 24,
|
||||||
|
targetTemperature: 22,
|
||||||
|
mode: 'cool',
|
||||||
|
fanSpeed: 'auto',
|
||||||
|
swing: true,
|
||||||
|
timer: null,
|
||||||
|
powerConsumption: 1.2,
|
||||||
|
status: 'online',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ac-2',
|
||||||
|
name: '主卧空调',
|
||||||
|
room: '主卧',
|
||||||
|
isOn: false,
|
||||||
|
temperature: 26,
|
||||||
|
targetTemperature: 24,
|
||||||
|
mode: 'cool',
|
||||||
|
fanSpeed: 'low',
|
||||||
|
swing: false,
|
||||||
|
timer: null,
|
||||||
|
powerConsumption: 0,
|
||||||
|
status: 'online',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ac-3',
|
||||||
|
name: '书房空调',
|
||||||
|
room: '书房',
|
||||||
|
isOn: true,
|
||||||
|
temperature: 25,
|
||||||
|
targetTemperature: 23,
|
||||||
|
mode: 'cool',
|
||||||
|
fanSpeed: 'medium',
|
||||||
|
swing: true,
|
||||||
|
timer: 120,
|
||||||
|
powerConsumption: 0.8,
|
||||||
|
status: 'online',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedDevice: 'ac-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const airConditioningSlice = createSlice({
|
||||||
|
name: 'airConditioning',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload)
|
||||||
|
if (device) {
|
||||||
|
device.isOn = !device.isOn
|
||||||
|
device.powerConsumption = device.isOn ? Math.random() * 1.5 + 0.5 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setTargetTemperature: (state, action: PayloadAction<{ id: string; temp: number }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.targetTemperature = action.payload.temp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setMode: (state, action: PayloadAction<{ id: string; mode: AirConditioningState['devices'][0]['mode'] }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.mode = action.payload.mode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setFanSpeed: (state, action: PayloadAction<{ id: string; speed: AirConditioningState['devices'][0]['fanSpeed'] }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.fanSpeed = action.payload.speed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleSwing: (state, action: PayloadAction<string>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload)
|
||||||
|
if (device) {
|
||||||
|
device.swing = !device.swing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setTimer: (state, action: PayloadAction<{ id: string; minutes: number | null }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.timer = action.payload.minutes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
state.selectedDevice = action.payload
|
||||||
|
},
|
||||||
|
updateTemperature: (state) => {
|
||||||
|
state.devices.forEach(device => {
|
||||||
|
if (device.isOn) {
|
||||||
|
const diff = device.targetTemperature - device.temperature
|
||||||
|
device.temperature += diff * 0.1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
toggleDevice,
|
||||||
|
setTargetTemperature,
|
||||||
|
setMode,
|
||||||
|
setFanSpeed,
|
||||||
|
toggleSwing,
|
||||||
|
setTimer,
|
||||||
|
selectDevice,
|
||||||
|
updateTemperature,
|
||||||
|
} = airConditioningSlice.actions
|
||||||
|
|
||||||
|
export default airConditioningSlice.reducer
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'air' | 'light' | 'sensor' | 'camera' | 'lock' | 'other'
|
||||||
|
room: string
|
||||||
|
status: 'online' | 'offline' | 'warning'
|
||||||
|
isOn: boolean
|
||||||
|
lastActive: string
|
||||||
|
battery?: number
|
||||||
|
signalStrength: number
|
||||||
|
firmware: string
|
||||||
|
ip?: string
|
||||||
|
mac?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceState {
|
||||||
|
devices: Device[]
|
||||||
|
rooms: string[]
|
||||||
|
selectedRoom: string | null
|
||||||
|
filterType: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: DeviceState = {
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: 'device-1',
|
||||||
|
name: '客厅空调',
|
||||||
|
type: 'air',
|
||||||
|
room: '客厅',
|
||||||
|
status: 'online',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
signalStrength: 95,
|
||||||
|
firmware: 'v2.1.3',
|
||||||
|
ip: '192.168.1.101',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-2',
|
||||||
|
name: '主卧空调',
|
||||||
|
type: 'air',
|
||||||
|
room: '主卧',
|
||||||
|
status: 'online',
|
||||||
|
isOn: false,
|
||||||
|
lastActive: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
signalStrength: 88,
|
||||||
|
firmware: 'v2.1.3',
|
||||||
|
ip: '192.168.1.102',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:02',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-3',
|
||||||
|
name: '客厅主灯',
|
||||||
|
type: 'light',
|
||||||
|
room: '客厅',
|
||||||
|
status: 'online',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
signalStrength: 92,
|
||||||
|
firmware: 'v1.5.0',
|
||||||
|
ip: '192.168.1.103',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:03',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-4',
|
||||||
|
name: '温湿度传感器',
|
||||||
|
type: 'sensor',
|
||||||
|
room: '客厅',
|
||||||
|
status: 'online',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
battery: 78,
|
||||||
|
signalStrength: 85,
|
||||||
|
firmware: 'v1.0.2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-5',
|
||||||
|
name: '门口摄像头',
|
||||||
|
type: 'camera',
|
||||||
|
room: '玄关',
|
||||||
|
status: 'online',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
signalStrength: 90,
|
||||||
|
firmware: 'v3.2.1',
|
||||||
|
ip: '192.168.1.104',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-6',
|
||||||
|
name: '智能门锁',
|
||||||
|
type: 'lock',
|
||||||
|
room: '玄关',
|
||||||
|
status: 'warning',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
battery: 15,
|
||||||
|
signalStrength: 75,
|
||||||
|
firmware: 'v2.0.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-7',
|
||||||
|
name: '书房台灯',
|
||||||
|
type: 'light',
|
||||||
|
room: '书房',
|
||||||
|
status: 'online',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
signalStrength: 95,
|
||||||
|
firmware: 'v1.5.0',
|
||||||
|
ip: '192.168.1.105',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:07',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-8',
|
||||||
|
name: '书房空调',
|
||||||
|
type: 'air',
|
||||||
|
room: '书房',
|
||||||
|
status: 'online',
|
||||||
|
isOn: true,
|
||||||
|
lastActive: new Date().toISOString(),
|
||||||
|
signalStrength: 91,
|
||||||
|
firmware: 'v2.1.3',
|
||||||
|
ip: '192.168.1.106',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:08',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-9',
|
||||||
|
name: '卧室传感器',
|
||||||
|
type: 'sensor',
|
||||||
|
room: '主卧',
|
||||||
|
status: 'offline',
|
||||||
|
isOn: false,
|
||||||
|
lastActive: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
battery: 0,
|
||||||
|
signalStrength: 0,
|
||||||
|
firmware: 'v1.0.2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rooms: ['全部', '客厅', '主卧', '书房', '餐厅', '玄关'],
|
||||||
|
selectedRoom: '全部',
|
||||||
|
filterType: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceSlice = createSlice({
|
||||||
|
name: 'devices',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
updateDeviceStatus: (state, action: PayloadAction<{ id: string; status: Device['status'] }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.status = action.payload.status
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload)
|
||||||
|
if (device) {
|
||||||
|
device.isOn = !device.isOn
|
||||||
|
device.lastActive = new Date().toISOString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectRoom: (state, action: PayloadAction<string>) => {
|
||||||
|
state.selectedRoom = action.payload
|
||||||
|
},
|
||||||
|
setFilterType: (state, action: PayloadAction<string | null>) => {
|
||||||
|
state.filterType = action.payload
|
||||||
|
},
|
||||||
|
updateDeviceSignal: (state, action: PayloadAction<{ id: string; signal: number }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.signalStrength = action.payload.signal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addDevice: (state, action: PayloadAction<Device>) => {
|
||||||
|
state.devices.push(action.payload)
|
||||||
|
},
|
||||||
|
removeDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
state.devices = state.devices.filter(d => d.id !== action.payload)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
updateDeviceStatus,
|
||||||
|
toggleDevice,
|
||||||
|
selectRoom,
|
||||||
|
setFilterType,
|
||||||
|
updateDeviceSignal,
|
||||||
|
addDevice,
|
||||||
|
removeDevice,
|
||||||
|
} = deviceSlice.actions
|
||||||
|
|
||||||
|
export default deviceSlice.reducer
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
interface EnergyData {
|
||||||
|
timestamp: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceEnergy {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'air' | 'light' | 'other'
|
||||||
|
consumption: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnergyState {
|
||||||
|
totalConsumption: number
|
||||||
|
dailyConsumption: number
|
||||||
|
monthlyConsumption: number
|
||||||
|
hourlyData: EnergyData[]
|
||||||
|
dailyData: EnergyData[]
|
||||||
|
monthlyData: EnergyData[]
|
||||||
|
deviceBreakdown: DeviceEnergy[]
|
||||||
|
costPerKwh: number
|
||||||
|
currency: string
|
||||||
|
alerts: {
|
||||||
|
id: string
|
||||||
|
type: 'warning' | 'info' | 'success'
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateHourlyData = (): EnergyData[] => {
|
||||||
|
const data: EnergyData[] = []
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const hour = new Date(now.getTime() - i * 60 * 60 * 1000)
|
||||||
|
data.push({
|
||||||
|
timestamp: `${hour.getHours()}:00`,
|
||||||
|
value: Math.random() * 2 + 0.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateDailyData = (): EnergyData[] => {
|
||||||
|
const data: EnergyData[] = []
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const day = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
|
||||||
|
data.push({
|
||||||
|
timestamp: `${day.getMonth() + 1}/${day.getDate()}`,
|
||||||
|
value: Math.random() * 20 + 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateMonthlyData = (): EnergyData[] => {
|
||||||
|
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||||
|
return months.map(month => ({
|
||||||
|
timestamp: month,
|
||||||
|
value: Math.random() * 200 + 150,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: EnergyState = {
|
||||||
|
totalConsumption: 156.8,
|
||||||
|
dailyConsumption: 12.5,
|
||||||
|
monthlyConsumption: 356.2,
|
||||||
|
hourlyData: generateHourlyData(),
|
||||||
|
dailyData: generateDailyData(),
|
||||||
|
monthlyData: generateMonthlyData(),
|
||||||
|
deviceBreakdown: [
|
||||||
|
{ id: 'ac-total', name: '空调系统', type: 'air', consumption: 8.2, percentage: 65.6 },
|
||||||
|
{ id: 'light-total', name: '照明系统', type: 'light', consumption: 2.1, percentage: 16.8 },
|
||||||
|
{ id: 'other', name: '其他设备', type: 'other', consumption: 2.2, percentage: 17.6 },
|
||||||
|
],
|
||||||
|
costPerKwh: 0.52,
|
||||||
|
currency: 'CNY',
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: 'alert-1',
|
||||||
|
type: 'warning',
|
||||||
|
message: '今日用电量已超过日均用电量15%',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-2',
|
||||||
|
type: 'info',
|
||||||
|
message: '建议在22:00后使用空调以享受谷电价格',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-3',
|
||||||
|
type: 'success',
|
||||||
|
message: '本月节能目标已完成80%',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const energySlice = createSlice({
|
||||||
|
name: 'energy',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
updateConsumption: (state, action: PayloadAction<{ type: string; value: number }>) => {
|
||||||
|
const device = state.deviceBreakdown.find(d => d.id === action.payload.type)
|
||||||
|
if (device) {
|
||||||
|
const diff = action.payload.value - device.consumption
|
||||||
|
device.consumption = action.payload.value
|
||||||
|
state.totalConsumption += diff
|
||||||
|
state.dailyConsumption = state.deviceBreakdown.reduce((sum, d) => sum + d.consumption, 0)
|
||||||
|
state.deviceBreakdown.forEach(d => {
|
||||||
|
d.percentage = (d.consumption / state.dailyConsumption) * 100
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addAlert: (state, action: PayloadAction<Omit<EnergyState['alerts'][0], 'id' | 'timestamp'>>) => {
|
||||||
|
state.alerts.unshift({
|
||||||
|
...action.payload,
|
||||||
|
id: `alert-${Date.now()}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
dismissAlert: (state, action: PayloadAction<string>) => {
|
||||||
|
state.alerts = state.alerts.filter(a => a.id !== action.payload)
|
||||||
|
},
|
||||||
|
refreshData: (state) => {
|
||||||
|
state.hourlyData = generateHourlyData()
|
||||||
|
state.dailyData = generateDailyData()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
updateConsumption,
|
||||||
|
addAlert,
|
||||||
|
dismissAlert,
|
||||||
|
refreshData,
|
||||||
|
} = energySlice.actions
|
||||||
|
|
||||||
|
export default energySlice.reducer
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
interface LightDevice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
room: string
|
||||||
|
isOn: boolean
|
||||||
|
brightness: number
|
||||||
|
colorTemp: number
|
||||||
|
color: string
|
||||||
|
scene: 'normal' | 'reading' | 'movie' | 'sleep' | 'party'
|
||||||
|
status: 'online' | 'offline' | 'warning'
|
||||||
|
powerConsumption: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightingState {
|
||||||
|
devices: LightDevice[]
|
||||||
|
selectedDevice: string | null
|
||||||
|
scenes: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
settings: Partial<LightDevice>
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: LightingState = {
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: 'light-1',
|
||||||
|
name: '客厅主灯',
|
||||||
|
room: '客厅',
|
||||||
|
isOn: true,
|
||||||
|
brightness: 80,
|
||||||
|
colorTemp: 4000,
|
||||||
|
color: '#ffffff',
|
||||||
|
scene: 'normal',
|
||||||
|
status: 'online',
|
||||||
|
powerConsumption: 0.06,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light-2',
|
||||||
|
name: '客厅灯带',
|
||||||
|
room: '客厅',
|
||||||
|
isOn: true,
|
||||||
|
brightness: 60,
|
||||||
|
colorTemp: 3000,
|
||||||
|
color: '#ff9f43',
|
||||||
|
scene: 'normal',
|
||||||
|
status: 'online',
|
||||||
|
powerConsumption: 0.02,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light-3',
|
||||||
|
name: '主卧主灯',
|
||||||
|
room: '主卧',
|
||||||
|
isOn: false,
|
||||||
|
brightness: 50,
|
||||||
|
colorTemp: 3500,
|
||||||
|
color: '#ffffff',
|
||||||
|
scene: 'sleep',
|
||||||
|
status: 'online',
|
||||||
|
powerConsumption: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light-4',
|
||||||
|
name: '书房台灯',
|
||||||
|
room: '书房',
|
||||||
|
isOn: true,
|
||||||
|
brightness: 100,
|
||||||
|
colorTemp: 5000,
|
||||||
|
color: '#ffffff',
|
||||||
|
scene: 'reading',
|
||||||
|
status: 'online',
|
||||||
|
powerConsumption: 0.01,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light-5',
|
||||||
|
name: '餐厅吊灯',
|
||||||
|
room: '餐厅',
|
||||||
|
isOn: true,
|
||||||
|
brightness: 70,
|
||||||
|
colorTemp: 3000,
|
||||||
|
color: '#ffffff',
|
||||||
|
scene: 'normal',
|
||||||
|
status: 'online',
|
||||||
|
powerConsumption: 0.04,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedDevice: 'light-1',
|
||||||
|
scenes: [
|
||||||
|
{
|
||||||
|
id: 'scene-normal',
|
||||||
|
name: '日常模式',
|
||||||
|
icon: 'sun',
|
||||||
|
settings: { brightness: 80, colorTemp: 4000, color: '#ffffff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scene-reading',
|
||||||
|
name: '阅读模式',
|
||||||
|
icon: 'book',
|
||||||
|
settings: { brightness: 100, colorTemp: 5000, color: '#ffffff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scene-movie',
|
||||||
|
name: '观影模式',
|
||||||
|
icon: 'film',
|
||||||
|
settings: { brightness: 20, colorTemp: 2700, color: '#ff9f43' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scene-sleep',
|
||||||
|
name: '睡眠模式',
|
||||||
|
icon: 'moon',
|
||||||
|
settings: { brightness: 10, colorTemp: 2200, color: '#ff7675' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scene-party',
|
||||||
|
name: '派对模式',
|
||||||
|
icon: 'music',
|
||||||
|
settings: { brightness: 100, colorTemp: 4000, color: '#a29bfe' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightingSlice = createSlice({
|
||||||
|
name: 'lighting',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleLight: (state, action: PayloadAction<string>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload)
|
||||||
|
if (device) {
|
||||||
|
device.isOn = !device.isOn
|
||||||
|
device.powerConsumption = device.isOn ? device.brightness * 0.001 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setBrightness: (state, action: PayloadAction<{ id: string; brightness: number }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.brightness = action.payload.brightness
|
||||||
|
device.powerConsumption = device.isOn ? action.payload.brightness * 0.001 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setColorTemp: (state, action: PayloadAction<{ id: string; colorTemp: number }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.colorTemp = action.payload.colorTemp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setColor: (state, action: PayloadAction<{ id: string; color: string }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.color = action.payload.color
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setScene: (state, action: PayloadAction<{ id: string; scene: LightDevice['scene'] }>) => {
|
||||||
|
const device = state.devices.find(d => d.id === action.payload.id)
|
||||||
|
if (device) {
|
||||||
|
device.scene = action.payload.scene
|
||||||
|
const sceneSettings = state.scenes.find(s => s.id === `scene-${action.payload.scene}`)
|
||||||
|
if (sceneSettings?.settings) {
|
||||||
|
Object.assign(device, sceneSettings.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applySceneToRoom: (state, action: PayloadAction<{ room: string; sceneId: string }>) => {
|
||||||
|
const scene = state.scenes.find(s => s.id === action.payload.sceneId)
|
||||||
|
if (scene?.settings) {
|
||||||
|
state.devices
|
||||||
|
.filter(d => d.room === action.payload.room)
|
||||||
|
.forEach(device => {
|
||||||
|
Object.assign(device, scene.settings)
|
||||||
|
device.scene = scene.id.replace('scene-', '') as LightDevice['scene']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectDevice: (state, action: PayloadAction<string>) => {
|
||||||
|
state.selectedDevice = action.payload
|
||||||
|
},
|
||||||
|
toggleAllLights: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.devices.forEach(device => {
|
||||||
|
device.isOn = action.payload
|
||||||
|
device.powerConsumption = action.payload ? device.brightness * 0.001 : 0
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
toggleLight,
|
||||||
|
setBrightness,
|
||||||
|
setColorTemp,
|
||||||
|
setColor,
|
||||||
|
setScene,
|
||||||
|
applySceneToRoom,
|
||||||
|
selectDevice,
|
||||||
|
toggleAllLights,
|
||||||
|
} = lightingSlice.actions
|
||||||
|
|
||||||
|
export default lightingSlice.reducer
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
darkMode: boolean
|
||||||
|
loading: boolean
|
||||||
|
notifications: {
|
||||||
|
id: string
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info'
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
}[]
|
||||||
|
activeTab: string
|
||||||
|
isMobile: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UIState = {
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
darkMode: true,
|
||||||
|
loading: false,
|
||||||
|
notifications: [],
|
||||||
|
activeTab: 'dashboard',
|
||||||
|
isMobile: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiSlice = createSlice({
|
||||||
|
name: 'ui',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleSidebar: (state) => {
|
||||||
|
state.sidebarCollapsed = !state.sidebarCollapsed
|
||||||
|
},
|
||||||
|
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.sidebarCollapsed = action.payload
|
||||||
|
},
|
||||||
|
toggleDarkMode: (state) => {
|
||||||
|
state.darkMode = !state.darkMode
|
||||||
|
},
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload
|
||||||
|
},
|
||||||
|
addNotification: (state, action: PayloadAction<Omit<UIState['notifications'][0], 'id' | 'timestamp'>>) => {
|
||||||
|
state.notifications.push({
|
||||||
|
...action.payload,
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeNotification: (state, action: PayloadAction<string>) => {
|
||||||
|
state.notifications = state.notifications.filter(n => n.id !== action.payload)
|
||||||
|
},
|
||||||
|
setActiveTab: (state, action: PayloadAction<string>) => {
|
||||||
|
state.activeTab = action.payload
|
||||||
|
},
|
||||||
|
setIsMobile: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.isMobile = action.payload
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
toggleSidebar,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
toggleDarkMode,
|
||||||
|
setLoading,
|
||||||
|
addNotification,
|
||||||
|
removeNotification,
|
||||||
|
setActiveTab,
|
||||||
|
setIsMobile,
|
||||||
|
} = uiSlice.actions
|
||||||
|
|
||||||
|
export default uiSlice.reducer
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { getApiBaseUrl, getTimeout } from '../config'
|
||||||
|
import { message } from 'antd'
|
||||||
|
|
||||||
|
const apiBaseUrl = getApiBaseUrl()
|
||||||
|
const timeout = getTimeout()
|
||||||
|
|
||||||
|
const axiosInstance: AxiosInstance = axios.create({
|
||||||
|
baseURL: apiBaseUrl,
|
||||||
|
timeout: timeout,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`)
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[API Request Error]', error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
console.log(`[API Response] ${response.config.method?.toUpperCase()} ${response.config.url}`, response.data)
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[API Response Error]', error)
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const { status, data } = error.response
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
message.error('认证失败,请重新登录')
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
message.error('没有权限访问该资源')
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
message.error('请求的资源不存在')
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
message.error('服务器内部错误')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message.error(data?.message || `请求失败 (${status})`)
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message.error('网络连接失败,请检查网络')
|
||||||
|
} else {
|
||||||
|
message.error('请求配置错误')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const request = {
|
||||||
|
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return axiosInstance.get(url, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return axiosInstance.post(url, data, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return axiosInstance.put(url, data, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return axiosInstance.delete(url, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
return axiosInstance.patch(url, data, config).then((res) => res.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default axiosInstance
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface AppConfig {
|
||||||
|
domain: string
|
||||||
|
port: string
|
||||||
|
basePath: string
|
||||||
|
apiPrefix: string
|
||||||
|
useHttps: boolean
|
||||||
|
timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __APP_CONFIG__: AppConfig
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_DOMAIN: string
|
||||||
|
readonly VITE_PORT: string
|
||||||
|
readonly VITE_BASE_PATH: string
|
||||||
|
readonly VITE_API_PREFIX: string
|
||||||
|
readonly VITE_USE_HTTPS: string
|
||||||
|
readonly VITE_TIMEOUT: string
|
||||||
|
readonly VITE_APP_TITLE?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#e6f7ff',
|
||||||
|
100: '#bae7ff',
|
||||||
|
200: '#91d5ff',
|
||||||
|
300: '#69c0ff',
|
||||||
|
400: '#40a9ff',
|
||||||
|
500: '#1890ff',
|
||||||
|
600: '#096dd9',
|
||||||
|
700: '#0050b3',
|
||||||
|
800: '#003a8c',
|
||||||
|
900: '#002766',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#0f1419',
|
||||||
|
card: '#1a1f2e',
|
||||||
|
border: '#2d3748',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['Space Grotesk', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
glow: {
|
||||||
|
'0%': { boxShadow: '0 0 5px rgba(24, 144, 255, 0.5)' },
|
||||||
|
'100%': { boxShadow: '0 0 20px rgba(24, 144, 255, 0.8)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
base: env.VITE_BASE_PATH || '/',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets',
|
||||||
|
sourcemap: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'antd': ['antd', '@ant-design/icons'],
|
||||||
|
'charts': ['echarts', 'echarts-for-react'],
|
||||||
|
'redux': ['@reduxjs/toolkit', 'react-redux'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__APP_CONFIG__: JSON.stringify({
|
||||||
|
domain: env.VITE_DOMAIN,
|
||||||
|
port: env.VITE_PORT,
|
||||||
|
basePath: env.VITE_BASE_PATH,
|
||||||
|
apiPrefix: env.VITE_API_PREFIX,
|
||||||
|
useHttps: env.VITE_USE_HTTPS === 'true',
|
||||||
|
timeout: parseInt(env.VITE_TIMEOUT || '30000'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue